From 8dc76890a6ac9e403b46f5f57c2685aecfd771d9 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 21 May 2026 18:30:55 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/wallet/v1/wallet.pb.go | 101 ++- api/proto/wallet/v1/wallet.proto | 9 + api/proto/wallet/v1/wallet_grpc.pb.go | 2 +- docs/flutter对接/幸运礼物Flutter对接.md | 588 ++++++++++++++++++ server/admin/internal/modules/resource/dto.go | 6 + .../internal/modules/resource/handler.go | 8 + .../internal/modules/resource/request.go | 39 ++ .../internal/service/game/leadercc_adapter.go | 5 + .../internal/service/game/service_test.go | 14 + .../internal/service/game/yomi_adapter.go | 5 +- .../http/resourceapi/resource_handler.go | 6 + .../transport/http/roomapi/gift_view.go | 6 + .../transport/http/walletapi/view_shared.go | 6 + .../mysql/initdb/001_wallet_service.sql | 44 +- .../internal/domain/resource/resource.go | 22 + .../internal/service/wallet/service_test.go | 93 +++ .../internal/storage/mysql/repository.go | 4 +- .../storage/mysql/resource_repository.go | 78 ++- .../internal/transport/grpc/resource.go | 9 + 19 files changed, 1017 insertions(+), 28 deletions(-) create mode 100644 docs/flutter对接/幸运礼物Flutter对接.md diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 9a7596e8..df63a59c 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc (unknown) +// protoc v5.27.3 // source: proto/wallet/v1/wallet.proto package walletv1 @@ -1069,6 +1069,9 @@ type Resource struct { CreatedAtMs int64 `protobuf:"varint,19,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` UpdatedAtMs int64 `protobuf:"varint,20,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` ManagerGrantEnabled bool `protobuf:"varint,21,opt,name=manager_grant_enabled,json=managerGrantEnabled,proto3" json:"manager_grant_enabled,omitempty"` + PriceType string `protobuf:"bytes,22,opt,name=price_type,json=priceType,proto3" json:"price_type,omitempty"` + CoinPrice int64 `protobuf:"varint,23,opt,name=coin_price,json=coinPrice,proto3" json:"coin_price,omitempty"` + GiftPointAmount int64 `protobuf:"varint,24,opt,name=gift_point_amount,json=giftPointAmount,proto3" json:"gift_point_amount,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1250,6 +1253,27 @@ func (x *Resource) GetManagerGrantEnabled() bool { return false } +func (x *Resource) GetPriceType() string { + if x != nil { + return x.PriceType + } + return "" +} + +func (x *Resource) GetCoinPrice() int64 { + if x != nil { + return x.CoinPrice + } + return 0 +} + +func (x *Resource) GetGiftPointAmount() int64 { + if x != nil { + return x.GiftPointAmount + } + return 0 +} + type ResourceGroupItem struct { state protoimpl.MessageState `protogen:"open.v1"` GroupItemId int64 `protobuf:"varint,1,opt,name=group_item_id,json=groupItemId,proto3" json:"group_item_id,omitempty"` @@ -2614,6 +2638,9 @@ type CreateResourceRequest struct { SortOrder int32 `protobuf:"varint,16,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` OperatorUserId int64 `protobuf:"varint,17,opt,name=operator_user_id,json=operatorUserId,proto3" json:"operator_user_id,omitempty"` ManagerGrantEnabled *bool `protobuf:"varint,18,opt,name=manager_grant_enabled,json=managerGrantEnabled,proto3,oneof" json:"manager_grant_enabled,omitempty"` + PriceType string `protobuf:"bytes,19,opt,name=price_type,json=priceType,proto3" json:"price_type,omitempty"` + CoinPrice int64 `protobuf:"varint,20,opt,name=coin_price,json=coinPrice,proto3" json:"coin_price,omitempty"` + GiftPointAmount int64 `protobuf:"varint,21,opt,name=gift_point_amount,json=giftPointAmount,proto3" json:"gift_point_amount,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2774,6 +2801,27 @@ func (x *CreateResourceRequest) GetManagerGrantEnabled() bool { return false } +func (x *CreateResourceRequest) GetPriceType() string { + if x != nil { + return x.PriceType + } + return "" +} + +func (x *CreateResourceRequest) GetCoinPrice() int64 { + if x != nil { + return x.CoinPrice + } + return 0 +} + +func (x *CreateResourceRequest) GetGiftPointAmount() int64 { + if x != nil { + return x.GiftPointAmount + } + return 0 +} + type UpdateResourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -2795,6 +2843,9 @@ type UpdateResourceRequest struct { SortOrder int32 `protobuf:"varint,17,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` OperatorUserId int64 `protobuf:"varint,18,opt,name=operator_user_id,json=operatorUserId,proto3" json:"operator_user_id,omitempty"` ManagerGrantEnabled *bool `protobuf:"varint,19,opt,name=manager_grant_enabled,json=managerGrantEnabled,proto3,oneof" json:"manager_grant_enabled,omitempty"` + PriceType string `protobuf:"bytes,20,opt,name=price_type,json=priceType,proto3" json:"price_type,omitempty"` + CoinPrice int64 `protobuf:"varint,21,opt,name=coin_price,json=coinPrice,proto3" json:"coin_price,omitempty"` + GiftPointAmount int64 `protobuf:"varint,22,opt,name=gift_point_amount,json=giftPointAmount,proto3" json:"gift_point_amount,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2962,6 +3013,27 @@ func (x *UpdateResourceRequest) GetManagerGrantEnabled() bool { return false } +func (x *UpdateResourceRequest) GetPriceType() string { + if x != nil { + return x.PriceType + } + return "" +} + +func (x *UpdateResourceRequest) GetCoinPrice() int64 { + if x != nil { + return x.CoinPrice + } + return 0 +} + +func (x *UpdateResourceRequest) GetGiftPointAmount() int64 { + if x != nil { + return x.GiftPointAmount + } + return 0 +} + type SetResourceStatusRequest struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -8961,7 +9033,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x17recharge_policy_version\x18\b \x01(\tR\x15rechargePolicyVersion\x12=\n" + "\x1brecharge_policy_coin_amount\x18\t \x01(\x03R\x18rechargePolicyCoinAmount\x12F\n" + " recharge_policy_usd_minor_amount\x18\n" + - " \x01(\x03R\x1crechargePolicyUsdMinorAmount\"\xfd\x05\n" + + " \x01(\x03R\x1crechargePolicyUsdMinorAmount\"\xe7\x06\n" + "\bResource\x12\x19\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x1f\n" + "\vresource_id\x18\x02 \x01(\x03R\n" + @@ -8987,7 +9059,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\x12updated_by_user_id\x18\x12 \x01(\x03R\x0fupdatedByUserId\x12\"\n" + "\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rupdated_at_ms\x18\x14 \x01(\x03R\vupdatedAtMs\x122\n" + - "\x15manager_grant_enabled\x18\x15 \x01(\bR\x13managerGrantEnabled\"\xc7\x03\n" + + "\x15manager_grant_enabled\x18\x15 \x01(\bR\x13managerGrantEnabled\x12\x1d\n" + + "\n" + + "price_type\x18\x16 \x01(\tR\tpriceType\x12\x1d\n" + + "\n" + + "coin_price\x18\x17 \x01(\x03R\tcoinPrice\x12*\n" + + "\x11gift_point_amount\x18\x18 \x01(\x03R\x0fgiftPointAmount\"\xc7\x03\n" + "\x11ResourceGroupItem\x12\"\n" + "\rgroup_item_id\x18\x01 \x01(\x03R\vgroupItemId\x12\x19\n" + "\bgroup_id\x18\x02 \x01(\x03R\agroupId\x12\x1f\n" + @@ -9144,7 +9221,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\vresource_id\x18\x03 \x01(\x03R\n" + "resourceId\"L\n" + "\x13GetResourceResponse\x125\n" + - "\bresource\x18\x01 \x01(\v2\x19.hyapp.wallet.v1.ResourceR\bresource\"\xaf\x05\n" + + "\bresource\x18\x01 \x01(\v2\x19.hyapp.wallet.v1.ResourceR\bresource\"\x99\x06\n" + "\x15CreateResourceRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -9167,8 +9244,13 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\n" + "sort_order\x18\x10 \x01(\x05R\tsortOrder\x12(\n" + "\x10operator_user_id\x18\x11 \x01(\x03R\x0eoperatorUserId\x127\n" + - "\x15manager_grant_enabled\x18\x12 \x01(\bH\x00R\x13managerGrantEnabled\x88\x01\x01B\x18\n" + - "\x16_manager_grant_enabled\"\xd0\x05\n" + + "\x15manager_grant_enabled\x18\x12 \x01(\bH\x00R\x13managerGrantEnabled\x88\x01\x01\x12\x1d\n" + + "\n" + + "price_type\x18\x13 \x01(\tR\tpriceType\x12\x1d\n" + + "\n" + + "coin_price\x18\x14 \x01(\x03R\tcoinPrice\x12*\n" + + "\x11gift_point_amount\x18\x15 \x01(\x03R\x0fgiftPointAmountB\x18\n" + + "\x16_manager_grant_enabled\"\xba\x06\n" + "\x15UpdateResourceRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + @@ -9193,7 +9275,12 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\n" + "sort_order\x18\x11 \x01(\x05R\tsortOrder\x12(\n" + "\x10operator_user_id\x18\x12 \x01(\x03R\x0eoperatorUserId\x127\n" + - "\x15manager_grant_enabled\x18\x13 \x01(\bH\x00R\x13managerGrantEnabled\x88\x01\x01B\x18\n" + + "\x15manager_grant_enabled\x18\x13 \x01(\bH\x00R\x13managerGrantEnabled\x88\x01\x01\x12\x1d\n" + + "\n" + + "price_type\x18\x14 \x01(\tR\tpriceType\x12\x1d\n" + + "\n" + + "coin_price\x18\x15 \x01(\x03R\tcoinPrice\x12*\n" + + "\x11gift_point_amount\x18\x16 \x01(\x03R\x0fgiftPointAmountB\x18\n" + "\x16_manager_grant_enabled\"\xb7\x01\n" + "\x18SetResourceStatusRequest\x12\x1d\n" + "\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index cdf457a0..27329efb 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -149,6 +149,9 @@ message Resource { int64 created_at_ms = 19; int64 updated_at_ms = 20; bool manager_grant_enabled = 21; + string price_type = 22; + int64 coin_price = 23; + int64 gift_point_amount = 24; } message ResourceGroupItem { @@ -320,6 +323,9 @@ message CreateResourceRequest { int32 sort_order = 16; int64 operator_user_id = 17; optional bool manager_grant_enabled = 18; + string price_type = 19; + int64 coin_price = 20; + int64 gift_point_amount = 21; } message UpdateResourceRequest { @@ -342,6 +348,9 @@ message UpdateResourceRequest { int32 sort_order = 17; int64 operator_user_id = 18; optional bool manager_grant_enabled = 19; + string price_type = 20; + int64 coin_price = 21; + int64 gift_point_amount = 22; } message SetResourceStatusRequest { diff --git a/api/proto/wallet/v1/wallet_grpc.pb.go b/api/proto/wallet/v1/wallet_grpc.pb.go index 6186567b..13dda2bf 100644 --- a/api/proto/wallet/v1/wallet_grpc.pb.go +++ b/api/proto/wallet/v1/wallet_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc (unknown) +// - protoc v5.27.3 // source: proto/wallet/v1/wallet.proto package walletv1 diff --git a/docs/flutter对接/幸运礼物Flutter对接.md b/docs/flutter对接/幸运礼物Flutter对接.md new file mode 100644 index 00000000..12df1c3e --- /dev/null +++ b/docs/flutter对接/幸运礼物Flutter对接.md @@ -0,0 +1,588 @@ +# 幸运礼物 Flutter 对接 + +本文描述 Flutter App 对接幸运礼物所需的 HTTP 接口、请求参数、返回值、错误处理和腾讯云 IM 自定义消息。幸运礼物支持多个互相独立的奖池,App 只负责传入 `pool_id`,不计算 RTP、倍率概率、奖池水位或中奖结果。 + +## 基础约定 + +本地开发地址: + +```text +http://127.0.0.1:13000 +``` + +业务接口统一响应 envelope: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": {} +} +``` + +| 字段 | 说明 | +| --- | --- | +| `code` | 成功固定 `OK`;失败为稳定错误码。 | +| `message` | 短错误文案,不用于客户端分支判断。 | +| `request_id` | 服务端链路追踪 ID;客户端日志要记录。 | +| `data` | 成功业务数据;失败时通常不存在。 | + +通用请求头: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Authorization` | 是 | `Bearer `。 | +| `X-App-Code` | 否 | App 编码,默认 `lalu`;登录态接口最终以 token 内 app_code 为准。 | +| `Content-Type` | POST 必填 | `application/json`。 | + +所有 `*_ms` 都是 Unix epoch milliseconds。金额单位都是整数金币。 + +## 核心概念 + +| 概念 | 说明 | +| --- | --- | +| `gift_id` | 用户实际送出的幸运礼物 ID,例如某个 100 金币、5000 金币或 500000 金币礼物。 | +| `pool_id` | 后台配置的幸运礼物奖池 ID,例如 `pool_95`、`pool_98`。不同 `pool_id` 的 RTP、奖池、风控和用户阶段完全隔离。 | +| `command_id` | 本次送礼动作幂等键。用户同一次点击产生一个值,HTTP 超时或重试必须复用。 | +| `coin_spent` | 服务端按礼物价格和数量计算的实际扣费金额,App 不上传该字段。 | +| `multiplier_ppm` | 倍率的 ppm 表达,`1x=1000000`、`0.5x=500000`、`100x=100000000`。 | +| `base_reward_coins` | 基础 RTP 返奖,计入平台成本控制。 | +| `effective_reward_coins` | 用户可见总奖励,等于基础返奖、房间气氛奖励、活动补贴之和。 | + +App 不要展示后台 RTP 数值,不要展示实时概率,不要本地推算“下一次该中”。客户端只展示服务端返回的结果。 + +## 当前实现状态 + +当前仓库已经具备: + +| 能力 | 状态 | +| --- | --- | +| `POST /api/v1/rooms/gift/send` 支持 `pool_id` 入参 | 已有 | +| 房间送礼 IM `room_gift_sent` 可携带 `pool_id` | 已有 | +| `activity-service` 内部 gRPC `CheckLuckyGift` / `ExecuteLuckyGiftDraw` | 已有 | +| 后台按 `pool_id` 配置多奖池 | 已有 | + +Flutter 完整体验还需要后端补齐: + +| 能力 | 建议 | +| --- | --- | +| App 查询某个奖池是否可抽 | 新增 HTTP `POST /api/v1/activities/lucky-gifts/check` | +| 送礼响应直接返回抽奖结果 | 在 `/rooms/gift/send` 成功 `data` 中增加 `lucky_gift` | +| 中奖房间表现 | 新增房间 IM `lucky_gift_drawn` | +| 返奖到账 | 钱包侧新增幸运礼物返奖入账后继续发 `WalletBalanceChanged` | + +下面文档按最终 Flutter 契约编写;已存在字段可直接对接,标注为“建议新增”的字段需要后端继续落地。 + +## 客户端流程 + +1. 进入房间后调用 `GET /api/v1/rooms/{room_id}/gift-panel` 获取礼物面板、金币余额和收礼人。 +2. 找到 `gift_type_code=lucky` 或 `gift_type_code=super_lucky` 的礼物,读取该礼物对应的 `pool_id`。当前可从 `presentation_json.lucky_pool_id` 下发,后续建议提升为顶层字段 `lucky_pool_id`。 +3. 用户点击幸运礼物前,可调用 `POST /api/v1/activities/lucky-gifts/check` 判断奖池是否启用。 +4. 用户确认送礼时调用 `POST /api/v1/rooms/gift/send`,请求体带上 `pool_id`。 +5. 成功响应里如果有 `lucky_gift`,立即播放中奖或未中奖反馈。 +6. 房间内监听 `room_gift_sent` 和 `lucky_gift_drawn` IM,给其他用户同步展示。 +7. 金币余额以 `WalletBalanceChanged` 私有 IM 或 `GET /api/v1/wallet/me/balances` 刷新为准,不在本地推算。 + +## 获取礼物面板 + +```http +GET /api/v1/rooms/{room_id}/gift-panel +Authorization: Bearer +X-App-Code: lalu +``` + +路径参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前语音房 ID。 | + +成功响应节选: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "coin_balance": 1000000, + "recipients": [], + "tabs": [], + "gifts": [ + { + "gift_id": "lucky_star_100", + "name": "幸运星", + "gift_type_code": "lucky", + "charge_asset_type": "COIN", + "coin_price": 100, + "presentation_json": "{\"lucky_pool_id\":\"pool_95\"}", + "resource": { + "asset_url": "https://cdn.example/lucky_star.png", + "animation_url": "https://cdn.example/lucky_star.svga" + } + } + ], + "quantity_presets": [1, 9, 99, 999] + } +} +``` + +幸运礼物识别规则: + +| 字段 | 说明 | +| --- | --- | +| `gift_type_code=lucky` | 普通幸运礼物。 | +| `gift_type_code=super_lucky` | 超级幸运礼物。 | +| `presentation_json.lucky_pool_id` | 建议下发的奖池 ID;为空时 App 传 `default` 或不传,由服务端按默认奖池处理。 | +| `coin_price` | 单个礼物价格;实际扣费仍以服务端钱包价格为准。 | + +## 检查幸运礼物状态 + +建议新增 App HTTP 接口: + +```http +POST /api/v1/activities/lucky-gifts/check +Authorization: Bearer +X-App-Code: lalu +Content-Type: application/json +``` + +请求体: + +```json +{ + "room_id": "lalu_xxx", + "gift_id": "lucky_star_100", + "pool_id": "pool_95" +} +``` + +参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前房间 ID。 | +| `gift_id` | string | 是 | 用户准备送出的幸运礼物 ID。 | +| `pool_id` | string | 否 | 奖池 ID;为空时服务端使用 `default`。 | + +成功响应: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "enabled": true, + "reason": "enabled", + "pool_id": "pool_95", + "gift_id": "lucky_star_100", + "rule_version": 12, + "experience_pool": "novice", + "server_time_ms": 1779259000000 + } +} +``` + +返回字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | bool | 是否允许当前用户在当前房间抽该奖池。 | +| `reason` | string | 服务端原因,客户端只做日志或灰度排查。 | +| `pool_id` | string | 实际使用的奖池 ID。 | +| `gift_id` | string | 礼物 ID。 | +| `rule_version` | int64 | 当前奖池规则版本。 | +| `experience_pool` | string | `novice`、`intermediate`、`advanced`,只影响展示文案或动效强度。 | +| `server_time_ms` | int64 | 服务端当前时间。 | + +客户端处理: + +| 场景 | 处理 | +| --- | --- | +| `enabled=true` | 允许点击送礼。 | +| `enabled=false` | 置灰幸运抽奖入口,但普通礼物展示可继续。 | +| 接口失败 | 不在本地兜底开奖;可以提示稍后重试或隐藏幸运玩法。 | + +## 送礼并抽奖 + +已存在 App HTTP 入口: + +```http +POST /api/v1/rooms/gift/send +Authorization: Bearer +X-App-Code: lalu +Content-Type: application/json +``` + +请求体: + +```json +{ + "room_id": "lalu_xxx", + "command_id": "gift_1779259000000_10001_01", + "target_type": "user", + "target_user_ids": [10002], + "gift_id": "lucky_star_100", + "gift_count": 1, + "pool_id": "pool_95" +} +``` + +参数: + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `room_id` | string | 是 | 当前房间 ID。 | +| `command_id` | string | 是 | 本次用户动作幂等键;同一次重试必须复用。 | +| `target_type` | string | 否 | 当前主流程使用 `user`;为空按 `user`。 | +| `target_user_ids` | int64[] | 是 | 收礼用户 ID;`target_type=user` 时必须且只能 1 个。 | +| `target_user_id` | int64 | 否 | 单目标兼容字段;新代码优先用 `target_user_ids`。 | +| `gift_id` | string | 是 | 礼物 ID。 | +| `gift_count` | int32 | 是 | 礼物数量,必须大于 0。 | +| `pool_id` | string | 否 | 幸运礼物奖池 ID;不同奖池完全隔离。普通礼物可不传。 | + +成功响应节选: + +```json +{ + "code": "OK", + "message": "ok", + "request_id": "req_xxx", + "data": { + "result": { + "applied": true, + "room_version": 14, + "server_time_ms": 1779259000000 + }, + "billing_receipt_id": "br_xxx", + "room_heat": 123456, + "gift_rank": [], + "room": {}, + "treasure": {}, + "lucky_gift": { + "enabled": true, + "draw_id": "lucky_draw_xxx", + "command_id": "gift_1779259000000_10001_01", + "pool_id": "pool_95", + "gift_id": "lucky_star_100", + "rule_version": 12, + "experience_pool": "novice", + "selected_tier_id": "novice_2x", + "multiplier_ppm": 2000000, + "base_reward_coins": 200, + "room_atmosphere_reward_coins": 0, + "activity_subsidy_coins": 0, + "effective_reward_coins": 200, + "reward_status": "granted", + "stage_feedback": false, + "high_multiplier": false, + "created_at_ms": 1779259000000 + } + } +} +``` + +`lucky_gift` 字段为建议新增。普通礼物或奖池未启用时可以为空。 + +`data` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `result.applied` | bool | 本次命令是否实际落地;幂等重放可能为 `false`。 | +| `result.room_version` | int64 | 房间版本。 | +| `result.server_time_ms` | int64 | 服务端时间。 | +| `billing_receipt_id` | string | 钱包扣费回执;存在表示送礼扣费成功。 | +| `room_heat` | int64 | 房间热度。 | +| `gift_rank` | array | 房间礼物榜快照。 | +| `room` | object | 房间快照。 | +| `treasure` | object | 宝箱状态;如果房间宝箱开启可直接更新。 | +| `lucky_gift` | object | 幸运礼物抽奖结果;建议新增。 | + +`lucky_gift` 字段: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `enabled` | bool | 本次是否执行了幸运礼物抽奖。 | +| `draw_id` | string | 抽奖记录 ID。 | +| `command_id` | string | 和送礼请求一致的幂等键。 | +| `pool_id` | string | 本次使用的奖池 ID。 | +| `gift_id` | string | 礼物 ID。 | +| `rule_version` | int64 | 抽奖时使用的规则版本。 | +| `experience_pool` | string | `novice`、`intermediate`、`advanced`。 | +| `selected_tier_id` | string | 服务端选中的奖档 ID。 | +| `multiplier_ppm` | int64 | 建议新增;用于直接展示倍率。后端暂未返回时客户端不要本地解析倍率。 | +| `base_reward_coins` | int64 | 基础 RTP 返奖。 | +| `room_atmosphere_reward_coins` | int64 | 房间气氛补贴奖励。 | +| `activity_subsidy_coins` | int64 | 活动补贴奖励。 | +| `effective_reward_coins` | int64 | 用户可见总奖励。 | +| `reward_status` | string | `pending`、`granted`、`failed`。 | +| `stage_feedback` | bool | 是否触发阶段反馈。可能没有金币奖励。 | +| `high_multiplier` | bool | 是否属于高倍结果,可用于强化动效。 | +| `created_at_ms` | int64 | 抽奖结果生成时间。 | + +展示规则: + +| 场景 | 处理 | +| --- | --- | +| `lucky_gift` 为空 | 按普通礼物成功展示。 | +| `effective_reward_coins=0` 且 `stage_feedback=false` | 展示未中奖或普通送礼效果。 | +| `stage_feedback=true` | 展示阶段进度、幸运值或轻量动画;不要本地加金币。 | +| `effective_reward_coins>0` 且 `reward_status=granted` | 展示中奖金额和到账效果;余额仍以钱包 IM 为准。 | +| `effective_reward_coins>0` 且 `reward_status=pending` | 展示“奖励处理中”,等待钱包 IM 或余额刷新。 | +| `reward_status=failed` | 不本地加金币;提示稍后刷新或走补偿查询。 | +| `high_multiplier=true` | 播放高倍中奖动效,并等待房间 IM 同步其他用户。 | + +## 错误处理 + +通用错误: + +| HTTP | `code` | 说明 | Flutter 处理 | +| --- | --- | --- | --- | +| 400 | `INVALID_JSON` | JSON 解析失败。 | 修正客户端请求体。 | +| 400 | `INVALID_ARGUMENT` | 参数不合法,例如缺 `room_id`、`gift_id`、`command_id`。 | 不重试,提示操作失败并打点。 | +| 401 | `UNAUTHORIZED` | token 无效或过期。 | 走登录刷新或重新登录。 | +| 403 | `PROFILE_REQUIRED` | 用户未完成资料。 | 引导补全资料。 | +| 403 | `PERMISSION_DENIED` | 没有权限。 | 提示不可操作。 | +| 409 | `INSUFFICIENT_BALANCE` | 金币不足。 | 弹充值或余额不足提示。 | +| 409 | `ROOM_CLOSED` | 房间已关闭。 | 退出或刷新房间状态。 | +| 409 | `RULE_NOT_ACTIVE` | 幸运礼物奖池未启用。 | 置灰幸运玩法,不本地开奖。 | +| 409 | `IDEMPOTENCY_CONFLICT` | 同一 `command_id` 对应的请求内容不一致。 | 生成新的 `command_id` 重新发起新动作;旧动作不再重放。 | +| 409 | `CONFLICT` | 规则、奖池、水位或风控状态冲突。 | 提示稍后再试,必要时刷新面板。 | +| 502 | `UPSTREAM_ERROR` | room、wallet、activity 等依赖暂不可用。 | 可保留同一个 `command_id` 重试同一次送礼。 | +| 500 | `INTERNAL_ERROR` | 服务端内部异常。 | 记录 `request_id`,提示稍后重试。 | + +幂等和重试规则: + +| 场景 | 处理 | +| --- | --- | +| HTTP 超时、断网、502 | 使用同一个 `command_id` 重试。 | +| 409 `IDEMPOTENCY_CONFLICT` | 不要复用该 `command_id`;说明客户端把同一个幂等键用于不同请求。 | +| 收到成功响应且有 `billing_receipt_id` | 扣费已成功,不要再次生成新命令补发。 | +| 成功响应丢失但钱包余额已变化 | 以钱包余额和房间 IM 为准,避免重复扣费。 | +| App 进程重启 | 本地持久化最近未完成的 `command_id`,优先用同一键查询或重试。 | + +## IM:房间送礼消息 + +已存在房间 IM: + +| 字段 | 值 | +| --- | --- | +| 腾讯消息类型 | `TIMCustomElem` | +| `Desc` | `room_gift_sent` | +| `Ext` / `extension` | `room_system_message` | +| 群 ID | 当前 `room_id` | +| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 | + +Payload 示例: + +```json +{ + "event_id": "evt_xxx", + "room_id": "lalu_xxx", + "event_type": "room_gift_sent", + "actor_user_id": 10001, + "target_user_id": 10002, + "gift_value": 100, + "room_heat": 123456, + "room_version": 14, + "attributes": { + "gift_id": "lucky_star_100", + "pool_id": "pool_95", + "gift_count": "1", + "billing_receipt_id": "br_xxx", + "coin_spent": "100", + "gift_point_added": "10", + "price_version": "pv_xxx" + } +} +``` + +处理规则: + +| 场景 | 处理 | +| --- | --- | +| `Ext` 不是 `room_system_message` | 忽略。 | +| `event_type` 不是 `room_gift_sent` | 交给其他房间消息处理器。 | +| `event_id` 已处理 | 忽略。 | +| `room_version` 小于本地当前版本 | 忽略或只展示轻量动画,不回滚房间状态。 | +| `attributes.pool_id` 为空 | 按普通礼物处理。 | +| `attributes.pool_id` 非空 | 展示幸运礼物送出效果,但中奖结果仍等 `lucky_gift_drawn` 或 HTTP `lucky_gift`。 | + +## IM:幸运礼物开奖结果 + +建议新增房间 IM: + +| 字段 | 值 | +| --- | --- | +| 腾讯消息类型 | `TIMCustomElem` | +| `Desc` | `lucky_gift_drawn` | +| `Ext` / `extension` | `room_system_message` | +| 群 ID | 当前 `room_id` | +| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 | + +Payload 示例: + +```json +{ + "event_id": "lucky_draw_event_xxx", + "room_id": "lalu_xxx", + "event_type": "lucky_gift_drawn", + "room_version": 14, + "draw_id": "lucky_draw_xxx", + "command_id": "gift_1779259000000_10001_01", + "pool_id": "pool_95", + "gift_id": "lucky_star_100", + "gift_count": 1, + "sender_user_id": 10001, + "target_user_id": 10002, + "coin_spent": 100, + "selected_tier_id": "novice_2x", + "multiplier_ppm": 2000000, + "effective_reward_coins": 200, + "reward_status": "granted", + "stage_feedback": false, + "high_multiplier": false, + "created_at_ms": 1779259000000 +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `event_id` | string | IM 事件幂等键。 | +| `event_type` | string | 固定 `lucky_gift_drawn`。 | +| `room_id` | string | 房间 ID。 | +| `room_version` | int64 | 房间版本。 | +| `draw_id` | string | 抽奖记录 ID。 | +| `command_id` | string | 送礼幂等键。 | +| `pool_id` | string | 奖池 ID。 | +| `gift_id` | string | 礼物 ID。 | +| `gift_count` | int32 | 礼物数量。 | +| `sender_user_id` | int64 | 送礼用户。 | +| `target_user_id` | int64 | 收礼用户。 | +| `coin_spent` | int64 | 本次实际扣费。 | +| `selected_tier_id` | string | 中奖奖档。 | +| `multiplier_ppm` | int64 | 中奖倍率,建议新增。 | +| `effective_reward_coins` | int64 | 用户可见奖励金币。 | +| `reward_status` | string | `pending`、`granted`、`failed`。 | +| `stage_feedback` | bool | 是否阶段反馈。 | +| `high_multiplier` | bool | 是否高倍结果。 | +| `created_at_ms` | int64 | 事件创建时间。 | + +展示规则: + +| 场景 | 处理 | +| --- | --- | +| 当前用户是 `sender_user_id` | 如果 HTTP 已展示过同一 `draw_id`,只更新状态,不重复播中奖动画。 | +| 当前用户不是 `sender_user_id` | 展示房间内中奖横幅、飘屏或公屏提示。 | +| `effective_reward_coins=0` | 不展示大奖飘屏,只保留普通送礼效果或阶段反馈。 | +| `high_multiplier=true` | 展示高倍动画。 | +| `reward_status=pending` | 文案使用“奖励处理中”,不要展示已到账。 | +| JSON 解析失败或字段缺失 | 忽略并记录客户端日志。 | + +## IM:钱包余额变更 + +幸运礼物扣费和返奖都应通过钱包私有 IM 同步余额。该 IM 已在钱包文档中定义。 + +| 字段 | 值 | +| --- | --- | +| 腾讯消息类型 | `TIMCustomElem` | +| `Desc` | `WalletBalanceChanged` | +| `Ext` / `extension` | `wallet_notice` | +| 通道 | C2C 私有消息 | + +Payload 示例: + +```json +{ + "event_type": "WalletBalanceChanged", + "event_id": "wev_xxx", + "app_code": "lalu", + "transaction_id": "tx_xxx", + "command_id": "gift_1779259000000_10001_01", + "user_id": "10001", + "asset_type": "COIN", + "available_delta": -100, + "frozen_delta": 0, + "available_after": 999900, + "frozen_after": 0, + "balance_version": 18, + "created_at_ms": 1779259000000, + "source_created_at_ms": 1779259000000, + "metadata": { + "biz_type": "gift_debit", + "gift_id": "lucky_star_100", + "pool_id": "pool_95" + } +} +``` + +返奖到账时 `available_delta` 为正数,`metadata.biz_type` 建议使用 `lucky_gift_reward`。 + +处理规则: + +| 场景 | 处理 | +| --- | --- | +| `Ext` 不是 `wallet_notice` | 忽略。 | +| `event_type` 不是 `WalletBalanceChanged` | 忽略。 | +| `event_id` 已处理 | 忽略。 | +| 本地 `asset_type` 的版本 `>= balance_version` | 忽略。 | +| 长时间没有收到余额 IM | 调 `GET /api/v1/wallet/me/balances` 兜底刷新。 | + +## IM:高倍全站或区域播报 + +建议新增播报 IM,用于高倍中奖或运营配置的大额中奖展示。 + +| 字段 | 值 | +| --- | --- | +| 腾讯消息类型 | `TIMCustomElem` | +| `Desc` | `lucky_gift_big_win` | +| `Ext` / `extension` | `im_broadcast` | +| 群 ID | 全站播报群或区域播报群 | + +Payload 示例: + +```json +{ + "event_id": "lucky_big_win_xxx", + "broadcast_type": "lucky_gift_big_win", + "scope": "region", + "app_code": "lalu", + "region_id": 1001, + "room_id": "lalu_xxx", + "sender_user_id": 10001, + "target_user_id": 10002, + "pool_id": "pool_95", + "gift_id": "lucky_star_100", + "multiplier_ppm": 100000000, + "effective_reward_coins": 10000, + "sent_at_ms": 1779259000000, + "action": { + "type": "enter_room", + "room_id": "lalu_xxx" + } +} +``` + +处理规则: + +| 场景 | 处理 | +| --- | --- | +| 当前用户已在该房间且已收到 `lucky_gift_drawn` | 可以只展示一次,避免重复刷屏。 | +| 当前用户不在该房间 | 展示全站或区域飘屏,点击 `action` 进房。 | +| `event_id` 已处理 | 忽略。 | + +## App 最小对接顺序 + +1. 登录后调用 `GET /api/v1/im/usersig`,登录腾讯云 IM。 +2. 进入房间后加入房间 IM 群,并调用 `GET /api/v1/rooms/{room_id}/gift-panel`。 +3. 从礼物面板识别幸运礼物和对应 `pool_id`。 +4. 用户点击送礼时生成 `command_id`,调用 `POST /api/v1/rooms/gift/send`。 +5. 使用 HTTP 响应里的 `lucky_gift` 做当前用户即时反馈。 +6. 监听房间 IM `room_gift_sent`、`lucky_gift_drawn`,同步其他用户看到的表现。 +7. 监听 C2C IM `WalletBalanceChanged`,更新金币余额。 +8. 任意 IM 丢失、页面重进或余额不一致时,重新拉取礼物面板和钱包余额纠正状态。 + diff --git a/server/admin/internal/modules/resource/dto.go b/server/admin/internal/modules/resource/dto.go index 1065fb3b..cfc9ca14 100644 --- a/server/admin/internal/modules/resource/dto.go +++ b/server/admin/internal/modules/resource/dto.go @@ -14,6 +14,9 @@ type resourceDTO struct { GrantStrategy string `json:"grantStrategy"` WalletAssetType string `json:"walletAssetType"` WalletAssetAmount int64 `json:"walletAssetAmount"` + PriceType string `json:"priceType"` + CoinPrice int64 `json:"coinPrice"` + GiftPointAmount int64 `json:"giftPointAmount"` UsageScopes []string `json:"usageScopes"` AssetURL string `json:"assetUrl"` PreviewURL string `json:"previewUrl"` @@ -172,6 +175,9 @@ func resourceFromProto(item *walletv1.Resource) resourceDTO { GrantStrategy: item.GetGrantStrategy(), WalletAssetType: item.GetWalletAssetType(), WalletAssetAmount: item.GetWalletAssetAmount(), + PriceType: item.GetPriceType(), + CoinPrice: item.GetCoinPrice(), + GiftPointAmount: item.GetGiftPointAmount(), UsageScopes: item.GetUsageScopes(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index f063f33f..cf83a95a 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -74,6 +74,10 @@ func (h *Handler) CreateResource(c *gin.Context) { response.BadRequest(c, "资源参数不正确") return } + if err := validateResourcePricing(req); err != nil { + response.BadRequest(c, err.Error()) + return + } resp, err := h.wallet.CreateResource(c.Request.Context(), req.createProto(c)) if err != nil { response.BadRequest(c, err.Error()) @@ -170,6 +174,10 @@ func (h *Handler) UpdateResource(c *gin.Context) { response.BadRequest(c, "资源参数不正确") return } + if err := validateResourcePricing(req); err != nil { + response.BadRequest(c, err.Error()) + return + } protoReq := req.updateProto(c, resourceID) resp, err := h.wallet.UpdateResource(c.Request.Context(), protoReq) if err != nil { diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index b472f99f..1e6000af 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -21,6 +21,11 @@ const ( resourceTypeEmojiPack = "emoji_pack" ) +const ( + resourcePriceTypeCoin = "coin" + resourcePriceTypeFree = "free" +) + const ( defaultEmojiPackCategory = "默认" emojiPackPricingFree = "free" @@ -33,6 +38,9 @@ type resourceRequest struct { Name string `json:"name"` Status string `json:"status"` Amount int64 `json:"amount"` + PriceType string `json:"priceType"` + CoinPrice int64 `json:"coinPrice"` + GiftPointAmount int64 `json:"giftPointAmount"` ManagerGrantEnabled *bool `json:"managerGrantEnabled"` AssetURL string `json:"assetUrl"` PreviewURL string `json:"previewUrl"` @@ -121,6 +129,7 @@ type grantGroupRequest struct { func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceRequest { resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) + priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) return &walletv1.CreateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), @@ -133,6 +142,9 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq GrantStrategy: resourceGrantStrategy(resourceType), WalletAssetType: walletAssetType, WalletAssetAmount: walletAssetAmount, + PriceType: priceType, + CoinPrice: coinPrice, + GiftPointAmount: giftPointAmount, UsageScopes: []string{resourceType}, AssetUrl: strings.TrimSpace(r.AssetURL), PreviewUrl: strings.TrimSpace(r.PreviewURL), @@ -146,6 +158,7 @@ func (r resourceRequest) createProto(c *gin.Context) *walletv1.CreateResourceReq func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1.UpdateResourceRequest { resourceType := normalizeResourceType(r.ResourceType) walletAssetType, walletAssetAmount := resourceWalletAsset(resourceType, r.Amount) + priceType, coinPrice, giftPointAmount := resourcePricing(r.PriceType, r.CoinPrice) return &walletv1.UpdateResourceRequest{ RequestId: middleware.CurrentRequestID(c), AppCode: appctx.FromContext(c.Request.Context()), @@ -159,6 +172,9 @@ func (r resourceRequest) updateProto(c *gin.Context, resourceID int64) *walletv1 GrantStrategy: resourceGrantStrategy(resourceType), WalletAssetType: walletAssetType, WalletAssetAmount: walletAssetAmount, + PriceType: priceType, + CoinPrice: coinPrice, + GiftPointAmount: giftPointAmount, UsageScopes: []string{resourceType}, AssetUrl: strings.TrimSpace(r.AssetURL), PreviewUrl: strings.TrimSpace(r.PreviewURL), @@ -340,6 +356,29 @@ func resourceWalletAsset(resourceType string, amount int64) (string, int64) { return "", 0 } +func resourcePricing(priceType string, coinPrice int64) (string, int64, int64) { + priceType = strings.ToLower(strings.TrimSpace(priceType)) + if priceType == resourcePriceTypeFree { + return resourcePriceTypeFree, 0, 0 + } + return resourcePriceTypeCoin, coinPrice, coinPrice +} + +func validateResourcePricing(req resourceRequest) error { + priceType := strings.ToLower(strings.TrimSpace(req.PriceType)) + switch priceType { + case resourcePriceTypeCoin: + if req.CoinPrice <= 0 { + return fmt.Errorf("请输入资源价格") + } + case resourcePriceTypeFree: + return nil + default: + return fmt.Errorf("请选择资源价格类型") + } + return nil +} + func resourceGrantStrategy(resourceType string) string { switch resourceType { case resourceTypeCoin: diff --git a/services/game-service/internal/service/game/leadercc_adapter.go b/services/game-service/internal/service/game/leadercc_adapter.go index abed78be..f3ee7fa8 100644 --- a/services/game-service/internal/service/game/leadercc_adapter.go +++ b/services/game-service/internal/service/game/leadercc_adapter.go @@ -179,6 +179,11 @@ func (s *Service) handleLeaderCCChangeCoin(ctx context.Context, app string, plat raw, contentType := leaderccJSON(code, message, map[string]any{"coin": balance}) return raw, contentType, strconv.Itoa(code), true, providerOrderID, nil } + if result.IdempotentReplay { + // 灵仙要求“成功订单的重复通知”也显式返回 10003;这里不重复改账,只回历史余额。 + raw, contentType := leaderccJSON(leaderccCodeDuplicatedOrder, leaderccMessageFromError(xerr.New(xerr.IdempotencyConflict, "duplicated order")), map[string]any{"coin": result.BalanceAfter}) + return raw, contentType, strconv.Itoa(leaderccCodeDuplicatedOrder), true, providerOrderID, nil + } raw, contentType := leaderccJSON(leaderccCodeOK, "", map[string]any{"coin": result.BalanceAfter}) return raw, contentType, strconv.Itoa(leaderccCodeOK), true, providerOrderID, nil } diff --git a/services/game-service/internal/service/game/service_test.go b/services/game-service/internal/service/game/service_test.go index fcbd1a74..204de2bf 100644 --- a/services/game-service/internal/service/game/service_test.go +++ b/services/game-service/internal/service/game/service_test.go @@ -309,6 +309,20 @@ func TestHandleLeaderCCChangeCoinAndRepairOrder(t *testing.T) { t.Fatalf("wallet command mismatch: %+v", wallet.lastApply) } + raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ + Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-change-replay", AppCode: "lalu"}, + PlatformCode: "lingxian", + Operation: "change_coin", + RawBody: []byte(`{"orderId":"order_1","gameId":"101","roundId":"round_1","uid":"42","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`), + RemoteAddr: "3.1.174.194:443", + }) + if err != nil { + t.Fatalf("duplicate HandleCallback failed: %v", err) + } + if !strings.Contains(string(raw), `"errorCode":10003`) || !strings.Contains(string(raw), `"code":10003`) || !strings.Contains(string(raw), `"coin":1560`) { + t.Fatalf("leadercc duplicate change response mismatch: %s", raw) + } + repairSign := leaderccTestSign(key, "repair_1", "101", "round_2", "42", "50", "2", "", "room_1") raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{ Meta: &gamev1.RequestMeta{RequestId: "req-leadercc-repair", AppCode: "lalu"}, diff --git a/services/game-service/internal/service/game/yomi_adapter.go b/services/game-service/internal/service/game/yomi_adapter.go index 623bcc65..fa27cbbc 100644 --- a/services/game-service/internal/service/game/yomi_adapter.go +++ b/services/game-service/internal/service/game/yomi_adapter.go @@ -260,7 +260,8 @@ func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platfor } type yomiCoinChangeResult struct { - BalanceAfter int64 + BalanceAfter int64 + IdempotentReplay bool } func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) { @@ -286,7 +287,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game } if exists && order.Status == gamedomain.OrderStatusSucceeded { // 已成功订单按幂等重放处理,直接返回历史余额,不能再次调用钱包。 - return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter}, nil + return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter, IdempotentReplay: true}, nil } if amount == 0 { // Yomi 文档允许投注/返奖金额为 0;这类订单仍需标记成功,但不能产生钱包流水。 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 593ae06e..17644157 100644 --- a/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go +++ b/services/gateway-service/internal/transport/http/resourceapi/resource_handler.go @@ -32,6 +32,9 @@ type resourceData struct { GrantStrategy string `json:"grant_strategy"` WalletAssetType string `json:"wallet_asset_type,omitempty"` WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"` + PriceType string `json:"price_type,omitempty"` + CoinPrice int64 `json:"coin_price,omitempty"` + GiftPointAmount int64 `json:"gift_point_amount,omitempty"` UsageScopes []string `json:"usage_scopes"` AssetURL string `json:"asset_url"` PreviewURL string `json:"preview_url"` @@ -328,6 +331,9 @@ func resourceFromProto(item *walletv1.Resource) resourceData { GrantStrategy: item.GetGrantStrategy(), WalletAssetType: item.GetWalletAssetType(), WalletAssetAmount: item.GetWalletAssetAmount(), + PriceType: item.GetPriceType(), + CoinPrice: item.GetCoinPrice(), + GiftPointAmount: item.GetGiftPointAmount(), UsageScopes: item.GetUsageScopes(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), 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 391818d2..76f07c38 100644 --- a/services/gateway-service/internal/transport/http/roomapi/gift_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/gift_view.go @@ -12,6 +12,9 @@ type resourceData struct { GrantStrategy string `json:"grant_strategy"` WalletAssetType string `json:"wallet_asset_type,omitempty"` WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"` + PriceType string `json:"price_type,omitempty"` + CoinPrice int64 `json:"coin_price,omitempty"` + GiftPointAmount int64 `json:"gift_point_amount,omitempty"` UsageScopes []string `json:"usage_scopes"` AssetURL string `json:"asset_url"` PreviewURL string `json:"preview_url"` @@ -106,6 +109,9 @@ func resourceFromProto(item *walletv1.Resource) resourceData { GrantStrategy: item.GetGrantStrategy(), WalletAssetType: item.GetWalletAssetType(), WalletAssetAmount: item.GetWalletAssetAmount(), + PriceType: item.GetPriceType(), + CoinPrice: item.GetCoinPrice(), + GiftPointAmount: item.GetGiftPointAmount(), UsageScopes: item.GetUsageScopes(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), diff --git a/services/gateway-service/internal/transport/http/walletapi/view_shared.go b/services/gateway-service/internal/transport/http/walletapi/view_shared.go index da67e6c6..d0d7d6f9 100644 --- a/services/gateway-service/internal/transport/http/walletapi/view_shared.go +++ b/services/gateway-service/internal/transport/http/walletapi/view_shared.go @@ -21,6 +21,9 @@ type resourceData struct { GrantStrategy string `json:"grant_strategy"` WalletAssetType string `json:"wallet_asset_type,omitempty"` WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"` + PriceType string `json:"price_type,omitempty"` + CoinPrice int64 `json:"coin_price,omitempty"` + GiftPointAmount int64 `json:"gift_point_amount,omitempty"` UsageScopes []string `json:"usage_scopes"` AssetURL string `json:"asset_url"` PreviewURL string `json:"preview_url"` @@ -53,6 +56,9 @@ func resourceFromProto(item *walletv1.Resource) resourceData { GrantStrategy: item.GetGrantStrategy(), WalletAssetType: item.GetWalletAssetType(), WalletAssetAmount: item.GetWalletAssetAmount(), + PriceType: item.GetPriceType(), + CoinPrice: item.GetCoinPrice(), + GiftPointAmount: item.GetGiftPointAmount(), UsageScopes: item.GetUsageScopes(), AssetURL: item.GetAssetUrl(), PreviewURL: item.GetPreviewUrl(), 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 4c80701b..47843a97 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -197,6 +197,9 @@ CREATE TABLE IF NOT EXISTS resources ( grant_strategy VARCHAR(32) NOT NULL COMMENT '发放策略', wallet_asset_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '钱包资产类型', wallet_asset_amount BIGINT NOT NULL DEFAULT 0 COMMENT '钱包资产数量', + price_type VARCHAR(32) NOT NULL DEFAULT '' COMMENT '资源价格类型:coin/free', + coin_price BIGINT NOT NULL DEFAULT 0 COMMENT '资源金币价格', + gift_point_amount BIGINT NOT NULL DEFAULT 0 COMMENT '资源礼物积分', usage_scope_json JSON NOT NULL COMMENT '使用范围 JSON 配置或快照', asset_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '资产 URL', preview_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '预览 URL', @@ -221,6 +224,33 @@ 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 = 'resources' AND COLUMN_NAME = 'price_type') = 0, + 'ALTER TABLE resources ADD COLUMN price_type VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''资源价格类型:coin/free'' AFTER wallet_asset_amount', + '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 = 'resources' AND COLUMN_NAME = 'coin_price') = 0, + 'ALTER TABLE resources ADD COLUMN coin_price BIGINT NOT NULL DEFAULT 0 COMMENT ''资源金币价格'' AFTER price_type', + '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 = 'resources' AND COLUMN_NAME = 'gift_point_amount') = 0, + 'ALTER TABLE resources ADD COLUMN gift_point_amount BIGINT NOT NULL DEFAULT 0 COMMENT ''资源礼物积分'' AFTER coin_price', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'resources' AND INDEX_NAME = 'idx_resources_manager_grant') = 0, 'ALTER TABLE resources ADD KEY idx_resources_manager_grant (app_code, manager_grant_enabled, status, grantable, sort_order)', @@ -570,22 +600,22 @@ INSERT IGNORE INTO wallet_gift_prices ( INSERT IGNORE INTO resources ( app_code, resource_code, resource_type, name, status, grantable, grant_strategy, - wallet_asset_type, wallet_asset_amount, usage_scope_json, asset_url, preview_url, + wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES - ('lalu', 'gift_rose', 'gift', 'Rose', 'active', TRUE, 'increase_quantity', '', 0, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 10, 0, 0, 0, 0), - ('lalu', 'gift_rocket', 'gift', 'Rocket', 'active', TRUE, 'increase_quantity', '', 0, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 20, 0, 0, 0, 0); + ('lalu', 'gift_rose', 'gift', 'Rose', 'active', TRUE, 'increase_quantity', '', 0, 'coin', 10, 10, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 10, 0, 0, 0, 0), + ('lalu', 'gift_rocket', 'gift', 'Rocket', 'active', TRUE, 'increase_quantity', '', 0, 'coin', 100, 100, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 20, 0, 0, 0, 0); INSERT IGNORE INTO resources ( app_code, resource_code, resource_type, name, status, grantable, grant_strategy, - wallet_asset_type, wallet_asset_amount, usage_scope_json, asset_url, preview_url, + wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms ) VALUES - ('lalu', 'vip1_avatar_frame', 'avatar_frame', 'VIP1 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 101, 0, 0, 0, 0), - ('lalu', 'vip2_avatar_frame', 'avatar_frame', 'VIP2 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 102, 0, 0, 0, 0), - ('lalu', 'vip3_vehicle', 'vehicle', 'VIP3 Vehicle', 'active', TRUE, 'set_active_flag', '', 0, JSON_ARRAY('room_entry'), '', '', '', JSON_OBJECT(), 103, 0, 0, 0, 0); + ('lalu', 'vip1_avatar_frame', 'avatar_frame', 'VIP1 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 101, 0, 0, 0, 0), + ('lalu', 'vip2_avatar_frame', 'avatar_frame', 'VIP2 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 102, 0, 0, 0, 0), + ('lalu', 'vip3_vehicle', 'vehicle', 'VIP3 Vehicle', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('room_entry'), '', '', '', JSON_OBJECT(), 103, 0, 0, 0, 0); INSERT IGNORE INTO resource_groups ( app_code, group_code, name, status, description, sort_order, diff --git a/services/wallet-service/internal/domain/resource/resource.go b/services/wallet-service/internal/domain/resource/resource.go index 92571ed3..4681edab 100644 --- a/services/wallet-service/internal/domain/resource/resource.go +++ b/services/wallet-service/internal/domain/resource/resource.go @@ -68,6 +68,9 @@ const ( GiftEffectAnimation = "animation" GiftEffectMusic = "music" GiftEffectGlobalBroadcast = "global_broadcast" + + PriceTypeCoin = "coin" + PriceTypeFree = "free" ) type Resource struct { @@ -81,6 +84,9 @@ type Resource struct { GrantStrategy string WalletAssetType string WalletAssetAmount int64 + PriceType string + CoinPrice int64 + GiftPointAmount int64 UsageScopes []string AssetURL string PreviewURL string @@ -229,6 +235,9 @@ type ResourceCommand struct { GrantStrategy string WalletAssetType string WalletAssetAmount int64 + PriceType string + CoinPrice int64 + GiftPointAmount int64 UsageScopes []string AssetURL string PreviewURL string @@ -472,6 +481,19 @@ func NormalizeWalletAssetType(value string) string { return strings.ToUpper(strings.TrimSpace(value)) } +func NormalizePriceType(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func ValidPriceType(value string) bool { + switch NormalizePriceType(value) { + case PriceTypeCoin, PriceTypeFree: + return true + default: + return false + } +} + func NormalizeGiftTypeCode(value string) string { value = strings.ToLower(strings.TrimSpace(value)) value = strings.ReplaceAll(value, "-", "_") diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 75d12df1..35e1a614 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -764,6 +764,99 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) { } } +func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) { + repository := mysqltest.NewRepository(t) + svc := walletservice.New(repository) + ctx := context.Background() + + paidResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "gift_price_sync", + ResourceType: resourcedomain.TypeGift, + Name: "Price Sync Gift", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, + PriceType: resourcedomain.PriceTypeCoin, + CoinPrice: 77, + UsageScopes: []string{"gift"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create priced gift resource failed: %v", err) + } + if paidResource.GiftPointAmount != 77 { + t.Fatalf("resource gift point should sync coin price: %+v", paidResource) + } + gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ + GiftID: "price-sync", + ResourceID: paidResource.ResourceID, + Status: resourcedomain.StatusActive, + Name: "Price Sync Gift", + PriceVersion: "v1", + ChargeAssetType: ledger.AssetDiamond, + CoinPrice: 1, + GiftPointAmount: 1, + HeatValue: 3, + GiftTypeCode: resourcedomain.GiftTypeNormal, + OperatorUserID: 90001, + RegionIDs: []int64{0}, + }) + if err != nil { + t.Fatalf("create synced gift config failed: %v", err) + } + if gift.ChargeAssetType != ledger.AssetCoin || gift.CoinPrice != 77 || gift.GiftPointAmount != 77 { + t.Fatalf("gift price should be overridden by resource price: %+v", gift) + } + + freeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{ + ResourceCode: "gift_free_sync", + ResourceType: resourcedomain.TypeGift, + Name: "Free Sync Gift", + Status: resourcedomain.StatusActive, + Grantable: true, + GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, + PriceType: resourcedomain.PriceTypeFree, + UsageScopes: []string{"gift"}, + OperatorUserID: 90001, + }) + if err != nil { + t.Fatalf("create free gift resource failed: %v", err) + } + freeGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{ + GiftID: "free-sync", + ResourceID: freeResource.ResourceID, + Status: resourcedomain.StatusActive, + Name: "Free Sync Gift", + PriceVersion: "v1", + CoinPrice: 9, + GiftPointAmount: 9, + HeatValue: 0, + GiftTypeCode: resourcedomain.GiftTypeNormal, + OperatorUserID: 90001, + RegionIDs: []int64{0}, + }) + if err != nil { + t.Fatalf("create free synced gift config failed: %v", err) + } + if freeGift.CoinPrice != 0 || freeGift.GiftPointAmount != 0 { + t.Fatalf("free gift price should sync to zero: %+v", freeGift) + } + receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{ + CommandID: "cmd-free-sync", + RoomID: "room-free", + SenderUserID: 53001, + TargetUserID: 53002, + GiftID: "free-sync", + GiftCount: 1, + }) + if err != nil { + t.Fatalf("free gift debit should not require existing balance: %v", err) + } + if receipt.ChargeAmount != 0 || receipt.GiftPointAdded != 0 { + t.Fatalf("free gift receipt mismatch: %+v", receipt) + } +} + func TestGiftTypeConfigDefaultsAndUpsert(t *testing.T) { repository := mysqltest.NewRepository(t) svc := walletservice.New(repository) diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index a6872e2b..933cda3b 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -130,7 +130,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm return ledger.Receipt{}, err } - sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, false, nowMs) + sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, chargeAmount == 0, nowMs) if err != nil { return ledger.Receipt{}, err } @@ -990,7 +990,7 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st } return giftPrice{}, err } - if price.CoinPrice <= 0 || price.GiftPointAmount < 0 || price.HeatValue < 0 { + if price.CoinPrice < 0 || price.GiftPointAmount < 0 || price.HeatValue < 0 { return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid") } price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType) diff --git a/services/wallet-service/internal/storage/mysql/resource_repository.go b/services/wallet-service/internal/storage/mysql/resource_repository.go index 2010ff04..542a0f90 100644 --- a/services/wallet-service/internal/storage/mysql/resource_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_repository.go @@ -117,10 +117,10 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain. result, err := tx.ExecContext(ctx, ` INSERT INTO resources ( app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled, grant_strategy, - wallet_asset_type, wallet_asset_amount, usage_scope_json, asset_url, preview_url, + wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, command.ResourceCode, command.ResourceType, @@ -131,6 +131,9 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain. command.GrantStrategy, command.WalletAssetType, command.WalletAssetAmount, + command.PriceType, + command.CoinPrice, + command.GiftPointAmount, usageJSON, command.AssetURL, command.PreviewURL, @@ -194,6 +197,7 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain. UPDATE resources SET resource_code = ?, resource_type = ?, name = ?, status = ?, grantable = ?, manager_grant_enabled = ?, grant_strategy = ?, wallet_asset_type = ?, wallet_asset_amount = ?, + price_type = ?, coin_price = ?, gift_point_amount = ?, usage_scope_json = ?, asset_url = ?, preview_url = ?, animation_url = ?, metadata_json = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND resource_id = ?`, @@ -206,6 +210,9 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain. command.GrantStrategy, command.WalletAssetType, command.WalletAssetAmount, + command.PriceType, + command.CoinPrice, + command.GiftPointAmount, usageJSON, command.AssetURL, command.PreviewURL, @@ -959,8 +966,8 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai ctx = contextWithCommandApp(ctx, command.AppCode) command.AppCode = appcode.FromContext(ctx) command = normalizeGiftConfigCommand(command) - if err := validateGiftConfigCommand(command); err != nil { - return resourcedomain.GiftConfig{}, err + if command.ResourceID <= 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config command is incomplete") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -975,6 +982,13 @@ func (r *Repository) upsertGiftConfig(ctx context.Context, command resourcedomai if resource.ResourceType != resourcedomain.TypeGift { return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift config must select gift resource") } + command = applyResourcePricingToGiftCommand(command, resource) + if resourcedomain.NormalizePriceType(resource.PriceType) == "" && command.CoinPrice <= 0 { + return resourcedomain.GiftConfig{}, xerr.New(xerr.InvalidArgument, "gift price is invalid") + } + if err := validateGiftConfigCommand(command); err != nil { + return resourcedomain.GiftConfig{}, err + } if command.Status == resourcedomain.StatusActive && resource.Status != resourcedomain.StatusActive { return resourcedomain.GiftConfig{}, xerr.New(xerr.Conflict, "gift resource is disabled") } @@ -1766,6 +1780,9 @@ func scanResource(scanner scanTarget) (resourcedomain.Resource, error) { &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, @@ -1823,6 +1840,9 @@ func scanResourceGroupItem(scanner scanTarget) (resourcedomain.ResourceGroupItem &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, @@ -1878,6 +1898,9 @@ func scanGiftConfig(scanner scanTarget) (resourcedomain.GiftConfig, error) { &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, @@ -1942,6 +1965,9 @@ func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourc &resource.GrantStrategy, &resource.WalletAssetType, &resource.WalletAssetAmount, + &resource.PriceType, + &resource.CoinPrice, + &resource.GiftPointAmount, &scopesJSON, &resource.AssetURL, &resource.PreviewURL, @@ -1990,7 +2016,7 @@ func resourceColumnsWithAlias(alias string) string { } return prefix + `app_code, ` + prefix + `resource_id, ` + prefix + `resource_code, ` + prefix + `resource_type, ` + prefix + `name, ` + prefix + `status, ` + prefix + `grantable, ` + prefix + `manager_grant_enabled, ` + prefix + `grant_strategy, ` + - prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + + prefix + `wallet_asset_type, ` + prefix + `wallet_asset_amount, ` + prefix + `price_type, ` + prefix + `coin_price, ` + prefix + `gift_point_amount, COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + prefix + `asset_url, ` + prefix + `preview_url, ` + prefix + `animation_url, COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), ` + prefix + `sort_order, ` + prefix + `created_by_user_id, ` + prefix + `updated_by_user_id, ` + prefix + `created_at_ms, ` + prefix + `updated_at_ms` } @@ -2003,7 +2029,7 @@ func nullableResourceColumnsWithAlias(alias string) string { return `COALESCE(` + prefix + `app_code, ''), COALESCE(` + prefix + `resource_id, 0), COALESCE(` + prefix + `resource_code, ''), ` + `COALESCE(` + prefix + `resource_type, ''), COALESCE(` + prefix + `name, ''), COALESCE(` + prefix + `status, ''), ` + `COALESCE(` + prefix + `grantable, FALSE), COALESCE(` + prefix + `manager_grant_enabled, FALSE), COALESCE(` + prefix + `grant_strategy, ''), COALESCE(` + prefix + `wallet_asset_type, ''), ` + - `COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + + `COALESCE(` + prefix + `wallet_asset_amount, 0), COALESCE(` + prefix + `price_type, ''), COALESCE(` + prefix + `coin_price, 0), COALESCE(` + prefix + `gift_point_amount, 0), COALESCE(CAST(` + prefix + `usage_scope_json AS CHAR), '[]'), ` + `COALESCE(` + prefix + `asset_url, ''), COALESCE(` + prefix + `preview_url, ''), COALESCE(` + prefix + `animation_url, ''), ` + `COALESCE(CAST(` + prefix + `metadata_json AS CHAR), '{}'), COALESCE(` + prefix + `sort_order, 0), ` + `COALESCE(` + prefix + `created_by_user_id, 0), COALESCE(` + prefix + `updated_by_user_id, 0), ` + @@ -2196,6 +2222,13 @@ func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedo command.Status = resourcedomain.NormalizeStatus(command.Status) command.GrantStrategy = resourcedomain.NormalizeGrantStrategy(command.GrantStrategy) command.WalletAssetType = resourcedomain.NormalizeWalletAssetType(command.WalletAssetType) + command.PriceType = resourcedomain.NormalizePriceType(command.PriceType) + if command.PriceType == resourcedomain.PriceTypeFree { + command.CoinPrice = 0 + command.GiftPointAmount = 0 + } else if command.PriceType == resourcedomain.PriceTypeCoin { + command.GiftPointAmount = command.CoinPrice + } command.AssetURL = strings.TrimSpace(command.AssetURL) command.PreviewURL = strings.TrimSpace(command.PreviewURL) command.AnimationURL = strings.TrimSpace(command.AnimationURL) @@ -2221,10 +2254,23 @@ func validateResourceCommand(command resourcedomain.ResourceCommand) error { if command.GrantStrategy != resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != ledger.AssetCoin || command.WalletAssetAmount <= 0 { return xerr.New(xerr.InvalidArgument, "coin resource must credit COIN") } + } else if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 { + return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit") + } + if command.PriceType == "" { + if command.CoinPrice != 0 || command.GiftPointAmount != 0 { + return xerr.New(xerr.InvalidArgument, "resource price type is required") + } return nil } - if command.GrantStrategy == resourcedomain.GrantStrategyWalletCredit || command.WalletAssetType != "" || command.WalletAssetAmount != 0 { - return xerr.New(xerr.InvalidArgument, "non-coin resource cannot use wallet credit") + if !resourcedomain.ValidPriceType(command.PriceType) { + return xerr.New(xerr.InvalidArgument, "resource price type is invalid") + } + if command.PriceType == resourcedomain.PriceTypeCoin && command.CoinPrice <= 0 { + return xerr.New(xerr.InvalidArgument, "resource coin price is invalid") + } + if command.CoinPrice < 0 || command.GiftPointAmount < 0 || command.GiftPointAmount != command.CoinPrice { + return xerr.New(xerr.InvalidArgument, "resource price is invalid") } return nil } @@ -2302,6 +2348,20 @@ func validGroupWalletAssetType(assetType string) bool { } } +func applyResourcePricingToGiftCommand(command resourcedomain.GiftConfigCommand, resource resourcedomain.Resource) resourcedomain.GiftConfigCommand { + switch resourcedomain.NormalizePriceType(resource.PriceType) { + case resourcedomain.PriceTypeCoin: + command.ChargeAssetType = ledger.AssetCoin + command.CoinPrice = resource.CoinPrice + command.GiftPointAmount = resource.GiftPointAmount + case resourcedomain.PriceTypeFree: + command.ChargeAssetType = ledger.AssetCoin + command.CoinPrice = 0 + command.GiftPointAmount = 0 + } + return command +} + func normalizeGiftConfigCommand(command resourcedomain.GiftConfigCommand) resourcedomain.GiftConfigCommand { command.GiftID = strings.TrimSpace(command.GiftID) command.Status = resourcedomain.NormalizeStatus(command.Status) @@ -2342,7 +2402,7 @@ func validateGiftConfigCommand(command resourcedomain.GiftConfigCommand) error { if !resourcedomain.ValidStatus(command.Status) { return xerr.New(xerr.InvalidArgument, "status is invalid") } - if command.CoinPrice <= 0 || command.GiftPointAmount < 0 || command.HeatValue < 0 { + if command.CoinPrice < 0 || command.GiftPointAmount < 0 || command.HeatValue < 0 { return xerr.New(xerr.InvalidArgument, "gift price is invalid") } if !resourcedomain.ValidGiftTypeCode(command.GiftTypeCode) { diff --git a/services/wallet-service/internal/transport/grpc/resource.go b/services/wallet-service/internal/transport/grpc/resource.go index a049342b..4e635d86 100644 --- a/services/wallet-service/internal/transport/grpc/resource.go +++ b/services/wallet-service/internal/transport/grpc/resource.go @@ -298,6 +298,9 @@ func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedoma GrantStrategy: req.GetGrantStrategy(), WalletAssetType: req.GetWalletAssetType(), WalletAssetAmount: req.GetWalletAssetAmount(), + PriceType: req.GetPriceType(), + CoinPrice: req.GetCoinPrice(), + GiftPointAmount: req.GetGiftPointAmount(), UsageScopes: req.GetUsageScopes(), AssetURL: req.GetAssetUrl(), PreviewURL: req.GetPreviewUrl(), @@ -320,6 +323,9 @@ func resourceCommandFromUpdate(req *walletv1.UpdateResourceRequest) resourcedoma GrantStrategy: req.GetGrantStrategy(), WalletAssetType: req.GetWalletAssetType(), WalletAssetAmount: req.GetWalletAssetAmount(), + PriceType: req.GetPriceType(), + CoinPrice: req.GetCoinPrice(), + GiftPointAmount: req.GetGiftPointAmount(), UsageScopes: req.GetUsageScopes(), AssetUrl: req.GetAssetUrl(), PreviewUrl: req.GetPreviewUrl(), @@ -439,6 +445,9 @@ func resourceToProto(resource resourcedomain.Resource) *walletv1.Resource { GrantStrategy: resource.GrantStrategy, WalletAssetType: resource.WalletAssetType, WalletAssetAmount: resource.WalletAssetAmount, + PriceType: resource.PriceType, + CoinPrice: resource.CoinPrice, + GiftPointAmount: resource.GiftPointAmount, UsageScopes: resource.UsageScopes, AssetUrl: resource.AssetURL, PreviewUrl: resource.PreviewURL,