新增商品购买
This commit is contained in:
parent
636a0085d0
commit
4398a442b4
File diff suppressed because it is too large
Load Diff
@ -254,6 +254,7 @@ message UserResourceEntitlement {
|
||||
string source_grant_id = 11;
|
||||
int64 created_at_ms = 12;
|
||||
int64 updated_at_ms = 13;
|
||||
bool equipped = 14;
|
||||
}
|
||||
|
||||
message ResourceGrantItem {
|
||||
@ -659,6 +660,23 @@ message ResourceShopItemResponse {
|
||||
ResourceShopItem item = 1;
|
||||
}
|
||||
|
||||
message PurchaseResourceShopItemRequest {
|
||||
string command_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
int64 shop_item_id = 4;
|
||||
}
|
||||
|
||||
message PurchaseResourceShopItemResponse {
|
||||
string order_id = 1;
|
||||
string transaction_id = 2;
|
||||
string resource_grant_id = 3;
|
||||
ResourceShopItem shop_item = 4;
|
||||
UserResourceEntitlement resource = 5;
|
||||
AssetBalance balance = 6;
|
||||
int64 coin_spent = 7;
|
||||
}
|
||||
|
||||
message RechargeBill {
|
||||
string app_code = 1;
|
||||
string transaction_id = 2;
|
||||
@ -1345,6 +1363,7 @@ service WalletService {
|
||||
rpc ListResourceShopItems(ListResourceShopItemsRequest) returns (ListResourceShopItemsResponse);
|
||||
rpc UpsertResourceShopItems(UpsertResourceShopItemsRequest) returns (UpsertResourceShopItemsResponse);
|
||||
rpc SetResourceShopItemStatus(SetResourceShopItemStatusRequest) returns (ResourceShopItemResponse);
|
||||
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
|
||||
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
||||
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
||||
|
||||
@ -48,6 +48,7 @@ const (
|
||||
WalletService_ListResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopItems"
|
||||
WalletService_UpsertResourceShopItems_FullMethodName = "/hyapp.wallet.v1.WalletService/UpsertResourceShopItems"
|
||||
WalletService_SetResourceShopItemStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceShopItemStatus"
|
||||
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
||||
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
||||
@ -114,6 +115,7 @@ type WalletServiceClient interface {
|
||||
ListResourceShopItems(ctx context.Context, in *ListResourceShopItemsRequest, opts ...grpc.CallOption) (*ListResourceShopItemsResponse, error)
|
||||
UpsertResourceShopItems(ctx context.Context, in *UpsertResourceShopItemsRequest, opts ...grpc.CallOption) (*UpsertResourceShopItemsResponse, error)
|
||||
SetResourceShopItemStatus(ctx context.Context, in *SetResourceShopItemStatusRequest, opts ...grpc.CallOption) (*ResourceShopItemResponse, error)
|
||||
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
|
||||
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
|
||||
@ -443,6 +445,16 @@ func (c *walletServiceClient) SetResourceShopItemStatus(ctx context.Context, in
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(PurchaseResourceShopItemResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_PurchaseResourceShopItem_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRechargeBillsResponse)
|
||||
@ -768,6 +780,7 @@ type WalletServiceServer interface {
|
||||
ListResourceShopItems(context.Context, *ListResourceShopItemsRequest) (*ListResourceShopItemsResponse, error)
|
||||
UpsertResourceShopItems(context.Context, *UpsertResourceShopItemsRequest) (*UpsertResourceShopItemsResponse, error)
|
||||
SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error)
|
||||
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
|
||||
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
||||
@ -894,6 +907,9 @@ func (UnimplementedWalletServiceServer) UpsertResourceShopItems(context.Context,
|
||||
func (UnimplementedWalletServiceServer) SetResourceShopItemStatus(context.Context, *SetResourceShopItemStatusRequest) (*ResourceShopItemResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetResourceShopItemStatus not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PurchaseResourceShopItem not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRechargeBills not implemented")
|
||||
}
|
||||
@ -1524,6 +1540,24 @@ func _WalletService_SetResourceShopItemStatus_Handler(srv interface{}, ctx conte
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_PurchaseResourceShopItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(PurchaseResourceShopItemRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).PurchaseResourceShopItem(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_PurchaseResourceShopItem_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).PurchaseResourceShopItem(ctx, req.(*PurchaseResourceShopItemRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRechargeBillsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2169,6 +2203,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "SetResourceShopItemStatus",
|
||||
Handler: _WalletService_SetResourceShopItemStatus_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PurchaseResourceShopItem",
|
||||
Handler: _WalletService_PurchaseResourceShopItem_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRechargeBills",
|
||||
Handler: _WalletService_ListRechargeBills_Handler,
|
||||
|
||||
434
docs/flutter对接/背包接口Flutter对接.md
Normal file
434
docs/flutter对接/背包接口Flutter对接.md
Normal file
@ -0,0 +1,434 @@
|
||||
# 背包接口 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 查询当前登录用户背包资源和佩戴装扮的 HTTP 接口。背包事实由 `wallet-service` 维护,App 只通过 gateway 读取当前用户自己的有效资源权益,不传 `user_id`,也不从礼物配置、房间消息或本地缓存推算用户是否拥有某个资源。
|
||||
|
||||
## 接口
|
||||
|
||||
Base URL:
|
||||
|
||||
```text
|
||||
https://<gateway-host>
|
||||
```
|
||||
|
||||
本地开发:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
| 功能 | Method | Path |
|
||||
| --- | --- | --- |
|
||||
| 查询我的背包资源 | `GET` | `/api/v1/users/me/resources` |
|
||||
| 佩戴我的资源 | `POST` | `/api/v1/users/me/resources/{resource_id}/equip` |
|
||||
|
||||
## 通用请求
|
||||
|
||||
Headers:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | `Bearer <access_token>` |
|
||||
| `Content-Type` | POST 必填 | `application/json` |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu` |
|
||||
| `X-App-Package` | 否 | 包名解析 App 时使用 |
|
||||
| `X-App-Platform` | 否 | `android` / `ios` |
|
||||
|
||||
统一成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
统一失败响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_ARGUMENT",
|
||||
"message": "invalid argument",
|
||||
"request_id": "req_abc"
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 只在 `code == "OK"` 时读取 `data`;失败时把 `request_id` 写入客户端日志,便于后端排查。
|
||||
|
||||
## 查询我的背包资源
|
||||
|
||||
```http
|
||||
GET /api/v1/users/me/resources?resource_type=avatar_frame
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `resource_type` | 否 | 不传返回全部有效权益;传入时只返回该类型。 |
|
||||
|
||||
当前可用于背包过滤的资源类型:
|
||||
|
||||
| 值 | 说明 | 可佩戴 |
|
||||
| --- | --- | --- |
|
||||
| `avatar_frame` | 头像框 | 是 |
|
||||
| `profile_card` | 资料卡 | 是 |
|
||||
| `vehicle` | 座驾 | 是 |
|
||||
| `chat_bubble` | 气泡 | 是 |
|
||||
| `badge` | 徽章 | 是 |
|
||||
| `gift` | 礼物权益 | 否 |
|
||||
| `floating_screen` | 飘屏 | 否 |
|
||||
| `mic_seat_icon` | 麦位图标 | 否 |
|
||||
| `mic_seat_animation` | 麦位动画 | 否 |
|
||||
| `emoji_pack` | 表情包 | 否 |
|
||||
|
||||
接口只返回当前有效权益:`status=active`、已到生效时间、未过期、`remaining_quantity > 0`。`expires_at_ms = 0` 表示永久有效。
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"entitlement_id": "ent_10001_9001_1710000000000",
|
||||
"resource_id": 9001,
|
||||
"resource": {
|
||||
"resource_id": 9001,
|
||||
"resource_code": "vip_avatar_frame_1",
|
||||
"resource_type": "avatar_frame",
|
||||
"name": "VIP 头像框",
|
||||
"status": "active",
|
||||
"grantable": true,
|
||||
"grant_strategy": "extend_expiry",
|
||||
"usage_scopes": ["profile", "room"],
|
||||
"asset_url": "https://cdn.example/avatar-frame.png",
|
||||
"preview_url": "https://cdn.example/avatar-frame-preview.png",
|
||||
"animation_url": "",
|
||||
"metadata_json": "{}",
|
||||
"sort_order": 10,
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000
|
||||
},
|
||||
"status": "active",
|
||||
"quantity": 1,
|
||||
"remaining_quantity": 1,
|
||||
"effective_at_ms": 1710000000000,
|
||||
"expires_at_ms": 1712592000000,
|
||||
"source_grant_id": "grant_xxx",
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000,
|
||||
"equipped": true
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `items` | 当前登录用户的有效资源权益列表。 |
|
||||
| `total` | 当前响应条数,等于 `items.length`;该接口当前不分页。 |
|
||||
| `entitlement_id` | 用户拥有的具体权益 ID;同一 `resource_id` 可能有多条不同期限的权益。 |
|
||||
| `resource_id` | 资源 ID,用于佩戴接口路径。 |
|
||||
| `resource.resource_type` | 资源类型,用于客户端分组展示。 |
|
||||
| `resource.asset_url` | 主展示资源,客户端渲染优先使用。 |
|
||||
| `resource.preview_url` | 预览资源,可为空。 |
|
||||
| `resource.animation_url` | 动效资源,可为空。 |
|
||||
| `resource.metadata_json` | 后台配置的扩展 JSON 字符串;客户端按业务需要解析,不能假设一定有字段。 |
|
||||
| `quantity` | 发放时的权益数量。 |
|
||||
| `remaining_quantity` | 剩余可用数量;大于 0 才会在背包列表返回。 |
|
||||
| `effective_at_ms` | 生效时间,Unix epoch milliseconds。 |
|
||||
| `expires_at_ms` | 过期时间,Unix epoch milliseconds;`0` 表示永久。 |
|
||||
| `equipped` | 当前权益是否是该资源类型的已佩戴项。每个可佩戴 `resource_type` 同一时间最多一条为 `true`。 |
|
||||
|
||||
## 佩戴我的资源
|
||||
|
||||
```http
|
||||
POST /api/v1/users/me/resources/9001/equip
|
||||
Authorization: Bearer <access_token>
|
||||
Content-Type: application/json
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
Path:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `resource_id` | 是 | 要佩戴的资源 ID,必须来自当前用户有效背包权益。 |
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"entitlement_id": "ent_10001_9001_1710000000000"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `entitlement_id` | 否 | 指定佩戴哪一条权益;不传时后端自动选择该资源当前有效权益。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"entitlement_id": "ent_10001_9001_1710000000000",
|
||||
"resource_id": 9001,
|
||||
"resource": {
|
||||
"resource_id": 9001,
|
||||
"resource_code": "vip_avatar_frame_1",
|
||||
"resource_type": "avatar_frame",
|
||||
"name": "VIP 头像框",
|
||||
"status": "active",
|
||||
"asset_url": "https://cdn.example/avatar-frame.png",
|
||||
"preview_url": "https://cdn.example/avatar-frame-preview.png",
|
||||
"animation_url": "",
|
||||
"metadata_json": "{}",
|
||||
"sort_order": 10,
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000
|
||||
},
|
||||
"status": "active",
|
||||
"quantity": 1,
|
||||
"remaining_quantity": 1,
|
||||
"effective_at_ms": 1710000000000,
|
||||
"expires_at_ms": 1712592000000,
|
||||
"source_grant_id": "grant_xxx",
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000,
|
||||
"equipped": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
佩戴规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
| --- | --- |
|
||||
| 可佩戴类型 | `avatar_frame`、`profile_card`、`vehicle`、`chat_bubble`、`badge`。 |
|
||||
| 不可佩戴类型 | `gift`、`floating_screen`、`mic_seat_icon`、`mic_seat_animation`、`emoji_pack`。 |
|
||||
| 同类替换 | 同一个 `resource_type` 只有一个当前佩戴项,新佩戴会替换旧佩戴。 |
|
||||
| 权益校验 | 必须是当前登录用户自己的有效权益;过期、禁用、剩余数量为 0 都不能佩戴。 |
|
||||
| 徽章展示 | 背包佩戴的 `badge` 是单装备槽;资料页多徽章展示仍使用 `/api/v1/badges/me` 和 `/api/v1/badges/display`。 |
|
||||
|
||||
常见错误:
|
||||
|
||||
| HTTP | `code` | 场景 |
|
||||
| --- | --- | --- |
|
||||
| `400` | `INVALID_ARGUMENT` | `resource_id` 非法、资源类型不可佩戴、请求 JSON 无效。 |
|
||||
| `401` | `UNAUTHORIZED` | 未登录或 access token 无效。 |
|
||||
| `403` | `PROFILE_REQUIRED` | 当前账号未完成必填资料。 |
|
||||
| `404` | `NOT_FOUND` | 当前用户没有这条有效权益,或指定 `entitlement_id` 不存在。 |
|
||||
| `502` | `UPSTREAM_ERROR` | gateway 调 wallet-service 失败。 |
|
||||
|
||||
## Flutter 解析示例
|
||||
|
||||
```dart
|
||||
class ApiEnvelope<T> {
|
||||
ApiEnvelope({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.requestId,
|
||||
this.data,
|
||||
});
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
final String requestId;
|
||||
final T? data;
|
||||
|
||||
factory ApiEnvelope.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(Object? raw) parseData,
|
||||
) {
|
||||
return ApiEnvelope<T>(
|
||||
code: json['code'] as String? ?? '',
|
||||
message: json['message'] as String? ?? '',
|
||||
requestId: json['request_id'] as String? ?? '',
|
||||
data: json['data'] == null ? null : parseData(json['data']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackpackResourceList {
|
||||
BackpackResourceList({
|
||||
required this.items,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final List<BackpackResourceEntitlement> items;
|
||||
final int total;
|
||||
|
||||
factory BackpackResourceList.fromJson(Object? raw) {
|
||||
final json = raw as Map<String, dynamic>? ?? const {};
|
||||
return BackpackResourceList(
|
||||
items: (json['items'] as List<dynamic>? ?? const [])
|
||||
.map((item) => BackpackResourceEntitlement.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackpackResourceEntitlement {
|
||||
BackpackResourceEntitlement({
|
||||
required this.entitlementId,
|
||||
required this.resourceId,
|
||||
required this.resource,
|
||||
required this.status,
|
||||
required this.quantity,
|
||||
required this.remainingQuantity,
|
||||
required this.effectiveAtMs,
|
||||
required this.expiresAtMs,
|
||||
required this.sourceGrantId,
|
||||
required this.createdAtMs,
|
||||
required this.updatedAtMs,
|
||||
required this.equipped,
|
||||
});
|
||||
|
||||
final String entitlementId;
|
||||
final int resourceId;
|
||||
final BackpackResource resource;
|
||||
final String status;
|
||||
final int quantity;
|
||||
final int remainingQuantity;
|
||||
final int effectiveAtMs;
|
||||
final int expiresAtMs;
|
||||
final String sourceGrantId;
|
||||
final int createdAtMs;
|
||||
final int updatedAtMs;
|
||||
final bool equipped;
|
||||
|
||||
bool get permanent => expiresAtMs == 0;
|
||||
bool get equipable => const {
|
||||
'avatar_frame',
|
||||
'profile_card',
|
||||
'vehicle',
|
||||
'chat_bubble',
|
||||
'badge',
|
||||
}.contains(resource.resourceType);
|
||||
|
||||
factory BackpackResourceEntitlement.fromJson(Map<String, dynamic> json) {
|
||||
return BackpackResourceEntitlement(
|
||||
entitlementId: json['entitlement_id'] as String? ?? '',
|
||||
resourceId: (json['resource_id'] as num?)?.toInt() ?? 0,
|
||||
resource: BackpackResource.fromJson(json['resource'] as Map<String, dynamic>? ?? const {}),
|
||||
status: json['status'] as String? ?? '',
|
||||
quantity: (json['quantity'] as num?)?.toInt() ?? 0,
|
||||
remainingQuantity: (json['remaining_quantity'] as num?)?.toInt() ?? 0,
|
||||
effectiveAtMs: (json['effective_at_ms'] as num?)?.toInt() ?? 0,
|
||||
expiresAtMs: (json['expires_at_ms'] as num?)?.toInt() ?? 0,
|
||||
sourceGrantId: json['source_grant_id'] as String? ?? '',
|
||||
createdAtMs: (json['created_at_ms'] as num?)?.toInt() ?? 0,
|
||||
updatedAtMs: (json['updated_at_ms'] as num?)?.toInt() ?? 0,
|
||||
equipped: json['equipped'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackpackResource {
|
||||
BackpackResource({
|
||||
required this.resourceId,
|
||||
required this.resourceCode,
|
||||
required this.resourceType,
|
||||
required this.name,
|
||||
required this.status,
|
||||
required this.assetUrl,
|
||||
required this.previewUrl,
|
||||
required this.animationUrl,
|
||||
required this.metadataJson,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
final int resourceId;
|
||||
final String resourceCode;
|
||||
final String resourceType;
|
||||
final String name;
|
||||
final String status;
|
||||
final String assetUrl;
|
||||
final String previewUrl;
|
||||
final String animationUrl;
|
||||
final String metadataJson;
|
||||
final int sortOrder;
|
||||
|
||||
factory BackpackResource.fromJson(Map<String, dynamic> json) {
|
||||
return BackpackResource(
|
||||
resourceId: (json['resource_id'] as num?)?.toInt() ?? 0,
|
||||
resourceCode: json['resource_code'] as String? ?? '',
|
||||
resourceType: json['resource_type'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
status: json['status'] as String? ?? '',
|
||||
assetUrl: json['asset_url'] as String? ?? '',
|
||||
previewUrl: json['preview_url'] as String? ?? '',
|
||||
animationUrl: json['animation_url'] as String? ?? '',
|
||||
metadataJson: json['metadata_json'] as String? ?? '{}',
|
||||
sortOrder: (json['sort_order'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Flutter 调用示例
|
||||
|
||||
```dart
|
||||
Future<BackpackResourceList> fetchBackpackResources({
|
||||
String? resourceType,
|
||||
}) async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
'/api/v1/users/me/resources',
|
||||
queryParameters: {
|
||||
if (resourceType != null && resourceType.isNotEmpty) 'resource_type': resourceType,
|
||||
},
|
||||
);
|
||||
final envelope = ApiEnvelope<BackpackResourceList>.fromJson(
|
||||
response.data ?? const {},
|
||||
BackpackResourceList.fromJson,
|
||||
);
|
||||
if (envelope.code != 'OK' || envelope.data == null) {
|
||||
throw ApiException(envelope.code, envelope.message, envelope.requestId);
|
||||
}
|
||||
return envelope.data!;
|
||||
}
|
||||
|
||||
Future<BackpackResourceEntitlement> equipBackpackResource({
|
||||
required int resourceId,
|
||||
String? entitlementId,
|
||||
}) async {
|
||||
final response = await dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/users/me/resources/$resourceId/equip',
|
||||
data: {
|
||||
if (entitlementId != null && entitlementId.isNotEmpty) 'entitlement_id': entitlementId,
|
||||
},
|
||||
);
|
||||
final envelope = ApiEnvelope<BackpackResourceEntitlement>.fromJson(
|
||||
response.data ?? const {},
|
||||
(raw) => BackpackResourceEntitlement.fromJson(raw as Map<String, dynamic>? ?? const {}),
|
||||
);
|
||||
if (envelope.code != 'OK' || envelope.data == null) {
|
||||
throw ApiException(envelope.code, envelope.message, envelope.requestId);
|
||||
}
|
||||
return envelope.data!;
|
||||
}
|
||||
```
|
||||
|
||||
客户端交互建议:
|
||||
|
||||
1. 背包首页先请求 `/api/v1/users/me/resources`,本地按 `resource.resource_type` 分组渲染 Tab。
|
||||
2. 单类型页面使用 `resource_type` 过滤,减少本地筛选和无关图片预加载。
|
||||
3. 佩戴成功后可直接用响应里的 `equipped=true` 更新当前类型列表:同类型其他 item 置为 `false`,当前 item 置为 `true`。
|
||||
4. 进入资料页或房间展示前,如需要最新装扮状态,重新拉背包或对应资料展示接口,不依赖旧缓存。
|
||||
5. 所有 `*_ms` 都是 Unix epoch milliseconds,展示层按用户本地时区格式化;不要把本地时区传回后端做背包有效期判断。
|
||||
@ -1703,7 +1703,7 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, emoji_pack]
|
||||
- name: keyword
|
||||
in: query
|
||||
required: false
|
||||
@ -1747,7 +1747,7 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, emoji_pack]
|
||||
- name: page
|
||||
in: query
|
||||
required: false
|
||||
@ -1931,7 +1931,7 @@ paths:
|
||||
in: query
|
||||
required: false
|
||||
type: string
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, emoji_pack]
|
||||
responses:
|
||||
"200":
|
||||
description: 返回当前用户有效资源权益。
|
||||
@ -3394,7 +3394,7 @@ definitions:
|
||||
type: string
|
||||
resource_type:
|
||||
type: string
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, emoji_pack]
|
||||
name:
|
||||
type: string
|
||||
status:
|
||||
@ -3447,7 +3447,7 @@ definitions:
|
||||
description: 资源 ID;按字符串返回避免 64 位整数在客户端丢精度。
|
||||
resource_type:
|
||||
type: string
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift]
|
||||
enum: [avatar_frame, profile_card, coin, vehicle, chat_bubble, badge, floating_screen, gift, mic_seat_icon, mic_seat_animation, emoji_pack]
|
||||
name:
|
||||
type: string
|
||||
asset_url:
|
||||
@ -3755,6 +3755,9 @@ definitions:
|
||||
format: int64
|
||||
source_grant_id:
|
||||
type: string
|
||||
equipped:
|
||||
type: boolean
|
||||
description: 当前权益是否是该资源类型的已佩戴项。
|
||||
UserResourceListData:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@ -27,6 +27,7 @@ type WalletClient interface {
|
||||
GetResource(ctx context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error)
|
||||
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error)
|
||||
ListResourceShopItems(ctx context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error)
|
||||
PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error)
|
||||
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
|
||||
ListGiftTypeConfigs(ctx context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error)
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
@ -116,6 +117,10 @@ func (c *grpcWalletClient) ListResourceShopItems(ctx context.Context, req *walle
|
||||
return c.client.ListResourceShopItems(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
|
||||
return c.client.PurchaseResourceShopItem(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
return c.client.ListGiftConfigs(ctx, req)
|
||||
}
|
||||
|
||||
@ -47,6 +47,7 @@ type AppHandlers struct {
|
||||
GetAppVersion http.HandlerFunc
|
||||
GetResourceGroup http.HandlerFunc
|
||||
ListResourceShopItems http.HandlerFunc
|
||||
PurchaseResourceShopItem http.HandlerFunc
|
||||
ListGifts http.HandlerFunc
|
||||
ListGiftTabs http.HandlerFunc
|
||||
ListEmojiPacks http.HandlerFunc
|
||||
@ -252,6 +253,7 @@ func (r routes) registerAppRoutes() {
|
||||
r.public("/app/version", http.MethodGet, h.GetAppVersion)
|
||||
r.public("/resource-groups/{group_id}", "", h.GetResourceGroup)
|
||||
r.public("/resource-shop/items", http.MethodGet, h.ListResourceShopItems)
|
||||
r.profile("/resource-shop/items/{shop_item_id}/purchase", http.MethodPost, h.PurchaseResourceShopItem)
|
||||
r.public("/gifts", "", h.ListGifts)
|
||||
r.public("/gift-tabs", http.MethodGet, h.ListGiftTabs)
|
||||
r.public("/emoji-packs", http.MethodGet, h.ListEmojiPacks)
|
||||
|
||||
@ -32,6 +32,10 @@ func (h *Handler) ListResourceShopItems(writer http.ResponseWriter, request *htt
|
||||
h.listResourceShopItems(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) PurchaseResourceShopItem(writer http.ResponseWriter, request *http.Request) {
|
||||
h.purchaseResourceShopItem(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ListGifts(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listGifts(writer, request)
|
||||
}
|
||||
|
||||
@ -83,6 +83,28 @@ type resourceShopItemData struct {
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type resourceShopPurchaseRequestBody struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDAlt string `json:"commandId"`
|
||||
}
|
||||
|
||||
type resourceShopPurchaseData struct {
|
||||
OrderID string `json:"order_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
ResourceGrantID string `json:"resource_grant_id"`
|
||||
CoinSpent int64 `json:"coin_spent"`
|
||||
Balance resourceAssetBalanceData `json:"balance"`
|
||||
ShopItem resourceShopItemData `json:"shop_item"`
|
||||
Resource userResourceData `json:"resource"`
|
||||
}
|
||||
|
||||
type resourceAssetBalanceData struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AvailableAmount int64 `json:"available_amount"`
|
||||
FrozenAmount int64 `json:"frozen_amount"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type giftConfigData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
@ -151,6 +173,7 @@ type userResourceData struct {
|
||||
SourceGrantID string `json:"source_grant_id"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
Equipped bool `json:"equipped"`
|
||||
}
|
||||
|
||||
func (h *Handler) listResources(writer http.ResponseWriter, request *http.Request) {
|
||||
@ -240,6 +263,41 @@ func (h *Handler) listResourceShopItems(writer http.ResponseWriter, request *htt
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": resp.GetTotal(), "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) purchaseResourceShopItem(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
shopItemID, ok := httpkit.PositiveInt64PathValue(request, "shop_item_id")
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
var body resourceShopPurchaseRequestBody
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
commandID = strings.TrimSpace(body.CommandIDAlt)
|
||||
}
|
||||
if commandID == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.walletClient.PurchaseResourceShopItem(request.Context(), &walletv1.PurchaseResourceShopItemRequest{
|
||||
CommandId: commandID,
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
ShopItemId: shopItemID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, resourceShopPurchaseFromProto(resp))
|
||||
}
|
||||
|
||||
func (h *Handler) listGifts(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
@ -526,6 +584,33 @@ func resourceShopItemFromProto(item *walletv1.ResourceShopItem) resourceShopItem
|
||||
}
|
||||
}
|
||||
|
||||
func resourceShopPurchaseFromProto(resp *walletv1.PurchaseResourceShopItemResponse) resourceShopPurchaseData {
|
||||
if resp == nil {
|
||||
return resourceShopPurchaseData{}
|
||||
}
|
||||
return resourceShopPurchaseData{
|
||||
OrderID: resp.GetOrderId(),
|
||||
TransactionID: resp.GetTransactionId(),
|
||||
ResourceGrantID: resp.GetResourceGrantId(),
|
||||
CoinSpent: resp.GetCoinSpent(),
|
||||
Balance: resourceBalanceFromProto(resp.GetBalance()),
|
||||
ShopItem: resourceShopItemFromProto(resp.GetShopItem()),
|
||||
Resource: userResourceFromProto(resp.GetResource()),
|
||||
}
|
||||
}
|
||||
|
||||
func resourceBalanceFromProto(balance *walletv1.AssetBalance) resourceAssetBalanceData {
|
||||
if balance == nil {
|
||||
return resourceAssetBalanceData{}
|
||||
}
|
||||
return resourceAssetBalanceData{
|
||||
AssetType: balance.GetAssetType(),
|
||||
AvailableAmount: balance.GetAvailableAmount(),
|
||||
FrozenAmount: balance.GetFrozenAmount(),
|
||||
Version: balance.GetVersion(),
|
||||
}
|
||||
}
|
||||
|
||||
func giftFromProto(gift *walletv1.GiftConfig) giftConfigData {
|
||||
if gift == nil {
|
||||
return giftConfigData{}
|
||||
@ -588,6 +673,7 @@ func userResourceFromProto(item *walletv1.UserResourceEntitlement) userResourceD
|
||||
SourceGrantID: item.GetSourceGrantId(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
Equipped: item.GetEquipped(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -399,6 +399,8 @@ type fakeWalletClient struct {
|
||||
resourceGroupsByID map[int64]*walletv1.ResourceGroup
|
||||
lastResourceShop *walletv1.ListResourceShopItemsRequest
|
||||
resourceShopResp *walletv1.ListResourceShopItemsResponse
|
||||
lastPurchaseShop *walletv1.PurchaseResourceShopItemRequest
|
||||
purchaseShopResp *walletv1.PurchaseResourceShopItemResponse
|
||||
lastListGiftConfigs *walletv1.ListGiftConfigsRequest
|
||||
listGiftConfigsResp *walletv1.ListGiftConfigsResponse
|
||||
lastListGiftTypes *walletv1.ListGiftTypeConfigsRequest
|
||||
@ -1238,6 +1240,17 @@ func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv
|
||||
return &walletv1.ListResourceShopItemsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
|
||||
f.lastPurchaseShop = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.purchaseShopResp != nil {
|
||||
return f.purchaseShopResp, nil
|
||||
}
|
||||
return &walletv1.PurchaseResourceShopItemResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
|
||||
f.lastListGiftConfigs = req
|
||||
if f.err != nil {
|
||||
|
||||
@ -135,6 +135,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
GetAppVersion: appAPI.GetAppVersion,
|
||||
GetResourceGroup: resourceAPI.GetResourceGroup,
|
||||
ListResourceShopItems: resourceAPI.ListResourceShopItems,
|
||||
PurchaseResourceShopItem: resourceAPI.PurchaseResourceShopItem,
|
||||
ListGifts: resourceAPI.ListGifts,
|
||||
ListGiftTabs: resourceAPI.ListGiftTabs,
|
||||
ListEmojiPacks: resourceAPI.ListEmojiPacks,
|
||||
|
||||
@ -423,6 +423,29 @@ CREATE TABLE IF NOT EXISTS resource_shop_items (
|
||||
KEY idx_resource_shop_items_effective (app_code, status, effective_from_ms, effective_to_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='道具商店售卖资源表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_shop_purchase_orders (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
order_id VARCHAR(96) NOT NULL COMMENT '道具商店订单 ID',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT 'App 购买命令幂等 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '购买用户 ID',
|
||||
shop_item_id BIGINT NOT NULL COMMENT '道具商店条目 ID',
|
||||
resource_id BIGINT NOT NULL COMMENT '资源 ID',
|
||||
duration_days INT NOT NULL COMMENT '购买生效天数,订单快照',
|
||||
price_coin BIGINT NOT NULL COMMENT '实扣金币,订单快照',
|
||||
status VARCHAR(32) NOT NULL COMMENT '订单状态',
|
||||
wallet_transaction_id VARCHAR(96) NOT NULL COMMENT '扣费钱包交易 ID',
|
||||
resource_grant_id VARCHAR(96) NOT NULL COMMENT '资源发放 ID',
|
||||
entitlement_id VARCHAR(96) NOT NULL COMMENT '最终权益 ID',
|
||||
request_hash VARCHAR(128) NOT NULL COMMENT '购买请求语义哈希',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, order_id),
|
||||
UNIQUE KEY uk_resource_shop_purchase_command (app_code, command_id),
|
||||
KEY idx_resource_shop_purchase_user_time (app_code, user_id, created_at_ms),
|
||||
KEY idx_resource_shop_purchase_tx (app_code, wallet_transaction_id),
|
||||
KEY idx_resource_shop_purchase_entitlement (app_code, entitlement_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='道具商店购买订单表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gift_type_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码,供 gift_configs 引用',
|
||||
|
||||
@ -36,6 +36,7 @@ const (
|
||||
GrantSourceManagerCenter = "manager_center"
|
||||
GrantSourceGrowthLevel = "growth_level"
|
||||
GrantSourceAchievement = "achievement"
|
||||
GrantSourceResourceShop = "resource_shop"
|
||||
GrantStatusDone = "succeeded"
|
||||
ResultWalletCredit = "wallet_credit"
|
||||
ResultEntitlement = "entitlement"
|
||||
@ -204,6 +205,7 @@ type UserResourceEntitlement struct {
|
||||
SourceGrantID string
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
Equipped bool
|
||||
}
|
||||
|
||||
type ResourceGrantItem struct {
|
||||
@ -435,6 +437,23 @@ type ResourceShopItemStatusCommand struct {
|
||||
OperatorUserID int64
|
||||
}
|
||||
|
||||
type ResourceShopPurchaseCommand struct {
|
||||
AppCode string
|
||||
CommandID string
|
||||
UserID int64
|
||||
ShopItemID int64
|
||||
}
|
||||
|
||||
type ResourceShopPurchaseReceipt struct {
|
||||
OrderID string
|
||||
TransactionID string
|
||||
ShopItem ResourceShopItem
|
||||
Resource UserResourceEntitlement
|
||||
Balance ledger.AssetBalance
|
||||
CoinSpent int64
|
||||
GrantID string
|
||||
}
|
||||
|
||||
// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection.
|
||||
type BadgeGrantOutbox struct {
|
||||
AppCode string
|
||||
|
||||
@ -241,3 +241,19 @@ func (s *Service) SetResourceShopItemStatus(ctx context.Context, command resourc
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
return s.repository.SetResourceShopItemStatus(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) {
|
||||
if strings.TrimSpace(command.CommandID) == "" || command.UserID <= 0 || command.ShopItemID <= 0 {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop purchase command is incomplete")
|
||||
}
|
||||
if len(command.CommandID) > 128 {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
return s.repository.PurchaseResourceShopItem(ctx, command)
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ type Repository interface {
|
||||
ListResourceShopItems(ctx context.Context, query resourcedomain.ListResourceShopItemsQuery) ([]resourcedomain.ResourceShopItem, int64, error)
|
||||
UpsertResourceShopItems(ctx context.Context, command resourcedomain.ResourceShopItemsCommand) ([]resourcedomain.ResourceShopItem, error)
|
||||
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
|
||||
PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error)
|
||||
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
|
||||
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error)
|
||||
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error
|
||||
|
||||
@ -1290,6 +1290,84 @@ func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。
|
||||
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(53001, 1000)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
|
||||
ResourceCode: "shop_frame_gold",
|
||||
ResourceType: resourcedomain.TypeAvatarFrame,
|
||||
Name: "Gold Frame",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
|
||||
PriceType: resourcedomain.PriceTypeCoin,
|
||||
CoinPrice: 100,
|
||||
UsageScopes: []string{"profile"},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create shop resource failed: %v", err)
|
||||
}
|
||||
shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
|
||||
Items: []resourcedomain.ResourceShopItemInput{{
|
||||
ResourceID: resource.ResourceID,
|
||||
Status: resourcedomain.StatusActive,
|
||||
DurationDays: resourcedomain.ShopDurationThreeDays,
|
||||
SortOrder: 1,
|
||||
}},
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert shop item failed: %v", err)
|
||||
}
|
||||
|
||||
receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||||
CommandID: "cmd-shop-frame",
|
||||
UserID: 53001,
|
||||
ShopItemID: shopItems[0].ShopItemID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PurchaseResourceShopItem failed: %v", err)
|
||||
}
|
||||
if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 300 || receipt.Balance.AvailableAmount != 700 {
|
||||
t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt)
|
||||
}
|
||||
if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() {
|
||||
t.Fatalf("resource shop entitlement mismatch: %+v", receipt.Resource)
|
||||
}
|
||||
|
||||
again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||||
CommandID: "cmd-shop-frame",
|
||||
UserID: 53001,
|
||||
ShopItemID: shopItems[0].ShopItemID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PurchaseResourceShopItem retry failed: %v", err)
|
||||
}
|
||||
if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount {
|
||||
t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again)
|
||||
}
|
||||
if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 {
|
||||
t.Fatalf("resource shop order should be persisted once, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-frame", "resource_shop_purchase"); got != 1 {
|
||||
t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-300)); got != 1 {
|
||||
t.Fatalf("resource shop purchase should write one debit entry, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 {
|
||||
t.Fatalf("resource shop purchase should write one resource grant, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(53001), resource.ResourceID); got != 1 {
|
||||
t.Fatalf("resource shop purchase should grant one entitlement, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
activity := &fakeActivityBadgeClient{status: "consumed"}
|
||||
@ -1620,6 +1698,20 @@ func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) {
|
||||
if equipped.EntitlementID == "" || equipped.EntitlementID != grant.Items[0].EntitlementID {
|
||||
t.Fatalf("equipped entitlement mismatch: equipped=%+v grant=%+v", equipped, grant)
|
||||
}
|
||||
if !equipped.Equipped {
|
||||
t.Fatalf("equip response should mark entitlement as equipped: %+v", equipped)
|
||||
}
|
||||
resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
|
||||
UserID: 43001,
|
||||
ResourceType: resourcedomain.TypeBadge,
|
||||
ActiveOnly: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list user resources failed: %v", err)
|
||||
}
|
||||
if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != equipped.EntitlementID {
|
||||
t.Fatalf("resource list should expose equipped entitlement: resources=%+v equipped=%+v", resources, equipped)
|
||||
}
|
||||
repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
|
||||
UserID: 43001,
|
||||
ResourceID: badgeResource.ResourceID,
|
||||
|
||||
@ -19,8 +19,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
bizTypeResourceGrant = "resource_grant"
|
||||
resourceOutboxAsset = "RESOURCE"
|
||||
bizTypeResourceGrant = "resource_grant"
|
||||
bizTypeResourceShopPurchase = "resource_shop_purchase"
|
||||
resourceOutboxAsset = "RESOURCE"
|
||||
)
|
||||
|
||||
type scanTarget interface {
|
||||
@ -899,6 +900,7 @@ func (r *Repository) EquipUserResource(ctx context.Context, command resourcedoma
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resourcedomain.UserResourceEntitlement{}, err
|
||||
}
|
||||
entitlement.Equipped = true
|
||||
return entitlement, nil
|
||||
}
|
||||
|
||||
@ -1088,6 +1090,146 @@ func (r *Repository) SetResourceShopItemStatus(ctx context.Context, command reso
|
||||
return r.getResourceShopItem(ctx, command.ShopItemID)
|
||||
}
|
||||
|
||||
// PurchaseResourceShopItem 是 App 道具商店的唯一购买入口;扣金币、写订单和发资源权益必须共享同一事务。
|
||||
func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
requestHash := resourceShopPurchaseRequestHash(command)
|
||||
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase); err != nil || exists {
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
item, err := r.getResourceShopItemTx(ctx, tx, command.ShopItemID, true)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if err := validatePurchasableResourceShopItem(item, nowMs); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if account.AvailableAmount < item.CoinPrice {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
|
||||
}
|
||||
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
orderID := "rshop_order_" + stableHash(command.AppCode+"|"+command.CommandID)
|
||||
grantID := resourceGrantID(command.AppCode, command.CommandID)
|
||||
durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond)
|
||||
coinBalanceAfter := account.AvailableAmount - item.CoinPrice
|
||||
metadata := map[string]any{
|
||||
"app_code": command.AppCode,
|
||||
"order_id": orderID,
|
||||
"user_id": command.UserID,
|
||||
"shop_item_id": item.ShopItemID,
|
||||
"resource_id": item.ResourceID,
|
||||
"resource_code": item.Resource.ResourceCode,
|
||||
"resource_type": item.Resource.ResourceType,
|
||||
"duration_days": item.DurationDays,
|
||||
"duration_ms": durationMS,
|
||||
"price_type": item.PriceType,
|
||||
"coin_spent": item.CoinPrice,
|
||||
"balance_after": coinBalanceAfter,
|
||||
"resource_grant_id": grantID,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableDelta: -item.CoinPrice,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: coinBalanceAfter,
|
||||
FrozenAfter: account.FrozenAmount,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
grantItem, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if grantItem.EntitlementID == "" {
|
||||
// 道具商店只出售权益类资源;如果配置成钱包入账资源,必须阻断而不是产生无背包道具的订单。
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.InvalidArgument, "resource shop item must grant entitlement")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO resource_shop_purchase_orders (
|
||||
app_code, order_id, command_id, user_id, shop_item_id, resource_id, duration_days, price_coin,
|
||||
status, wallet_transaction_id, resource_grant_id, entitlement_id, request_hash, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?, ?)`,
|
||||
command.AppCode, orderID, command.CommandID, command.UserID, item.ShopItemID, item.ResourceID, item.DurationDays, item.CoinPrice,
|
||||
transactionID, grantID, grantItem.EntitlementID, requestHash, nowMs, nowMs,
|
||||
); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
resource, err := r.getUserResourceEntitlementTx(ctx, tx, grantItem.EntitlementID)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
|
||||
resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs),
|
||||
{
|
||||
EventID: eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset),
|
||||
EventType: "ResourceShopItemPurchased",
|
||||
TransactionID: transactionID,
|
||||
CommandID: command.CommandID,
|
||||
UserID: command.UserID,
|
||||
AssetType: resourceOutboxAsset,
|
||||
Payload: metadata,
|
||||
CreatedAtMS: nowMs,
|
||||
},
|
||||
}); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{
|
||||
OrderID: orderID,
|
||||
TransactionID: transactionID,
|
||||
ShopItem: item,
|
||||
Resource: resource,
|
||||
Balance: ledger.AssetBalance{
|
||||
AppCode: command.AppCode,
|
||||
UserID: command.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableAmount: coinBalanceAfter,
|
||||
FrozenAmount: account.FrozenAmount,
|
||||
Version: account.Version + 1,
|
||||
},
|
||||
CoinSpent: item.CoinPrice,
|
||||
GrantID: grantID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (resourcedomain.ResourceGrant, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return resourcedomain.ResourceGrant{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
@ -1725,6 +1867,60 @@ func (r *Repository) getResourceShopItemWithQuerier(ctx context.Context, querier
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *Repository) getUserResourceEntitlementTx(ctx context.Context, tx *sql.Tx, entitlementID string) (resourcedomain.UserResourceEntitlement, error) {
|
||||
item, err := scanUserResourceEntitlement(tx.QueryRowContext(ctx,
|
||||
userResourceSelectSQL()+` WHERE e.app_code = ? AND e.entitlement_id = ?`,
|
||||
appcode.FromContext(ctx), entitlementID,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return resourcedomain.UserResourceEntitlement{}, xerr.New(xerr.NotFound, "user resource entitlement not found")
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (resourcedomain.ResourceShopPurchaseReceipt, error) {
|
||||
var orderID string
|
||||
var shopItemID int64
|
||||
var priceCoin int64
|
||||
var grantID string
|
||||
var entitlementID string
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT order_id, shop_item_id, price_coin, resource_grant_id, entitlement_id
|
||||
FROM resource_shop_purchase_orders
|
||||
WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`,
|
||||
appcode.FromContext(ctx), transactionID, userID,
|
||||
).Scan(&orderID, &shopItemID, &priceCoin, &grantID, &entitlementID)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
item, err := r.getResourceShopItemTx(ctx, tx, shopItemID, false)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
resource, err := r.getUserResourceEntitlementTx(ctx, tx, entitlementID)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin)
|
||||
if err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
}
|
||||
if account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, ledger.AssetCoin); err != nil {
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||
} else if exists {
|
||||
balance.Version = account.Version
|
||||
}
|
||||
return resourcedomain.ResourceShopPurchaseReceipt{
|
||||
OrderID: orderID,
|
||||
TransactionID: transactionID,
|
||||
ShopItem: item,
|
||||
Resource: resource,
|
||||
Balance: balance,
|
||||
CoinSpent: priceCoin,
|
||||
GrantID: grantID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, resourceIDs []int64) ([]resourcedomain.ResourceShopItem, error) {
|
||||
resourceIDs = compactPositiveInt64s(resourceIDs)
|
||||
if len(resourceIDs) == 0 {
|
||||
@ -2197,6 +2393,7 @@ func scanUserResourceEntitlement(scanner scanTarget) (resourcedomain.UserResourc
|
||||
&item.SourceGrantID,
|
||||
&item.CreatedAtMS,
|
||||
&item.UpdatedAtMS,
|
||||
&item.Equipped,
|
||||
&resource.AppCode,
|
||||
&resource.ResourceID,
|
||||
&resource.ResourceCode,
|
||||
@ -2318,9 +2515,14 @@ func userResourceSelectSQL() string {
|
||||
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
|
||||
e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
|
||||
e.source_grant_id, e.created_at_ms, e.updated_at_ms,
|
||||
CASE WHEN eq.entitlement_id IS NULL THEN FALSE ELSE TRUE END AS equipped,
|
||||
` + resourceColumnsWithAlias("r") + `
|
||||
FROM user_resource_entitlements e
|
||||
JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
|
||||
LEFT JOIN user_resource_equipment eq ON eq.app_code = e.app_code
|
||||
AND eq.user_id = e.user_id
|
||||
AND eq.resource_type = r.resource_type
|
||||
AND eq.entitlement_id = e.entitlement_id
|
||||
`
|
||||
}
|
||||
|
||||
@ -2641,8 +2843,8 @@ func validateResourceShopResource(resource resourcedomain.Resource) error {
|
||||
if !resourcedomain.ResourceTypeSellableInShop(resource.ResourceType) {
|
||||
return xerr.New(xerr.InvalidArgument, "resource_type cannot be sold in shop")
|
||||
}
|
||||
if resource.Status != resourcedomain.StatusActive {
|
||||
return xerr.New(xerr.Conflict, "resource is disabled")
|
||||
if err := validateGrantableResource(resource); err != nil {
|
||||
return err
|
||||
}
|
||||
if resourcedomain.NormalizePriceType(resource.PriceType) != resourcedomain.PriceTypeCoin || resource.CoinPrice <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource coin price is required")
|
||||
@ -2650,6 +2852,22 @@ func validateResourceShopResource(resource resourcedomain.Resource) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePurchasableResourceShopItem(item resourcedomain.ResourceShopItem, nowMs int64) error {
|
||||
if item.Status != resourcedomain.StatusActive {
|
||||
return xerr.New(xerr.Conflict, "resource shop item is disabled")
|
||||
}
|
||||
if item.EffectiveFromMS > 0 && item.EffectiveFromMS > nowMs {
|
||||
return xerr.New(xerr.Conflict, "resource shop item is not effective")
|
||||
}
|
||||
if item.EffectiveToMS > 0 && item.EffectiveToMS <= nowMs {
|
||||
return xerr.New(xerr.Conflict, "resource shop item is expired")
|
||||
}
|
||||
if !resourcedomain.ValidShopDurationDays(item.DurationDays) || item.CoinPrice <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "resource shop item price is invalid")
|
||||
}
|
||||
return validateResourceShopResource(item.Resource)
|
||||
}
|
||||
|
||||
func normalizeResourceGroupCommand(command resourcedomain.ResourceGroupCommand) resourcedomain.ResourceGroupCommand {
|
||||
command.GroupCode = strings.TrimSpace(command.GroupCode)
|
||||
command.Name = strings.TrimSpace(command.Name)
|
||||
@ -3038,6 +3256,12 @@ func grantResourceGroupRequestHash(command resourcedomain.GrantResourceGroupComm
|
||||
))
|
||||
}
|
||||
|
||||
func resourceShopPurchaseRequestHash(command resourcedomain.ResourceShopPurchaseCommand) string {
|
||||
return stableHash(fmt.Sprintf("resource_shop_purchase|%s|%d|%d",
|
||||
appcode.Normalize(command.AppCode), command.UserID, command.ShopItemID,
|
||||
))
|
||||
}
|
||||
|
||||
func resourceWalletCreditHash(grantID string, resourceID int64, quantity int64) string {
|
||||
return stableHash(fmt.Sprintf("resource_wallet_credit|%s|%d|%d", grantID, resourceID, quantity))
|
||||
}
|
||||
|
||||
@ -179,7 +179,7 @@ func validateTableName(table string) string {
|
||||
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
|
||||
"coin_seller_stock_records", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests",
|
||||
"vip_levels", "user_vip_memberships", "vip_purchase_orders", "user_vip_history",
|
||||
"resources", "resource_groups", "resource_group_items", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events",
|
||||
"resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events",
|
||||
"resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":
|
||||
return table
|
||||
default:
|
||||
|
||||
@ -336,6 +336,19 @@ func (s *Server) SetResourceShopItemStatus(ctx context.Context, req *walletv1.Se
|
||||
return &walletv1.ResourceShopItemResponse{Item: resourceShopItemToProto(item)}, nil
|
||||
}
|
||||
|
||||
func (s *Server) PurchaseResourceShopItem(ctx context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
|
||||
receipt, err := s.svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
CommandID: req.GetCommandId(),
|
||||
UserID: req.GetUserId(),
|
||||
ShopItemID: req.GetShopItemId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return resourceShopPurchaseReceiptToProto(receipt), nil
|
||||
}
|
||||
|
||||
func resourceCommandFromCreate(req *walletv1.CreateResourceRequest) resourcedomain.ResourceCommand {
|
||||
return resourcedomain.ResourceCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
@ -550,6 +563,18 @@ func resourceShopItemToProto(item resourcedomain.ResourceShopItem) *walletv1.Res
|
||||
}
|
||||
}
|
||||
|
||||
func resourceShopPurchaseReceiptToProto(receipt resourcedomain.ResourceShopPurchaseReceipt) *walletv1.PurchaseResourceShopItemResponse {
|
||||
return &walletv1.PurchaseResourceShopItemResponse{
|
||||
OrderId: receipt.OrderID,
|
||||
TransactionId: receipt.TransactionID,
|
||||
ResourceGrantId: receipt.GrantID,
|
||||
ShopItem: resourceShopItemToProto(receipt.ShopItem),
|
||||
Resource: userResourceToProto(receipt.Resource),
|
||||
Balance: balanceToProto(receipt.Balance),
|
||||
CoinSpent: receipt.CoinSpent,
|
||||
}
|
||||
}
|
||||
|
||||
func managerGrantEnabledOrDefault(value *bool) bool {
|
||||
if value == nil {
|
||||
// 新建/编辑入口如果没显式携带开关,仍按产品规则默认允许经理中心赠送。
|
||||
@ -649,6 +674,7 @@ func userResourceToProto(item resourcedomain.UserResourceEntitlement) *walletv1.
|
||||
SourceGrantId: item.SourceGrantID,
|
||||
CreatedAtMs: item.CreatedAtMS,
|
||||
UpdatedAtMs: item.UpdatedAtMS,
|
||||
Equipped: item.Equipped,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user