礼物墙
This commit is contained in:
parent
7f64a8b443
commit
dbc5c5ec88
File diff suppressed because it is too large
Load Diff
@ -618,6 +618,45 @@ message GetWalletValueSummaryResponse {
|
||||
WalletValueSummary summary = 1;
|
||||
}
|
||||
|
||||
// GiftWallItem 是用户礼物墙按礼物类型聚合后的当前投影。
|
||||
message GiftWallItem {
|
||||
string gift_id = 1;
|
||||
string gift_name = 2;
|
||||
int64 resource_id = 3;
|
||||
Resource resource = 4;
|
||||
string resource_snapshot_json = 5;
|
||||
string gift_type_code = 6;
|
||||
string presentation_json = 7;
|
||||
int64 gift_count = 8;
|
||||
int64 total_value = 9;
|
||||
int64 total_coin_value = 10;
|
||||
int64 total_diamond_value = 11;
|
||||
int64 total_gift_point = 12;
|
||||
int64 total_heat_value = 13;
|
||||
string charge_asset_type = 14;
|
||||
string last_price_version = 15;
|
||||
int64 first_received_at_ms = 16;
|
||||
int64 last_received_at_ms = 17;
|
||||
int32 sort_order = 18;
|
||||
}
|
||||
|
||||
message GetUserGiftWallRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
}
|
||||
|
||||
message GetUserGiftWallResponse {
|
||||
repeated GiftWallItem items = 1;
|
||||
int64 gift_kind_count = 2;
|
||||
int64 gift_total_count = 3;
|
||||
int64 total_value = 4;
|
||||
int64 total_coin_value = 5;
|
||||
int64 total_diamond_value = 6;
|
||||
int64 total_gift_point = 7;
|
||||
int64 total_heat_value = 8;
|
||||
}
|
||||
|
||||
// RechargeProduct 是 App 充值页可展示的区域化充值档位。
|
||||
message RechargeProduct {
|
||||
int64 product_id = 1;
|
||||
@ -931,6 +970,7 @@ service WalletService {
|
||||
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
|
||||
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
|
||||
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
||||
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);
|
||||
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
|
||||
rpc ListAdminRechargeProducts(ListAdminRechargeProductsRequest) returns (ListAdminRechargeProductsResponse);
|
||||
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
|
||||
|
||||
@ -46,6 +46,7 @@ const (
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
|
||||
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
||||
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
|
||||
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
|
||||
WalletService_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
|
||||
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
|
||||
@ -94,6 +95,7 @@ type WalletServiceClient interface {
|
||||
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)
|
||||
GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error)
|
||||
ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, error)
|
||||
ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error)
|
||||
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
|
||||
@ -387,6 +389,16 @@ func (c *walletServiceClient) GetWalletValueSummary(ctx context.Context, in *Get
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetUserGiftWallResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetUserGiftWall_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRechargeProductsResponse)
|
||||
@ -550,6 +562,7 @@ type WalletServiceServer interface {
|
||||
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
|
||||
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
||||
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
|
||||
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
|
||||
ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error)
|
||||
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
|
||||
@ -654,6 +667,9 @@ func (UnimplementedWalletServiceServer) GetWalletOverview(context.Context, *GetW
|
||||
func (UnimplementedWalletServiceServer) GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetWalletValueSummary not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUserGiftWall not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeProducts not implemented")
|
||||
}
|
||||
@ -1200,6 +1216,24 @@ func _WalletService_GetWalletValueSummary_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetUserGiftWall_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserGiftWallRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetUserGiftWall(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetUserGiftWall_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetUserGiftWall(ctx, req.(*GetUserGiftWallRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListRechargeProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRechargeProductsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1549,6 +1583,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetWalletValueSummary",
|
||||
Handler: _WalletService_GetWalletValueSummary_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUserGiftWall",
|
||||
Handler: _WalletService_GetUserGiftWall_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRechargeProducts",
|
||||
Handler: _WalletService_ListRechargeProducts_Handler,
|
||||
|
||||
158
docs/flutter对接/礼物墙Flutter对接.md
Normal file
158
docs/flutter对接/礼物墙Flutter对接.md
Normal file
@ -0,0 +1,158 @@
|
||||
# Flutter 礼物墙对接
|
||||
|
||||
本文描述 Flutter App 查询当前登录用户礼物墙的 HTTP 接口。礼物墙事实由 `wallet-service` 在送礼扣费成功后通过异步投影聚合,客户端只展示 gateway 返回的当前投影,不从房间消息或本地缓存推算收礼数量。
|
||||
|
||||
## 接口
|
||||
|
||||
`GET /api/v1/users/me/gift-wall`
|
||||
|
||||
登录接口,需要 `Authorization: Bearer <access_token>`。接口固定查询当前登录用户,不接受客户端传 `user_id`。
|
||||
|
||||
一致性边界:礼物墙是最终一致读模型,送礼成功后允许延迟数秒到几十秒才出现在接口结果中。客户端不要用该接口判断刚刚送礼是否成功;送礼成功状态仍以送礼接口响应和房间 IM 消息为准。
|
||||
|
||||
请求头:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | App 登录 access token。 |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/v1/users/me/gift-wall
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"gift_id": "rose",
|
||||
"gift_name": "Rose",
|
||||
"resource_id": 11,
|
||||
"resource": {
|
||||
"resource_id": 11,
|
||||
"resource_code": "gift_rose",
|
||||
"resource_type": "gift",
|
||||
"name": "Rose",
|
||||
"status": "active",
|
||||
"grantable": true,
|
||||
"grant_strategy": "increase_quantity",
|
||||
"usage_scopes": ["gift"],
|
||||
"asset_url": "https://cdn.example/rose.png",
|
||||
"preview_url": "",
|
||||
"animation_url": "",
|
||||
"metadata_json": "{}",
|
||||
"sort_order": 10,
|
||||
"created_at_ms": 1710000000000,
|
||||
"updated_at_ms": 1710000000000
|
||||
},
|
||||
"resource_snapshot_json": "{\"ResourceID\":11}",
|
||||
"gift_type_code": "normal",
|
||||
"presentation_json": "{}",
|
||||
"gift_count": 5,
|
||||
"total_value": 50,
|
||||
"total_coin_value": 50,
|
||||
"total_diamond_value": 0,
|
||||
"total_gift_point": 50,
|
||||
"total_heat_value": 50,
|
||||
"charge_asset_type": "COIN",
|
||||
"last_price_version": "v1",
|
||||
"first_received_at_ms": 1710000000000,
|
||||
"last_received_at_ms": 1710000005000,
|
||||
"sort_order": 10
|
||||
}
|
||||
],
|
||||
"gift_kind_count": 1,
|
||||
"gift_total_count": 5,
|
||||
"total_value": 50,
|
||||
"total_coin_value": 50,
|
||||
"total_diamond_value": 0,
|
||||
"total_gift_point": 50,
|
||||
"total_heat_value": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `items` | 按 `gift_id` 聚合后的礼物列表,固定按后台礼物排序、价值和最近收礼时间排序。 |
|
||||
| `gift_count` | 当前用户收到该礼物的累计个数。 |
|
||||
| `gift_kind_count` | 当前用户收到过的礼物种类数,等于 `items.length`。 |
|
||||
| `gift_total_count` | 当前用户收到的所有礼物数量总和。 |
|
||||
| `total_value` | 礼物总价值,按送礼时服务端结算的 `charge_amount` 累加。 |
|
||||
| `total_coin_value` / `total_diamond_value` | 按收费资产拆分的总价值;当前普通礼物通常只读 `total_coin_value`。 |
|
||||
| `total_gift_point` | 主播礼物积分累计值,不等同于可提现余额。 |
|
||||
| `total_heat_value` | 房间热度累计值,可用于展示贡献热度,不要反推价格。 |
|
||||
| `charge_asset_type` | 该礼物历史上只用一种资产收费时为 `COIN` 或 `DIAMOND`;发生过资产切换时为 `MIXED`。 |
|
||||
| `resource` | 礼物展示资源快照,客户端优先用 `asset_url` / `preview_url` / `animation_url` 渲染。 |
|
||||
| `resource_snapshot_json` | 原始资源快照 JSON,主要用于客户端兜底和日志排查。 |
|
||||
| `*_ms` | Unix epoch milliseconds;展示层按用户本地时区格式化。 |
|
||||
|
||||
## Flutter 解析示例
|
||||
|
||||
```dart
|
||||
class GiftWall {
|
||||
GiftWall({
|
||||
required this.items,
|
||||
required this.giftKindCount,
|
||||
required this.giftTotalCount,
|
||||
required this.totalValue,
|
||||
});
|
||||
|
||||
final List<GiftWallItem> items;
|
||||
final int giftKindCount;
|
||||
final int giftTotalCount;
|
||||
final int totalValue;
|
||||
|
||||
factory GiftWall.fromJson(Map<String, dynamic> json) {
|
||||
return GiftWall(
|
||||
items: (json['items'] as List<dynamic>? ?? const [])
|
||||
.map((item) => GiftWallItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
giftKindCount: (json['gift_kind_count'] as num?)?.toInt() ?? 0,
|
||||
giftTotalCount: (json['gift_total_count'] as num?)?.toInt() ?? 0,
|
||||
totalValue: (json['total_value'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GiftWallItem {
|
||||
GiftWallItem({
|
||||
required this.giftId,
|
||||
required this.giftName,
|
||||
required this.giftCount,
|
||||
required this.totalValue,
|
||||
required this.assetUrl,
|
||||
});
|
||||
|
||||
final String giftId;
|
||||
final String giftName;
|
||||
final int giftCount;
|
||||
final int totalValue;
|
||||
final String assetUrl;
|
||||
|
||||
factory GiftWallItem.fromJson(Map<String, dynamic> json) {
|
||||
final resource = json['resource'] as Map<String, dynamic>? ?? const {};
|
||||
return GiftWallItem(
|
||||
giftId: json['gift_id'] as String? ?? '',
|
||||
giftName: json['gift_name'] as String? ?? '',
|
||||
giftCount: (json['gift_count'] as num?)?.toInt() ?? 0,
|
||||
totalValue: (json['total_value'] as num?)?.toInt() ?? 0,
|
||||
assetUrl: resource['asset_url'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
调用时先解析外层 envelope:只有 `code == "OK"` 时读取 `data`;其他错误按统一网关错误处理,并把 `request_id` 写入客户端日志。
|
||||
@ -35,6 +35,7 @@
|
||||
| GET | `/api/v1/users/me/identity` | users | `getMyIdentity` | 查询当前用户展示短号状态 |
|
||||
| GET | `/api/v1/users/me/host-identity` | users | `getMyHostIdentity` | 查询当前用户 Host/Agency/BD 身份 |
|
||||
| GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 |
|
||||
| GET | `/api/v1/users/me/gift-wall` | users | `getMyGiftWall` | 查询当前用户收到的礼物墙聚合 |
|
||||
| GET | `/api/v1/users/me/role-summary` | users | `getMyRoleSummary` | 查询当前用户角色摘要 |
|
||||
| POST | `/api/v1/users/me/onboarding/complete` | users | `completeMyOnboarding` | 完成注册页必填资料 |
|
||||
| POST | `/api/v1/users/me/profile/update` | users | `updateMyProfile` | 修改当前用户基础资料 |
|
||||
|
||||
@ -13,6 +13,7 @@ type WalletClient interface {
|
||||
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
|
||||
GetWalletOverview(ctx context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error)
|
||||
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
|
||||
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
|
||||
ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error)
|
||||
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
|
||||
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
|
||||
@ -50,6 +51,10 @@ func (c *grpcWalletClient) GetWalletValueSummary(ctx context.Context, req *walle
|
||||
return c.client.GetWalletValueSummary(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) {
|
||||
return c.client.GetUserGiftWall(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) {
|
||||
return c.client.ListRechargeProducts(ctx, req)
|
||||
}
|
||||
|
||||
@ -64,6 +64,38 @@ type walletTransactionData struct {
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
type giftWallData struct {
|
||||
Items []giftWallItemData `json:"items"`
|
||||
GiftKindCount int64 `json:"gift_kind_count"`
|
||||
GiftTotalCount int64 `json:"gift_total_count"`
|
||||
TotalValue int64 `json:"total_value"`
|
||||
TotalCoinValue int64 `json:"total_coin_value"`
|
||||
TotalDiamondValue int64 `json:"total_diamond_value"`
|
||||
TotalGiftPoint int64 `json:"total_gift_point"`
|
||||
TotalHeatValue int64 `json:"total_heat_value"`
|
||||
}
|
||||
|
||||
type giftWallItemData struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
GiftName string `json:"gift_name"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
Resource *resourceData `json:"resource,omitempty"`
|
||||
ResourceSnapshotJSON string `json:"resource_snapshot_json"`
|
||||
GiftTypeCode string `json:"gift_type_code"`
|
||||
PresentationJSON string `json:"presentation_json"`
|
||||
GiftCount int64 `json:"gift_count"`
|
||||
TotalValue int64 `json:"total_value"`
|
||||
TotalCoinValue int64 `json:"total_coin_value"`
|
||||
TotalDiamondValue int64 `json:"total_diamond_value"`
|
||||
TotalGiftPoint int64 `json:"total_gift_point"`
|
||||
TotalHeatValue int64 `json:"total_heat_value"`
|
||||
ChargeAssetType string `json:"charge_asset_type"`
|
||||
LastPriceVersion string `json:"last_price_version"`
|
||||
FirstReceivedAtMS int64 `json:"first_received_at_ms"`
|
||||
LastReceivedAtMS int64 `json:"last_received_at_ms"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
type withdrawalApplyRequestBody struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDAlt string `json:"commandId"`
|
||||
@ -185,6 +217,24 @@ func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *
|
||||
httpkit.WriteOK(writer, request, map[string]any{"rules": rules})
|
||||
}
|
||||
|
||||
// getMyGiftWall 返回当前登录用户已经收到的礼物墙;gateway 不接受 user_id,避免越权查询他人收礼事实。
|
||||
func (h *Handler) getMyGiftWall(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
resp, err := h.walletClient.GetUserGiftWall(request.Context(), &walletv1.GetUserGiftWallRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, giftWallFromProto(resp))
|
||||
}
|
||||
|
||||
// listWalletTransactions 返回当前用户钱包流水分页。
|
||||
func (h *Handler) listWalletTransactions(writer http.ResponseWriter, request *http.Request) {
|
||||
h.listWalletTransactionsByAsset(writer, request, strings.TrimSpace(request.URL.Query().Get("asset_type")), 20)
|
||||
@ -345,6 +395,52 @@ func walletTransactionFromProto(item *walletv1.WalletTransaction) walletTransact
|
||||
}
|
||||
}
|
||||
|
||||
func giftWallFromProto(resp *walletv1.GetUserGiftWallResponse) giftWallData {
|
||||
if resp == nil {
|
||||
return giftWallData{}
|
||||
}
|
||||
items := make([]giftWallItemData, 0, len(resp.GetItems()))
|
||||
for _, item := range resp.GetItems() {
|
||||
items = append(items, giftWallItemFromProto(item))
|
||||
}
|
||||
return giftWallData{
|
||||
Items: items,
|
||||
GiftKindCount: resp.GetGiftKindCount(),
|
||||
GiftTotalCount: resp.GetGiftTotalCount(),
|
||||
TotalValue: resp.GetTotalValue(),
|
||||
TotalCoinValue: resp.GetTotalCoinValue(),
|
||||
TotalDiamondValue: resp.GetTotalDiamondValue(),
|
||||
TotalGiftPoint: resp.GetTotalGiftPoint(),
|
||||
TotalHeatValue: resp.GetTotalHeatValue(),
|
||||
}
|
||||
}
|
||||
|
||||
func giftWallItemFromProto(item *walletv1.GiftWallItem) giftWallItemData {
|
||||
if item == nil {
|
||||
return giftWallItemData{}
|
||||
}
|
||||
return giftWallItemData{
|
||||
GiftID: item.GetGiftId(),
|
||||
GiftName: item.GetGiftName(),
|
||||
ResourceID: item.GetResourceId(),
|
||||
Resource: resourcePointerFromProto(item.GetResource()),
|
||||
ResourceSnapshotJSON: item.GetResourceSnapshotJson(),
|
||||
GiftTypeCode: item.GetGiftTypeCode(),
|
||||
PresentationJSON: item.GetPresentationJson(),
|
||||
GiftCount: item.GetGiftCount(),
|
||||
TotalValue: item.GetTotalValue(),
|
||||
TotalCoinValue: item.GetTotalCoinValue(),
|
||||
TotalDiamondValue: item.GetTotalDiamondValue(),
|
||||
TotalGiftPoint: item.GetTotalGiftPoint(),
|
||||
TotalHeatValue: item.GetTotalHeatValue(),
|
||||
ChargeAssetType: item.GetChargeAssetType(),
|
||||
LastPriceVersion: item.GetLastPriceVersion(),
|
||||
FirstReceivedAtMS: item.GetFirstReceivedAtMs(),
|
||||
LastReceivedAtMS: item.GetLastReceivedAtMs(),
|
||||
SortOrder: item.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawalFromProto(item *walletv1.WithdrawalRequest) withdrawalData {
|
||||
if item == nil {
|
||||
return withdrawalData{}
|
||||
|
||||
@ -57,6 +57,7 @@ type UserHandlers struct {
|
||||
BatchUserProfiles http.HandlerFunc
|
||||
BatchRoomDisplayProfiles http.HandlerFunc
|
||||
GetMyOverview http.HandlerFunc
|
||||
GetMyGiftWall http.HandlerFunc
|
||||
GetMyIdentity http.HandlerFunc
|
||||
GetMyHostIdentity http.HandlerFunc
|
||||
GetMyRoleSummary http.HandlerFunc
|
||||
@ -226,6 +227,7 @@ func (r routes) registerUserRoutes() {
|
||||
r.profile("/users/profiles:batch", "", h.BatchUserProfiles)
|
||||
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
|
||||
r.profile("/users/me/overview", "", h.GetMyOverview)
|
||||
r.profile("/users/me/gift-wall", http.MethodGet, h.GetMyGiftWall)
|
||||
r.auth("/users/me/identity", "", h.GetMyIdentity)
|
||||
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
|
||||
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary)
|
||||
|
||||
@ -303,6 +303,8 @@ type fakeWalletClient struct {
|
||||
lastValueSummary *walletv1.GetWalletValueSummaryRequest
|
||||
valueSummaryResp *walletv1.GetWalletValueSummaryResponse
|
||||
valueSummaryErr error
|
||||
lastGiftWall *walletv1.GetUserGiftWallRequest
|
||||
giftWallResp *walletv1.GetUserGiftWallResponse
|
||||
lastRechargeProducts *walletv1.ListRechargeProductsRequest
|
||||
rechargeProductsResp *walletv1.ListRechargeProductsResponse
|
||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
||||
@ -889,6 +891,17 @@ func (f *fakeWalletClient) GetWalletValueSummary(_ context.Context, req *walletv
|
||||
return &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{CoinAmount: coinAmount}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GetUserGiftWall(_ context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) {
|
||||
f.lastGiftWall = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.giftWallResp != nil {
|
||||
return f.giftWallResp, nil
|
||||
}
|
||||
return &walletv1.GetUserGiftWallResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) {
|
||||
f.lastRechargeProducts = req
|
||||
if f.err != nil {
|
||||
@ -2657,6 +2670,67 @@ func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyGiftWallUsesAuthenticatedUserID(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{giftWallResp: &walletv1.GetUserGiftWallResponse{
|
||||
GiftKindCount: 1,
|
||||
GiftTotalCount: 5,
|
||||
TotalValue: 50,
|
||||
TotalCoinValue: 50,
|
||||
Items: []*walletv1.GiftWallItem{{
|
||||
GiftId: "rose",
|
||||
GiftName: "Rose",
|
||||
ResourceId: 11,
|
||||
GiftTypeCode: "normal",
|
||||
PresentationJson: "{}",
|
||||
GiftCount: 5,
|
||||
TotalValue: 50,
|
||||
TotalCoinValue: 50,
|
||||
ChargeAssetType: "COIN",
|
||||
LastPriceVersion: "v1",
|
||||
Resource: &walletv1.Resource{
|
||||
ResourceId: 11,
|
||||
ResourceCode: "gift_rose",
|
||||
ResourceType: "gift",
|
||||
Name: "Rose",
|
||||
AssetUrl: "https://cdn.example/rose.png",
|
||||
},
|
||||
ResourceSnapshotJson: `{"ResourceID":11}`,
|
||||
LastReceivedAtMs: 1_700_000_000_000,
|
||||
}},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/gift-wall", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-gift-wall")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.lastGiftWall == nil || walletClient.lastGiftWall.GetUserId() != 42 ||
|
||||
walletClient.lastGiftWall.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-gift-wall") {
|
||||
t.Fatalf("gift wall request mismatch: %+v", walletClient.lastGiftWall)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
items, itemsOK := data["items"].([]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 || data["gift_total_count"] != float64(5) || data["total_value"] != float64(50) {
|
||||
t.Fatalf("gift wall response mismatch: %+v", response)
|
||||
}
|
||||
item, ok := items[0].(map[string]any)
|
||||
resource, resourceOK := item["resource"].(map[string]any)
|
||||
if !ok || item["gift_id"] != "rose" || item["gift_count"] != float64(5) || !resourceOK || resource["asset_url"] == "" {
|
||||
t.Fatalf("gift wall item mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCoinTransactionsForcesCoinAndDefaultsPageSize30(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
|
||||
Total: 1,
|
||||
|
||||
@ -47,6 +47,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
BatchUserProfiles: h.batchUserProfiles,
|
||||
BatchRoomDisplayProfiles: h.batchRoomDisplayProfiles,
|
||||
GetMyOverview: h.getMyOverview,
|
||||
GetMyGiftWall: h.getMyGiftWall,
|
||||
GetMyIdentity: h.getMyIdentity,
|
||||
GetMyHostIdentity: h.getMyHostIdentity,
|
||||
GetMyRoleSummary: h.getMyRoleSummary,
|
||||
|
||||
@ -338,6 +338,49 @@ CREATE TABLE IF NOT EXISTS gift_config_regions (
|
||||
KEY idx_gift_config_regions_region (app_code, region_id, gift_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_gift_wall (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
user_id BIGINT NOT NULL,
|
||||
gift_id VARCHAR(96) NOT NULL,
|
||||
gift_name VARCHAR(128) NOT NULL,
|
||||
resource_id BIGINT NOT NULL DEFAULT 0,
|
||||
resource_snapshot_json JSON NOT NULL,
|
||||
gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||||
presentation_json JSON NULL,
|
||||
gift_count BIGINT NOT NULL DEFAULT 0,
|
||||
total_value BIGINT NOT NULL DEFAULT 0,
|
||||
total_coin_value BIGINT NOT NULL DEFAULT 0,
|
||||
total_diamond_value BIGINT NOT NULL DEFAULT 0,
|
||||
total_gift_point BIGINT NOT NULL DEFAULT 0,
|
||||
total_heat_value BIGINT NOT NULL DEFAULT 0,
|
||||
charge_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN',
|
||||
last_price_version VARCHAR(64) NOT NULL DEFAULT '',
|
||||
first_received_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_received_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, user_id, gift_id),
|
||||
KEY idx_user_gift_wall_user_sort (app_code, user_id, sort_order, total_value, last_received_at_ms),
|
||||
KEY idx_user_gift_wall_gift (app_code, gift_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_projection_events (
|
||||
projection_name VARCHAR(64) NOT NULL,
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
locked_by VARCHAR(96) NOT NULL DEFAULT '',
|
||||
locked_until_ms BIGINT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
processed_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (projection_name, app_code, event_id),
|
||||
KEY idx_wallet_projection_claim (projection_name, app_code, status, locked_until_ms, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
grant_id VARCHAR(96) NOT NULL,
|
||||
|
||||
@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
@ -27,6 +28,10 @@ type App struct {
|
||||
health *grpchealth.ServingChecker
|
||||
healthHTTP *healthhttp.Server
|
||||
mysqlRepo *mysqlstorage.Repository
|
||||
walletSvc *walletservice.Service
|
||||
nodeID string
|
||||
stopWorker context.CancelFunc
|
||||
workers sync.WaitGroup
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
@ -68,15 +73,19 @@ func New(cfg config.Config) (*App, error) {
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
mysqlRepo: repository,
|
||||
walletSvc: svc,
|
||||
nodeID: cfg.NodeID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run 启动 gRPC 服务。
|
||||
func (a *App) Run() error {
|
||||
a.runHealthHTTP()
|
||||
a.runGiftWallProjectionWorker()
|
||||
a.health.MarkServing()
|
||||
err := a.server.Serve(a.listener)
|
||||
a.health.MarkStopped()
|
||||
a.closeGiftWallProjectionWorker()
|
||||
if errors.Is(err, grpc.ErrServerStopped) {
|
||||
return nil
|
||||
}
|
||||
@ -88,6 +97,7 @@ func (a *App) Run() error {
|
||||
func (a *App) Close() {
|
||||
a.closeOnce.Do(func() {
|
||||
a.health.MarkDraining()
|
||||
a.closeGiftWallProjectionWorker()
|
||||
grpcshutdown.GracefulStop(a.server, 15*time.Second)
|
||||
a.closeHealthHTTP()
|
||||
if a.mysqlRepo != nil {
|
||||
@ -97,6 +107,45 @@ func (a *App) Close() {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) runGiftWallProjectionWorker() {
|
||||
if a.walletSvc == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.stopWorker = cancel
|
||||
workerID := "gift-wall-" + a.nodeID
|
||||
a.workers.Add(1)
|
||||
go func() {
|
||||
defer a.workers.Done()
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
// 礼物墙是展示读模型,worker 常驻在 wallet-service 内消费账务 outbox。
|
||||
// 服务重启会回滚未提交的投影事务;已完成事件由 projection done 状态防重。
|
||||
processed, err := a.walletSvc.ProjectPendingGiftWallEvents(ctx, workerID, 50, 30*time.Second)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
logx.Error(ctx, "gift_wall_projection_failed", err, slog.String("worker_id", workerID))
|
||||
}
|
||||
if processed >= 50 {
|
||||
// 批次打满说明还有积压,立即继续 drain,避免固定 tick 放大展示延迟。
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) closeGiftWallProjectionWorker() {
|
||||
if a.stopWorker != nil {
|
||||
a.stopWorker()
|
||||
}
|
||||
a.workers.Wait()
|
||||
}
|
||||
|
||||
func (a *App) runHealthHTTP() {
|
||||
if a.healthHTTP == nil {
|
||||
return
|
||||
|
||||
@ -187,6 +187,62 @@ type WalletValueSummary struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// GiftWallResource 是礼物墙展示所需的资源快照字段,避免读取接口再依赖资源配置实时状态。
|
||||
type GiftWallResource struct {
|
||||
AppCode string
|
||||
ResourceID int64
|
||||
ResourceCode string
|
||||
ResourceType string
|
||||
Name string
|
||||
Status string
|
||||
Grantable bool
|
||||
GrantStrategy string
|
||||
WalletAssetType string
|
||||
WalletAssetAmount int64
|
||||
UsageScopes []string
|
||||
AssetURL string
|
||||
PreviewURL string
|
||||
AnimationURL string
|
||||
MetadataJSON string
|
||||
SortOrder int32
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// GiftWallItem 是当前用户收到礼物的按 gift_id 聚合投影。
|
||||
type GiftWallItem struct {
|
||||
GiftID string
|
||||
GiftName string
|
||||
ResourceID int64
|
||||
Resource GiftWallResource
|
||||
ResourceSnapshotJSON string
|
||||
GiftTypeCode string
|
||||
PresentationJSON string
|
||||
GiftCount int64
|
||||
TotalValue int64
|
||||
TotalCoinValue int64
|
||||
TotalDiamondValue int64
|
||||
TotalGiftPoint int64
|
||||
TotalHeatValue int64
|
||||
ChargeAssetType string
|
||||
LastPriceVersion string
|
||||
FirstReceivedAtMS int64
|
||||
LastReceivedAtMS int64
|
||||
SortOrder int32
|
||||
}
|
||||
|
||||
// UserGiftWall 汇总礼物墙首屏需要的全量统计和按礼物类型聚合列表。
|
||||
type UserGiftWall struct {
|
||||
Items []GiftWallItem
|
||||
GiftKindCount int64
|
||||
GiftTotalCount int64
|
||||
TotalValue int64
|
||||
TotalCoinValue int64
|
||||
TotalDiamondValue int64
|
||||
TotalGiftPoint int64
|
||||
TotalHeatValue int64
|
||||
}
|
||||
|
||||
// RechargeProduct 是用户充值页读取的充值档位。
|
||||
type RechargeProduct struct {
|
||||
AppCode string
|
||||
|
||||
@ -3,6 +3,7 @@ package wallet
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
@ -22,6 +23,7 @@ type Repository interface {
|
||||
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
|
||||
GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error)
|
||||
GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error)
|
||||
GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error)
|
||||
ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string) ([]ledger.RechargeProduct, []string, error)
|
||||
ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error)
|
||||
CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
|
||||
@ -52,6 +54,7 @@ type Repository interface {
|
||||
ListUserResources(ctx context.Context, query resourcedomain.ListUserResourcesQuery) ([]resourcedomain.UserResourceEntitlement, error)
|
||||
EquipUserResource(ctx context.Context, command resourcedomain.EquipUserResourceCommand) (resourcedomain.UserResourceEntitlement, error)
|
||||
ListResourceGrants(ctx context.Context, query resourcedomain.ListResourceGrantsQuery) ([]resourcedomain.ResourceGrant, int64, error)
|
||||
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
|
||||
}
|
||||
|
||||
// Service 承载钱包账务用例。
|
||||
@ -289,6 +292,30 @@ func (s *Service) GetWalletValueSummary(ctx context.Context, userID int64) (ledg
|
||||
return s.repository.GetWalletValueSummary(ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserGiftWall 返回用户收礼聚合投影;收礼事实只来自成功送礼账务,不读取房间运行态。
|
||||
func (s *Service) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) {
|
||||
if userID <= 0 {
|
||||
return ledger.UserGiftWall{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
|
||||
return s.repository.GetUserGiftWall(ctx, userID)
|
||||
}
|
||||
|
||||
// ProjectPendingGiftWallEvents 消费钱包 outbox 中的送礼成功事件,异步维护用户礼物墙投影。
|
||||
func (s *Service) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL)
|
||||
}
|
||||
|
||||
// ListRechargeProducts 返回区域化充值档位;region_id 必须由 gateway 从 user-service 资料解析。
|
||||
func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string) ([]ledger.RechargeProduct, []string, error) {
|
||||
if userID <= 0 || regionID <= 0 {
|
||||
|
||||
@ -61,6 +61,63 @@ func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。
|
||||
func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(10001, 1000)
|
||||
repository.SetGiftPrice("rose", "v9", 7, 3, 11)
|
||||
repository.SetGiftPrice("rocket", "v2", 100, 50, 200)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
commands := []ledger.DebitGiftCommand{
|
||||
{CommandID: "cmd-wall-rose-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9"},
|
||||
{CommandID: "cmd-wall-rose-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 3, PriceVersion: "v9"},
|
||||
{CommandID: "cmd-wall-rocket", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rocket", GiftCount: 1, PriceVersion: "v2"},
|
||||
}
|
||||
for _, command := range commands {
|
||||
if _, err := svc.DebitGift(context.Background(), command); err != nil {
|
||||
t.Fatalf("DebitGift %s failed: %v", command.CommandID, err)
|
||||
}
|
||||
}
|
||||
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 {
|
||||
t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got)
|
||||
}
|
||||
processed, err := svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPendingGiftWallEvents failed: %v", err)
|
||||
}
|
||||
if processed != 3 {
|
||||
t.Fatalf("projection should process three gift events, got %d", processed)
|
||||
}
|
||||
processed, err = svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectPendingGiftWallEvents retry failed: %v", err)
|
||||
}
|
||||
if processed != 0 {
|
||||
t.Fatalf("projection must be idempotent after done events, got %d", processed)
|
||||
}
|
||||
|
||||
wall, err := svc.GetUserGiftWall(context.Background(), 10002)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserGiftWall failed: %v", err)
|
||||
}
|
||||
if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalGiftPoint != 65 || wall.TotalHeatValue != 255 {
|
||||
t.Fatalf("gift wall summary mismatch: %+v", wall)
|
||||
}
|
||||
if len(wall.Items) != 2 || wall.Items[0].GiftID != "rose" || wall.Items[0].GiftCount != 5 || wall.Items[0].TotalValue != 35 {
|
||||
t.Fatalf("rose gift wall item mismatch: %+v", wall.Items)
|
||||
}
|
||||
if wall.Items[0].Resource.ResourceID == 0 || wall.Items[0].ResourceSnapshotJSON == "" {
|
||||
t.Fatalf("gift wall should keep display resource snapshot: %+v", wall.Items[0])
|
||||
}
|
||||
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 {
|
||||
t.Fatalf("gift wall should keep one row per gift_id, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "user_gift_wall", "done"); got != 3 {
|
||||
t.Fatalf("projection should mark each gift event done, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。
|
||||
func TestDebitGiftIsIdempotent(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -51,6 +51,77 @@ func (r *Repository) GetWalletValueSummary(ctx context.Context, userID int64) (l
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserGiftWall 读取送礼事务维护的收礼聚合表;它只表达已扣费成功的礼物事实。
|
||||
func (r *Repository) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
appCode := appcode.FromContext(ctx)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT gift_id, gift_name, resource_id, COALESCE(CAST(resource_snapshot_json AS CHAR), '{}'),
|
||||
gift_type_code, COALESCE(CAST(presentation_json AS CHAR), '{}'), gift_count,
|
||||
total_value, total_coin_value, total_diamond_value, total_gift_point, total_heat_value,
|
||||
charge_asset_type, last_price_version, first_received_at_ms, last_received_at_ms, sort_order
|
||||
FROM user_gift_wall
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
ORDER BY sort_order ASC, total_value DESC, last_received_at_ms DESC, gift_id ASC`,
|
||||
appCode, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return ledger.UserGiftWall{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
wall := ledger.UserGiftWall{Items: make([]ledger.GiftWallItem, 0)}
|
||||
for rows.Next() {
|
||||
var item ledger.GiftWallItem
|
||||
if err := rows.Scan(
|
||||
&item.GiftID,
|
||||
&item.GiftName,
|
||||
&item.ResourceID,
|
||||
&item.ResourceSnapshotJSON,
|
||||
&item.GiftTypeCode,
|
||||
&item.PresentationJSON,
|
||||
&item.GiftCount,
|
||||
&item.TotalValue,
|
||||
&item.TotalCoinValue,
|
||||
&item.TotalDiamondValue,
|
||||
&item.TotalGiftPoint,
|
||||
&item.TotalHeatValue,
|
||||
&item.ChargeAssetType,
|
||||
&item.LastPriceVersion,
|
||||
&item.FirstReceivedAtMS,
|
||||
&item.LastReceivedAtMS,
|
||||
&item.SortOrder,
|
||||
); err != nil {
|
||||
return ledger.UserGiftWall{}, err
|
||||
}
|
||||
item.Resource = giftWallResourceFromJSON(item.ResourceSnapshotJSON)
|
||||
wall.Items = append(wall.Items, item)
|
||||
wall.GiftKindCount++
|
||||
wall.GiftTotalCount += item.GiftCount
|
||||
wall.TotalValue += item.TotalValue
|
||||
wall.TotalCoinValue += item.TotalCoinValue
|
||||
wall.TotalDiamondValue += item.TotalDiamondValue
|
||||
wall.TotalGiftPoint += item.TotalGiftPoint
|
||||
wall.TotalHeatValue += item.TotalHeatValue
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return ledger.UserGiftWall{}, err
|
||||
}
|
||||
return wall, nil
|
||||
}
|
||||
|
||||
func giftWallResourceFromJSON(raw string) ledger.GiftWallResource {
|
||||
var resource ledger.GiftWallResource
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return resource
|
||||
}
|
||||
// 礼物墙统计不能因为展示资源快照异常而丢失;原始 JSON 仍会返回给客户端用于排查。
|
||||
_ = json.Unmarshal([]byte(raw), &resource)
|
||||
return resource
|
||||
}
|
||||
|
||||
// GetDiamondExchangeConfig 返回后台配置的钻石兑换规则,未配置时返回空列表而不是 gateway 默认值。
|
||||
func (r *Repository) GetDiamondExchangeConfig(ctx context.Context, _ int64) ([]ledger.DiamondExchangeRule, error) {
|
||||
if r == nil || r.db == nil {
|
||||
|
||||
@ -0,0 +1,311 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
const (
|
||||
giftWallProjectionName = "user_gift_wall"
|
||||
walletGiftDebitedEvent = "WalletGiftDebited"
|
||||
|
||||
// projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。
|
||||
// 这里不复用 wallet_outbox.status,避免展示读模型反向污染账务事件分发语义。
|
||||
projectionStatusProcessing = "processing"
|
||||
projectionStatusFailed = "failed"
|
||||
projectionStatusDone = "done"
|
||||
projectionStatusDead = "dead"
|
||||
projectionMaxRetries = 10
|
||||
)
|
||||
|
||||
type giftWallProjectionCandidate struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
}
|
||||
|
||||
type giftWallProjectionEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
TransactionID string
|
||||
CommandID string
|
||||
PayloadJSON string
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// ProjectPendingGiftWallEvents 把 WalletGiftDebited outbox 事件异步投影到 user_gift_wall。
|
||||
// 扣费主事务只写账务事实和 outbox,礼物墙由 worker 后补,因此查询天然允许短暂延迟。
|
||||
// 单条事件的聚合写入和 done 标记在同一事务内提交;服务重启时未提交事务回滚,已 done 的事件不会重复聚合。
|
||||
func (r *Repository) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = 30 * time.Second
|
||||
}
|
||||
|
||||
candidates, err := r.listGiftWallProjectionCandidates(ctx, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
for _, candidate := range candidates {
|
||||
ok, err := r.projectGiftWallEvent(ctx, candidate, workerID, lockTTL)
|
||||
if err != nil {
|
||||
return processed, err
|
||||
}
|
||||
if ok {
|
||||
processed++
|
||||
}
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT wo.app_code, wo.event_id
|
||||
FROM wallet_outbox wo
|
||||
LEFT JOIN wallet_projection_events pe
|
||||
ON pe.projection_name = ? AND pe.app_code = wo.app_code AND pe.event_id = wo.event_id
|
||||
WHERE wo.event_type = ?
|
||||
AND (
|
||||
pe.event_id IS NULL
|
||||
OR (pe.status IN (?, ?) AND pe.locked_until_ms <= ?)
|
||||
)
|
||||
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
||||
LIMIT ?`,
|
||||
giftWallProjectionName,
|
||||
walletGiftDebitedEvent,
|
||||
projectionStatusFailed,
|
||||
projectionStatusProcessing,
|
||||
nowMs,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
candidates := make([]giftWallProjectionCandidate, 0, limit)
|
||||
for rows.Next() {
|
||||
var candidate giftWallProjectionCandidate
|
||||
if err := rows.Scan(&candidate.AppCode, &candidate.EventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
return candidates, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repository) projectGiftWallEvent(ctx context.Context, candidate giftWallProjectionCandidate, workerID string, lockTTL time.Duration) (bool, error) {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
|
||||
ctx = appcode.WithContext(ctx, candidate.AppCode)
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
event, err := r.lockGiftWallOutboxEvent(ctx, tx, candidate)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 候选扫描和事务锁定之间可能被清理或并发处理;这种竞争不应让 worker 失败。
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
|
||||
if err != nil || !claimed {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var metadata giftMetadata
|
||||
if err := json.Unmarshal([]byte(event.PayloadJSON), &metadata); err != nil {
|
||||
// 坏 payload 是单条事件的数据问题,记录失败后跳过本轮,避免一个坏事件卡死整个队列。
|
||||
if markErr := r.markProjectionFailedInTx(ctx, tx, event, "invalid gift projection payload: "+err.Error(), nowMs); markErr != nil {
|
||||
return false, markErr
|
||||
}
|
||||
return true, tx.Commit()
|
||||
}
|
||||
if err := validateGiftWallMetadata(metadata); err != nil {
|
||||
if markErr := r.markProjectionFailedInTx(ctx, tx, event, err.Error(), nowMs); markErr != nil {
|
||||
return false, markErr
|
||||
}
|
||||
return true, tx.Commit()
|
||||
}
|
||||
if err := r.upsertUserGiftWall(ctx, tx, metadata, event.CreatedAtMS); err != nil {
|
||||
// 聚合写失败时先释放当前事务锁,再把 projection 标记为 failed;下轮按 retry_count 继续重试。
|
||||
_ = tx.Rollback()
|
||||
if markErr := r.markProjectionFailed(ctx, event, err, nowMs); markErr != nil {
|
||||
return false, markErr
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
if err := r.markProjectionDoneInTx(ctx, tx, event, nowMs); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, tx.Commit()
|
||||
}
|
||||
|
||||
func (r *Repository) lockGiftWallOutboxEvent(ctx context.Context, tx *sql.Tx, candidate giftWallProjectionCandidate) (giftWallProjectionEvent, error) {
|
||||
var event giftWallProjectionEvent
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT app_code, event_id, transaction_id, command_id, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
|
||||
FROM wallet_outbox
|
||||
WHERE app_code = ? AND event_id = ? AND event_type = ?
|
||||
FOR UPDATE`,
|
||||
appcode.Normalize(candidate.AppCode),
|
||||
candidate.EventID,
|
||||
walletGiftDebitedEvent,
|
||||
).Scan(&event.AppCode, &event.EventID, &event.TransactionID, &event.CommandID, &event.PayloadJSON, &event.CreatedAtMS)
|
||||
return event, err
|
||||
}
|
||||
|
||||
func (r *Repository) claimProjectionEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
|
||||
// 新事件通过 INSERT 获取处理权;并发 worker 命中同一事件时只有一个实例会插入成功。
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO wallet_projection_events (
|
||||
projection_name, app_code, event_id, status, retry_count, locked_by,
|
||||
locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 0, ?, ?, '', 0, ?, ?)`,
|
||||
giftWallProjectionName,
|
||||
event.AppCode,
|
||||
event.EventID,
|
||||
projectionStatusProcessing,
|
||||
workerID,
|
||||
lockUntilMS,
|
||||
nowMs,
|
||||
nowMs,
|
||||
)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if !isMySQLDuplicateError(err) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// 已存在的 failed/过期 processing 事件允许重新认领;done/dead 永远不被重复投影。
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE wallet_projection_events
|
||||
SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
|
||||
WHERE projection_name = ? AND app_code = ? AND event_id = ?
|
||||
AND status IN (?, ?) AND locked_until_ms <= ?`,
|
||||
projectionStatusProcessing,
|
||||
workerID,
|
||||
lockUntilMS,
|
||||
nowMs,
|
||||
giftWallProjectionName,
|
||||
event.AppCode,
|
||||
event.EventID,
|
||||
projectionStatusFailed,
|
||||
projectionStatusProcessing,
|
||||
nowMs,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
return affected > 0, err
|
||||
}
|
||||
|
||||
func (r *Repository) markProjectionDoneInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, nowMs int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE wallet_projection_events
|
||||
SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', processed_at_ms = ?, updated_at_ms = ?
|
||||
WHERE projection_name = ? AND app_code = ? AND event_id = ?`,
|
||||
projectionStatusDone,
|
||||
nowMs,
|
||||
nowMs,
|
||||
giftWallProjectionName,
|
||||
event.AppCode,
|
||||
event.EventID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) markProjectionFailedInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, reason string, nowMs int64) error {
|
||||
reason = trimProjectionError(reason)
|
||||
// 失败次数达到上限后转 dead,保留 last_error 给后台排查,不再无限重试污染 MySQL。
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE wallet_projection_events
|
||||
SET status = IF(retry_count + 1 >= ?, ?, ?),
|
||||
retry_count = retry_count + 1,
|
||||
locked_by = '',
|
||||
locked_until_ms = 0,
|
||||
last_error = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE projection_name = ? AND app_code = ? AND event_id = ?`,
|
||||
projectionMaxRetries,
|
||||
projectionStatusDead,
|
||||
projectionStatusFailed,
|
||||
reason,
|
||||
nowMs,
|
||||
giftWallProjectionName,
|
||||
event.AppCode,
|
||||
event.EventID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) markProjectionFailed(ctx context.Context, event giftWallProjectionEvent, cause error, nowMs int64) error {
|
||||
reason := trimProjectionError(cause.Error())
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO wallet_projection_events (
|
||||
projection_name, app_code, event_id, status, retry_count, locked_by,
|
||||
locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 1, '', 0, ?, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = IF(retry_count + 1 >= ?, ?, ?),
|
||||
retry_count = retry_count + 1,
|
||||
locked_by = '',
|
||||
locked_until_ms = 0,
|
||||
last_error = VALUES(last_error),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
giftWallProjectionName,
|
||||
event.AppCode,
|
||||
event.EventID,
|
||||
projectionStatusFailed,
|
||||
reason,
|
||||
nowMs,
|
||||
nowMs,
|
||||
projectionMaxRetries,
|
||||
projectionStatusDead,
|
||||
projectionStatusFailed,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateGiftWallMetadata(metadata giftMetadata) error {
|
||||
// 礼物墙以收礼人、礼物 ID 和数量为最小可投影事实;缺任何一个都不能安全聚合。
|
||||
if metadata.TargetUserID <= 0 || strings.TrimSpace(metadata.GiftID) == "" || metadata.GiftCount <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "gift projection metadata is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trimProjectionError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 1024 {
|
||||
return value[:1024]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -33,6 +33,7 @@ const (
|
||||
bizTypeGameRefund = "game_refund"
|
||||
bizTypeGameReverse = "game_reverse"
|
||||
outboxStatusPending = "pending"
|
||||
giftWallChargeAssetMixed = "MIXED"
|
||||
)
|
||||
|
||||
// Repository 是 wallet-service 的 MySQL v2 账本入口。
|
||||
@ -148,6 +149,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
GiftName: giftConfig.Name,
|
||||
ResourceID: giftConfig.ResourceID,
|
||||
ResourceSnapshot: mustJSON(giftConfig.Resource),
|
||||
GiftTypeCode: giftConfig.GiftTypeCode,
|
||||
PresentationJSON: giftConfig.PresentationJSON,
|
||||
SortOrder: giftConfig.SortOrder,
|
||||
GiftCount: command.GiftCount,
|
||||
PriceVersion: price.PriceVersion,
|
||||
ChargeAssetType: price.ChargeAssetType,
|
||||
@ -275,6 +279,84 @@ func (r *Repository) GetBalances(ctx context.Context, userID int64, assetTypes [
|
||||
return balances, nil
|
||||
}
|
||||
|
||||
func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadata giftMetadata, nowMs int64) error {
|
||||
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
|
||||
chargeAmount := metadata.ChargeAmount
|
||||
if chargeAmount == 0 {
|
||||
// metadata 同时保留 coin_spent 和 charge_amount;投影使用最终收费金额作为礼物价值。
|
||||
chargeAmount = metadata.CoinSpent
|
||||
}
|
||||
totalCoinValue, totalDiamondValue := int64(0), int64(0)
|
||||
if chargeAssetType == ledger.AssetDiamond {
|
||||
totalDiamondValue = chargeAmount
|
||||
} else {
|
||||
totalCoinValue = chargeAmount
|
||||
}
|
||||
resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot)
|
||||
if resourceSnapshotJSON == "" {
|
||||
resourceSnapshotJSON = "{}"
|
||||
}
|
||||
presentationJSON := strings.TrimSpace(metadata.PresentationJSON)
|
||||
if presentationJSON == "" {
|
||||
presentationJSON = "{}"
|
||||
}
|
||||
giftTypeCode := strings.TrimSpace(metadata.GiftTypeCode)
|
||||
if giftTypeCode == "" {
|
||||
giftTypeCode = "normal"
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_gift_wall (
|
||||
app_code, user_id, gift_id, gift_name, resource_id, resource_snapshot_json,
|
||||
gift_type_code, presentation_json, gift_count, total_value, total_coin_value,
|
||||
total_diamond_value, total_gift_point, total_heat_value, charge_asset_type,
|
||||
last_price_version, first_received_at_ms, last_received_at_ms, sort_order,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
gift_name = VALUES(gift_name),
|
||||
resource_id = VALUES(resource_id),
|
||||
resource_snapshot_json = VALUES(resource_snapshot_json),
|
||||
gift_type_code = VALUES(gift_type_code),
|
||||
presentation_json = VALUES(presentation_json),
|
||||
gift_count = gift_count + VALUES(gift_count),
|
||||
total_value = total_value + VALUES(total_value),
|
||||
total_coin_value = total_coin_value + VALUES(total_coin_value),
|
||||
total_diamond_value = total_diamond_value + VALUES(total_diamond_value),
|
||||
total_gift_point = total_gift_point + VALUES(total_gift_point),
|
||||
total_heat_value = total_heat_value + VALUES(total_heat_value),
|
||||
charge_asset_type = IF(charge_asset_type = VALUES(charge_asset_type), charge_asset_type, ?),
|
||||
last_price_version = VALUES(last_price_version),
|
||||
last_received_at_ms = VALUES(last_received_at_ms),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`,
|
||||
appcode.FromContext(ctx),
|
||||
metadata.TargetUserID,
|
||||
metadata.GiftID,
|
||||
metadata.GiftName,
|
||||
metadata.ResourceID,
|
||||
resourceSnapshotJSON,
|
||||
giftTypeCode,
|
||||
presentationJSON,
|
||||
int64(metadata.GiftCount),
|
||||
chargeAmount,
|
||||
totalCoinValue,
|
||||
totalDiamondValue,
|
||||
metadata.GiftPointAdded,
|
||||
metadata.HeatValue,
|
||||
chargeAssetType,
|
||||
metadata.PriceVersion,
|
||||
nowMs,
|
||||
nowMs,
|
||||
metadata.SortOrder,
|
||||
nowMs,
|
||||
nowMs,
|
||||
giftWallChargeAssetMixed,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminCreditAsset 在一个事务内写入后台入账交易、分录和 outbox。
|
||||
func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@ -1176,6 +1258,9 @@ type giftMetadata struct {
|
||||
GiftName string `json:"gift_name"`
|
||||
ResourceID int64 `json:"resource_id"`
|
||||
ResourceSnapshot string `json:"resource_snapshot_json"`
|
||||
GiftTypeCode string `json:"gift_type_code"`
|
||||
PresentationJSON string `json:"presentation_json"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
PriceVersion string `json:"price_version"`
|
||||
CoinPrice int64 `json:"coin_price"`
|
||||
|
||||
@ -160,7 +160,8 @@ 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_configs", "gift_config_regions", "resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":
|
||||
"resources", "resource_groups", "resource_group_items", "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:
|
||||
panic(fmt.Sprintf("unsupported wallet test table %q", table))
|
||||
|
||||
@ -101,6 +101,29 @@ func (s *Server) GetWalletValueSummary(ctx context.Context, req *walletv1.GetWal
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// GetUserGiftWall 返回当前用户收到礼物的聚合墙;调用方负责限定 user_id 为当前登录用户。
|
||||
func (s *Server) GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
wall, err := s.svc.GetUserGiftWall(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
resp := &walletv1.GetUserGiftWallResponse{
|
||||
Items: make([]*walletv1.GiftWallItem, 0, len(wall.Items)),
|
||||
GiftKindCount: wall.GiftKindCount,
|
||||
GiftTotalCount: wall.GiftTotalCount,
|
||||
TotalValue: wall.TotalValue,
|
||||
TotalCoinValue: wall.TotalCoinValue,
|
||||
TotalDiamondValue: wall.TotalDiamondValue,
|
||||
TotalGiftPoint: wall.TotalGiftPoint,
|
||||
TotalHeatValue: wall.TotalHeatValue,
|
||||
}
|
||||
for _, item := range wall.Items {
|
||||
resp.Items = append(resp.Items, giftWallItemToProto(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ListRechargeProducts 返回当前用户区域可用的充值渠道和档位。
|
||||
func (s *Server) ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
@ -379,6 +402,55 @@ func balanceToProto(balance ledger.AssetBalance) *walletv1.AssetBalance {
|
||||
}
|
||||
}
|
||||
|
||||
func giftWallItemToProto(item ledger.GiftWallItem) *walletv1.GiftWallItem {
|
||||
return &walletv1.GiftWallItem{
|
||||
GiftId: item.GiftID,
|
||||
GiftName: item.GiftName,
|
||||
ResourceId: item.ResourceID,
|
||||
Resource: giftWallResourceToProto(item.Resource),
|
||||
ResourceSnapshotJson: item.ResourceSnapshotJSON,
|
||||
GiftTypeCode: item.GiftTypeCode,
|
||||
PresentationJson: item.PresentationJSON,
|
||||
GiftCount: item.GiftCount,
|
||||
TotalValue: item.TotalValue,
|
||||
TotalCoinValue: item.TotalCoinValue,
|
||||
TotalDiamondValue: item.TotalDiamondValue,
|
||||
TotalGiftPoint: item.TotalGiftPoint,
|
||||
TotalHeatValue: item.TotalHeatValue,
|
||||
ChargeAssetType: item.ChargeAssetType,
|
||||
LastPriceVersion: item.LastPriceVersion,
|
||||
FirstReceivedAtMs: item.FirstReceivedAtMS,
|
||||
LastReceivedAtMs: item.LastReceivedAtMS,
|
||||
SortOrder: item.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func giftWallResourceToProto(resource ledger.GiftWallResource) *walletv1.Resource {
|
||||
if resource.ResourceID == 0 {
|
||||
return nil
|
||||
}
|
||||
return &walletv1.Resource{
|
||||
AppCode: resource.AppCode,
|
||||
ResourceId: resource.ResourceID,
|
||||
ResourceCode: resource.ResourceCode,
|
||||
ResourceType: resource.ResourceType,
|
||||
Name: resource.Name,
|
||||
Status: resource.Status,
|
||||
Grantable: resource.Grantable,
|
||||
GrantStrategy: resource.GrantStrategy,
|
||||
WalletAssetType: resource.WalletAssetType,
|
||||
WalletAssetAmount: resource.WalletAssetAmount,
|
||||
UsageScopes: resource.UsageScopes,
|
||||
AssetUrl: resource.AssetURL,
|
||||
PreviewUrl: resource.PreviewURL,
|
||||
AnimationUrl: resource.AnimationURL,
|
||||
MetadataJson: resource.MetadataJSON,
|
||||
SortOrder: resource.SortOrder,
|
||||
CreatedAtMs: resource.CreatedAtMS,
|
||||
UpdatedAtMs: resource.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func walletFeatureFlagsToProto(flags ledger.WalletFeatureFlags) *walletv1.WalletFeatureFlags {
|
||||
return &walletv1.WalletFeatureFlags{
|
||||
RechargeEnabled: flags.RechargeEnabled,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user