vip相关

This commit is contained in:
zhx 2026-07-22 17:30:05 +08:00
parent f354aa7d88
commit a38a3395c1
43 changed files with 777 additions and 366 deletions

View File

@ -5678,8 +5678,10 @@ type GiftConfig struct {
EffectiveToMs int64 `protobuf:"varint,21,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
EffectTypes []string `protobuf:"bytes,22,rep,name=effect_types,json=effectTypes,proto3" json:"effect_types,omitempty"`
CpRelationType string `protobuf:"bytes,23,opt,name=cp_relation_type,json=cpRelationType,proto3" json:"cp_relation_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// vip_level 是 VIP/贵族礼物对应的 VIP 等级;非 VIP 礼物固定为 0。
VipLevel int32 `protobuf:"varint,24,opt,name=vip_level,json=vipLevel,proto3" json:"vip_level,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GiftConfig) Reset() {
@ -5873,6 +5875,13 @@ func (x *GiftConfig) GetCpRelationType() string {
return ""
}
func (x *GiftConfig) GetVipLevel() int32 {
if x != nil {
return x.VipLevel
}
return 0
}
type GiftTypeConfig struct {
state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
@ -8722,8 +8731,10 @@ type CreateGiftConfigRequest struct {
EffectiveToMs int64 `protobuf:"varint,19,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
EffectTypes []string `protobuf:"bytes,20,rep,name=effect_types,json=effectTypes,proto3" json:"effect_types,omitempty"`
CpRelationType string `protobuf:"bytes,21,opt,name=cp_relation_type,json=cpRelationType,proto3" json:"cp_relation_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// vip_level 仅允许 VIP/贵族礼物填写,且必须对应当前 App 已配置的 VIP 等级。
VipLevel int32 `protobuf:"varint,22,opt,name=vip_level,json=vipLevel,proto3" json:"vip_level,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateGiftConfigRequest) Reset() {
@ -8903,6 +8914,13 @@ func (x *CreateGiftConfigRequest) GetCpRelationType() string {
return ""
}
func (x *CreateGiftConfigRequest) GetVipLevel() int32 {
if x != nil {
return x.VipLevel
}
return 0
}
type BatchCreateGiftConfigsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
@ -9039,8 +9057,10 @@ type UpdateGiftConfigRequest struct {
EffectiveToMs int64 `protobuf:"varint,19,opt,name=effective_to_ms,json=effectiveToMs,proto3" json:"effective_to_ms,omitempty"`
EffectTypes []string `protobuf:"bytes,20,rep,name=effect_types,json=effectTypes,proto3" json:"effect_types,omitempty"`
CpRelationType string `protobuf:"bytes,21,opt,name=cp_relation_type,json=cpRelationType,proto3" json:"cp_relation_type,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// vip_level 仅允许 VIP/贵族礼物填写,且必须对应当前 App 已配置的 VIP 等级。
VipLevel int32 `protobuf:"varint,22,opt,name=vip_level,json=vipLevel,proto3" json:"vip_level,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateGiftConfigRequest) Reset() {
@ -9220,6 +9240,13 @@ func (x *UpdateGiftConfigRequest) GetCpRelationType() string {
return ""
}
func (x *UpdateGiftConfigRequest) GetVipLevel() int32 {
if x != nil {
return x.VipLevel
}
return 0
}
type SetGiftConfigStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
@ -27724,7 +27751,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x13required_region_ids\x18\b \x03(\x03R\x11requiredRegionIds\x120\n" +
"\x14required_all_regions\x18\t \x01(\bR\x12requiredAllRegions\"f\n" +
" PinResourceGroupSnapshotResponse\x12B\n" +
"\bsnapshot\x18\x01 \x01(\v2&.hyapp.wallet.v1.ResourceGroupSnapshotR\bsnapshot\"\xd3\x06\n" +
"\bsnapshot\x18\x01 \x01(\v2&.hyapp.wallet.v1.ResourceGroupSnapshotR\bsnapshot\"\xf0\x06\n" +
"\n" +
"GiftConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
@ -27755,7 +27782,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x11effective_from_ms\x18\x14 \x01(\x03R\x0feffectiveFromMs\x12&\n" +
"\x0feffective_to_ms\x18\x15 \x01(\x03R\reffectiveToMs\x12!\n" +
"\feffect_types\x18\x16 \x03(\tR\veffectTypes\x12(\n" +
"\x10cp_relation_type\x18\x17 \x01(\tR\x0ecpRelationType\"\xce\x02\n" +
"\x10cp_relation_type\x18\x17 \x01(\tR\x0ecpRelationType\x12\x1b\n" +
"\tvip_level\x18\x18 \x01(\x05R\bvipLevel\"\xce\x02\n" +
"\x0eGiftTypeConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x1b\n" +
"\ttype_code\x18\x02 \x01(\tR\btypeCode\x12\x12\n" +
@ -28061,7 +28089,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"sort_order\x18\a \x01(\x05R\tsortOrder\x12(\n" +
"\x10operator_user_id\x18\b \x01(\x03R\x0eoperatorUserId\"V\n" +
"\x16GiftTypeConfigResponse\x12<\n" +
"\tgift_type\x18\x01 \x01(\v2\x1f.hyapp.wallet.v1.GiftTypeConfigR\bgiftType\"\xf8\x05\n" +
"\tgift_type\x18\x01 \x01(\v2\x1f.hyapp.wallet.v1.GiftTypeConfigR\bgiftType\"\x95\x06\n" +
"\x17CreateGiftConfigRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -28090,7 +28118,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x11effective_from_ms\x18\x12 \x01(\x03R\x0feffectiveFromMs\x12&\n" +
"\x0feffective_to_ms\x18\x13 \x01(\x03R\reffectiveToMs\x12!\n" +
"\feffect_types\x18\x14 \x03(\tR\veffectTypes\x12(\n" +
"\x10cp_relation_type\x18\x15 \x01(\tR\x0ecpRelationType\"\xc3\x01\n" +
"\x10cp_relation_type\x18\x15 \x01(\tR\x0ecpRelationType\x12\x1b\n" +
"\tvip_level\x18\x16 \x01(\x05R\bvipLevel\"\xc3\x01\n" +
"\x1dBatchCreateGiftConfigsRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -28098,7 +28127,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x05items\x18\x03 \x03(\v2(.hyapp.wallet.v1.CreateGiftConfigRequestR\x05items\x12(\n" +
"\x10operator_user_id\x18\x04 \x01(\x03R\x0eoperatorUserId\"S\n" +
"\x1eBatchCreateGiftConfigsResponse\x121\n" +
"\x05gifts\x18\x01 \x03(\v2\x1b.hyapp.wallet.v1.GiftConfigR\x05gifts\"\xf8\x05\n" +
"\x05gifts\x18\x01 \x03(\v2\x1b.hyapp.wallet.v1.GiftConfigR\x05gifts\"\x95\x06\n" +
"\x17UpdateGiftConfigRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -28127,7 +28156,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x11effective_from_ms\x18\x12 \x01(\x03R\x0feffectiveFromMs\x12&\n" +
"\x0feffective_to_ms\x18\x13 \x01(\x03R\reffectiveToMs\x12!\n" +
"\feffect_types\x18\x14 \x03(\tR\veffectTypes\x12(\n" +
"\x10cp_relation_type\x18\x15 \x01(\tR\x0ecpRelationType\"\xb1\x01\n" +
"\x10cp_relation_type\x18\x15 \x01(\tR\x0ecpRelationType\x12\x1b\n" +
"\tvip_level\x18\x16 \x01(\x05R\bvipLevel\"\xb1\x01\n" +
"\x1aSetGiftConfigStatusRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +

View File

@ -724,6 +724,8 @@ message GiftConfig {
int64 effective_to_ms = 21;
repeated string effect_types = 22;
string cp_relation_type = 23;
// vip_level VIP/ VIP VIP 0
int32 vip_level = 24;
}
message GiftTypeConfig {
@ -1060,6 +1062,8 @@ message CreateGiftConfigRequest {
int64 effective_to_ms = 19;
repeated string effect_types = 20;
string cp_relation_type = 21;
// vip_level VIP/ App VIP
int32 vip_level = 22;
}
message BatchCreateGiftConfigsRequest {
@ -1096,6 +1100,8 @@ message UpdateGiftConfigRequest {
int64 effective_to_ms = 19;
repeated string effect_types = 20;
string cp_relation_type = 21;
// vip_level VIP/ App VIP
int32 vip_level = 22;
}
message SetGiftConfigStatusRequest {

View File

@ -1152,6 +1152,7 @@ PATCH
- 进房展示必须同时满足 `effective_benefits``room_entry_notice``room_entry_notice_enabled=true`
- 上线展示必须同时满足 `online_global_notice` 权益和开关;最终以触发接口的服务端判定为准。
- 隐藏个人数据、匿名访问主页、榜单隐身分别要求对应权益和同名开关同时成立;客户端不得只看设置值授权。
- 匿名访问主页生效时,访问接口返回 `recorded=false`,服务端不会新增或刷新对方的访客记录,`visitors_count` 也不变化;历史公开记录不会因开启开关而删除。
- 设置按 App 隔离Fami 的修改不影响 Lalu。
## 15. 每日 VIP 金币返现

View File

@ -95,6 +95,8 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
无记录时,进房/上线通知默认为 `true`,隐藏个人数据/匿名访问/榜单隐身默认为 `false`,首次修改才写行。偏好不授予权益:无 VIP 或对应等级没有权益时,即使开关为开也不会放行。用户可在无 VIP、VIP 过期或切换体验卡时保留偏好Fami 与 Lalu 互不影响。
`anonymous_profile_visit` 生效后user-service 对本次主页访问不新增也不刷新访客记录,目标用户的 `visitors_count` 不变化;开关只影响后续访问,不反向删除已公开的历史记录。
### 权益展示投影
`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未知扩展因此不会变成客户端隐形配置表。

View File

@ -66,6 +66,7 @@ X-App-Code: lalu
"gift_id": "rose",
"resource_id": 11,
"gift_type_code": "normal",
"vip_level": 0,
"name": "Rose",
"coin_price": 100,
"resource": {
@ -101,6 +102,8 @@ X-App-Code: lalu
| `updated_at_ms` | int64 | 更新时间Unix epoch milliseconds。 |
| `gifts` | array | 当前 Tab 下的 active 礼物列表;没有礼物时返回空数组。 |
`gifts[].vip_level` 是后台明确配置的 VIP 等级。`gift_type_code=noble|vip` 时为正整数;其他类型固定为 `0`不能根据价格、名称或排序推断。VIP 礼物左上角标签必须读取 `/api/v1/vip/packages` 中同等级、`benefit_code=vip_badge_identity``resource`,不能在客户端拼文字或绑定本地等级图。
## 获取礼物面板
```http
@ -152,6 +155,7 @@ X-App-Code: lalu
{
"key": "normal",
"gift_type_code": "normal",
"vip_level": 0,
"label": "normal",
"order": 10
}
@ -215,6 +219,7 @@ X-App-Code: lalu
| `gift_id` | 送礼请求使用的礼物 ID。 |
| `name` | 展示名称。 |
| `gift_type_code` | 分类编码,例如 `normal``lucky``super_lucky`。 |
| `vip_level` | VIP/贵族礼物对应的 VIP 等级;非 VIP 礼物为 `0`。 |
| `charge_asset_type` | 当前收费资产;普通金币礼物为 `COIN`。 |
| `coin_price` | 单个礼物金币价格。仅用于展示和按钮预估,扣费以后端为准。 |
| `gift_point_amount` | 主播收到的礼物积分增量口径。 |
@ -420,6 +425,7 @@ int parseSenderVipLevel(Map<String, dynamic> data) {
| `NOT_FOUND` | 房间或礼物不存在;刷新房间和礼物面板。 |
| `FAILED_PRECONDITION` | 用户不在房间、房间不可送礼或状态不允许;刷新房间详情。 |
| `INSUFFICIENT_BALANCE` | 金币不足;跳转充值或刷新余额。 |
| `VIP_GIFT_LEVEL_REQUIRED` | 当前 effective VIP 不足;读取 `data.required_level`,用本地国际化文案提示需要的 VIP 等级,不展示后端英文 message。 |
| `CONFLICT` | 幂等冲突或命令状态冲突;复用原 `command_id` 查询/重试,不生成新命令导致重复送礼。 |
| `UPSTREAM_ERROR` / 5xx | 保留原 `command_id` 允许用户重试。 |
@ -511,6 +517,7 @@ class GiftItem {
required this.giftId,
required this.name,
required this.giftTypeCode,
required this.vipLevel,
required this.coinPrice,
required this.assetUrl,
required this.animationUrl,
@ -520,6 +527,7 @@ class GiftItem {
final String giftId;
final String name;
final String giftTypeCode;
final int vipLevel;
final int coinPrice;
final String assetUrl;
final String animationUrl;
@ -531,6 +539,7 @@ class GiftItem {
giftId: json['gift_id'] as String? ?? '',
name: json['name'] as String? ?? '',
giftTypeCode: json['gift_type_code'] as String? ?? '',
vipLevel: (json['vip_level'] as num?)?.toInt() ?? 0,
coinPrice: (json['coin_price'] as num?)?.toInt() ?? 0,
assetUrl: resource['asset_url'] as String? ?? '',
animationUrl: resource['animation_url'] as String? ?? '',

View File

@ -1080,7 +1080,8 @@ paths:
summary: 记录一次主页访问并返回目标资料展示状态
operationId: recordProfileVisit
description: >-
匿名身份在写入时按访问者当时生效的 anonymous_profile_visit 权益与开关固化;历史记录不会因开关变化反向改名。
访问者当前 anonymous_profile_visit 权益与开关同时生效时,本次请求不新增、不刷新任何访客记录,
recorded=false目标用户的 visitors_count 也不变化;开关不会反向删除此前已经产生的公开记录。
目标用户当前 hide_profile_data 生效时返回 target_stats_hidden=true并完全省略 target_stats。
当前 App 访问链路无好友或管理员白名单,所有非本人查看者使用同一隐藏结论。
security:
@ -1094,7 +1095,7 @@ paths:
description: 被访问用户的系统 user_id不是展示短号。
responses:
"200":
description: 访问事实已写入或命中同一访客聚合记录
description: 公开访问已写入或刷新聚合记录;匿名访问成功但不留访问事实
schema:
$ref: "#/definitions/ProfileVisitEnvelope"
"400":
@ -1105,10 +1106,6 @@ paths:
$ref: "#/responses/Forbidden"
"404":
$ref: "#/responses/NotFound"
"409":
description: 极低概率的匿名展示 ID 碰撞code=CONFLICT本次事务已回滚不会降级写入公开访问记录。
schema:
$ref: "#/definitions/ErrorEnvelope"
"500":
$ref: "#/responses/Internal"
"502":
@ -1117,9 +1114,9 @@ paths:
get:
tags:
- users
summary: 查询访问我的主页的公开与匿名访客
summary: 查询访问我的主页的公开访客及历史匿名记录
operationId: listMyProfileVisitors
description: 匿名记录只返回 anonymous_visit_id省略真实 visitor_user_idanonymous_visit_id 仅在同一目标用户下稳定,不能用于资料查询。
description: 新的匿名访问不会进入列表。兼容历史数据时,旧匿名记录只返回 anonymous_visit_id 并省略真实 visitor_user_id该 ID 不能用于资料查询。
security:
- BearerAuth: []
parameters:
@ -4959,10 +4956,15 @@ definitions:
- super_lucky
- exclusive
- noble
- vip
- flag
- activity
- magic
- custom
vip_level:
type: integer
format: int32
description: VIP/贵族礼物对应的 VIP 等级;非 VIP 礼物为 0。
charge_asset_type:
type: string
enum:
@ -7071,6 +7073,7 @@ definitions:
properties:
recorded:
type: boolean
description: 公开访问写入或刷新记录时为 true匿名访问不留足迹固定为 false。
target_stats_hidden:
type: boolean
description: true 时 target_stats 字段完全省略;该值按目标用户当前 VIP 权益实时判定。

View File

@ -912,6 +912,8 @@ class VipEntitlementState {
5 个字段都使用 PATCH 语义:不传表示保持原值,显式 `false` 表示关闭。没有持久记录时,进房/上线通知默认 `true`,隐藏个人数据/匿名访问/榜单隐身默认 `false`;设置只限制用户主动启用对应效果,不会单独授予 VIP 权益。服务端执行时使用“当前有效权益 AND 用户设置”判定Flutter 不能只看开关。
匿名访问主页生效时,`POST /api/v1/users/{user_id}/visit` 返回 `recorded=false`,后端不新增、不刷新访客记录,也不增加目标用户的 `visitors_count`;客户端不能再自行发送第二次访问记录请求。
## 8. 每日 VIP 金币返现
### 8.1 查询当前返现

View File

@ -31,6 +31,7 @@ sequenceDiagram
- 删除好友是双向删除。
- 自己访问自己、关注自己、加自己好友全部拒绝。
- 重复访问只增加访问次数,不重复增加 `visitors_count`
- VIP 匿名访问权益与用户开关同时生效时,本次访问完全不写访客事实、不刷新旧记录,也不增加 `visitors_count`;开启开关不会删除此前的公开访问历史。
- 重复关注、重复取消关注、重复同意、重复删除都按幂等处理。
## HTTP Endpoints
@ -74,6 +75,8 @@ sequenceDiagram
}
```
匿名访问成功时 `recorded=false`,仍可返回目标资料与未隐藏的统计,但 `GET /api/v1/users/me/visitors` 不会出现本次访问。
申请好友:
```json
@ -122,6 +125,7 @@ sequenceDiagram
| --- | --- | --- |
| `user_profile_stats` | user-service | 我的页计数 read model访问人数、关注数、粉丝数、好友数 |
| `user_profile_visits` | user-service | 用户主页访问去重记录,主键 `(app_code,target_user_id,visitor_user_id)` |
| `user_profile_anonymous_visits` | user-service | 只读兼容历史匿名记录;新匿名访问不再写入 |
| `user_follows` | user-service | 单向关注关系,取消关注只改状态 |
| `user_friend_applications` | user-service | 好友申请pending/accepted |
| `user_friendships` | user-service | 双向好友边,每个好友关系写两行 |

View File

@ -230,6 +230,7 @@ gift_configs(
sort_order INT NOT NULL DEFAULT 0,
presentation_json JSON NULL,
gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal',
vip_level INT NOT NULL DEFAULT 0,
effective_from_ms BIGINT NOT NULL DEFAULT 0,
effective_to_ms BIGINT NOT NULL DEFAULT 0,
effect_types_json JSON NULL,
@ -422,7 +423,7 @@ GET /api/v1/users/me/resources
POST /api/v1/users/me/resources/{resource_id}/equip
```
礼物列表按区域和有效期过滤:传入 `region_id=N` 时返回绑定到 `N` 的礼物和 `region_id=0` 的 GLOBAL 兜底礼物;不传 `region_id` 时只返回 GLOBAL 兜底礼物。只返回 `status=active`、资源 active、当前时间落在有效期内的礼物。响应会包含 `gift_type_code``charge_asset_type`、`effective_from_ms``effective_to_ms``effect_types`,客户端只能用于展示和选择,不能在送礼时回传这些值作为结算依据。
礼物列表按区域和有效期过滤:传入 `region_id=N` 时返回绑定到 `N` 的礼物和 `region_id=0` 的 GLOBAL 兜底礼物;不传 `region_id` 时只返回 GLOBAL 兜底礼物。只返回 `status=active`、资源 active、当前时间落在有效期内的礼物。响应会包含 `gift_type_code``vip_level`、`charge_asset_type`、`effective_from_ms``effective_to_ms``effect_types``noble`/`vip` 礼物的 `vip_level` 必须是当前 App 已配置的正整数 VIP 等级其他类型固定为 `0`客户端只能用于展示和选择,不能在送礼时回传这些值作为结算依据。
装备资源的边界:

View File

@ -110,6 +110,7 @@ var catalog = map[Code]Spec{
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"),
VIPGiftLevelRequired: spec(codes.PermissionDenied, httpStatusForbidden, VIPGiftLevelRequired, "vip level is required for this gift"),
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"),

View File

@ -125,6 +125,8 @@ const (
VIPTrialCardExpired Code = "VIP_TRIAL_CARD_EXPIRED"
// VIPBenefitRequired 表示当前操作需要一项未生效或已被用户关闭的 VIP 权益。
VIPBenefitRequired Code = "VIP_BENEFIT_REQUIRED"
// VIPGiftLevelRequired 表示发送者当前生效 VIP 等级低于礼物配置要求。
VIPGiftLevelRequired Code = "VIP_GIFT_LEVEL_REQUIRED"
// VIPAntiKick 表示普通房主/管理员踢人动作被目标用户的防踢权益拦截。
VIPAntiKick Code = "VIP_ANTI_KICK"
// VIPAntiMute 表示普通房主/管理员禁言动作被目标用户的防禁言权益拦截。

View File

@ -72,6 +72,7 @@ type giftDTO struct {
PresentationJSON string `json:"presentationJson"`
PriceVersion string `json:"priceVersion"`
GiftTypeCode string `json:"giftTypeCode"`
VIPLevel int32 `json:"vipLevel"`
CPRelationType string `json:"cpRelationType"`
ChargeAssetType string `json:"chargeAssetType"`
CoinPrice int64 `json:"coinPrice"`
@ -344,6 +345,7 @@ func giftFromProto(gift *walletv1.GiftConfig) giftDTO {
PresentationJSON: gift.GetPresentationJson(),
PriceVersion: gift.GetPriceVersion(),
GiftTypeCode: gift.GetGiftTypeCode(),
VIPLevel: gift.GetVipLevel(),
CPRelationType: gift.GetCpRelationType(),
ChargeAssetType: gift.GetChargeAssetType(),
CoinPrice: gift.GetCoinPrice(),

View File

@ -775,11 +775,13 @@ func (h *Handler) validateGenericGrantResource(c *gin.Context, resourceID int64,
if requireManagerGrant && (resp.GetResource().GetStatus() != "active" || !resp.GetResource().GetGrantable() || !resp.GetResource().GetManagerGrantEnabled()) {
return fmt.Errorf("该资源未开放外管发放")
}
// VIP 体验卡除了资源权益,还必须同步写入卡片等级、独立失效时间和 VIP 状态投影;
// 通用资源赠送只会创建 entitlement放行会产生“背包可见但无法生效”的半成品数据。
if resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {
return fmt.Errorf("VIP 体验卡必须通过 VIP 配置页赠送")
if requireManagerGrant && resp.GetResource().GetResourceType() == resourceTypeVIPTrialCard {
// 外管资源目录仍不展示体验卡,因此 ID 直提也必须保持同一边界;本次放开的是
// 主 Admin 与活动资源组,不扩大经理中心的可操作资源集合。
return fmt.Errorf("该资源未开放外管发放")
}
// Wallet owner 已将 vip_trial_card 的 entitlement、卡片元数据和 VIP 投影收敛到
// 同一事务Admin 不再按入口屏蔽它,直接资源赠送与资源组活动可以复用同一语义。
return nil
}
@ -792,23 +794,8 @@ func (h *Handler) validateGenericGrantGroup(c *gin.Context, groupID int64) error
if err != nil {
return err
}
for _, item := range resp.GetGroup().GetItems() {
resourceType := item.GetResource().GetResourceType()
// 老资源组数据可能只返回 resource_id逐项回查可防止因嵌套 Resource 缺失而绕过体验卡专用发放链路。
if resourceType == "" && item.GetResourceId() > 0 {
resourceResp, err := h.wallet.GetResource(c.Request.Context(), &walletv1.GetResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: item.GetResourceId(),
})
if err != nil {
return err
}
resourceType = resourceResp.GetResource().GetResourceType()
}
if resourceType == resourceTypeVIPTrialCard {
return fmt.Errorf("包含 VIP 体验卡的资源组必须通过 VIP 配置页赠送")
}
if resp.GetGroup() == nil {
return fmt.Errorf("资源组不存在")
}
return nil
}

View File

@ -186,7 +186,7 @@ func TestBatchCreateGiftsCallsWalletBatchCreate(t *testing.T) {
wallet := &mockResourceWallet{}
router := newResourceHandlerTestRouter(New(wallet, nil, nil, time.Second, nil))
body := `{"items":[{"giftId":"rose","resourceId":11,"status":"active","name":"Rose","priceVersion":"default","giftTypeCode":"normal","chargeAssetType":"COIN","coinPrice":10,"regionIds":[0]},{"giftId":"ring","resourceId":12,"status":"active","name":"Ring","priceVersion":"default","giftTypeCode":"normal","chargeAssetType":"COIN","coinPrice":20,"regionIds":[0]}]}`
body := `{"items":[{"giftId":"rose","resourceId":11,"status":"active","name":"Rose","priceVersion":"default","giftTypeCode":"normal","chargeAssetType":"COIN","coinPrice":10,"regionIds":[0]},{"giftId":"ring","resourceId":12,"status":"active","name":"Ring","priceVersion":"default","giftTypeCode":"noble","vipLevel":5,"chargeAssetType":"COIN","coinPrice":20,"regionIds":[0]}]}`
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/admin/gifts/batch", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
@ -199,7 +199,7 @@ func TestBatchCreateGiftsCallsWalletBatchCreate(t *testing.T) {
t.Fatalf("expected one batch gift create request, got %d", len(wallet.batchCreatedGifts))
}
createReq := wallet.batchCreatedGifts[0]
if createReq.GetAppCode() != "lalu" || len(createReq.GetItems()) != 2 || createReq.GetItems()[0].GetGiftId() != "rose" || createReq.GetItems()[1].GetGiftId() != "ring" {
if createReq.GetAppCode() != "lalu" || len(createReq.GetItems()) != 2 || createReq.GetItems()[0].GetGiftId() != "rose" || createReq.GetItems()[1].GetGiftId() != "ring" || createReq.GetItems()[1].GetVipLevel() != 5 {
t.Fatalf("batch create gift request mismatch: %+v", createReq)
}
}
@ -220,7 +220,7 @@ func TestIdentityAutoGrantConfigRouteDoesNotHitResourceGroupID(t *testing.T) {
}
}
func TestGenericResourceGrantRejectsVIPTrialCard(t *testing.T) {
func TestGenericResourceGrantAllowsVIPTrialCard(t *testing.T) {
wallet := &mockResourceWallet{resources: map[int64]*walletv1.Resource{
99: {AppCode: "lalu", ResourceId: 99, ResourceCode: "vip_trial_5", ResourceType: resourceTypeVIPTrialCard, Name: "VIP5体验卡"},
}}
@ -230,8 +230,8 @@ func TestGenericResourceGrantRejectsVIPTrialCard(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "VIP 体验卡必须通过 VIP 配置页赠送") {
t.Fatalf("generic grant must reject vip trial card: status=%d body=%s", recorder.Code, recorder.Body.String())
if recorder.Code != http.StatusCreated || len(wallet.grantRequests) != 1 || wallet.grantRequests[0].GetResourceId() != 99 {
t.Fatalf("generic grant must forward vip trial card to wallet owner: status=%d body=%s requests=%+v", recorder.Code, recorder.Body.String(), wallet.grantRequests)
}
}
@ -312,7 +312,7 @@ func TestExternalResourceListUsesManagerCatalogAndHidesVIPTrialCard(t *testing.T
}
}
func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) {
func TestGenericResourceGroupGrantAllowsVIPTrialCardItem(t *testing.T) {
wallet := &mockResourceWallet{resourceGroups: map[int64]*walletv1.ResourceGroup{
88: {
AppCode: "lalu",
@ -329,8 +329,8 @@ func TestGenericResourceGroupGrantRejectsVIPTrialCardItem(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "包含 VIP 体验卡的资源组必须通过 VIP 配置页赠送") {
t.Fatalf("generic group grant must reject vip trial card: status=%d body=%s", recorder.Code, recorder.Body.String())
if recorder.Code != http.StatusCreated || len(wallet.grantGroupRequests) != 1 || wallet.grantGroupRequests[0].GetGroupId() != 88 {
t.Fatalf("generic group grant must forward vip trial card group to wallet owner: status=%d body=%s requests=%+v", recorder.Code, recorder.Body.String(), wallet.grantGroupRequests)
}
}
@ -481,6 +481,7 @@ type mockResourceWallet struct {
listResourceRequests []*walletv1.ListResourcesRequest
listedResources []*walletv1.Resource
grantRequests []*walletv1.GrantResourceRequest
grantGroupRequests []*walletv1.GrantResourceGroupRequest
revokeUserResourceRequests []*walletv1.RevokeUserResourceRequest
}
@ -497,6 +498,14 @@ func (m *mockResourceWallet) GrantResource(_ context.Context, req *walletv1.Gran
}}, nil
}
func (m *mockResourceWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
m.grantGroupRequests = append(m.grantGroupRequests, req)
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
AppCode: req.GetAppCode(), GrantId: "group-grant-result", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(),
GrantSource: req.GetGrantSource(), GrantSubjectType: "resource_group", GrantSubjectId: fmt.Sprint(req.GetGroupId()), Status: "succeeded",
}}, 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{
@ -582,6 +591,7 @@ func (m *mockResourceWallet) BatchCreateGiftConfigs(ctx context.Context, req *wa
ResourceId: item.GetResourceId(),
Status: item.GetStatus(),
Name: item.GetName(),
VipLevel: item.GetVipLevel(),
})
}
return resp, nil

View File

@ -96,6 +96,8 @@ type resourceRequest struct {
PreviewURL string `json:"previewUrl"`
AnimationURL string `json:"animationUrl"`
SortOrder int32 `json:"sortOrder"`
VIPLevel int32 `json:"vipLevel"`
DurationDays int64 `json:"durationDays"`
}
type resourceMP4LayoutBatchRequest struct {
@ -145,6 +147,8 @@ type resourceMetadataPayload struct {
TextColors []string `json:"text_colors,omitempty"`
Stops []float64 `json:"stops,omitempty"`
AngleDegrees *float64 `json:"angle_degrees,omitempty"`
VIPLevel int32 `json:"vip_level,omitempty"`
DurationDays int64 `json:"duration_days,omitempty"`
}
type mp4AlphaLayoutMetadataPayload struct {
@ -201,6 +205,7 @@ type giftRequest struct {
PresentationJSON string `json:"presentationJson"`
PriceVersion string `json:"priceVersion"`
GiftTypeCode string `json:"giftTypeCode"`
VIPLevel int32 `json:"vipLevel"`
CPRelationType string `json:"cpRelationType"`
ChargeAssetType string `json:"chargeAssetType"`
CoinPrice int64 `json:"coinPrice"`
@ -272,15 +277,17 @@ type resourceShopItemRequest struct {
func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
resourceCode, name := resourceIdentity(r)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
priceType, coinPrice, giftPointAmount := resourcePricingForRequest(r)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind, int64(r.VIPLevel), r.DurationDays)
assetURL, previewURL, animationURL := resourceMaterialURLs(r)
return &walletv1.CreateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceCode: resourceCode,
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Name: name,
Status: strings.TrimSpace(r.Status),
Grantable: true,
ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled),
@ -291,9 +298,9 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq
CoinPrice: coinPrice,
GiftPointAmount: giftPointAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL),
AssetUrl: assetURL,
PreviewUrl: previewURL,
AnimationUrl: animationURL,
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
@ -341,16 +348,18 @@ func parseGrantTargetUserID(value any) (int64, error) {
func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1.UpdateResourceRequest {
resourceType := normalizeResourceType(r.ResourceType)
resourceCode, name := resourceIdentity(r)
walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount)
priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind)
priceType, coinPrice, giftPointAmount := resourcePricingForRequest(r)
metadataJSON := resourceMetadataJSON(resourceType, r.BadgeForm, r.MetadataJSON, r.BadgeKind, r.LevelTrack, r.AvatarFrameKind, int64(r.VIPLevel), r.DurationDays)
assetURL, previewURL, animationURL := resourceMaterialURLs(r)
return &walletv1.UpdateResourceRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceId: resourceID,
ResourceCode: strings.TrimSpace(r.ResourceCode),
ResourceCode: resourceCode,
ResourceType: resourceType,
Name: strings.TrimSpace(r.Name),
Name: name,
Status: strings.TrimSpace(r.Status),
Grantable: true,
ManagerGrantEnabled: managerGrantEnabledOrDefault(r.ManagerGrantEnabled),
@ -361,9 +370,9 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1
CoinPrice: coinPrice,
GiftPointAmount: giftPointAmount,
UsageScopes: []string{resourceType},
AssetUrl: strings.TrimSpace(r.AssetURL),
PreviewUrl: strings.TrimSpace(r.PreviewURL),
AnimationUrl: strings.TrimSpace(r.AnimationURL),
AssetUrl: assetURL,
PreviewUrl: previewURL,
AnimationUrl: animationURL,
MetadataJson: metadataJSON,
SortOrder: r.SortOrder,
OperatorUserId: actorID(c),
@ -446,6 +455,7 @@ func (r giftRequest) createProto(c *gin.Context) *walletv1.CreateGiftConfigReque
PresentationJson: strings.TrimSpace(r.PresentationJSON),
PriceVersion: strings.TrimSpace(r.PriceVersion),
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
VipLevel: r.VIPLevel,
CpRelationType: r.cpRelationType(),
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
CoinPrice: r.CoinPrice,
@ -472,6 +482,7 @@ func (r giftRequest) updateProto(c *gin.Context, giftID string) *walletv1.Update
PresentationJson: strings.TrimSpace(r.PresentationJSON),
PriceVersion: strings.TrimSpace(r.PriceVersion),
GiftTypeCode: strings.TrimSpace(r.GiftTypeCode),
VipLevel: r.VIPLevel,
CpRelationType: r.cpRelationType(),
ChargeAssetType: strings.TrimSpace(r.ChargeAssetType),
CoinPrice: r.CoinPrice,
@ -607,7 +618,43 @@ func resourcePricing(priceType string, coinPrice int64) (string, int64, int64) {
return resourcePriceTypeCoin, coinPrice, 0
}
func resourcePricingForRequest(req resourceRequest) (string, int64, int64) {
// VIP 体验卡不是商城商品;价格是资源目录的无关字段,统一落 free/0避免后台
// 隐藏价格控件后仍被旧表单默认值污染。
if normalizeResourceType(req.ResourceType) == resourceTypeVIPTrialCard {
return resourcePriceTypeFree, 0, 0
}
return resourcePricing(req.PriceType, req.CoinPrice)
}
func resourceIdentity(req resourceRequest) (string, string) {
resourceCode := strings.TrimSpace(req.ResourceCode)
name := strings.TrimSpace(req.Name)
if normalizeResourceType(req.ResourceType) != resourceTypeVIPTrialCard {
return resourceCode, name
}
// 新建体验卡只让运营选择等级和天数,稳定编码由后台生成;编辑历史标准资源时
// 保留原编码,避免把 vip_trial_card_<level> 改名后破坏专用发卡接口的回查。
if resourceCode == "" {
resourceCode = fmt.Sprintf("vip_trial_card_%d_%dd", req.VIPLevel, req.DurationDays)
}
if name == "" {
name = fmt.Sprintf("VIP%d体验卡%d天", req.VIPLevel, req.DurationDays)
}
return resourceCode, name
}
func resourceMaterialURLs(req resourceRequest) (string, string, string) {
if normalizeResourceType(req.ResourceType) == resourceTypeVIPTrialCard {
return "", "", ""
}
return strings.TrimSpace(req.AssetURL), strings.TrimSpace(req.PreviewURL), strings.TrimSpace(req.AnimationURL)
}
func validateResourcePricing(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) == resourceTypeVIPTrialCard {
return validateVIPTrialCardRequest(req)
}
priceType := strings.ToLower(strings.TrimSpace(req.PriceType))
switch priceType {
case resourcePriceTypeCoin:
@ -622,6 +669,16 @@ func validateResourcePricing(req resourceRequest) error {
return nil
}
func validateVIPTrialCardRequest(req resourceRequest) error {
if req.VIPLevel <= 0 {
return fmt.Errorf("请选择VIP等级")
}
if req.DurationDays <= 0 || req.DurationDays > 36500 {
return fmt.Errorf("VIP体验卡有效天数必须在 1 到 36500 天之间")
}
return nil
}
func validateResourceBadgeForm(req resourceRequest) error {
if normalizeResourceType(req.ResourceType) != resourceTypeBadge {
return nil
@ -711,6 +768,8 @@ func resourceGrantStrategy(resourceType string) string {
return "extend_expiry"
case "badge":
return "set_active_flag"
case resourceTypeVIPTrialCard:
return "new_entitlement"
default:
return "increase_quantity"
}
@ -749,10 +808,25 @@ func resourceRequiresExplicitVisualFormat(resourceType string) bool {
}
}
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string) string {
func resourceMetadataJSON(resourceType string, badgeForm string, metadataJSON string, badgeKind string, levelTrack string, avatarFrameKind string, vipFields ...int64) string {
if resourceType == resourceTypeBadge {
return badgeMetadataJSON(badgeForm, badgeKind, levelTrack)
}
if resourceType == resourceTypeVIPTrialCard {
var vipLevel int32
var durationDays int64
if len(vipFields) > 0 {
vipLevel = int32(vipFields[0])
}
if len(vipFields) > 1 {
durationDays = vipFields[1]
}
body, err := json.Marshal(resourceMetadataPayload{VIPLevel: vipLevel, DurationDays: durationDays})
if err != nil {
return "{}"
}
return string(body)
}
rawPayload, _, err := parseResourceMetadataPayload(metadataJSON)
if err != nil {

View File

@ -124,16 +124,18 @@ type giftConfigData struct {
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
// VIPLevel 让客户端直接按结构化等级展示/过滤 VIP 礼物;非 VIP 礼物为 0。
VIPLevel int32 `json:"vip_level"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
}
type giftTabData struct {
@ -692,6 +694,7 @@ func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
PresentationJSON: gift.GetPresentationJson(),
PriceVersion: gift.GetPriceVersion(),
GiftTypeCode: gift.GetGiftTypeCode(),
VIPLevel: gift.GetVipLevel(),
ChargeAssetType: gift.GetChargeAssetType(),
CoinPrice: gift.GetCoinPrice(),
GiftPointAmount: gift.GetGiftPointAmount(),

View File

@ -9424,7 +9424,8 @@ func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
ResourceId: 11,
Status: "active",
Name: "Rose",
GiftTypeCode: "normal",
GiftTypeCode: "noble",
VipLevel: 4,
CoinPrice: 100,
Resource: &walletv1.Resource{
ResourceId: 11,
@ -9440,9 +9441,9 @@ func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{
GiftTypes: []*walletv1.GiftTypeConfig{
{
TypeCode: "normal",
Name: "普通礼物",
TabKey: "Gift",
TypeCode: "noble",
Name: "VIP礼物",
TabKey: "VIP",
Status: "active",
SortOrder: 10,
CreatedAtMs: 1_700_000_000_000,
@ -9484,6 +9485,7 @@ func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
Gifts []struct {
GiftID string `json:"gift_id"`
GiftTypeCode string `json:"gift_type_code"`
VIPLevel int32 `json:"vip_level"`
CoinPrice int64 `json:"coin_price"`
} `json:"gifts"`
} `json:"items"`
@ -9502,10 +9504,10 @@ func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 {
t.Fatalf("gift tabs envelope mismatch: %+v", envelope)
}
if envelope.Data.Items[0].Key != "normal" || envelope.Data.Items[0].GiftTypeCode != "normal" || envelope.Data.Items[0].Name != "普通礼物" || envelope.Data.Items[0].Label != "Gift" || envelope.Data.Items[0].TabKey != "Gift" || envelope.Data.Items[0].Order != 10 || envelope.Data.Items[0].SortOrder != 10 || envelope.Data.Items[0].UpdatedAtMS != 1_700_000_000_100 {
if envelope.Data.Items[0].Key != "noble" || envelope.Data.Items[0].GiftTypeCode != "noble" || envelope.Data.Items[0].Name != "VIP礼物" || envelope.Data.Items[0].Label != "VIP" || envelope.Data.Items[0].TabKey != "VIP" || envelope.Data.Items[0].Order != 10 || envelope.Data.Items[0].SortOrder != 10 || envelope.Data.Items[0].UpdatedAtMS != 1_700_000_000_100 {
t.Fatalf("first gift tab mismatch: %+v", envelope.Data.Items[0])
}
if len(envelope.Data.Items[0].Gifts) != 1 || envelope.Data.Items[0].Gifts[0].GiftID != "rose" || envelope.Data.Items[0].Gifts[0].GiftTypeCode != "normal" || envelope.Data.Items[0].Gifts[0].CoinPrice != 100 {
if len(envelope.Data.Items[0].Gifts) != 1 || envelope.Data.Items[0].Gifts[0].GiftID != "rose" || envelope.Data.Items[0].Gifts[0].GiftTypeCode != "noble" || envelope.Data.Items[0].Gifts[0].VIPLevel != 4 || envelope.Data.Items[0].Gifts[0].CoinPrice != 100 {
t.Fatalf("first gift tab gifts mismatch: %+v", envelope.Data.Items[0].Gifts)
}
if len(envelope.Data.Items[1].Gifts) != 0 {
@ -9610,9 +9612,9 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
}
walletClient.giftCatalogVersion = 2
walletClient.listGiftTypesResp = &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
TypeCode: "lucky",
Name: "幸运礼物",
TabKey: "Lucky",
TypeCode: "noble",
Name: "VIP礼物",
TabKey: "VIP",
Status: "active",
SortOrder: 20,
}}}
@ -9622,7 +9624,8 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
Resource: lilyResource,
Status: "active",
Name: "Lily",
GiftTypeCode: "lucky",
GiftTypeCode: "noble",
VipLevel: 6,
PriceVersion: "v2",
ChargeAssetType: "COIN",
CoinPrice: 200,
@ -9642,6 +9645,7 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
Gifts []struct {
GiftID string `json:"gift_id"`
GiftTypeCode string `json:"gift_type_code"`
VIPLevel int32 `json:"vip_level"`
CoinPrice int64 `json:"coin_price"`
} `json:"gifts"`
} `json:"data"`
@ -9649,7 +9653,10 @@ func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
t.Fatalf("decode changed-version gift panel failed: %v", err)
}
if envelope.Code != httpkit.CodeOK || len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "lucky" || envelope.Data.Gifts[0].CoinPrice != 200 {
if len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "noble" || envelope.Data.Gifts[0].VIPLevel != 6 {
t.Fatalf("gift panel VIP level mismatch: %+v", envelope.Data.Gifts)
}
if envelope.Code != httpkit.CodeOK || len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "noble" || envelope.Data.Gifts[0].VIPLevel != 6 || envelope.Data.Gifts[0].CoinPrice != 200 {
t.Fatalf("gift panel changed-version response should use refetched config: %+v", envelope)
}
}

View File

@ -423,6 +423,7 @@ func giftPanelTestGiftConfigs() *walletv1.ListGiftConfigsResponse {
Status: "active",
Name: "Rose",
GiftTypeCode: "normal",
VipLevel: 0,
CoinPrice: 100,
}}}
}

View File

@ -26,30 +26,32 @@ type resourceData struct {
}
type giftConfigData struct {
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
CPRelationType string `json:"cp_relation_type,omitempty"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
Source string `json:"source,omitempty"`
EntitlementID string `json:"entitlement_id,omitempty"`
OwnedQuantity int64 `json:"owned_quantity,omitempty"`
RemainingQuantity int64 `json:"remaining_quantity,omitempty"`
GiftID string `json:"gift_id"`
ResourceID int64 `json:"resource_id"`
Resource resourceData `json:"resource"`
Status string `json:"status"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
PresentationJSON string `json:"presentation_json"`
PriceVersion string `json:"price_version"`
GiftTypeCode string `json:"gift_type_code"`
// VIPLevel 是后台明确绑定的礼物等级,不使用价格、排序或名称在客户端推断。
VIPLevel int32 `json:"vip_level"`
CPRelationType string `json:"cp_relation_type,omitempty"`
ChargeAssetType string `json:"charge_asset_type"`
CoinPrice int64 `json:"coin_price"`
GiftPointAmount int64 `json:"gift_point_amount"`
HeatValue int64 `json:"heat_value"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
EffectTypes []string `json:"effect_types"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
RegionIDs []int64 `json:"region_ids"`
Source string `json:"source,omitempty"`
EntitlementID string `json:"entitlement_id,omitempty"`
OwnedQuantity int64 `json:"owned_quantity,omitempty"`
RemainingQuantity int64 `json:"remaining_quantity,omitempty"`
}
type giftTypeConfigData struct {
@ -74,6 +76,7 @@ func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
PresentationJSON: gift.GetPresentationJson(),
PriceVersion: gift.GetPriceVersion(),
GiftTypeCode: gift.GetGiftTypeCode(),
VIPLevel: gift.GetVipLevel(),
CPRelationType: gift.GetCpRelationType(),
ChargeAssetType: gift.GetChargeAssetType(),
CoinPrice: gift.GetCoinPrice(),

View File

@ -175,7 +175,7 @@ CREATE TABLE IF NOT EXISTS user_profile_anonymous_visits (
PRIMARY KEY (app_code, target_user_id, visitor_user_id),
UNIQUE KEY uk_user_profile_anonymous_visit_id (app_code, target_user_id, anonymous_visit_id),
KEY idx_user_profile_anonymous_visits_target_time (app_code, target_user_id, last_visited_at_ms, anonymous_visit_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料匿名访问 owner 表';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料历史匿名访问兼容表,新匿名访问不再写入';
CREATE TABLE IF NOT EXISTS user_follows (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',

View File

@ -2,7 +2,8 @@ SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 匿名访问使用独立表,避免修改已有 user_profile_visits 热表主键或回填历史数据:
-- 该表只保留 2026-07-22 语义调整前已经产生的匿名访问记录,供列表兼容读取;
-- 新匿名访问不再写事实。继续保留表结构可保证滚动升级与历史数据读取,不需要执行高风险删表或回填:
-- 1. CREATE TABLE 不扫描、复制或锁住旧访问表,迁移成本与旧表数据量无关;
-- 2. 旧表中的所有历史记录继续作为公开访问返回,不会因用户当前 VIP 状态被倒推改写;
-- 3. 主键服务单 target/visitor 的幂等累计target_time 索引服务目标用户分页,查询不扫全表。

View File

@ -345,8 +345,8 @@ const (
)
// ProfileVisitRecord 是访问某个用户主页的去重记录。
// 公开访问使用 VisitorUserIDVisitorIdentityHidden=true 时只向上游暴露随机 AnonymousVisitID
// 真实访客 ID 仅留在 user-service 的 owner 表中用于同一目标下累计次数,绝不能进入协议转换层。
// 新的匿名访问不会再产生记录VisitorIdentityHidden/AnonymousVisitID 仅用于兼容已经落库的历史匿名事实
// 真实访客 ID 仍只留在 user-service owner 表中,绝不能进入协议转换层。
type ProfileVisitRecord struct {
VisitorUserID int64
TargetUserID int64

View File

@ -74,14 +74,6 @@ type UserRepository interface {
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
}
// AnonymousProfileVisitRepository 是匿名访问的可选持久化能力。
// 它与旧的 UserRepository 分离,避免把新增隐私表方法扩散到只覆盖基础用户接口的测试替身;
// 生产 MySQL repository 必须实现该接口,匿名写入缺失时 service 会拒绝降级为公开访问。
type AnonymousProfileVisitRepository interface {
// RecordAnonymousProfileVisit 把本次访问固化为匿名事实;同一 target/visitor 只累计同一条匿名记录。
RecordAnonymousProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, anonymousVisitID string, nowMs int64) (bool, userdomain.ProfileStats, error)
}
// PrivacyBenefitChecker 只暴露 user-service 执行资料隐私所需的单项 VIP 权益判断。
// 具体权益、体验卡排除项和用户开关均由 wallet-service 统一计算user-service 不复制 VIP 规则。
type PrivacyBenefitChecker interface {
@ -262,8 +254,6 @@ type Service struct {
appRegistryRepository AppRegistryRepository
// userRepository 持有 users 主表视角的读写能力。
userRepository UserRepository
// anonymousProfileVisitRepository 把匿名足迹保存在独立 owner 表,避免改写历史公开访问表主键。
anonymousProfileVisitRepository AnonymousProfileVisitRepository
// privacyBenefitChecker 实时读取 wallet-service 计算后的隐私权益RPC 失败时业务层按保护隐私处理。
privacyBenefitChecker PrivacyBenefitChecker
// vipBenefitDecisionChecker 执行动态头像精确权益门禁并提供 required/current level 元数据。
@ -329,10 +319,6 @@ func New(userRepository UserRepository, options ...Option) *Service {
// MySQL 用户 repository 同时持有 auth_sessions 表的同库事务能力。
svc.moderationRepository = repository
}
if repository, ok := userRepository.(AnonymousProfileVisitRepository); ok {
// MySQL 用户 repository 同时持有公开和匿名足迹表,写入路径在同一 owner 数据库内保持原子。
svc.anonymousProfileVisitRepository = repository
}
if repository, ok := userRepository.(AvatarUploadRepository); ok {
// 生产 MySQL 用户 repository 同时拥有 users 与头像授权表,凭证消费才能和资料更新保持原子。
svc.avatarUploadRepository = repository

View File

@ -2,8 +2,6 @@ package user
import (
"context"
"crypto/rand"
"encoding/hex"
"strings"
"hyapp/pkg/xerr"
@ -40,8 +38,8 @@ func (s *Service) RecordProfileVisit(ctx context.Context, requestID string, visi
return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
// 两项隐私都必须在访问发生时实时判断:目标用户的数据隐藏影响本次响应,访客匿名则决定
// 本次事实写入哪张表。目标权益查询失败时仅隐藏 stats匿名权益查询失败时中止写入既不泄漏身份也不越权授予付费匿名能力
// 两项隐私都必须在访问发生时实时判断:目标用户的数据隐藏影响本次响应;访客匿名一旦生效,
// 本次访问就不能新增或刷新任何访客事实。匿名权益查询失败时中止请求,绝不能降级公开写入泄漏身份
anonymousVisit, err := s.anonymousVisitAllowed(ctx, requestID, visitorUserID)
if err != nil {
return ProfileVisitResult{}, err
@ -49,31 +47,28 @@ func (s *Service) RecordProfileVisit(ctx context.Context, requestID string, visi
// 匿名门禁先成功,才读取目标用户的当前隐藏设置;这样 wallet 整体不可用时不做无意义第二次 RPC
// 正常链路下 target_stats 的判定也更接近最终响应时点。
targetStatsHidden := s.targetStatsMustBeHidden(ctx, requestID, targetUserID)
nowMs := s.now().UnixMilli()
var (
recorded bool
stats userdomain.ProfileStats
writeErr error
)
if anonymousVisit {
if s.anonymousProfileVisitRepository == nil {
// 匿名 owner 表缺失时必须拒绝写入,绝不能悄悄回退到公开足迹表暴露真实 visitor_user_id。
return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "anonymous profile visit repository is not configured")
// 匿名访问虽然不留足迹,仍必须确认目标用户真实存在;否则伪造 user_id 会被错误返回为成功。
if _, err := s.userRepository.GetUser(ctx, targetUserID); err != nil {
return ProfileVisitResult{}, err
}
anonymousVisitID, generateErr := newAnonymousVisitID()
if generateErr != nil {
return ProfileVisitResult{}, xerr.New(xerr.Unavailable, "anonymous visit id generation failed")
result := ProfileVisitResult{Recorded: false, TargetStatsHidden: targetStatsHidden}
if !targetStatsHidden {
// 目标资料统计来自既有聚合表,只读查询不会改变 visitors_count也不会触碰公开或历史匿名记录。
stats, err := s.userRepository.GetMyProfileStats(ctx, targetUserID)
if err != nil {
return ProfileVisitResult{}, err
}
result.TargetStats = stats
}
recorded, stats, writeErr = s.anonymousProfileVisitRepository.RecordAnonymousProfileVisit(ctx, visitorUserID, targetUserID, anonymousVisitID, nowMs)
} else {
// 当前不具备匿名权益时仍写旧公开表;已有匿名历史不会被迁移或反向暴露。
recorded, stats, writeErr = s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, nowMs)
}
if writeErr != nil {
return ProfileVisitResult{}, writeErr
return result, nil
}
// 未启用匿名权益时保持公开访问的原子写入:足迹聚合和 visitors_count 在同一事务内更新。
recorded, stats, err := s.userRepository.RecordProfileVisit(ctx, visitorUserID, targetUserID, s.now().UnixMilli())
if err != nil {
return ProfileVisitResult{}, err
}
result := ProfileVisitResult{Recorded: recorded, TargetStatsHidden: targetStatsHidden}
if !targetStatsHidden {
result.TargetStats = stats
@ -104,16 +99,6 @@ func (s *Service) anonymousVisitAllowed(ctx context.Context, requestID string, u
return allowed, nil
}
// newAnonymousVisitID 生成不含 user_id、时间或 target 信息的随机展示 ID。
// ID 在首次插入后由数据库保持不变;同一访客访问不同 target 会生成不同 ID客户端无法跨用户关联。
func newAnonymousVisitID() (string, error) {
var entropy [16]byte
if _, err := rand.Read(entropy[:]); err != nil {
return "", err
}
return "anon_" + hex.EncodeToString(entropy[:]), nil
}
func (s *Service) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
if userID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")

View File

@ -3,10 +3,8 @@ package user
import (
"context"
"database/sql"
"errors"
"strings"
mysqldriver "github.com/go-sql-driver/mysql"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
@ -69,75 +67,6 @@ func (r *Repository) RecordProfileVisit(ctx context.Context, visitorUserID int64
return inserted, stats, tx.Commit()
}
// RecordAnonymousProfileVisit 把匿名足迹固化到独立表。
// 真实 visitor_user_id 只作为 owner 内部聚合键;对外随机 ID 首次写入后永不覆盖,因此 VIP 到期也不会反向暴露历史身份。
func (r *Repository) RecordAnonymousProfileVisit(ctx context.Context, visitorUserID int64, targetUserID int64, anonymousVisitID string, nowMs int64) (bool, userdomain.ProfileStats, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return false, userdomain.ProfileStats{}, err
}
defer tx.Rollback()
appCode := appcode.FromContext(ctx)
if err := ensureSocialUser(ctx, tx, appCode, visitorUserID); err != nil {
return false, userdomain.ProfileStats{}, err
}
if err := ensureSocialUser(ctx, tx, appCode, targetUserID); err != nil {
return false, userdomain.ProfileStats{}, err
}
if err := ensureProfileStatsRow(ctx, tx, appCode, targetUserID, nowMs); err != nil {
return false, userdomain.ProfileStats{}, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO user_profile_anonymous_visits (
app_code, target_user_id, visitor_user_id, anonymous_visit_id, visit_count,
first_visited_at_ms, last_visited_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 1, ?, ?, ?)`,
appCode, targetUserID, visitorUserID, anonymousVisitID, nowMs, nowMs, nowMs,
)
inserted := err == nil
if err != nil {
if !anonymousVisitDuplicate(err) {
return false, userdomain.ProfileStats{}, err
}
// 主键冲突表示同一 target/visitor 的历史匿名记录,按主键累计且保留首次随机 ID。
// 若冲突仅来自随机 ID 唯一键,则本 visitor 主键不存在、UPDATE affected=0事务会 fail-closed 回滚。
result, updateErr := tx.ExecContext(ctx, `
UPDATE user_profile_anonymous_visits
SET visit_count = visit_count + 1, last_visited_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND target_user_id = ? AND visitor_user_id = ?`,
nowMs, nowMs, appCode, targetUserID, visitorUserID,
)
if updateErr != nil {
return false, userdomain.ProfileStats{}, updateErr
}
affected, affectedErr := result.RowsAffected()
if affectedErr != nil {
return false, userdomain.ProfileStats{}, affectedErr
}
if affected != 1 {
return false, userdomain.ProfileStats{}, xerr.New(xerr.Conflict, "anonymous visit id collision")
}
}
if inserted {
// 公开与匿名记录是两个独立展示聚合;同一真实用户先公开后匿名会各占一个访客项,
// 这样历史公开事实不会被改写,匿名阶段也不会借旧记录泄漏身份。
if _, err := tx.ExecContext(ctx, `
UPDATE user_profile_stats
SET visitors_count = visitors_count + 1, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?`,
nowMs, appCode, targetUserID,
); err != nil {
return false, userdomain.ProfileStats{}, err
}
}
stats, err := queryProfileStatsTx(ctx, tx, appCode, targetUserID)
if err != nil {
return false, userdomain.ProfileStats{}, err
}
return inserted, stats, tx.Commit()
}
func (r *Repository) ListProfileVisitors(ctx context.Context, userID int64, page int32, pageSize int32) ([]userdomain.ProfileVisitRecord, int64, error) {
// gRPC 内部调用也必须受同一有界窗口保护,不能只相信 gateway 的 HTTP 参数校验。
// page/page_size 双上限固定公开协议window 上限再防御未来任一上限被单独调整后重新引入无界物化。
@ -859,8 +788,3 @@ func socialOffset(page int32, pageSize int32) int64 {
// 转为 int64 后再相乘,避免合法 int32 page 在高页码时先于 SQL LIMIT/OFFSET 发生整数溢出。
return (int64(page) - 1) * int64(pageSize)
}
func anonymousVisitDuplicate(err error) bool {
var mysqlErr *mysqldriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

View File

@ -1468,6 +1468,7 @@ CREATE TABLE IF NOT EXISTS gift_configs (
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
presentation_json JSON NULL COMMENT '展示配置 JSON 配置或快照',
gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '礼物类型编码',
vip_level INT NOT NULL DEFAULT 0 COMMENT 'VIP/贵族礼物对应的 VIP 等级,非 VIP 礼物为 0',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间UTC epoch ms',
effect_types_json JSON NULL COMMENT '礼物特效类型 JSON 配置',
@ -1500,6 +1501,15 @@ 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 = 'gift_configs' AND COLUMN_NAME = 'vip_level') = 0,
'ALTER TABLE gift_configs ADD COLUMN vip_level INT NOT NULL DEFAULT 0 COMMENT ''VIP/贵族礼物对应的 VIP 等级,非 VIP 礼物为 0'' AFTER gift_type_code',
'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 = 'gift_configs' AND COLUMN_NAME = 'effective_from_ms') = 0,
'ALTER TABLE gift_configs ADD COLUMN effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''生效开始时间UTC epoch ms'' AFTER gift_type_code',

View File

@ -0,0 +1,18 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET time_zone = '+00:00';
USE hyapp_wallet;
-- gift_configs 是礼物目录配置表而非送礼流水。新增列使用常量默认值MySQL 8 可原地完成,
-- 不扫描 wallet_transactions、wallet_entries 等高频大表,也不猜测历史 VIP 礼物等级。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_configs' AND COLUMN_NAME = 'vip_level') = 0,
'ALTER TABLE gift_configs ADD COLUMN vip_level INT NOT NULL DEFAULT 0 COMMENT ''VIP/贵族礼物对应的 VIP 等级,非 VIP 礼物为 0'' AFTER gift_type_code, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 旧数据保持 0必须由运营在后台明确选择等级后保存根据价格、名称或排序自动回填都会制造错误权益。

View File

@ -63,10 +63,13 @@ const (
GiftTypeSuperLucky = "super_lucky"
GiftTypeExclusive = "exclusive"
GiftTypeNoble = "noble"
GiftTypeFlag = "flag"
GiftTypeActivity = "activity"
GiftTypeMagic = "magic"
GiftTypeCustom = "custom"
// GiftTypeVIP 兼容运营已创建的显式 vip 类型;历史默认“贵族礼物”使用 noble
// 两者都属于需要绑定具体 VIP 等级的礼物目录类型。
GiftTypeVIP = "vip"
GiftTypeFlag = "flag"
GiftTypeActivity = "activity"
GiftTypeMagic = "magic"
GiftTypeCustom = "custom"
GiftTypeTabKeyNormal = "Gift"
GiftTypeTabKeyCP = "CP"
@ -90,3 +93,14 @@ const (
ShopDurationThreeDays int32 = 3
ShopDurationSevenDays int32 = 7
)
// IsVIPGiftType 将历史 noble 与显式 vip 编码收敛为同一业务语义,避免运营只改
// 展示名称后,后台和客户端对是否需要 vip_level 产生不同判断。
func IsVIPGiftType(value string) bool {
switch NormalizeGiftTypeCode(value) {
case GiftTypeNoble, GiftTypeVIP:
return true
default:
return false
}
}

View File

@ -20,6 +20,8 @@ type GiftConfig struct {
GiftPointAmount int64
HeatValue int64
GiftTypeCode string
// VIPLevel 是 VIP/贵族礼物对应的 VIP 等级;普通礼物固定为 0。
VIPLevel int32
// CPRelationType 只在 gift_type_code=cp 的礼物上使用,值为 cp、brother 或 sister。
CPRelationType string
ChargeAssetType string
@ -79,6 +81,8 @@ type GiftConfigCommand struct {
HeatValue int64
EffectiveAtMS int64
GiftTypeCode string
// VIPLevel 使用独立列保存,不能藏进 presentation_json 造成服务端无法校验。
VIPLevel int32
// CPRelationType 会写入 presentation_json避免新增 gift_configs 表字段。
CPRelationType string
ChargeAssetType string

View File

@ -4812,6 +4812,70 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) {
t.Fatalf("gift config mismatch: %+v", gift)
}
_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "vip-spark-missing-level",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "VIP Spark Missing Level",
PriceVersion: "v1",
CoinPrice: 5,
GiftTypeCode: resourcedomain.GiftTypeNoble,
OperatorUserID: 90001,
})
if !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("expected INVALID_ARGUMENT for VIP gift without level, got %v", err)
}
vipGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
GiftID: "vip-spark",
ResourceID: giftResource.ResourceID,
Status: resourcedomain.StatusActive,
Name: "VIP Spark",
PriceVersion: "v1",
CoinPrice: 5,
GiftTypeCode: resourcedomain.GiftTypeNoble,
VIPLevel: 3,
OperatorUserID: 90001,
})
if err != nil {
t.Fatalf("create VIP gift config failed: %v", err)
}
if vipGift.GiftTypeCode != resourcedomain.GiftTypeNoble || vipGift.VIPLevel != 3 {
t.Fatalf("VIP gift level must round-trip from MySQL: %+v", vipGift)
}
repository.SetBalance(41003, 100)
_, err = svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-vip-spark-denied",
RoomID: "room-1",
SenderUserID: 41003,
TargetUserID: 41002,
GiftID: "vip-spark",
GiftCount: 1,
})
if !xerr.IsCode(err, xerr.VIPGiftLevelRequired) {
t.Fatalf("expected VIP_GIFT_LEVEL_REQUIRED below configured level, got %v", err)
}
if detail, ok := xerr.As(err); !ok || detail.Metadata["required_level"] != "3" || detail.Metadata["current_effective_level"] != "0" {
t.Fatalf("VIP gift denial must expose stable level metadata, got %+v", detail)
}
assertBalance(t, svc, 41003, ledger.AssetCoin, 100)
nowMS := time.Now().UnixMilli()
repository.SeedPaidVIPAtWindow(appcode.Default, 41003, 3, nowMS-1000, nowMS+60_000)
vipReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-vip-spark-allowed",
RoomID: "room-1",
SenderUserID: 41003,
TargetUserID: 41002,
GiftID: "vip-spark",
GiftCount: 1,
})
if err != nil {
t.Fatalf("DebitGift must allow sender at configured VIP level: %v", err)
}
if vipReceipt.CoinSpent != 5 {
t.Fatalf("VIP gift settlement mismatch: %+v", vipReceipt)
}
repository.SetBalance(41001, 100)
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
CommandID: "cmd-spark-gift",

View File

@ -82,6 +82,9 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
return ledger.BatchGiftReceipt{}, err
}
giftConfig := resolvedSource.GiftConfig
if err := r.requireVIPGiftLevel(ctx, tx, giftConfig, command.SenderUserID, nowMs); err != nil {
return ledger.BatchGiftReceipt{}, err
}
// 批量钱包命令只承载真实用户多目标扣费,不存在 RobotGift 语义;同一 sender 共享一次查询,
// 每个目标交易固化同一快照,避免 room-service 拆成多个 lucky draw 时因目标顺序或重试时刻跨层。
rechargeSnapshot, err := r.getLuckyGiftRechargeSnapshot(ctx, tx, command.SenderUserID, giftConfig.GiftTypeCode, nowMs)

View File

@ -249,6 +249,7 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) {
&gift.SortOrder,
&gift.PresentationJSON,
&gift.GiftTypeCode,
&gift.VIPLevel,
&gift.EffectiveFromMS,
&gift.EffectiveToMS,
&effectTypesJSON,
@ -295,7 +296,8 @@ func giftConfigSelectSQL() string {
return `
SELECT gc.app_code, gc.gift_id, gc.resource_id, gc.status, gc.name, gc.sort_order,
COALESCE(CAST(gc.presentation_json AS CHAR), '{}'),
COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0),
COALESCE(gc.gift_type_code, 'normal'), COALESCE(gc.vip_level, 0),
COALESCE(gc.effective_from_ms, 0), COALESCE(gc.effective_to_ms, 0),
COALESCE(CAST(gc.effect_types_json AS CHAR), '[]'),
gc.created_by_user_id, gc.updated_by_user_id, gc.created_at_ms, gc.updated_at_ms,
` + resourceColumnsWithAlias("r") + `

View File

@ -173,11 +173,11 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai
result, err := tx.ExecContext(ctx, `
UPDATE gift_configs
SET resource_id = ?, status = ?, name = ?, sort_order = ?, presentation_json = ?,
gift_type_code = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?,
gift_type_code = ?, vip_level = ?, effective_from_ms = ?, effective_to_ms = ?, effect_types_json = ?,
updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND gift_id = ?`,
command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
command.GiftTypeCode, command.VIPLevel, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
command.OperatorUserID, nowMs, command.AppCode, command.GiftID,
)
if err != nil {
@ -221,11 +221,11 @@ func (r *Repository) createGiftConfigTx(ctx context.Context, tx *sql.Tx, command
if _, err := tx.ExecContext(ctx, `
INSERT INTO gift_configs (
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
gift_type_code, vip_level, effective_from_ms, effective_to_ms, effect_types_json,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode, command.GiftID, command.ResourceID, command.Status, command.Name, command.SortOrder, presentation,
command.GiftTypeCode, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
command.GiftTypeCode, command.VIPLevel, command.EffectiveFromMS, command.EffectiveToMS, string(effectTypesJSON),
command.OperatorUserID, command.OperatorUserID, nowMs, nowMs,
); err != nil {
return resourcedomain.GiftConfig{}, err
@ -265,5 +265,19 @@ func (r *Repository) prepareGiftConfigCommandTx(ctx context.Context, tx *sql.Tx,
} else if !ok {
return xerr.New(xerr.InvalidArgument, "gift type is not configured")
}
if resourcedomain.IsVIPGiftType(command.GiftTypeCode) {
// vip_levels 主键以 (app_code, level) 点查,保存时拒绝不存在的等级,避免后台
// 写入客户端永远无法匹配的 VIP 礼物配置;不要求等级启用,以兼容预配置流程。
var exists int
if err := tx.QueryRowContext(ctx, `
SELECT 1 FROM vip_levels WHERE app_code = ? AND level = ? LIMIT 1`,
command.AppCode, command.VIPLevel,
).Scan(&exists); err != nil {
if err == sql.ErrNoRows {
return xerr.New(xerr.InvalidArgument, "vip gift level is not configured")
}
return err
}
}
return nil
}

View File

@ -18,6 +18,11 @@ func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resour
command.PriceVersion = strings.TrimSpace(command.PriceVersion)
command.RegionIDs = normalizeRegionIDs(command.RegionIDs)
command.GiftTypeCode = resourcedomain.NormalizeGiftTypeCode(command.GiftTypeCode)
// 非 VIP 礼物不保留陈旧等级;类型切换后服务端主动归零,保证客户端只需按
// gift_type_code + vip_level 解释目录,不会看到普通礼物携带误导性的等级。
if !resourcedomain.IsVIPGiftType(command.GiftTypeCode) {
command.VIPLevel = 0
}
command.ChargeAssetType = strings.ToUpper(strings.TrimSpace(command.ChargeAssetType))
if command.ChargeAssetType == "" {
command.ChargeAssetType = ledger.AssetCoin
@ -50,6 +55,9 @@ func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error {
if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) {
return xerr.New(xerr.InvalidArgument, "gift type is invalid")
}
if resourcedomain.IsVIPGiftType(command.GiftTypeCode) && command.VIPLevel <= 0 {
return xerr.New(xerr.InvalidArgument, "vip gift level is required")
}
if command.CPRelationType != "" && command.GiftTypeCode != resourcedomain.GiftTypeCP {
return xerr.New(xerr.InvalidArgument, "cp relation type requires cp gift type")
}

View File

@ -64,6 +64,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
return ledger.Receipt{}, err
}
giftConfig := resolvedSource.GiftConfig
if !command.RobotGift {
if err := r.requireVIPGiftLevel(ctx, tx, giftConfig, command.SenderUserID, nowMs); err != nil {
return ledger.Receipt{}, err
}
}
price := resolvedSource.Price
chargeAssetType := price.ChargeAssetType
if command.RobotGift {

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"hyapp/pkg/appcode"
@ -26,6 +27,41 @@ type resolvedGiftDebitSource struct {
Entitlement *resourcedomain.UserResourceEntitlement
}
// requireVIPGiftLevel 在任何扣币或背包消耗前校验 Wallet owner 的 effective VIP。
// paid 与已佩戴体验卡的覆盖顺序必须和 GetVipState 一致;错误 metadata 是客户端国际化提示的稳定契约,
// 不能把服务端英文 message 当作展示文案。查询复用 (app_code,user_id) 主键和现有体验卡索引,
// 单次送礼固定为一条 program 主键查询及一条 paid/trial 合并查询,不随礼物数量增长。
func (r *Repository) requireVIPGiftLevel(ctx context.Context, tx *sql.Tx, gift resourcedomain.GiftConfig, senderUserID int64, nowMS int64) error {
if !resourcedomain.IsVIPGiftType(gift.GiftTypeCode) || gift.VIPLevel <= 0 {
return nil
}
currentLevel := int32(0)
program, err := r.requireVipProgramConfig(ctx, tx)
if err != nil {
return err
}
if program.Status == ledger.VipStatusActive {
levels, queryErr := r.batchVipEffectiveLevelsFromSnapshot(ctx, tx, []int64{senderUserID}, nowMS)
if queryErr != nil {
return queryErr
}
access := levels[senderUserID]
currentLevel = access.Paid
// 已佩戴体验卡是显式身份选择,即使等级低于付费 VIP也必须保持现有 effective VIP 语义。
if program.TrialCardEnabled && access.Trial > 0 {
currentLevel = access.Trial
}
}
if currentLevel >= gift.VIPLevel {
return nil
}
return xerr.NewWithMetadata(xerr.VIPGiftLevelRequired, "vip level is required for this gift", map[string]string{
"gift_id": strings.TrimSpace(gift.GiftID),
"required_level": fmt.Sprintf("%d", gift.VIPLevel),
"current_effective_level": fmt.Sprintf("%d", currentLevel),
})
}
// resolveGiftDebitSource is the only price authority for send-gift. Coin/legacy bag gifts use the live catalog;
// pinned bag gifts use the wallet-owned entitlement bytes and deliberately ignore caller price_version.
func (r *Repository) resolveGiftDebitSource(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, quantity int64, nowMS int64) (resolvedGiftDebitSource, error) {

View File

@ -597,6 +597,9 @@ func validateResourceCommand(command resourcedomain.ResourceCommand) error {
if err := validateRoomDecorationResource(command); err != nil {
return err
}
if err := validateVIPTrialCardCatalogResource(command); err != nil {
return err
}
if command.ResourceType == resourcedomain.TypeCoin {
if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN")
@ -622,6 +625,28 @@ func validateResourceCommand(command resourcedomain.ResourceCommand) error {
return nil
}
func validateVIPTrialCardCatalogResource(command resourcedomain.ResourceCommand) error {
if command.ResourceType != resourcedomain.TypeVIPTrialCard {
return nil
}
// 体验卡的等级和固定时长属于可发放事实,必须由 Wallet owner 校验,不能只依赖
// Admin 隐藏价格/素材控件;活动快照和资源组随后都直接消费这份 metadata。
metadata, err := vipTrialCardResourceMetadata(command.MetadataJSON)
if err != nil || metadata.Level <= 0 || metadata.DurationMS <= 0 {
return xerr.New(xerr.InvalidArgument, "vip trial card level and duration are required")
}
if command.GrantStrategy != resourcedomain.GrantStrategyNewEntitlement {
return xerr.New(xerr.InvalidArgument, "vip trial card must use new_entitlement strategy")
}
if command.PriceType != resourcedomain.PriceTypeFree || command.CoinPrice != 0 || command.GiftPointAmount != 0 {
return xerr.New(xerr.InvalidArgument, "vip trial card must be free")
}
if command.AssetURL != "" || command.PreviewURL != "" || command.AnimationURL != "" {
return xerr.New(xerr.InvalidArgument, "vip trial card cannot contain visual assets")
}
return nil
}
func validateRoomDecorationResource(command resourcedomain.ResourceCommand) error {
switch command.ResourceType {
case resourcedomain.TypeMicSeatAnimation, resourcedomain.TypeRoomBorder:

View File

@ -45,9 +45,6 @@ func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.G
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
if resourcedomain.NormalizeResourceType(resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "vip_trial_card must use GrantVipTrialCard")
}
if err := validateGrantableResourceForSource(resource, command.GrantSource); err != nil {
return resourcedomain.ResourceGrant{}, err
}
@ -55,12 +52,32 @@ func (r *Repository) GrantResource(ctx context.Context, command resourcedomain.G
if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", command.ResourceID), requestHash, "", command.Reason, command.OperatorUserID, nowMs); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil {
outboxEvents := []walletOutboxEvent{
resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs),
}
if resourcedomain.NormalizeResourceType(resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
if command.Quantity != 1 {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "vip trial card quantity must be 1")
}
metadata, durationMS, err := resolveVipTrialCardGrantFacts(resource, command.DurationMS)
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
GrantID: grantID, CommandID: command.CommandID,
RequestHash: vipTrialCardGrantItemHash(grantID, command.CommandID, command.TargetUserID, resource.ResourceID, durationMS),
TargetUserID: command.TargetUserID, Resource: resource, ExpectedLevel: metadata.Level,
DurationMS: durationMS, GrantSource: command.GrantSource, OperatorUserID: command.OperatorUserID,
Reason: command.Reason, NowMS: nowMs, ValidateGrantSource: true,
})
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
outboxEvents = append(outboxEvents, vipTrialCardGrantedEvent(command.CommandID, result, command.TargetUserID, resource.ResourceID, nowMs))
} else if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, command.Quantity, command.DurationMS, nowMs); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, command.ResourceID, map[string]any{"grant_id": grantID, "resource": resource}, nowMs),
}); err != nil {
if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if err := tx.Commit(); err != nil {
@ -116,6 +133,7 @@ func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedom
if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.TargetUserID, command.GrantSource, resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", command.GroupID), requestHash, string(groupSnapshot), command.Reason, command.OperatorUserID, nowMs); err != nil {
return resourcedomain.ResourceGrant{}, err
}
outboxEvents := make([]walletOutboxEvent, 0, len(group.Items)+1)
for _, item := range group.Items {
if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset {
if _, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item, nowMs); err != nil {
@ -123,19 +141,34 @@ func (r *Repository) GrantResourceGroup(ctx context.Context, command resourcedom
}
continue
}
if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.InvalidArgument, "resource group cannot grant vip_trial_card")
}
if err := validateActiveResourceGroupResource(item.Resource); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
metadata, durationMS, err := resolveVipTrialCardGrantFacts(item.Resource, item.DurationMS)
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
itemCommandID := resourceGroupVipTrialCardCommandID(command.CommandID, item.GroupItemID, item.ResourceID)
result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
GrantID: grantID, CommandID: itemCommandID,
RequestHash: vipTrialCardGrantItemHash(grantID, itemCommandID, command.TargetUserID, item.ResourceID, durationMS),
TargetUserID: command.TargetUserID, Resource: item.Resource, ExpectedLevel: metadata.Level,
DurationMS: durationMS, GrantSource: command.GrantSource, OperatorUserID: command.OperatorUserID,
Reason: command.Reason, NowMS: nowMs, ValidateGrantSource: true,
})
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
outboxEvents = append(outboxEvents, vipTrialCardGrantedEvent(itemCommandID, result, command.TargetUserID, item.ResourceID, nowMs))
continue
}
if _, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, item.Resource, item.Quantity, item.DurationMS, nowMs); err != nil {
return resourcedomain.ResourceGrant{}, err
}
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs),
}); err != nil {
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, command.GroupID, map[string]any{"grant_id": grantID, "group": group}, nowMs))
if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if err := tx.Commit(); err != nil {
@ -194,6 +227,7 @@ func (r *Repository) GrantPinnedResourceGroup(ctx context.Context, command resou
command.Reason, command.OperatorUserID, nowMS); err != nil {
return resourcedomain.ResourceGrant{}, err
}
outboxEvents := make([]walletOutboxEvent, 0, len(group.Items)+1)
for _, item := range group.Items {
if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset {
if item.ResourceID != 0 || item.Quantity != 1 || item.DurationMS != 0 || !validGroupWalletAssetType(item.WalletAssetType) || item.WalletAssetAmount <= 0 {
@ -204,15 +238,43 @@ func (r *Repository) GrantPinnedResourceGroup(ctx context.Context, command resou
}
continue
}
if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot contains unsupported vip_trial_card")
}
if item.ResourceID <= 0 || item.Resource.ResourceID != item.ResourceID || item.Quantity <= 0 || item.DurationMS < 0 || item.WalletAssetType != "" || item.WalletAssetAmount != 0 {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot contains an invalid resource item")
}
if err := validatePinnedResourceGroupResource(item.Resource); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
metadata, durationMS, err := resolveVipTrialCardGrantFacts(item.Resource, item.DurationMS)
if err != nil {
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Internal, "resource group snapshot contains invalid vip_trial_card: "+err.Error())
}
resourceSnapshotJSON, err := resourcedomain.CanonicalResourceJSON(item.Resource)
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
resourceSnapshotHash, err := resourcedomain.ResourceContentHash(item.Resource)
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
itemCommandID := resourceGroupVipTrialCardCommandID(command.CommandID, item.GroupItemID, item.ResourceID)
result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
GrantID: grantID, CommandID: itemCommandID,
RequestHash: vipTrialCardGrantItemHash(grantID, itemCommandID, command.TargetUserID, item.ResourceID, durationMS),
TargetUserID: command.TargetUserID, Resource: item.Resource, ExpectedLevel: metadata.Level,
DurationMS: durationMS, GrantSource: command.GrantSource, OperatorUserID: command.OperatorUserID,
Reason: command.Reason, NowMS: nowMS, ValidateGrantSource: false,
EntitlementSnapshot: resourceEntitlementSnapshot{
SourceSnapshotID: snapshot.SnapshotID, ResourceSnapshotHash: resourceSnapshotHash,
ResourceSnapshotJSON: string(resourceSnapshotJSON),
},
})
if err != nil {
return resourcedomain.ResourceGrant{}, err
}
outboxEvents = append(outboxEvents, vipTrialCardGrantedEvent(itemCommandID, result, command.TargetUserID, item.ResourceID, nowMS))
continue
}
giftSnapshotJSON := ""
giftSnapshotHash := ""
if resourcedomain.NormalizeResourceType(item.Resource.ResourceType) == resourcedomain.TypeGift {
@ -248,13 +310,12 @@ func (r *Repository) GrantPinnedResourceGroup(ctx context.Context, command resou
return resourcedomain.ResourceGrant{}, err
}
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, snapshot.SourceGroupID, map[string]any{
"grant_id": grantID, "snapshot_id": snapshot.SnapshotID, "snapshot_hash": snapshot.SnapshotHash,
"snapshot_version": snapshot.VersionNo, "grant_subject_type": resourcedomain.GrantSubjectGroupSnapshot,
"grant_subject_id": snapshot.SnapshotID, "group": group,
}, nowMS),
}); err != nil {
outboxEvents = append(outboxEvents, resourceOutboxEvent("ResourceGroupGranted", command.CommandID, command.TargetUserID, snapshot.SourceGroupID, map[string]any{
"grant_id": grantID, "snapshot_id": snapshot.SnapshotID, "snapshot_hash": snapshot.SnapshotHash,
"snapshot_version": snapshot.VersionNo, "grant_subject_type": resourcedomain.GrantSubjectGroupSnapshot,
"grant_subject_id": snapshot.SnapshotID, "group": group,
}, nowMS))
if err := r.insertWalletOutbox(ctx, tx, outboxEvents); err != nil {
return resourcedomain.ResourceGrant{}, err
}
if err := tx.Commit(); err != nil {
@ -1078,6 +1139,38 @@ func groupWalletAssetCreditHash(grantID string, groupItemID int64, assetType str
return stableHash(fmt.Sprintf("resource_group_wallet_asset_credit|%s|%d|%s|%d", grantID, groupItemID, resourcedomain.NormalizeWalletAssetType(assetType), amount))
}
func resolveVipTrialCardGrantFacts(resource resourcedomain.Resource, requestedDurationMS int64) (vipTrialCardMetadata, int64, error) {
metadata, err := vipTrialCardResourceMetadata(resource.MetadataJSON)
if err != nil || metadata.Level <= 0 {
return vipTrialCardMetadata{}, 0, xerr.New(xerr.Conflict, "vip trial card resource metadata is invalid")
}
// 新资源把天数固化在 metadata历史标准资源只有 vip_level继续兼容调用方或
// 老资源组明确携带的 duration_ms避免上线后让既有专用发卡和已发布活动失效。
durationMS := metadata.DurationMS
if durationMS <= 0 {
durationMS = requestedDurationMS
}
if durationMS <= 0 {
return vipTrialCardMetadata{}, 0, xerr.New(xerr.InvalidArgument, "vip trial card duration is required")
}
return metadata, durationMS, nil
}
func resourceGroupVipTrialCardCommandID(parentCommandID string, groupItemID int64, resourceID int64) string {
hash := stableHash(fmt.Sprintf("resource_group_vip_trial_card|%s|%d|%d", strings.TrimSpace(parentCommandID), groupItemID, resourceID))
return fmt.Sprintf("rgg_vip:%d:%d:%s", groupItemID, resourceID, hash[:24])
}
func vipTrialCardGrantItemHash(grantID string, commandID string, targetUserID int64, resourceID int64, durationMS int64) string {
return stableHash(fmt.Sprintf("vip_trial_card_grant_item|%s|%s|%d|%d|%d", grantID, commandID, targetUserID, resourceID, durationMS))
}
func vipTrialCardGrantedEvent(commandID string, result vipTrialCardGrantItemResult, targetUserID int64, resourceID int64, nowMS int64) walletOutboxEvent {
return resourceOutboxEvent("VipTrialCardGranted", commandID, targetUserID, resourceID, map[string]any{
"trial_card": result.Card, "program_type": result.Program.ProgramType, "config_version": result.Program.ConfigVersion,
}, nowMS)
}
func resourceGroupWalletAssetCommandID(commandID string, groupItemID int64, assetType string) string {
// wallet_transactions.command_id 是对外幂等键字段,长度固定为 128CP 周榜等上游命令本身可能很长,
// 组内钱包资产 item 使用稳定短 hash 生成内部交易命令,同时在 metadata.command_id 保留完整上游命令用于审计。

View File

@ -356,6 +356,16 @@ func (r *Repository) replaceResourceGroupItemsTx(ctx context.Context, tx *sql.Tx
return err
}
}
if resourcedomain.NormalizeResourceType(resource.ResourceType) == resourcedomain.TypeVIPTrialCard {
// 体验卡天数属于资源本身;资源组只引用该承诺,不能用条目里的任意天数
// 悄悄改写。仅对没有 duration_days 的历史标准资源保留旧条目时长。
_, durationMS, err := resolveVipTrialCardGrantFacts(resource, item.DurationMS)
if err != nil {
return err
}
item.Quantity = 1
item.DurationMS = durationMS
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO resource_group_items (
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,

View File

@ -21,14 +21,29 @@ type activeVipBenefitResource struct {
Resource resourcedomain.Resource
}
// listActiveVipBenefitResourcesTx 只返回当前最终来源真正可用的资源。trial 必须同时满足
// trial_enabled且金币返现即使被历史脏配置绑定资源也会硬排除paid 则读取全部 active benefit。
// 先走配置覆盖索引,再按 resource 主键加锁,保证 admin 改绑和发放处于同一配置版本边界。
// listActiveVipBenefitResourcesTx 只返回当前最终等级真正可用的资源。权益和自动佩戴仍遵守
// target_only不能因为付费购买会发放低等级历史物品就把低等级样式当成当前有效权益。
func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, level int32, effectiveSource string) ([]activeVipBenefitResource, error) {
return r.listVipBenefitResourcesTx(ctx, tx, level, effectiveSource, false)
}
// listPaidVipRewardResourcesTx 收集目标等级及其以下每一级显式绑定的物品。这里只改变付费 VIP
// 的背包发放集合effective_benefits、佩戴门禁和 trial 仍只读当前等级的完整配置。
func (r *Repository) listPaidVipRewardResourcesTx(ctx context.Context, tx *sql.Tx, level int32) ([]activeVipBenefitResource, error) {
return r.listVipBenefitResourcesTx(ctx, tx, level, ledger.VipEffectiveSourcePaid, true)
}
// listVipBenefitResourcesTx 先走 vip_level_benefits 的 (app_code, level, benefit_code) 主键范围,
// 查询上界受 program.level_count 和购买校验约束;再按 resource 主键加锁,保证 admin 改绑与发放处于同一事务边界。
func (r *Repository) listVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, level int32, effectiveSource string, includeLowerLevels bool) ([]activeVipBenefitResource, error) {
if level <= 0 || (effectiveSource != ledger.VipEffectiveSourcePaid && effectiveSource != ledger.VipEffectiveSourceTrial) {
return nil, nil
}
trialOnly := effectiveSource == ledger.VipEffectiveSourceTrial
levelPredicate := `b.level = ?`
if includeLowerLevels {
levelPredicate = `b.level <= ?`
}
rows, err := tx.QueryContext(ctx, `
SELECT MIN(b.benefit_code), MAX(b.auto_equip), b.resource_id
FROM vip_level_benefits AS b
@ -36,7 +51,7 @@ func (r *Repository) listActiveVipBenefitResourcesTx(ctx context.Context, tx *sq
AND resource.resource_id = b.resource_id
AND resource.status = 'active'
AND resource.resource_type = b.resource_type
WHERE b.app_code = ? AND b.level = ? AND b.status = 'active' AND b.resource_id > 0
WHERE b.app_code = ? AND `+levelPredicate+` AND b.status = 'active' AND b.resource_id > 0
AND (? = FALSE OR (b.trial_enabled = TRUE AND b.benefit_code <> ?))
GROUP BY b.resource_id
ORDER BY b.resource_id ASC`, appcode.FromContext(ctx), level, trialOnly, ledger.VipBenefitCodeDailyCoinRebate)
@ -157,14 +172,14 @@ func (r *Repository) grantVipTrialBenefitResourcesTx(ctx context.Context, tx *sq
})
}
// grantPaidVipBenefitResourcesTx 直接从当前 active benefit bindings 发付费 entitlement彻底
// 去掉“运营还要同步 reward group”的隐含前置条件。grant subject 绑定本次会员有效窗;配置改绑
// 只补新资源,未变化资源复用同一有效窗已有 entitlement
// grantPaidVipBenefitResourcesTx 直接从 active benefit bindings 发付费 entitlement彻底去掉
// “运营还要同步 reward group”的隐含前置条件。直购高等级时要把 1..level 各级物品都发入背包,
// 但同一 resource_id 只发一次grant subject 仍绑定本次会员有效窗,不扩大当前权益和佩戴集合
func (r *Repository) grantPaidVipBenefitResourcesTx(ctx context.Context, tx *sql.Tx, vip ledger.UserVip, nowMS int64) ([]ledger.VipRewardItem, string, error) {
if !vip.Active || vip.Level <= 0 || vip.ExpiresAtMS <= nowMS {
return nil, "", nil
}
items, err := r.listActiveVipBenefitResourcesTx(ctx, tx, vip.Level, ledger.VipEffectiveSourcePaid)
items, err := r.listPaidVipRewardResourcesTx(ctx, tx, vip.Level)
if err != nil || len(items) == 0 {
return nil, "", err
}

View File

@ -14,6 +14,27 @@ import (
"time"
)
const vipTrialCardDayMS int64 = 24 * 60 * 60 * 1000
type vipTrialCardMetadata struct {
Level int32
DurationMS int64
}
func vipTrialCardResourceMetadata(metadataJSON string) (vipTrialCardMetadata, error) {
var payload struct {
VIPLevel int32 `json:"vip_level"`
DurationDays int64 `json:"duration_days"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &payload); err != nil {
return vipTrialCardMetadata{}, err
}
if payload.DurationDays < 0 || payload.DurationDays > 36500 {
return vipTrialCardMetadata{}, fmt.Errorf("vip trial card duration_days is invalid")
}
return vipTrialCardMetadata{Level: payload.VIPLevel, DurationMS: payload.DurationDays * vipTrialCardDayMS}, nil
}
// GrantVipTrialCard 原子写入资源发放、背包 entitlement、体验卡元数据和 outbox。
// 发卡时即开始绝对计时,但不会自动佩戴;切卡因此不会暂停、延长或销毁任何卡片。
func (r *Repository) GrantVipTrialCard(ctx context.Context, command ledger.GrantVipTrialCardCommand) (ledger.GrantVipTrialCardReceipt, error) {
@ -55,26 +76,6 @@ func (r *Repository) GrantVipTrialCard(ctx context.Context, command ledger.Grant
return ledger.GrantVipTrialCardReceipt{}, err
}
program, err := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL()+` LOCK IN SHARE MODE`, command.AppCode))
if errors.Is(err, sql.ErrNoRows) {
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.NotFound, "vip program config not found")
}
if err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
if program.Status != ledger.VipStatusActive || !program.TrialCardEnabled || program.GrantMode != ledger.VipGrantModeTrialCard {
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.Conflict, "vip trial card is disabled for this app")
}
if command.Level > program.LevelCount {
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.InvalidArgument, "vip trial card level exceeds program level_count")
}
level, err := r.getVipLevelForUpdate(ctx, tx, command.Level)
if err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
if level.Status != ledger.VipStatusActive {
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled")
}
resourceID, err := r.resolveVipTrialCardResourceID(ctx, tx, command.Level, command.ResourceID)
if err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
@ -83,13 +84,6 @@ func (r *Repository) GrantVipTrialCard(ctx context.Context, command ledger.Grant
if err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
if err := validateVipTrialCardResource(resource, command.Level); err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
if err := validateGrantableResourceForSource(resource, vipResourceGrantSource(command.GrantSource)); err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
grantID := resourceGrantID(command.AppCode, command.CommandID)
if err := r.insertResourceGrant(
ctx,
@ -115,63 +109,17 @@ func (r *Repository) GrantVipTrialCard(ctx context.Context, command ledger.Grant
}
return ledger.GrantVipTrialCardReceipt{}, err
}
item, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.TargetUserID, resource, 1, command.DurationMS, nowMS)
result, err := r.grantVipTrialCardItemTx(ctx, tx, vipTrialCardGrantItemCommand{
GrantID: grantID, CommandID: command.CommandID, RequestHash: requestHash,
TargetUserID: command.TargetUserID, Resource: resource, ExpectedLevel: command.Level,
DurationMS: command.DurationMS, GrantSource: command.GrantSource,
OperatorUserID: command.OperatorUserID, Reason: command.Reason, NowMS: nowMS,
ValidateGrantSource: true,
})
if err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
if item.EntitlementID == "" {
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.LedgerConflict, "vip trial card did not create entitlement")
}
expiresAtMS := nowMS + command.DurationMS
trialCardID := "vip_card_" + stableHash(command.AppCode+"|"+command.CommandID)
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_vip_trial_cards (
app_code, trial_card_id, command_id, request_hash, user_id, entitlement_id,
resource_id, level, name, status, duration_ms, effective_at_ms, expires_at_ms,
grant_source, source_grant_id, operator_user_id, reason, program_type,
config_version, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode,
trialCardID,
command.CommandID,
requestHash,
command.TargetUserID,
item.EntitlementID,
resource.ResourceID,
level.Level,
level.Name,
ledger.VipTrialCardStatusActive,
command.DurationMS,
nowMS,
expiresAtMS,
command.GrantSource,
grantID,
command.OperatorUserID,
command.Reason,
program.ProgramType,
program.ConfigVersion,
nowMS,
nowMS,
); err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
}
card := ledger.VipTrialCard{
TrialCardID: trialCardID,
EntitlementID: item.EntitlementID,
ResourceID: resource.ResourceID,
UserID: command.TargetUserID,
Level: level.Level,
Name: level.Name,
Status: ledger.VipTrialCardStatusActive,
DurationMS: command.DurationMS,
EffectiveAtMS: nowMS,
ExpiresAtMS: expiresAtMS,
RemainingDurationMS: command.DurationMS,
GrantSource: command.GrantSource,
SourceGrantID: grantID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
card := result.Card
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
resourceOutboxEvent("ResourceGranted", command.CommandID, command.TargetUserID, resource.ResourceID, map[string]any{
"grant_id": grantID,
@ -179,8 +127,8 @@ func (r *Repository) GrantVipTrialCard(ctx context.Context, command ledger.Grant
}, nowMS),
resourceOutboxEvent("VipTrialCardGranted", command.CommandID, command.TargetUserID, resource.ResourceID, map[string]any{
"trial_card": card,
"program_type": program.ProgramType,
"config_version": program.ConfigVersion,
"program_type": result.Program.ProgramType,
"config_version": result.Program.ConfigVersion,
}, nowMS),
}); err != nil {
return ledger.GrantVipTrialCardReceipt{}, err
@ -195,6 +143,103 @@ func (r *Repository) GrantVipTrialCard(ctx context.Context, command ledger.Grant
return ledger.GrantVipTrialCardReceipt{TrialCard: card, State: state, ServerTimeMS: nowMS}, nil
}
type vipTrialCardGrantItemCommand struct {
GrantID string
CommandID string
RequestHash string
TargetUserID int64
Resource resourcedomain.Resource
ExpectedLevel int32
DurationMS int64
GrantSource string
OperatorUserID int64
Reason string
NowMS int64
EntitlementSnapshot resourceEntitlementSnapshot
ValidateGrantSource bool
}
type vipTrialCardGrantItemResult struct {
Item resourcedomain.ResourceGrantItem
Card ledger.VipTrialCard
Program ledger.VipProgramConfig
}
// grantVipTrialCardItemTx 是专用发卡、通用资源赠送和资源组展开共用的事务内原语。
// 它同时写 entitlement 与 user_vip_trial_cards确保任何入口都不会产生只有背包资源、
// 没有 VIP 等级投影的半成品体验卡;父级 grant/outbox 仍由各入口按自己的审计语义负责。
func (r *Repository) grantVipTrialCardItemTx(ctx context.Context, tx *sql.Tx, command vipTrialCardGrantItemCommand) (vipTrialCardGrantItemResult, error) {
if command.DurationMS <= 0 || command.ExpectedLevel <= 0 {
return vipTrialCardGrantItemResult{}, xerr.New(xerr.InvalidArgument, "vip trial card level and duration are required")
}
program, err := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL()+` LOCK IN SHARE MODE`, appcode.FromContext(ctx)))
if errors.Is(err, sql.ErrNoRows) {
return vipTrialCardGrantItemResult{}, xerr.New(xerr.NotFound, "vip program config not found")
}
if err != nil {
return vipTrialCardGrantItemResult{}, err
}
if program.Status != ledger.VipStatusActive || !program.TrialCardEnabled || program.GrantMode != ledger.VipGrantModeTrialCard {
return vipTrialCardGrantItemResult{}, xerr.New(xerr.Conflict, "vip trial card is disabled for this app")
}
if command.ExpectedLevel > program.LevelCount {
return vipTrialCardGrantItemResult{}, xerr.New(xerr.InvalidArgument, "vip trial card level exceeds program level_count")
}
level, err := r.getVipLevelForUpdate(ctx, tx, command.ExpectedLevel)
if err != nil {
return vipTrialCardGrantItemResult{}, err
}
if level.Status != ledger.VipStatusActive {
return vipTrialCardGrantItemResult{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled")
}
if err := validateVipTrialCardResource(command.Resource, command.ExpectedLevel); err != nil {
return vipTrialCardGrantItemResult{}, err
}
if command.ValidateGrantSource {
if err := validateGrantableResourceForSource(command.Resource, vipResourceGrantSource(command.GrantSource)); err != nil {
return vipTrialCardGrantItemResult{}, err
}
}
item, err := r.applyGrantItemWithEntitlementSnapshotOptions(
ctx, tx, command.GrantID, command.CommandID, command.TargetUserID, command.Resource,
1, command.DurationMS, command.NowMS, resourcedomain.GrantStrategyNewEntitlement,
command.EntitlementSnapshot, false,
)
if err != nil {
return vipTrialCardGrantItemResult{}, err
}
if item.EntitlementID == "" {
return vipTrialCardGrantItemResult{}, xerr.New(xerr.LedgerConflict, "vip trial card did not create entitlement")
}
expiresAtMS := command.NowMS + command.DurationMS
trialCardID := "vip_card_" + stableHash(appcode.FromContext(ctx)+"|"+command.CommandID)
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_vip_trial_cards (
app_code, trial_card_id, command_id, request_hash, user_id, entitlement_id,
resource_id, level, name, status, duration_ms, effective_at_ms, expires_at_ms,
grant_source, source_grant_id, operator_user_id, reason, program_type,
config_version, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.FromContext(ctx), trialCardID, command.CommandID, command.RequestHash,
command.TargetUserID, item.EntitlementID, command.Resource.ResourceID,
level.Level, level.Name, ledger.VipTrialCardStatusActive, command.DurationMS,
command.NowMS, expiresAtMS, command.GrantSource, command.GrantID,
command.OperatorUserID, command.Reason, program.ProgramType, program.ConfigVersion,
command.NowMS, command.NowMS,
); err != nil {
return vipTrialCardGrantItemResult{}, err
}
card := ledger.VipTrialCard{
TrialCardID: trialCardID, EntitlementID: item.EntitlementID, ResourceID: command.Resource.ResourceID,
UserID: command.TargetUserID, Level: level.Level, Name: level.Name,
Status: ledger.VipTrialCardStatusActive, DurationMS: command.DurationMS,
EffectiveAtMS: command.NowMS, ExpiresAtMS: expiresAtMS, RemainingDurationMS: command.DurationMS,
GrantSource: command.GrantSource, SourceGrantID: command.GrantID,
CreatedAtMS: command.NowMS, UpdatedAtMS: command.NowMS,
}
return vipTrialCardGrantItemResult{Item: item, Card: card, Program: program}, nil
}
func (r *Repository) vipTrialCardReceiptForCommand(ctx context.Context, command ledger.GrantVipTrialCardCommand, requestHash string, known ledger.VipTrialCard) (ledger.GrantVipTrialCardReceipt, error) {
nowMS := time.Now().UnixMilli()
card := known
@ -439,10 +484,8 @@ func validateVipTrialCardResource(resource resourcedomain.Resource, expectedLeve
if resourcedomain.NormalizeResourceType(resource.ResourceType) != resourcedomain.TypeVIPTrialCard {
return xerr.New(xerr.InvalidArgument, "resource is not a vip trial card")
}
var metadata struct {
VIPLevel int32 `json:"vip_level"`
}
if err := json.Unmarshal([]byte(resource.MetadataJSON), &metadata); err != nil || metadata.VIPLevel != expectedLevel {
metadata, err := vipTrialCardResourceMetadata(resource.MetadataJSON)
if err != nil || metadata.Level != expectedLevel {
return xerr.New(xerr.Conflict, "vip trial card resource level mismatch")
}
if resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) != resourcedomain.GrantStrategyNewEntitlement {

View File

@ -99,6 +99,7 @@ func giftConfigCommandFromCreate(req *walletv1.CreateGiftConfigRequest) resource
HeatValue: req.GetHeatValue(),
EffectiveAtMS: req.GetEffectiveAtMs(),
GiftTypeCode: req.GetGiftTypeCode(),
VIPLevel: req.GetVipLevel(),
CPRelationType: req.GetCpRelationType(),
ChargeAssetType: req.GetChargeAssetType(),
EffectiveFromMS: req.GetEffectiveFromMs(),
@ -124,6 +125,7 @@ func giftConfigCommandFromUpdate(req *walletv1.UpdateGiftConfigRequest) resource
HeatValue: req.GetHeatValue(),
EffectiveAtMS: req.GetEffectiveAtMs(),
GiftTypeCode: req.GetGiftTypeCode(),
VIPLevel: req.GetVipLevel(),
CPRelationType: req.GetCpRelationType(),
ChargeAssetType: req.GetChargeAssetType(),
EffectiveFromMS: req.GetEffectiveFromMs(),

View File

@ -154,6 +154,7 @@ func giftConfigToProto(gift resourcedomain.GiftConfig) *walletv1.GiftConfig {
GiftPointAmount: 0,
HeatValue: gift.HeatValue,
GiftTypeCode: gift.GiftTypeCode,
VipLevel: gift.VIPLevel,
CpRelationType: gift.CPRelationType,
EffectiveFromMs: gift.EffectiveFromMS,
EffectiveToMs: gift.EffectiveToMS,