From a38a3395c155a3fe42c9be5973fe7832ab87f603 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 22 Jul 2026 17:30:05 +0800 Subject: [PATCH] =?UTF-8?q?vip=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/wallet/v1/wallet.pb.go | 54 ++++- api/proto/wallet/v1/wallet.proto | 6 + docs/Fami_VIP_Flutter对接方案.md | 1 + docs/VIP系统架构.md | 2 + docs/flutter对接/礼物TabFlutter对接.md | 9 + docs/openapi/gateway.swagger.yaml | 19 +- docs/新版VIP权限策略_Flutter客户端对接文档.md | 2 + docs/社交关系接口.md | 4 + docs/资源组与礼物架构.md | 3 +- pkg/xerr/catalog.go | 1 + pkg/xerr/errors.go | 2 + server/admin/internal/modules/resource/dto.go | 2 + .../internal/modules/resource/handler.go | 29 +-- .../internal/modules/resource/handler_test.go | 26 ++- .../internal/modules/resource/request.go | 104 +++++++-- .../http/resourceapi/resource_handler.go | 23 +- .../internal/transport/http/response_test.go | 29 ++- .../http/roomapi/gift_panel_pipeline_test.go | 1 + .../transport/http/roomapi/gift_view.go | 51 ++-- .../deploy/mysql/initdb/001_user_service.sql | 2 +- .../019_user_profile_anonymous_visits.sql | 3 +- .../user-service/internal/domain/user/user.go | 4 +- .../internal/service/user/service.go | 14 -- .../internal/service/user/social.go | 53 ++--- .../storage/mysql/user/social_repository.go | 76 ------ .../mysql/initdb/001_wallet_service.sql | 10 + .../mysql/migrations/019_gift_vip_level.sql | 18 ++ .../internal/domain/resource/constants.go | 22 +- .../internal/domain/resource/gift_config.go | 4 + .../internal/service/wallet/service_test.go | 64 ++++++ .../mysql/gift_batch_debit_repository.go | 3 + .../mysql/gift_config_query_repository.go | 4 +- .../storage/mysql/gift_config_repository.go | 24 +- .../storage/mysql/gift_config_validation.go | 8 + .../storage/mysql/gift_debit_repository.go | 5 + .../mysql/gift_entitlement_repository.go | 36 +++ .../mysql/resource_catalog_repository.go | 25 ++ .../mysql/resource_grant_repository.go | 139 +++++++++-- .../mysql/resource_group_repository.go | 10 + .../mysql/vip_benefit_resource_repository.go | 31 ++- .../mysql/vip_trial_card_repository.go | 217 +++++++++++------- .../transport/grpc/resource_command_mapper.go | 2 + .../transport/grpc/resource_mapper.go | 1 + 43 files changed, 777 insertions(+), 366 deletions(-) create mode 100644 services/wallet-service/deploy/mysql/migrations/019_gift_vip_level.sql diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 55666896..f3992145 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -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" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 3c5be45c..cfd926cc 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -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 { diff --git a/docs/Fami_VIP_Flutter对接方案.md b/docs/Fami_VIP_Flutter对接方案.md index 50d4f842..6b887ded 100644 --- a/docs/Fami_VIP_Flutter对接方案.md +++ b/docs/Fami_VIP_Flutter对接方案.md @@ -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 金币返现 diff --git a/docs/VIP系统架构.md b/docs/VIP系统架构.md index 4cf6b9b5..7e082f5c 100644 --- a/docs/VIP系统架构.md +++ b/docs/VIP系统架构.md @@ -95,6 +95,8 @@ Fami 的 VIP1–VIP9 初始全部为 `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;未知扩展因此不会变成客户端隐形配置表。 diff --git a/docs/flutter对接/礼物TabFlutter对接.md b/docs/flutter对接/礼物TabFlutter对接.md index e10af994..b1d04fae 100644 --- a/docs/flutter对接/礼物TabFlutter对接.md +++ b/docs/flutter对接/礼物TabFlutter对接.md @@ -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 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? ?? '', diff --git a/docs/openapi/gateway.swagger.yaml b/docs/openapi/gateway.swagger.yaml index 99e24380..4d719dc5 100644 --- a/docs/openapi/gateway.swagger.yaml +++ b/docs/openapi/gateway.swagger.yaml @@ -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_id;anonymous_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 权益实时判定。 diff --git a/docs/新版VIP权限策略_Flutter客户端对接文档.md b/docs/新版VIP权限策略_Flutter客户端对接文档.md index e49243c8..5410c52f 100644 --- a/docs/新版VIP权限策略_Flutter客户端对接文档.md +++ b/docs/新版VIP权限策略_Flutter客户端对接文档.md @@ -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 查询当前返现 diff --git a/docs/社交关系接口.md b/docs/社交关系接口.md index f283e8ab..fe8c2399 100644 --- a/docs/社交关系接口.md +++ b/docs/社交关系接口.md @@ -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 | 双向好友边,每个好友关系写两行 | diff --git a/docs/资源组与礼物架构.md b/docs/资源组与礼物架构.md index 8f4132f2..320396fc 100644 --- a/docs/资源组与礼物架构.md +++ b/docs/资源组与礼物架构.md @@ -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`;客户端只能用于展示和选择,不能在送礼时回传这些值作为结算依据。 装备资源的边界: diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index 2ca7af6c..be7c5141 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -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"), diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index c70576fe..d9d41240 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -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 表示普通房主/管理员禁言动作被目标用户的防禁言权益拦截。 diff --git a/server/admin/internal/modules/resource/dto.go b/server/admin/internal/modules/resource/dto.go index 0cc2431b..3498787e 100644 --- a/server/admin/internal/modules/resource/dto.go +++ b/server/admin/internal/modules/resource/dto.go @@ -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(), diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index 791b2eb3..52dc7e44 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -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 } diff --git a/server/admin/internal/modules/resource/handler_test.go b/server/admin/internal/modules/resource/handler_test.go index 268e0bdc..ee3c7483 100644 --- a/server/admin/internal/modules/resource/handler_test.go +++ b/server/admin/internal/modules/resource/handler_test.go @@ -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 diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index 4674b195..eefb4287 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -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_ 改名后破坏专用发卡接口的回查。 + 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 { diff --git a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go index 9604d5ca..6ee3913e 100644 --- a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go +++ b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go @@ -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(), diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index c750f6f6..6117ab1f 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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) } } diff --git a/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline_test.go b/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline_test.go index c08b833d..d06fb375 100644 --- a/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline_test.go +++ b/services/gateway-service/internal/transport/http/roomapi/gift_panel_pipeline_test.go @@ -423,6 +423,7 @@ func giftPanelTestGiftConfigs() *walletv1.ListGiftConfigsResponse { Status: "active", Name: "Rose", GiftTypeCode: "normal", + VipLevel: 0, CoinPrice: 100, }}} } diff --git a/services/gateway-service/internal/transport/http/roomapi/gift_view.go b/services/gateway-service/internal/transport/http/roomapi/gift_view.go index a86e5e8b..a57f05ce 100644 --- a/services/gateway-service/internal/transport/http/roomapi/gift_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/gift_view.go @@ -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(), diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index c20cc3a9..8826d41e 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -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 '应用编码,用于多租户隔离', diff --git a/services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql b/services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql index 1d5fb92b..96a9b853 100644 --- a/services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql +++ b/services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql @@ -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 索引服务目标用户分页,查询不扫全表。 diff --git a/services/user-service/internal/domain/user/user.go b/services/user-service/internal/domain/user/user.go index 8d6c560d..fe834f48 100644 --- a/services/user-service/internal/domain/user/user.go +++ b/services/user-service/internal/domain/user/user.go @@ -345,8 +345,8 @@ const ( ) // ProfileVisitRecord 是访问某个用户主页的去重记录。 -// 公开访问使用 VisitorUserID;VisitorIdentityHidden=true 时只向上游暴露随机 AnonymousVisitID, -// 真实访客 ID 仅留在 user-service 的 owner 表中用于同一目标下累计次数,绝不能进入协议转换层。 +// 新的匿名访问不会再产生记录;VisitorIdentityHidden/AnonymousVisitID 仅用于兼容已经落库的历史匿名事实, +// 真实访客 ID 仍只留在 user-service owner 表中,绝不能进入协议转换层。 type ProfileVisitRecord struct { VisitorUserID int64 TargetUserID int64 diff --git a/services/user-service/internal/service/user/service.go b/services/user-service/internal/service/user/service.go index e7ae03bb..5e5574c2 100644 --- a/services/user-service/internal/service/user/service.go +++ b/services/user-service/internal/service/user/service.go @@ -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 diff --git a/services/user-service/internal/service/user/social.go b/services/user-service/internal/service/user/social.go index 27dd7523..2b405cfd 100644 --- a/services/user-service/internal/service/user/social.go +++ b/services/user-service/internal/service/user/social.go @@ -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") diff --git a/services/user-service/internal/storage/mysql/user/social_repository.go b/services/user-service/internal/storage/mysql/user/social_repository.go index 09ca11ce..6ea33078 100644 --- a/services/user-service/internal/storage/mysql/user/social_repository.go +++ b/services/user-service/internal/storage/mysql/user/social_repository.go @@ -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 -} diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 061b4b41..7b2168fd 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -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', diff --git a/services/wallet-service/deploy/mysql/migrations/019_gift_vip_level.sql b/services/wallet-service/deploy/mysql/migrations/019_gift_vip_level.sql new file mode 100644 index 00000000..a73a1f32 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/019_gift_vip_level.sql @@ -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,必须由运营在后台明确选择等级后保存;根据价格、名称或排序自动回填都会制造错误权益。 diff --git a/services/wallet-service/internal/domain/resource/constants.go b/services/wallet-service/internal/domain/resource/constants.go index f919a038..813501b9 100644 --- a/services/wallet-service/internal/domain/resource/constants.go +++ b/services/wallet-service/internal/domain/resource/constants.go @@ -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 + } +} diff --git a/services/wallet-service/internal/domain/resource/gift_config.go b/services/wallet-service/internal/domain/resource/gift_config.go index aa02f94e..2ac2d598 100644 --- a/services/wallet-service/internal/domain/resource/gift_config.go +++ b/services/wallet-service/internal/domain/resource/gift_config.go @@ -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 diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 8692e6fa..14e320bd 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -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", diff --git a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go index ba301569..6b5b723f 100644 --- a/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_batch_debit_repository.go @@ -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) diff --git a/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go b/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go index 9ea2e0f9..212ed470 100644 --- a/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_config_query_repository.go @@ -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") + ` diff --git a/services/wallet-service/internal/storage/mysql/gift_config_repository.go b/services/wallet-service/internal/storage/mysql/gift_config_repository.go index 44a797d1..8765b86b 100644 --- a/services/wallet-service/internal/storage/mysql/gift_config_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_config_repository.go @@ -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 } diff --git a/services/wallet-service/internal/storage/mysql/gift_config_validation.go b/services/wallet-service/internal/storage/mysql/gift_config_validation.go index dffdbe2d..366f8a6a 100644 --- a/services/wallet-service/internal/storage/mysql/gift_config_validation.go +++ b/services/wallet-service/internal/storage/mysql/gift_config_validation.go @@ -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") } diff --git a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go index b24cb11f..b5c613f7 100644 --- a/services/wallet-service/internal/storage/mysql/gift_debit_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_debit_repository.go @@ -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 { diff --git a/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go b/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go index 48090e7d..88d65e38 100644 --- a/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go +++ b/services/wallet-service/internal/storage/mysql/gift_entitlement_repository.go @@ -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) { diff --git a/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go b/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go index c2a8640d..0375ea8b 100644 --- a/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_catalog_repository.go @@ -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: diff --git a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go index 12b69294..a2b5b676 100644 --- a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go @@ -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 是对外幂等键字段,长度固定为 128;CP 周榜等上游命令本身可能很长, // 组内钱包资产 item 使用稳定短 hash 生成内部交易命令,同时在 metadata.command_id 保留完整上游命令用于审计。 diff --git a/services/wallet-service/internal/storage/mysql/resource_group_repository.go b/services/wallet-service/internal/storage/mysql/resource_group_repository.go index 528836ab..eb6d551a 100644 --- a/services/wallet-service/internal/storage/mysql/resource_group_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_group_repository.go @@ -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, diff --git a/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go b/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go index 0cea9d18..0d9f2356 100644 --- a/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_benefit_resource_repository.go @@ -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 } diff --git a/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go b/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go index fd9c053a..0eb9745d 100644 --- a/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go +++ b/services/wallet-service/internal/storage/mysql/vip_trial_card_repository.go @@ -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 { diff --git a/services/wallet-service/internal/transport/grpc/resource_command_mapper.go b/services/wallet-service/internal/transport/grpc/resource_command_mapper.go index 69a18c98..2ddcf846 100644 --- a/services/wallet-service/internal/transport/grpc/resource_command_mapper.go +++ b/services/wallet-service/internal/transport/grpc/resource_command_mapper.go @@ -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(), diff --git a/services/wallet-service/internal/transport/grpc/resource_mapper.go b/services/wallet-service/internal/transport/grpc/resource_mapper.go index 310a9605..381e6005 100644 --- a/services/wallet-service/internal/transport/grpc/resource_mapper.go +++ b/services/wallet-service/internal/transport/grpc/resource_mapper.go @@ -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,