礼物墙

This commit is contained in:
zhx 2026-05-13 10:58:33 +08:00
parent 7f64a8b443
commit dbc5c5ec88
20 changed files with 2520 additions and 899 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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` 写入客户端日志。

View File

@ -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` | 修改当前用户基础资料 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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