新增商品购买

This commit is contained in:
zhx 2026-05-26 08:20:54 +08:00
parent 636a0085d0
commit 4398a442b4
19 changed files with 1659 additions and 460 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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展示层按用户本地时区格式化不要把本地时区传回后端做背包有效期判断。

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
}
}

View File

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

View File

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

View File

@ -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 引用',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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