Compare commits

...

5 Commits

Author SHA1 Message Date
zhx
a0ee5962df 幸运礼物 2026-05-26 02:39:00 +08:00
zhx
144cd3d781 修复 2026-05-26 01:58:08 +08:00
zhx
b496a87b84 修复sql缺失 2026-05-26 01:35:01 +08:00
zhx
ecef1df1c6 修改相关部署 2026-05-26 01:21:39 +08:00
zhx
0b428fc9c7 增加钱包确认 2026-05-26 01:02:12 +08:00
57 changed files with 3925 additions and 1067 deletions

View File

@ -10705,6 +10705,7 @@ type LuckyGiftDrawResult struct {
HighMultiplier bool `protobuf:"varint,18,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
CreatedAtMs int64 `protobuf:"varint,19,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
PoolId string `protobuf:"bytes,20,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,21,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -10879,6 +10880,13 @@ func (x *LuckyGiftDrawResult) GetPoolId() string {
return ""
}
func (x *LuckyGiftDrawResult) GetMultiplierPpm() int64 {
if x != nil {
return x.MultiplierPpm
}
return 0
}
type ExecuteLuckyGiftDrawRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"`
@ -12673,7 +12681,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
"\apool_id\x18\b \x01(\tR\x06poolId\"\xce\x06\n" +
"\apool_id\x18\b \x01(\tR\x06poolId\"\xf5\x06\n" +
"\x13LuckyGiftDrawResult\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" +
@ -12696,7 +12704,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" +
"\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" +
"\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\x17\n" +
"\apool_id\x18\x14 \x01(\tR\x06poolId\"^\n" +
"\apool_id\x18\x14 \x01(\tR\x06poolId\x12%\n" +
"\x0emultiplier_ppm\x18\x15 \x01(\x03R\rmultiplierPpm\"^\n" +
"\x1bExecuteLuckyGiftDrawRequest\x12?\n" +
"\n" +
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" +

View File

@ -1218,6 +1218,7 @@ message LuckyGiftDrawResult {
bool high_multiplier = 18;
int64 created_at_ms = 19;
string pool_id = 20;
int64 multiplier_ppm = 21;
}
message ExecuteLuckyGiftDrawRequest {

File diff suppressed because it is too large Load Diff

View File

@ -63,6 +63,27 @@ message RankItem {
int64 updated_at_ms = 4;
}
// LuckyGiftDrawResult SendGift
message LuckyGiftDrawResult {
bool enabled = 1;
string draw_id = 2;
string command_id = 3;
string pool_id = 4;
string gift_id = 5;
int64 rule_version = 6;
string experience_pool = 7;
string selected_tier_id = 8;
int64 multiplier_ppm = 9;
int64 base_reward_coins = 10;
int64 room_atmosphere_reward_coins = 11;
int64 activity_subsidy_coins = 12;
int64 effective_reward_coins = 13;
string reward_status = 14;
bool stage_feedback = 15;
bool high_multiplier = 16;
int64 created_at_ms = 17;
}
// RoomTreasureRewardItem
message RoomTreasureRewardItem {
string reward_item_id = 1;
@ -695,6 +716,7 @@ message SendGiftResponse {
repeated RankItem gift_rank = 4;
RoomSnapshot room = 5;
RoomTreasureState treasure = 6;
LuckyGiftDrawResult lucky_gift = 7;
}
// CheckSpeakPermissionRequest IM gateway

File diff suppressed because it is too large Load Diff

View File

@ -745,6 +745,32 @@ message ListRechargeProductsResponse {
repeated string channels = 2;
}
message ConfirmGooglePaymentRequest {
string request_id = 1;
string app_code = 2;
string command_id = 3;
int64 user_id = 4;
int64 region_id = 5;
int64 product_id = 6;
string product_code = 7;
string package_name = 8;
string purchase_token = 9;
string order_id = 10;
int64 purchase_time_ms = 11;
}
message ConfirmGooglePaymentResponse {
string payment_order_id = 1;
string transaction_id = 2;
string status = 3;
int64 product_id = 4;
string product_code = 5;
int64 coin_amount = 6;
AssetBalance balance = 7;
bool idempotent_replay = 8;
string consume_state = 9;
}
// ListAdminRechargeProductsRequest
message ListAdminRechargeProductsRequest {
string request_id = 1;
@ -1233,6 +1259,7 @@ service WalletService {
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
rpc ConfirmGooglePayment(ConfirmGooglePaymentRequest) returns (ConfirmGooglePaymentResponse);
rpc ListAdminRechargeProducts(ListAdminRechargeProductsRequest) returns (ListAdminRechargeProductsResponse);
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);

View File

@ -50,6 +50,7 @@ const (
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_ConfirmGooglePayment_FullMethodName = "/hyapp.wallet.v1.WalletService/ConfirmGooglePayment"
WalletService_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
@ -111,6 +112,7 @@ type WalletServiceClient interface {
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)
ConfirmGooglePayment(ctx context.Context, in *ConfirmGooglePaymentRequest, opts ...grpc.CallOption) (*ConfirmGooglePaymentResponse, error)
ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
@ -453,6 +455,16 @@ func (c *walletServiceClient) ListRechargeProducts(ctx context.Context, in *List
return out, nil
}
func (c *walletServiceClient) ConfirmGooglePayment(ctx context.Context, in *ConfirmGooglePaymentRequest, opts ...grpc.CallOption) (*ConfirmGooglePaymentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ConfirmGooglePaymentResponse)
err := c.cc.Invoke(ctx, WalletService_ConfirmGooglePayment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAdminRechargeProductsResponse)
@ -710,6 +722,7 @@ type WalletServiceServer interface {
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
ConfirmGooglePayment(context.Context, *ConfirmGooglePaymentRequest) (*ConfirmGooglePaymentResponse, error)
ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error)
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*RechargeProductResponse, error)
@ -835,6 +848,9 @@ func (UnimplementedWalletServiceServer) GetUserGiftWall(context.Context, *GetUse
func (UnimplementedWalletServiceServer) ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRechargeProducts not implemented")
}
func (UnimplementedWalletServiceServer) ConfirmGooglePayment(context.Context, *ConfirmGooglePaymentRequest) (*ConfirmGooglePaymentResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ConfirmGooglePayment not implemented")
}
func (UnimplementedWalletServiceServer) ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAdminRechargeProducts not implemented")
}
@ -1480,6 +1496,24 @@ func _WalletService_ListRechargeProducts_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _WalletService_ConfirmGooglePayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ConfirmGooglePaymentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).ConfirmGooglePayment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_ConfirmGooglePayment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).ConfirmGooglePayment(ctx, req.(*ConfirmGooglePaymentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_ListAdminRechargeProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAdminRechargeProductsRequest)
if err := dec(in); err != nil {
@ -2007,6 +2041,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRechargeProducts",
Handler: _WalletService_ListRechargeProducts_Handler,
},
{
MethodName: "ConfirmGooglePayment",
Handler: _WalletService_ConfirmGooglePayment_Handler,
},
{
MethodName: "ListAdminRechargeProducts",
Handler: _WalletService_ListAdminRechargeProducts_Handler,

View File

@ -0,0 +1,4 @@
IMAGE=10.2.1.3:18082/hyapp/statistics-service:RELEASE_TAG
CONTAINER_NAME=hyapp-statistics-service
CONFIG_PATH=/etc/hyapp/statistics-service/config.yaml
STOP_TIMEOUT_SEC=60

View File

@ -0,0 +1,17 @@
[Unit]
Description=HYApp statistics-service
After=docker.service network-online.target
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
EnvironmentFile=/etc/hyapp/statistics-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly ${IMAGE}
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -10,7 +10,7 @@ StartLimitBurst=3
Type=simple
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/etc/hyapp/secrets,dst=/etc/hyapp/secrets,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
Restart=on-failure
RestartSec=5s
@ -21,4 +21,3 @@ LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target

View File

@ -523,7 +523,7 @@ fi
echo "== hyapp containers =="
docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true
echo "== listening ports =="
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13100|:13101|:13104|:13105|:13106|:13108|:13109/' || true
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13010|:13100|:13101|:13104|:13105|:13106|:13108|:13109|:13110/' || true
"""

View File

@ -194,6 +194,25 @@
],
"drain_seconds": 5
}
},
"statistics-service": {
"unit": "hyapp-statistics-service",
"container": "hyapp-statistics-service",
"env_file": "/etc/hyapp/statistics-service/docker.env",
"image_template": "${REGISTRY}/statistics-service:${TAG}",
"target_port": 13010,
"hosts": [
"new-app-1",
"new-app-2"
],
"clb": {
"enabled": true,
"load_balancer_id": "lb-epvnr4o0",
"listener_ids": [
"lbl-a0myhr6q"
],
"drain_seconds": 5
}
}
}
}

View File

@ -0,0 +1,268 @@
# Explore tab Flutter App 对接文档
本文档定义 App 端 Explore 页 H5 tab 的读取接口。后台在 `HYApp 管理平台 / APP配置 / Explore配置` 维护 tab 名称和 H5 链接App 只读取已启用配置并按服务端返回顺序展示。
## 基础约定
本地网关地址:
```text
http://127.0.0.1:13000
```
所有 `/api/v1` 业务接口使用统一响应外壳:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
客户端成功判断:
```text
HTTP 200 && code == "OK"
```
失败时保留 `request_id`,用于后端排查日志。
## App 标识 Header
Explore tab 按 App 隔离,接口没有 query/body 形式的 `app_code` 参数。gateway 通过 Header 解析 App
| Header | 必填 | 说明 |
| --- | --- | --- |
| `X-App-Code` | 推荐 | 内部 App code例如 `lalu`。 |
| `X-HY-App-Code` | 否 | `X-App-Code` 的兼容别名。 |
| `X-App-Package` | 推荐二选一 | App 包名,例如 `com.org.laluparty`。传包名时 gateway 可解析到内部 `app_code`。 |
| `X-App-Platform` | 包名解析时推荐 | 平台,例如 `android``ios`。 |
当前开发默认 `app_code``lalu`。Flutter 仍建议显式传 `X-App-Code``X-App-Package`,避免多 App 环境串配置。
## 获取 Explore tab 列表
接口地址:
```http
GET /api/v1/app/explore-tabs
```
完整本地地址:
```text
http://127.0.0.1:13000/api/v1/app/explore-tabs
```
鉴权:
```text
不需要 Bearer token
```
服务端行为:
- 只返回当前 App 已启用的 Explore tabs。
- 后台关闭的 tab 不下发给 App。
- 返回顺序为后台 `sort_order` 升序,同排序时按较新的记录优先。
### 请求 Header
```http
X-App-Code: lalu
```
或:
```http
X-App-Package: com.org.laluparty
X-App-Platform: android
```
### Query 参数
无。
### Request Body
无。
### curl 示例
```bash
curl -X GET 'http://127.0.0.1:13000/api/v1/app/explore-tabs' \
-H 'X-App-Code: lalu'
```
包名解析方式:
```bash
curl -X GET 'http://127.0.0.1:13000/api/v1/app/explore-tabs' \
-H 'X-App-Package: com.org.laluparty' \
-H 'X-App-Platform: android'
```
### 成功响应
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"items": [
{
"id": 7,
"app_code": "lalu",
"tab": "Farm",
"h5_url": "https://www.baidu.com",
"enabled": true,
"sort_order": 1,
"updated_at_ms": 1700000000000
}
],
"total": 1
}
}
```
### `data` 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `items` | array | Explore tab 列表。 |
| `total` | int | 本次返回数量。 |
### `items[]` 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | int | 后台配置记录 ID只用于排查或本地缓存 key不作为业务路由。 |
| `app_code` | string | tab 所属 App例如 `lalu`。 |
| `tab` | string | App 展示的 tab 文案。 |
| `h5_url` | string | 点击 tab 后加载的 H5 地址。 |
| `enabled` | bool | 是否启用。App 接口只返回 `true` 的记录。 |
| `sort_order` | int | 展示排序,数值越小越靠前。 |
| `updated_at_ms` | int | 更新时间Unix epoch milliseconds。 |
### 空列表响应
当后台没有启用的 Explore tab 时,返回空数组,不是错误:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"items": [],
"total": 0
}
}
```
### 错误响应
配置读取异常时:
```json
{
"code": "UPSTREAM_ERROR",
"message": "upstream service error",
"request_id": "req_xxx"
}
```
客户端处理建议:
- `UPSTREAM_ERROR`:展示空态或保留上一次本地缓存。
- 非 `OK`:不要使用 `data`
- 记录 `request_id` 到客户端日志,便于问题排查。
## Flutter 数据模型
```dart
class ExploreTabList {
final List<ExploreTabItem> items;
final int total;
ExploreTabList({
required this.items,
required this.total,
});
factory ExploreTabList.fromJson(Map<String, dynamic> json) {
final rawItems = json['items'] as List<dynamic>? ?? const [];
return ExploreTabList(
items: rawItems
.map((e) => ExploreTabItem.fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int? ?? rawItems.length,
);
}
}
class ExploreTabItem {
final int id;
final String appCode;
final String tab;
final String h5Url;
final bool enabled;
final int sortOrder;
final int updatedAtMs;
ExploreTabItem({
required this.id,
required this.appCode,
required this.tab,
required this.h5Url,
required this.enabled,
required this.sortOrder,
required this.updatedAtMs,
});
factory ExploreTabItem.fromJson(Map<String, dynamic> json) {
return ExploreTabItem(
id: json['id'] as int? ?? 0,
appCode: json['app_code'] as String? ?? '',
tab: json['tab'] as String? ?? '',
h5Url: json['h5_url'] as String? ?? '',
enabled: json['enabled'] as bool? ?? false,
sortOrder: json['sort_order'] as int? ?? 0,
updatedAtMs: json['updated_at_ms'] as int? ?? 0,
);
}
}
```
## Flutter Dio 示例
```dart
Future<ExploreTabList> fetchExploreTabs(Dio dio) async {
final response = await dio.get(
'/api/v1/app/explore-tabs',
options: Options(
headers: {
'X-App-Code': 'lalu',
},
),
);
final body = response.data as Map<String, dynamic>;
if (body['code'] != 'OK') {
throw Exception('${body['code']}: ${body['message']} request_id=${body['request_id']}');
}
return ExploreTabList.fromJson(body['data'] as Map<String, dynamic>);
}
```
## 展示和跳转规则
- App 按 `items` 返回顺序展示,不需要二次排序。
- `tab` 为空或 `h5_url` 为空时,建议 App 跳过该项,避免展示不可点击入口。
- 点击 tab 后打开 `h5_url`,由 H5 页面自身处理内部导航。
- 如果接口失败App 可以使用本地缓存的上一版 tabs没有缓存时展示空态。

View File

@ -20,16 +20,16 @@ Flutter 只有在 `code == "OK"` 时读取 `data`。其他错误必须记录 `re
已实现:
- `GET /api/v1/wallet/recharge/products`App 端充值商品列表。
- `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token后端调用 Google Play Developer API 校验、入账、写支付订单审计并 consume。
- `GET /api/v1/wallet/me/balances`:刷新金币余额。
- 钱包余额 IM 通知:`WalletBalanceChanged`
- `payment_orders`:保存 Google provider 订单审计purchase token 只落 SHA256不落明文。
后端待补齐:
- `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token后端校验并入账。
- Google Play RTDN / Pub/Sub 回调:只做补偿通知,不替代客户端确认接口。
- `payment_orders` 和 Google provider 订单审计表。
Flutter 可以先按本文契约接商品列表和本地 Google Billing 流程;确认支付接口需要等后端补齐后联调
Flutter 可以按本文契约联调商品列表、本地 Google Billing 流程和确认支付接口
## 2. Flutter 依赖
@ -200,7 +200,7 @@ void startPurchaseListener() {
## 6. 确认 Google 支付
后端实现接口:
后端实现接口:
`POST /api/v1/wallet/payments/google/confirm`
@ -214,7 +214,7 @@ void startPurchaseListener() {
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-App-Platform` | 是 | 固定传 `android`。 |
请求体建议契约
请求体:
```json
{
@ -232,7 +232,7 @@ void startPurchaseListener() {
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试必须使用同一个值。 |
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试建议使用同一个值。后端最终还会按 purchase token 去重。 |
| `product_id` | 是 | 后端商品列表返回的本地商品 ID。 |
| `product_code` | 是 | 后端商品列表返回的 Google productId。 |
| `package_name` | 是 | 当前 Android 包名。 |
@ -246,7 +246,7 @@ void startPurchaseListener() {
final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}';
```
成功响应建议契约
成功响应:
```json
{
@ -254,7 +254,7 @@ final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}
"message": "ok",
"request_id": "req_abc",
"data": {
"payment_order_id": "pay_google_xxx",
"payment_order_id": "gpay_xxx",
"transaction_id": "wtx_xxx",
"status": "credited",
"product_id": 11,
@ -276,16 +276,26 @@ Flutter 成功处理:
- `status=credited`:展示充值成功,刷新 `/wallet/me/balances`
- `idempotent_replay=true`:按成功处理,不重复提示异常。
- `consume_state=consume_pending`:仍按到账成功处理;后端会补偿 consume。
- `consume_state=consumed`:后端已完成 Google consume。
- `consume_state=consume_pending`:仍按到账成功处理;说明入账已成功但后端 consume 调用短时失败,下一次提交同一 token 会继续尝试 consume。
- 后端成功后调用 `InAppPurchase.instance.completePurchase(purchaseDetails)`,结束客户端 pending 状态。
失败处理:
- `UNAUTHORIZED`:登录失效,走登录态恢复。
- `INVALID_ARGUMENT`:商品 ID、token、包名等参数错误记录 `request_id`
- `INVALID_ARGUMENT`:商品 ID、token、包名等参数错误或 Google 判定 token 无效,记录 `request_id`
- `CONFLICT`商品未上架、商品区域不匹配、Google purchase state 不是 `PURCHASED`、Google productId/orderId 与后端商品不一致;不入账。
- `UPSTREAM_ERROR` / `INTERNAL_ERROR`:保留订单为待确认,稍后重试。
- provider 校验失败:不入账,不 complete purchase记录 `request_id` 并触发补单或客服入口。
后端校验和审计规则:
- 后端使用 Google Play Developer API `purchases.productsv2.getproductpurchasev2` 查询 purchase token只接受 `purchaseState=PURCHASED`
- Google 返回的 `productLineItem[0].productId` 必须等于后端商品 `product_code`
- 后端以 `payment_orders(app_code, provider, purchase_token_hash)` 做 provider 幂等,同一个 token 不会重复发币。
- 后端入账成功后写 `wallet_transactions``wallet_entries``wallet_recharge_records``payment_orders``wallet_outbox`
- 后端不会记录明文 `purchase_token`,只保存 SHA256 hash 和 Google 返回快照。
## 7. 补单规则
Flutter 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。

View File

@ -2156,6 +2156,38 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/wallet/payments/google/confirm:
post:
tags:
- wallet
summary: 确认 Google Play 充值支付
operationId: confirmGooglePayment
description: App 支付成功后提交 Google purchase token后端调用 Google Play Developer API 校验成功后写入钱包账本、充值记录、payment_orders 审计并尝试 consume。接口支持按 purchase token 幂等重试。
security:
- BearerAuth: []
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/GooglePaymentConfirmRequest"
responses:
"200":
description: 校验成功并已入账;`consume_state=consume_pending` 也表示已入账成功,只是 Google consume 待后续重试。
schema:
$ref: "#/definitions/GooglePaymentConfirmEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
$ref: "#/responses/Forbidden"
"409":
$ref: "#/responses/Conflict"
"500":
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/wallet/coin-seller/transfer:
post:
tags:
@ -3800,6 +3832,75 @@ definitions:
type: array
items:
$ref: "#/definitions/RechargeProductData"
GooglePaymentConfirmRequest:
type: object
required:
- command_id
- product_id
- package_name
- purchase_token
properties:
command_id:
type: string
description: 客户端确认支付幂等键;同一 purchase token 重试建议复用。
product_id:
type: integer
format: int64
description: 后端充值商品 ID。
product_code:
type: string
description: 后端返回的 Google productId必须与 Play Console 商品 ID 一致。
package_name:
type: string
description: Android 包名。
purchase_token:
type: string
description: Google Play Billing 返回的 serverVerificationData服务端只保存 SHA256。
order_id:
type: string
description: Google orderId客户端拿不到时可不传。
purchase_time_ms:
type: integer
format: int64
description: 客户端购买时间UTC epoch ms仅用于排障。
GooglePaymentConfirmData:
type: object
properties:
payment_order_id:
type: string
transaction_id:
type: string
status:
type: string
enum: [credited]
product_id:
type: integer
format: int64
product_code:
type: string
coin_amount:
type: integer
format: int64
balance:
type: object
properties:
asset_type:
type: string
example: COIN
available_amount:
type: integer
format: int64
frozen_amount:
type: integer
format: int64
version:
type: integer
format: int64
idempotent_replay:
type: boolean
consume_state:
type: string
enum: [consume_pending, consumed]
CoinSellerTransferRequest:
type: object
required:
@ -5095,6 +5196,13 @@ definitions:
properties:
data:
$ref: "#/definitions/RechargeProductListData"
GooglePaymentConfirmEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/GooglePaymentConfirmData"
CoinSellerTransferEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"

View File

@ -40,7 +40,7 @@ wallet_service:
addr: "10.2.1.15:13004"
request_timeout: "3s"
room_service:
addr: "10.2.1.15:13001"
addr: "10.2.1.16:13001"
request_timeout: "3s"
activity_service:
addr: "10.2.1.16:13006"

View File

@ -88,6 +88,7 @@ type drawDTO struct {
RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"`
SelectedTierID string `json:"selected_tier_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
BaseRewardCoins int64 `json:"base_reward_coins"`
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
@ -346,6 +347,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
RuleVersion: draw.GetRuleVersion(),
ExperiencePool: draw.GetExperiencePool(),
SelectedTierID: draw.GetSelectedTierId(),
MultiplierPPM: draw.GetMultiplierPpm(),
BaseRewardCoins: draw.GetBaseRewardCoins(),
RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(),
ActivitySubsidyCoins: draw.GetActivitySubsidyCoins(),

View File

@ -351,6 +351,37 @@ func (h *Handler) UpdateGiftType(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) UpdateGiftTypes(c *gin.Context) {
var req giftTypesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "礼物类型参数不正确")
return
}
if len(req.Items) == 0 {
response.BadRequest(c, "礼物类型不能为空")
return
}
items := make([]giftTypeDTO, 0, len(req.Items))
tabKeys := make([]string, 0, len(req.Items))
for _, requestItem := range req.Items {
tabKey := strings.TrimSpace(requestItem.TabKey)
if tabKey == "" {
response.BadRequest(c, "类型编码不正确")
return
}
resp, err := h.wallet.UpsertGiftTypeConfig(c.Request.Context(), requestItem.upsertProto(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
item := giftTypeFromProto(resp.GetGiftType())
items = append(items, item)
tabKeys = append(tabKeys, item.TabKey)
}
h.auditLog(c, "update-gift-types", "gift_type_configs", "batch", "success", strings.Join(tabKeys, ","))
response.OK(c, items)
}
func (h *Handler) CreateGift(c *gin.Context) {
var req giftRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -122,6 +122,18 @@ type giftTypeRequest struct {
SortOrder int32 `json:"sortOrder"`
}
type giftTypesRequest struct {
Items []giftTypeItemRequest `json:"items"`
}
type giftTypeItemRequest struct {
TabKey string `json:"tabKey"`
DisplayName string `json:"displayName"`
TabName string `json:"tabName"`
Status string `json:"status"`
SortOrder int32 `json:"sortOrder"`
}
type grantResourceRequest struct {
CommandID string `json:"commandId"`
TargetUserID int64 `json:"targetUserId"`
@ -326,6 +338,15 @@ func (r giftTypeRequest) upsertProto(c *gin.Context, typeCode string) *walletv1.
}
}
func (r giftTypeItemRequest) upsertProto(c *gin.Context) *walletv1.UpsertGiftTypeConfigRequest {
return giftTypeRequest{
DisplayName: r.DisplayName,
TabName: r.TabName,
Status: r.Status,
SortOrder: r.SortOrder,
}.upsertProto(c, r.TabKey)
}
func managerGrantEnabledOrDefault(value *bool) *bool {
enabled := true
if value != nil {

View File

@ -32,6 +32,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/gifts", middleware.RequirePermission("gift:view"), h.ListGifts)
protected.GET("/admin/gift-types", middleware.RequirePermission("gift:view"), h.ListGiftTypes)
protected.PUT("/admin/gift-types", middleware.RequirePermission("gift:update"), h.UpdateGiftTypes)
protected.PUT("/admin/gift-types/:type_code", middleware.RequirePermission("gift:update"), h.UpdateGiftType)
protected.POST("/admin/gifts", middleware.RequirePermission("gift:create"), h.CreateGift)
protected.PUT("/admin/gifts/:gift_id", middleware.RequirePermission("gift:update"), h.UpdateGift)

View File

@ -0,0 +1,63 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 补齐活动管理下由既有 bootstrap seed 提供、但历史 migration 未覆盖的生产菜单。
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('用户榜单查看', 'user-leaderboard:view', 'menu', '', @now_ms, @now_ms),
('红包配置查看', 'red-packet:view', 'menu', '', @now_ms, @now_ms),
('红包配置更新', 'red-packet:update', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '用户榜单', 'user-leaderboard', '/activities/user-leaderboards', 'leaderboard', 'user-leaderboard:view', 74, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'activities'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '红包配置', 'red-packet', '/activities/red-packets', 'redeem', 'red-packet:view', 76, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'activities'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('user-leaderboard:view', 'red-packet:view', 'red-packet:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'ops-admin'
AND admin_permission.code IN ('user-leaderboard:view', 'red-packet:view', 'red-packet:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code IN ('user-leaderboard:view', 'red-packet:view');

View File

@ -0,0 +1,38 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 老生产库曾由早期 AutoMigrate 建表,缺少当前模型依赖的描述和创建时间列。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_configs' AND COLUMN_NAME = 'description') = 0,
'ALTER TABLE admin_app_configs ADD COLUMN description VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''描述信息'' AFTER value',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_configs' AND COLUMN_NAME = 'created_at_ms') = 0,
'ALTER TABLE admin_app_configs ADD COLUMN created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''创建时间UTC epoch ms'' AFTER description',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_banners' AND COLUMN_NAME = 'created_at_ms') = 0,
'ALTER TABLE admin_app_banners ADD COLUMN created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''创建时间UTC epoch ms'' AFTER ends_at_ms',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_versions' AND COLUMN_NAME = 'created_at_ms') = 0,
'ALTER TABLE admin_app_versions ADD COLUMN created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''创建时间UTC epoch ms'' AFTER description',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -860,3 +860,30 @@ CREATE TABLE IF NOT EXISTS message_fanout_jobs (
UNIQUE KEY uk_message_fanout_command (app_code, command_id),
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息分发任务表';
-- 本地开发必须开箱即可验证幸运礼物链路INSERT IGNORE 只补缺省奖池,不覆盖后台已发布规则。
SET @lucky_seed_now_ms := 1779259000000;
SET @lucky_seed_tiers := JSON_ARRAY(
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true)
);
INSERT IGNORE INTO lucky_gift_rules (
app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm,
global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit,
high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm,
gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool,
platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap,
user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap,
room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve,
activity_budget, activity_daily_limit, large_tier_enabled, tiers_json,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms),
('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms);

View File

@ -112,6 +112,7 @@ type DrawResult struct {
RuleVersion int64
ExperiencePool string
SelectedTierID string
MultiplierPPM int64
BaseRewardCoins int64
RoomAtmosphereRewardCoins int64
ActivitySubsidyCoins int64

View File

@ -415,6 +415,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
RuleVersion: config.RuleVersion,
ExperiencePool: experiencePool,
SelectedTierID: candidate.TierID,
MultiplierPPM: candidate.MultiplierPPM,
BaseRewardCoins: candidate.BaseReward,
RoomAtmosphereRewardCoins: candidate.RoomReward,
ActivitySubsidyCoins: candidate.ActivityReward,
@ -604,6 +605,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
args = append(args, limit, offset)
rows, err := r.db.QueryContext(ctx, `
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
@ -619,6 +621,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
for rows.Next() {
var item domain.DrawResult
if err := rows.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
&item.MultiplierPPM,
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
@ -1256,6 +1259,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
}
row := tx.QueryRowContext(ctx, `
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
@ -1265,6 +1269,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
)
var item domain.DrawResult
if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
&item.MultiplierPPM,
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {

View File

@ -270,6 +270,7 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
RuleVersion: result.RuleVersion,
ExperiencePool: result.ExperiencePool,
SelectedTierId: result.SelectedTierID,
MultiplierPpm: result.MultiplierPPM,
BaseRewardCoins: result.BaseRewardCoins,
RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins,
ActivitySubsidyCoins: result.ActivitySubsidyCoins,

View File

@ -66,6 +66,8 @@ type UserCountryQueryClient interface {
// UserHostClient 抽象 gateway 对 user-service host domain 身份查询能力的依赖。
type UserHostClient interface {
SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error)
ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error)
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
@ -294,6 +296,14 @@ func (c *grpcUserCountryQueryClient) ListLoginRiskBlockedCountries(ctx context.C
return c.client.ListLoginRiskBlockedCountries(ctx, req)
}
func (c *grpcUserHostClient) SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
return c.client.SearchAgencies(ctx, req)
}
func (c *grpcUserHostClient) ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
return c.client.ApplyToAgency(ctx, req)
}
func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
return c.client.GetHostProfile(ctx, req)
}

View File

@ -15,6 +15,7 @@ type WalletClient interface {
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)
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error)
@ -66,6 +67,10 @@ func (c *grpcWalletClient) ListRechargeProducts(ctx context.Context, req *wallet
return c.client.ListRechargeProducts(ctx, req)
}
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
return c.client.ConfirmGooglePayment(ctx, req)
}
func (c *grpcWalletClient) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
return c.client.GetDiamondExchangeConfig(ctx, req)
}

View File

@ -221,6 +221,10 @@ func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
// RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
// request_id 只做链路追踪command_id 必须来自客户端用户动作gateway 只裁剪空白。
func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
sessionID := auth.SessionIDFromContext(request.Context())
if sessionID == "" {
sessionID = idgen.New("sess")
}
return &roomv1.RequestMeta{
RequestId: RequestIDFromContext(request.Context()),
CommandId: strings.TrimSpace(commandID),
@ -228,7 +232,7 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
RoomId: roomID,
AppCode: appcode.FromContext(request.Context()),
GatewayNodeId: "gateway-local",
SessionId: idgen.New("sess"),
SessionId: sessionID,
SentAtMs: time.Now().UnixMilli(),
}
}

View File

@ -67,6 +67,8 @@ type UserHandlers struct {
GetMyIdentity http.HandlerFunc
GetMyHostIdentity http.HandlerFunc
GetMyRoleSummary http.HandlerFunc
SearchHostAgencies http.HandlerFunc
ApplyToHostAgency http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
ChangeMyCountry http.HandlerFunc
@ -154,6 +156,7 @@ type WalletHandlers struct {
GetWalletOverview http.HandlerFunc
GetMyBalances http.HandlerFunc
ListRechargeProducts http.HandlerFunc
ConfirmGooglePayment http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ApplyWithdrawal http.HandlerFunc
ListCoinTransactions http.HandlerFunc
@ -267,6 +270,8 @@ func (r routes) registerUserRoutes() {
r.auth("/users/me/identity", "", h.GetMyIdentity)
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary)
r.profile("/host/agencies/search", http.MethodGet, h.SearchHostAgencies)
r.profile("/host/agency-applications", http.MethodPost, h.ApplyToHostAgency)
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
@ -361,6 +366,7 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/me/overview", http.MethodGet, h.GetWalletOverview)
r.profile("/wallet/me/balances", "", h.GetMyBalances)
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)

View File

@ -52,6 +52,7 @@ type fakeRoomClient struct {
lastKick *roomv1.KickUserRequest
lastUnban *roomv1.UnbanUserRequest
lastGift *roomv1.SendGiftRequest
sendGiftResp *roomv1.SendGiftResponse
lastFollowRoom *roomv1.FollowRoomRequest
lastUnfollowRoom *roomv1.UnfollowRoomRequest
createErr error
@ -219,6 +220,9 @@ func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserReque
func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
f.lastGift = req
if f.sendGiftResp != nil {
return f.sendGiftResp, nil
}
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
}
@ -315,21 +319,27 @@ type fakeUserCountryQueryClient struct {
}
type fakeUserHostClient struct {
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
lastSearchAgencies *userv1.SearchAgenciesRequest
searchAgenciesResp *userv1.SearchAgenciesResponse
searchAgenciesErr error
lastApplyAgency *userv1.ApplyToAgencyRequest
applyAgencyResp *userv1.ApplyToAgencyResponse
applyAgencyErr error
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
lastHost *userv1.GetHostProfileRequest
hostProfile *userv1.HostProfile
hostErr error
lastBD *userv1.GetBDProfileRequest
bdProfile *userv1.BDProfile
bdErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest
roleSummary *userv1.UserRoleSummary
roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
}
type fakeWalletClient struct {
@ -346,6 +356,8 @@ type fakeWalletClient struct {
giftWallResp *walletv1.GetUserGiftWallResponse
lastRechargeProducts *walletv1.ListRechargeProductsRequest
rechargeProductsResp *walletv1.ListRechargeProductsResponse
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
lastTransactions *walletv1.ListWalletTransactionsRequest
@ -901,6 +913,28 @@ func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req
return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: "hy_lalu_bc_r_1001", Removed: true}, nil
}
func (f *fakeUserHostClient) SearchAgencies(_ context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
f.lastSearchAgencies = req
if f.searchAgenciesErr != nil {
return nil, f.searchAgenciesErr
}
if f.searchAgenciesResp != nil {
return f.searchAgenciesResp, nil
}
return &userv1.SearchAgenciesResponse{}, nil
}
func (f *fakeUserHostClient) ApplyToAgency(_ context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
f.lastApplyAgency = req
if f.applyAgencyErr != nil {
return nil, f.applyAgencyErr
}
if f.applyAgencyResp != nil {
return f.applyAgencyResp, nil
}
return &userv1.ApplyToAgencyResponse{}, nil
}
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req
if f.hostErr != nil {
@ -1041,6 +1075,17 @@ func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1
return &walletv1.ListRechargeProductsResponse{}, nil
}
func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
f.lastGoogleConfirm = req
if f.err != nil {
return nil, f.err
}
if f.googleConfirmResp != nil {
return f.googleConfirmResp, nil
}
return &walletv1.ConfirmGooglePaymentResponse{}, nil
}
func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
f.lastDiamondExchange = req
if f.err != nil {
@ -1515,6 +1560,55 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
}
}
func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
BillingReceiptId: "receipt-lucky",
RoomHeat: 100,
LuckyGift: &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: "lucky_draw_test",
CommandId: "cmd-gift-lucky",
PoolId: "super_lucky",
GiftId: "rose",
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
EffectiveRewardCoins: 200,
RewardStatus: "pending",
CreatedAtMs: 1_779_258_000_000,
},
}}
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
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())
}
var envelope struct {
Code string `json:"code"`
Data struct {
LuckyGift struct {
DrawID string `json:"draw_id"`
PoolID string `json:"pool_id"`
MultiplierPPM int64 `json:"multiplier_ppm"`
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
} `json:"lucky_gift"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
}
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 {
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
}
}
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
previousNow := roomapi.TimeNow
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
@ -2500,6 +2594,77 @@ func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) {
}
}
func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
hostClient := &fakeUserHostClient{searchAgenciesResp: &userv1.SearchAgenciesResponse{Agencies: []*userv1.Agency{
{
AgencyId: 7001,
OwnerUserId: 901,
RegionId: 30,
Name: "Admin Seed Agency",
Status: "active",
JoinEnabled: true,
MaxHosts: 100,
CreatedAtMs: 1700000000000,
UpdatedAtMs: 1700000001000,
},
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=99", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-host-agency-search")
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 hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 20 {
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data := response.Data.(map[string]any)
items := data["items"].([]any)
first := items[0].(map[string]any)
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || data["page_size"] != float64(20) {
t.Fatalf("host agency search response mismatch: %+v", response)
}
}
func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
ApplicationId: 8001,
CommandId: "cmd-apply-agency",
ApplicantUserId: 42,
AgencyId: 7001,
RegionId: 30,
Status: "pending",
CreatedAtMs: 1700000000000,
UpdatedAtMs: 1700000001000,
}}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/host/agency-applications", bytes.NewReader([]byte(`{"command_id":"cmd-apply-agency","agency_id":"7001"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-host-agency-apply")
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 hostClient.lastApplyAgency == nil || hostClient.lastApplyAgency.GetUserId() != 42 || hostClient.lastApplyAgency.GetCommandId() != "cmd-apply-agency" || hostClient.lastApplyAgency.GetAgencyId() != 7001 {
t.Fatalf("host agency apply request mismatch: %+v", hostClient.lastApplyAgency)
}
}
func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
client := &fakeRoomClient{}
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
@ -3624,6 +3789,60 @@ func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) {
}
}
func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T) {
walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{
PaymentOrderId: "gpay_abc",
TransactionId: "wtx_abc",
Status: "credited",
ProductId: 11,
ProductCode: "iap_android_11",
CoinAmount: 1500,
Balance: &walletv1.AssetBalance{
AssetType: "COIN",
AvailableAmount: 2500,
},
ConsumeState: "consumed",
}}
profileClient := &fakeUserProfileClient{regionID: 2002}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := `{"command_id":"pay-cmd-1","product_id":11,"product_code":"iap_android_11","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "req-google-confirm")
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 profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet)
}
if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetProductId() != 11 ||
walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" ||
walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-1" ||
walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm") {
t.Fatalf("google confirm request mismatch: %+v", walletClient.lastGoogleConfirm)
}
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)
if response.Code != httpkit.CodeOK || !ok || data["payment_order_id"] != "gpay_abc" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(1500) {
t.Fatalf("google confirm response mismatch: %+v", response)
}
}
func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller",

View File

@ -48,6 +48,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
GetMyIdentity: h.getMyIdentity,
GetMyHostIdentity: h.getMyHostIdentity,
GetMyRoleSummary: h.getMyRoleSummary,
SearchHostAgencies: h.searchHostAgencies,
ApplyToHostAgency: h.applyToHostAgency,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry,

View File

@ -0,0 +1,185 @@
package userapi
import (
"encoding/json"
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type hostAgencyData struct {
AgencyID string `json:"agency_id"`
OwnerUserID string `json:"owner_user_id"`
RegionID int64 `json:"region_id"`
ParentBDUserID string `json:"parent_bd_user_id,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
JoinEnabled bool `json:"join_enabled"`
MaxHosts int32 `json:"max_hosts"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type hostAgencyApplicationData struct {
ApplicationID string `json:"application_id"`
CommandID string `json:"command_id"`
ApplicantUserID string `json:"applicant_user_id"`
AgencyID string `json:"agency_id"`
RegionID int64 `json:"region_id"`
Status string `json:"status"`
ReviewedByUserID string `json:"reviewed_by_user_id,omitempty"`
ReviewReason string `json:"review_reason,omitempty"`
ReviewedAtMS int64 `json:"reviewed_at_ms,omitempty"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
keyword := strings.TrimSpace(request.URL.Query().Get("short_id"))
if keyword == "" {
keyword = strings.TrimSpace(request.URL.Query().Get("keyword"))
}
if keyword == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if pageSize > 20 {
pageSize = 20
}
resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Keyword: keyword,
PageSize: pageSize,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]hostAgencyData, 0, len(resp.GetAgencies()))
for _, agency := range resp.GetAgencies() {
items = append(items, hostAgencyFromProto(agency))
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
}
func (h *Handler) applyToHostAgency(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
AgencyID json.RawMessage `json:"agency_id"`
AgencyIDAlt json.RawMessage `json:"agencyId"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
agencyID, ok := firstRawInt64(body.AgencyID, body.AgencyIDAlt)
if commandID == "" || !ok || agencyID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userHostClient.ApplyToAgency(request.Context(), &userv1.ApplyToAgencyRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: commandID,
UserId: auth.UserIDFromContext(request.Context()),
AgencyId: agencyID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"application": hostAgencyApplicationFromProto(resp.GetApplication())})
}
func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData {
if agency == nil {
return hostAgencyData{}
}
return hostAgencyData{
AgencyID: int64String(agency.GetAgencyId()),
OwnerUserID: int64String(agency.GetOwnerUserId()),
RegionID: agency.GetRegionId(),
ParentBDUserID: int64String(agency.GetParentBdUserId()),
Name: agency.GetName(),
Status: agency.GetStatus(),
JoinEnabled: agency.GetJoinEnabled(),
MaxHosts: agency.GetMaxHosts(),
CreatedByUserID: int64String(agency.GetCreatedByUserId()),
CreatedAtMS: agency.GetCreatedAtMs(),
UpdatedAtMS: agency.GetUpdatedAtMs(),
}
}
func hostAgencyApplicationFromProto(application *userv1.AgencyApplication) hostAgencyApplicationData {
if application == nil {
return hostAgencyApplicationData{}
}
return hostAgencyApplicationData{
ApplicationID: int64String(application.GetApplicationId()),
CommandID: application.GetCommandId(),
ApplicantUserID: int64String(application.GetApplicantUserId()),
AgencyID: int64String(application.GetAgencyId()),
RegionID: application.GetRegionId(),
Status: application.GetStatus(),
ReviewedByUserID: int64String(application.GetReviewedByUserId()),
ReviewReason: application.GetReviewReason(),
ReviewedAtMS: application.GetReviewedAtMs(),
CreatedAtMS: application.GetCreatedAtMs(),
UpdatedAtMS: application.GetUpdatedAtMs(),
}
}
func int64String(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func firstRawInt64(values ...json.RawMessage) (int64, bool) {
for _, raw := range values {
value, ok := parseRawInt64(raw)
if ok {
return value, true
}
}
return 0, false
}
func parseRawInt64(raw json.RawMessage) (int64, bool) {
text := strings.TrimSpace(string(raw))
if text == "" || text == "null" {
return 0, false
}
if strings.HasPrefix(text, `"`) {
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return 0, false
}
text = strings.TrimSpace(value)
}
value, err := strconv.ParseInt(text, 10, 64)
return value, err == nil
}

View File

@ -105,6 +105,35 @@ type withdrawalApplyRequestBody struct {
Reason string `json:"reason"`
}
type googlePaymentConfirmRequestBody struct {
CommandID string `json:"command_id"`
CommandIDAlt string `json:"commandId"`
ProductID int64 `json:"product_id"`
ProductIDAlt int64 `json:"productId"`
ProductCode string `json:"product_code"`
ProductCodeAlt string `json:"productCode"`
PackageName string `json:"package_name"`
PackageNameAlt string `json:"packageName"`
PurchaseToken string `json:"purchase_token"`
PurchaseAlt string `json:"purchaseToken"`
OrderID string `json:"order_id"`
OrderIDAlt string `json:"orderId"`
PurchaseTimeMS int64 `json:"purchase_time_ms"`
PurchaseTimeAlt int64 `json:"purchaseTimeMs"`
}
type googlePaymentConfirmData struct {
PaymentOrderID string `json:"payment_order_id"`
TransactionID string `json:"transaction_id"`
Status string `json:"status"`
ProductID int64 `json:"product_id"`
ProductCode string `json:"product_code"`
CoinAmount int64 `json:"coin_amount"`
Balance assetBalanceData `json:"balance"`
IdempotentReplay bool `json:"idempotent_replay"`
ConsumeState string `json:"consume_state"`
}
type withdrawalData struct {
WithdrawalID string `json:"withdrawal_id"`
UserID int64 `json:"user_id"`
@ -199,6 +228,77 @@ func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http
httpkit.WriteOK(writer, request, map[string]any{"channels": resp.GetChannels(), "products": products})
}
// confirmGooglePayment 接收 App 支付成功后的 Google purchase_token转交 wallet-service 做服务端校验和入账。
func (h *Handler) confirmGooglePayment(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body googlePaymentConfirmRequestBody
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := strings.TrimSpace(body.CommandID)
if commandID == "" {
commandID = strings.TrimSpace(body.CommandIDAlt)
}
productID := body.ProductID
if productID <= 0 {
productID = body.ProductIDAlt
}
productCode := strings.TrimSpace(body.ProductCode)
if productCode == "" {
productCode = strings.TrimSpace(body.ProductCodeAlt)
}
packageName := strings.TrimSpace(body.PackageName)
if packageName == "" {
packageName = strings.TrimSpace(body.PackageNameAlt)
}
purchaseToken := strings.TrimSpace(body.PurchaseToken)
if purchaseToken == "" {
purchaseToken = strings.TrimSpace(body.PurchaseAlt)
}
orderID := strings.TrimSpace(body.OrderID)
if orderID == "" {
orderID = strings.TrimSpace(body.OrderIDAlt)
}
purchaseTimeMS := body.PurchaseTimeMS
if purchaseTimeMS <= 0 {
purchaseTimeMS = body.PurchaseTimeAlt
}
if commandID == "" || productID <= 0 || packageName == "" || purchaseToken == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
userID := auth.UserIDFromContext(request.Context())
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
resp, err := h.walletClient.ConfirmGooglePayment(request.Context(), &walletv1.ConfirmGooglePaymentRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
CommandId: commandID,
UserId: userID,
RegionId: profileResp.GetUser().GetRegionId(),
ProductId: productID,
ProductCode: productCode,
PackageName: packageName,
PurchaseToken: purchaseToken,
OrderId: orderID,
PurchaseTimeMs: purchaseTimeMS,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, googlePaymentConfirmFromProto(resp))
}
// getDiamondExchangeConfig 返回钻石兑换配置。
func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *http.Request) {
if h.walletClient == nil {
@ -347,6 +447,23 @@ func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProduct
}
}
func googlePaymentConfirmFromProto(resp *walletv1.ConfirmGooglePaymentResponse) googlePaymentConfirmData {
if resp == nil {
return googlePaymentConfirmData{}
}
return googlePaymentConfirmData{
PaymentOrderID: resp.GetPaymentOrderId(),
TransactionID: resp.GetTransactionId(),
Status: resp.GetStatus(),
ProductID: resp.GetProductId(),
ProductCode: resp.GetProductCode(),
CoinAmount: resp.GetCoinAmount(),
Balance: balanceFromProto(resp.GetBalance()),
IdempotentReplay: resp.GetIdempotentReplay(),
ConsumeState: resp.GetConsumeState(),
}
}
func diamondExchangeRuleFromProto(rule *walletv1.DiamondExchangeRule) diamondExchangeRuleData {
if rule == nil {
return diamondExchangeRuleData{}

View File

@ -40,6 +40,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
GetWalletOverview: h.getWalletOverview,
GetMyBalances: h.getMyBalances,
ListRechargeProducts: h.listRechargeProducts,
ConfirmGooglePayment: h.confirmGooglePayment,
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions,

View File

@ -84,7 +84,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
var activityConn *grpc.ClientConn
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
if cfg.ActivityServiceAddr != "" {
activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = walletConn.Close()
@ -189,6 +189,10 @@ func New(cfg config.Config) (*App, error) {
treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic)
}
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
var luckyGiftClient integration.LuckyGiftClient
if activityConn != nil {
luckyGiftClient = integration.NewGRPCLuckyGiftClient(activityConn)
}
// 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。
svc := roomservice.New(roomservice.Config{
@ -200,7 +204,7 @@ func New(cfg config.Config) (*App, error) {
MicPublishTimeout: cfg.MicPublishTimeout,
RTCUserRemover: rtcUserRemover,
RoomTreasureOpenScheduler: treasureOpenScheduler,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
if cfg.RocketMQ.TreasureOpen.Enabled {
treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes))
if err != nil {

View File

@ -4,6 +4,7 @@ package integration
import (
"context"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/tencentim"
@ -17,6 +18,14 @@ type WalletClient interface {
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
}
// LuckyGiftClient 抽象 room-service 对 activity-service 幸运礼物抽奖边界的同步依赖。
type LuckyGiftClient interface {
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
// ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。
ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error)
}
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
type RoomEventPublisher interface {
// EnsureRoomGroup 确保房间在腾讯云 IM 中有对应群组。

View File

@ -5,6 +5,7 @@ import (
"context"
"google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
@ -32,3 +33,20 @@ func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1
// 宝箱奖励以 resource group 为后台配置单位wallet-service 负责展开资产和权益。
return c.client.GrantResourceGroup(ctx, req)
}
type grpcLuckyGiftClient struct {
client activityv1.LuckyGiftServiceClient
}
// NewGRPCLuckyGiftClient 用 gRPC 连接创建 activity-service 幸运礼物客户端。
func NewGRPCLuckyGiftClient(conn *grpc.ClientConn) LuckyGiftClient {
return &grpcLuckyGiftClient{client: activityv1.NewLuckyGiftServiceClient(conn)}
}
func (c *grpcLuckyGiftClient) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
return c.client.CheckLuckyGift(ctx, req)
}
func (c *grpcLuckyGiftClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
return c.client.ExecuteLuckyGiftDraw(ctx, req)
}

View File

@ -6,6 +6,7 @@ import (
"strings"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -66,6 +67,26 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
if !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
luckyEnabled := false
if cmd.PoolID != "" {
if s.luckyGift == nil {
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured")
}
checkResp, err := s.luckyGift.CheckLuckyGift(ctx, &activityv1.CheckLuckyGiftRequest{
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
UserId: cmd.ActorUserID(),
RoomId: cmd.RoomID(),
GiftId: cmd.GiftID,
PoolId: cmd.PoolID,
})
if err != nil {
return mutationResult{}, nil, err
}
if checkResp == nil || !checkResp.GetEnabled() {
return mutationResult{}, nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
}
luckyEnabled = true
}
treasureConfig, err := s.roomTreasureConfig(ctx)
if err != nil {
return mutationResult{}, nil, err
@ -98,6 +119,33 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.HeatValue = heatValue
settledCommand.PriceVersion = billing.GetPriceVersion()
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
if !luckyEnabled {
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
}
var luckyGift *roomv1.LuckyGiftDrawResult
if luckyEnabled {
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
LuckyGift: &activityv1.LuckyGiftMeta{
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
CommandId: cmd.ID(),
UserId: cmd.ActorUserID(),
DeviceId: luckyGiftDeviceID(cmd),
RoomId: cmd.RoomID(),
AnchorId: luckyGiftAnchorID(roomMeta),
GiftId: cmd.GiftID,
CoinSpent: billing.GetCoinSpent(),
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
},
})
if err != nil {
return mutationResult{}, nil, err
}
if drawResp == nil {
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
}
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
}
// 扣费成功后Room Cell 同步更新热度和本地礼物榜。
current.Heat += heatValue
@ -167,6 +215,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
billingReceiptID: billing.GetBillingReceiptId(),
roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank),
luckyGift: luckyGift,
commandPayload: commandPayload,
walletDebitMS: walletDebitMS,
syncEvent: &tencentim.RoomEvent{
@ -202,9 +251,71 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
GiftRank: result.giftRank,
Room: result.snapshot,
Treasure: result.snapshot.GetTreasure(),
LuckyGift: result.luckyGift,
}, nil
}
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
if s.luckyGift == nil {
return false
}
switch strings.TrimSpace(giftTypeCode) {
case "lucky", "super_lucky":
return true
default:
return strings.TrimSpace(poolID) != ""
}
}
func activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activityv1.RequestMeta {
return &activityv1.RequestMeta{
RequestId: meta.GetRequestId(),
Caller: "room-service",
GatewayNodeId: meta.GetGatewayNodeId(),
SentAtMs: time.Now().UTC().UnixMilli(),
AppCode: appcode.FromContext(ctx),
}
}
func luckyGiftDeviceID(cmd command.SendGift) string {
if value := strings.TrimSpace(cmd.SessionID); value != "" {
return value
}
return cmd.ID()
}
func luckyGiftAnchorID(roomMeta RoomMeta) string {
if roomMeta.OwnerUserID <= 0 {
return roomMeta.RoomID
}
return fmt.Sprintf("%d", roomMeta.OwnerUserID)
}
func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult {
if result == nil {
return nil
}
return &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: result.GetDrawId(),
CommandId: result.GetCommandId(),
PoolId: result.GetPoolId(),
GiftId: result.GetGiftId(),
RuleVersion: result.GetRuleVersion(),
ExperiencePool: result.GetExperiencePool(),
SelectedTierId: result.GetSelectedTierId(),
MultiplierPpm: result.GetMultiplierPpm(),
BaseRewardCoins: result.GetBaseRewardCoins(),
RoomAtmosphereRewardCoins: result.GetRoomAtmosphereRewardCoins(),
ActivitySubsidyCoins: result.GetActivitySubsidyCoins(),
EffectiveRewardCoins: result.GetEffectiveRewardCoins(),
RewardStatus: result.GetRewardStatus(),
StageFeedback: result.GetStageFeedback(),
HighMultiplier: result.GetHighMultiplier(),
CreatedAtMs: result.GetCreatedAtMs(),
}
}
func normalizeGiftTargetType(raw string) string {
raw = strings.TrimSpace(raw)
switch raw {

View File

@ -0,0 +1,110 @@
package service_test
import (
"context"
"testing"
"time"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type luckyGiftTestClient struct {
checks []*activityv1.CheckLuckyGiftRequest
draws []*activityv1.ExecuteLuckyGiftDrawRequest
}
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
c.checks = append(c.checks, req)
return &activityv1.CheckLuckyGiftResponse{
Enabled: true,
Reason: "enabled",
GiftId: req.GetGiftId(),
PoolId: req.GetPoolId(),
RuleVersion: 12,
ExperiencePool: "novice",
}, nil
}
func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
c.draws = append(c.draws, req)
meta := req.GetLuckyGift()
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{
DrawId: "lucky_draw_test",
CommandId: meta.GetCommandId(),
PoolId: meta.GetPoolId(),
GiftId: meta.GetGiftId(),
RuleVersion: 12,
ExperiencePool: "novice",
SelectedTierId: "novice_2x",
MultiplierPpm: 2_000_000,
BaseRewardCoins: 200,
EffectiveRewardCoins: 200,
RewardStatus: "pending",
CreatedAtMs: 1_779_258_000_000,
}}, nil
}
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-lucky",
CoinSpent: 100,
GiftPointAdded: 100,
HeatValue: 100,
GiftTypeCode: "super_lucky",
}}}
luckyGift := &luckyGiftTestClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-lucky-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
roomID := "room-lucky-gift"
ownerID := int64(101)
viewerID := int64(202)
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-lucky",
CommandId: "cmd-lucky",
ActorUserId: ownerID,
RoomId: roomID,
SessionId: "device-session-1",
AppCode: appcode.Default,
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
},
TargetType: "user",
TargetUserId: viewerID,
GiftId: "rose",
GiftCount: 1,
PoolId: "super_lucky",
})
if err != nil {
t.Fatalf("send lucky gift failed: %v", err)
}
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 {
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
}
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" {
t.Fatalf("lucky check request mismatch: %+v", luckyGift.checks)
}
if len(luckyGift.draws) != 1 {
t.Fatalf("expected one lucky draw, got %d", len(luckyGift.draws))
}
drawMeta := luckyGift.draws[0].GetLuckyGift()
if drawMeta.GetCommandId() != "cmd-lucky" || drawMeta.GetCoinSpent() != 100 || drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetAnchorId() != "101" {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
}
}

View File

@ -60,6 +60,8 @@ type Service struct {
repository Repository
// wallet 是 SendGift 的同步扣费依赖,扣费失败不能进入 Room Cell 状态变更。
wallet integration.WalletClient
// luckyGift 是幸运礼物同步检查和抽奖依赖;未配置时幸运礼物按未启用处理。
luckyGift integration.LuckyGiftClient
// syncPublisher 是 room-service 到腾讯云 IM 的低时延房间系统事件桥。
syncPublisher integration.RoomEventPublisher
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
@ -97,6 +99,8 @@ type mutationResult struct {
roomHeat int64
// giftRank 是 SendGift 后的本地礼物榜投影。
giftRank []*roomv1.RankItem
// luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。
luckyGift *roomv1.LuckyGiftDrawResult
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。
commandPayload []byte
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。
@ -117,7 +121,7 @@ type mutationResult struct {
}
// New 初始化 room-service 领域服务。
func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher) *Service {
func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher, luckyGift ...integration.LuckyGiftClient) *Service {
usedClock := cfg.Clock
if usedClock == nil {
// 生产默认系统时钟,测试可注入固定时钟保证断言稳定。
@ -148,6 +152,11 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
micPublishTimeout = 15 * time.Second
}
var luckyGiftClient integration.LuckyGiftClient
if len(luckyGift) > 0 {
luckyGiftClient = luckyGift[0]
}
return &Service{
nodeID: cfg.NodeID,
leaseTTL: cfg.LeaseTTL,
@ -159,6 +168,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
directory: directory,
repository: repository,
wallet: wallet,
luckyGift: luckyGiftClient,
syncPublisher: syncPublisher,
outboxPublisher: outboxPublisher,
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,

View File

@ -400,6 +400,13 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
t.Fatalf("created agency should be searchable: %+v", agencies)
}
agencies, err = svc.SearchAgencies(ctx, 902, displayID(901), 20)
if err != nil {
t.Fatalf("SearchAgencies by owner display id failed: %v", err)
}
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
t.Fatalf("created agency should be searchable by owner display id: %+v", agencies)
}
disabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
CommandID: "admin-disable-agency-join-901",

View File

@ -30,8 +30,13 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
`, agencyColumns)
args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive}
if strings.TrimSpace(command.Keyword) != "" {
query += " AND name LIKE ?"
args = append(args, "%"+strings.TrimSpace(command.Keyword)+"%")
keyword := strings.TrimSpace(command.Keyword)
query += ` AND (name LIKE ? OR owner_user_id IN (
SELECT user_id
FROM users
WHERE app_code = ? AND current_display_user_id = ?
))`
args = append(args, "%"+keyword+"%", appcode.FromContext(ctx), keyword)
}
query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?"
args = append(args, command.PageSize)

View File

@ -31,6 +31,15 @@ rocketmq:
enabled: true
topic: "hyapp_wallet_outbox"
producer_group: "hyapp-wallet-outbox-producer"
google_play:
enabled: false
package_name: ""
service_account_file: ""
service_account_json: ""
api_base_url: "https://androidpublisher.googleapis.com"
token_url: "https://oauth2.googleapis.com/token"
http_timeout: "10s"
consume_enabled: true
outbox_worker:
enabled: true
poll_interval: "1s"

View File

@ -31,6 +31,15 @@ rocketmq:
enabled: true
topic: "hyapp_wallet_outbox"
producer_group: "hyapp-wallet-outbox-producer"
google_play:
enabled: false
package_name: "com.example.hyapp"
service_account_file: "/etc/hyapp/secrets/google-play-service-account.json"
service_account_json: ""
api_base_url: "https://androidpublisher.googleapis.com"
token_url: "https://oauth2.googleapis.com/token"
http_timeout: "10s"
consume_enabled: true
outbox_worker:
enabled: true
poll_interval: "1s"

View File

@ -31,6 +31,15 @@ rocketmq:
enabled: false
topic: "hyapp_wallet_outbox"
producer_group: "hyapp-wallet-outbox-producer"
google_play:
enabled: false
package_name: ""
service_account_file: ""
service_account_json: ""
api_base_url: "https://androidpublisher.googleapis.com"
token_url: "https://oauth2.googleapis.com/token"
http_timeout: "10s"
consume_enabled: true
outbox_worker:
enabled: false
poll_interval: "1s"

View File

@ -243,6 +243,35 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_product_regions (
KEY idx_wallet_recharge_product_regions_region (app_code, region_id, product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值商品区域表';
CREATE TABLE IF NOT EXISTS payment_orders (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
payment_order_id VARCHAR(96) NOT NULL COMMENT '平台支付订单 ID',
provider VARCHAR(32) NOT NULL COMMENT '支付提供方',
channel VARCHAR(32) NOT NULL COMMENT '充值渠道',
status VARCHAR(32) NOT NULL COMMENT '订单状态',
command_id VARCHAR(128) NOT NULL COMMENT '客户端确认命令幂等 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
product_id BIGINT NOT NULL COMMENT '后台充值商品 ID',
product_code VARCHAR(128) NOT NULL COMMENT 'Google Play 商品 ID',
package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT 'Google Play 包名',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Google Play orderId',
purchase_token_hash VARCHAR(128) NOT NULL COMMENT 'Google purchase token SHA256不落明文 token',
purchase_state VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Google purchase state',
consume_state VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Google consume 状态',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '商品币种编码',
amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '商品金额微单位',
provider_payload JSON NULL COMMENT 'Google Play 校验返回快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, payment_order_id),
UNIQUE KEY uk_payment_orders_token (app_code, provider, purchase_token_hash),
UNIQUE KEY uk_payment_orders_command (app_code, command_id),
KEY idx_payment_orders_user_time (app_code, user_id, created_at_ms),
KEY idx_payment_orders_provider_order (app_code, provider, provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单与 provider 审计表';
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
stock_id VARCHAR(96) NOT NULL COMMENT '库存 ID',
@ -804,95 +833,13 @@ CREATE TABLE IF NOT EXISTS user_vip_history (
KEY idx_user_vip_history_user_time (app_code, user_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 VIP 历史表';
INSERT IGNORE INTO wallet_gift_prices (
app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value,
effective_at_ms, created_at_ms, updated_at_ms
) VALUES
('lalu', 'rose', 'v1', 'active', 'COIN', 10, 10, 10, 0, 0, 0),
('lalu', 'rocket', 'v1', 'active', 'COIN', 100, 100, 100, 0, 0, 0);
INSERT IGNORE INTO resources (
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url,
animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
created_at_ms, updated_at_ms
) VALUES
('lalu', 'gift_rose', 'gift', 'Rose', 'active', TRUE, 'increase_quantity', '', 0, 'coin', 10, 10, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 10, 0, 0, 0, 0),
('lalu', 'gift_rocket', 'gift', 'Rocket', 'active', TRUE, 'increase_quantity', '', 0, 'coin', 100, 100, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 20, 0, 0, 0, 0);
INSERT IGNORE INTO resources (
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url,
animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
created_at_ms, updated_at_ms
) VALUES
('lalu', 'vip1_avatar_frame', 'avatar_frame', 'VIP1 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 101, 0, 0, 0, 0),
('lalu', 'vip2_avatar_frame', 'avatar_frame', 'VIP2 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 102, 0, 0, 0, 0),
('lalu', 'vip3_vehicle', 'vehicle', 'VIP3 Vehicle', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('room_entry'), '', '', '', JSON_OBJECT(), 103, 0, 0, 0, 0);
INSERT IGNORE INTO resource_groups (
app_code, group_code, name, status, description, sort_order,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 'vip1_rewards', 'VIP1 Rewards', 'active', 'VIP1 purchase rewards', 101, 0, 0, 0, 0),
('lalu', 'vip2_rewards', 'VIP2 Rewards', 'active', 'VIP2 purchase rewards', 102, 0, 0, 0, 0),
('lalu', 'vip3_rewards', 'VIP3 Rewards', 'active', 'VIP3 purchase rewards', 103, 0, 0, 0, 0);
INSERT IGNORE INTO resource_group_items (
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
)
SELECT 'lalu', g.group_id, 'resource', r.resource_id, '', 0, 1, 604800000, 10, 0, 0
FROM resource_groups g
JOIN resources r ON r.app_code = g.app_code AND r.resource_code = 'vip1_avatar_frame'
WHERE g.app_code = 'lalu' AND g.group_code = 'vip1_rewards';
INSERT IGNORE INTO resource_group_items (
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
)
SELECT 'lalu', g.group_id, 'resource', r.resource_id, '', 0, 1, 604800000, 10, 0, 0
FROM resource_groups g
JOIN resources r ON r.app_code = g.app_code AND r.resource_code = 'vip2_avatar_frame'
WHERE g.app_code = 'lalu' AND g.group_code = 'vip2_rewards';
INSERT IGNORE INTO resource_group_items (
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
)
SELECT 'lalu', g.group_id, 'resource', r.resource_id, '', 0, 1, 604800000, 10, 0, 0
FROM resource_groups g
JOIN resources r ON r.app_code = g.app_code AND r.resource_code = 'vip3_vehicle'
WHERE g.app_code = 'lalu' AND g.group_code = 'vip3_rewards';
INSERT IGNORE INTO vip_levels (
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
sort_order, created_at_ms, updated_at_ms
)
SELECT 'lalu', 1, 'VIP1', 'active', 1000, 604800000, group_id, 0, 10, 0, 0
FROM resource_groups
WHERE app_code = 'lalu' AND group_code = 'vip1_rewards';
INSERT IGNORE INTO vip_levels (
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
sort_order, created_at_ms, updated_at_ms
)
SELECT 'lalu', 2, 'VIP2', 'active', 3000, 604800000, group_id, 0, 20, 0, 0
FROM resource_groups
WHERE app_code = 'lalu' AND group_code = 'vip2_rewards';
INSERT IGNORE INTO vip_levels (
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
sort_order, created_at_ms, updated_at_ms
)
SELECT 'lalu', 3, 'VIP3', 'active', 8000, 604800000, group_id, 0, 30, 0, 0
FROM resource_groups
WHERE app_code = 'lalu' AND group_code = 'vip3_rewards';
INSERT IGNORE INTO vip_levels (
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
sort_order, created_at_ms, updated_at_ms
) VALUES
('lalu', 1, 'VIP1', 'active', 1000, 604800000, 0, 0, 10, 0, 0),
('lalu', 2, 'VIP2', 'active', 3000, 604800000, 0, 0, 20, 0, 0),
('lalu', 3, 'VIP3', 'active', 8000, 604800000, 0, 0, 30, 0, 0),
('lalu', 4, 'VIP4', 'disabled', 0, 604800000, 0, 0, 40, 0, 0),
('lalu', 5, 'VIP5', 'disabled', 0, 604800000, 0, 0, 50, 0, 0),
('lalu', 6, 'VIP6', 'disabled', 0, 604800000, 0, 0, 60, 0, 0),
@ -922,38 +869,3 @@ INSERT IGNORE INTO gift_type_configs (
('lalu', 'activity', '活动礼物', 'Activity', 'active', 80, 0, 0, 0, 0),
('lalu', 'magic', '魔法礼物', 'Magic', 'active', 90, 0, 0, 0, 0),
('lalu', 'custom', '定制礼物', 'Custom', 'active', 100, 0, 0, 0, 0);
INSERT IGNORE INTO gift_configs (
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT 'lalu', 'rose', resource_id, 'active', 'Rose', 10, JSON_OBJECT(),
'normal', 0, 0, JSON_ARRAY(),
0, 0, 0, 0
FROM resources
WHERE app_code = 'lalu' AND resource_code = 'gift_rose'
LIMIT 1;
INSERT IGNORE INTO gift_configs (
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
SELECT 'lalu', 'rocket', resource_id, 'active', 'Rocket', 20, JSON_OBJECT(),
'normal', 0, 0, JSON_ARRAY('animation', 'global_broadcast'),
0, 0, 0, 0
FROM resources
WHERE app_code = 'lalu' AND resource_code = 'gift_rocket'
LIMIT 1;
INSERT IGNORE INTO gift_config_regions (
app_code, gift_id, region_id, created_at_ms, updated_at_ms
)
SELECT gc.app_code, gc.gift_id, 0, 0, 0
FROM gift_configs gc
LEFT JOIN gift_config_regions existing
ON existing.app_code = gc.app_code AND existing.gift_id = gc.gift_id
WHERE existing.gift_id IS NULL
AND gc.app_code = 'lalu'
AND gc.gift_id IN ('rose', 'rocket');

View File

@ -22,6 +22,7 @@ import (
"hyapp/pkg/rocketmqx"
"hyapp/pkg/walletmq"
"hyapp/services/wallet-service/internal/client"
"hyapp/services/wallet-service/internal/client/googleplay"
"hyapp/services/wallet-service/internal/config"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
@ -82,6 +83,19 @@ func New(cfg config.Config) (*App, error) {
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
if cfg.GooglePlay.Enabled {
googleClient, err := googleplay.New(cfg.GooglePlay)
if err != nil {
if outboxProducer != nil {
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
svc.SetGooglePlayClient(googleClient)
}
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
Name: "mysql",

View File

@ -0,0 +1,281 @@
package googleplay
import (
"bytes"
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/config"
"hyapp/services/wallet-service/internal/domain/ledger"
)
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"
// Client implements the minimal Google Play Developer API calls needed by wallet confirm.
type Client struct {
httpClient *http.Client
apiBaseURL string
tokenURL string
packageName string
consumeEnabled bool
email string
privateKey *rsa.PrivateKey
mu sync.Mutex
accessToken string
expiresAt time.Time
}
type serviceAccount struct {
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
TokenURI string `json:"token_uri"`
}
// New creates a Google Play Developer API client from wallet-service config.
func New(cfg config.GooglePlayConfig) (*Client, error) {
body := strings.TrimSpace(cfg.ServiceAccountJSON)
if body == "" && strings.TrimSpace(cfg.ServiceAccountFile) != "" {
fileBody, err := os.ReadFile(strings.TrimSpace(cfg.ServiceAccountFile))
if err != nil {
return nil, err
}
body = string(fileBody)
}
var account serviceAccount
if err := json.Unmarshal([]byte(body), &account); err != nil {
return nil, fmt.Errorf("parse google play service account: %w", err)
}
account.ClientEmail = strings.TrimSpace(account.ClientEmail)
account.PrivateKey = strings.TrimSpace(account.PrivateKey)
account.TokenURI = strings.TrimSpace(account.TokenURI)
if account.ClientEmail == "" || account.PrivateKey == "" {
return nil, errors.New("google play service account is incomplete")
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey))
if err != nil {
return nil, fmt.Errorf("parse google play private key: %w", err)
}
tokenURL := strings.TrimSpace(cfg.TokenURL)
if tokenURL == "" {
tokenURL = account.TokenURI
}
if tokenURL == "" {
tokenURL = "https://oauth2.googleapis.com/token"
}
apiBaseURL := strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
if apiBaseURL == "" {
apiBaseURL = "https://androidpublisher.googleapis.com"
}
timeout := cfg.HTTPTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
httpClient: &http.Client{Timeout: timeout},
apiBaseURL: apiBaseURL,
tokenURL: tokenURL,
packageName: strings.TrimSpace(cfg.PackageName),
consumeEnabled: cfg.ConsumeEnabled,
email: account.ClientEmail,
privateKey: privateKey,
}, nil
}
func (c *Client) GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) {
if c == nil {
return ledger.GooglePlayPurchase{}, xerr.New(xerr.Unavailable, "google play client is not configured")
}
packageName = c.normalizePackageName(packageName)
purchaseToken = strings.TrimSpace(purchaseToken)
if packageName == "" || purchaseToken == "" {
return ledger.GooglePlayPurchase{}, xerr.New(xerr.InvalidArgument, "google play purchase request is incomplete")
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/purchases/productsv2/tokens/%s",
c.apiBaseURL,
url.PathEscape(packageName),
url.PathEscape(purchaseToken),
)
body, err := c.doJSON(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ledger.GooglePlayPurchase{}, err
}
var parsed productPurchaseV2
if err := json.Unmarshal(body, &parsed); err != nil {
return ledger.GooglePlayPurchase{}, err
}
purchase := ledger.GooglePlayPurchase{
PackageName: packageName,
OrderID: parsed.OrderID,
PurchaseState: parsed.PurchaseStateContext.PurchaseState,
AcknowledgementState: parsed.AcknowledgementState,
PurchaseCompletionTime: parsed.PurchaseCompletionTime,
RawJSON: string(body),
}
if len(parsed.ProductLineItem) > 0 {
purchase.ProductID = parsed.ProductLineItem[0].ProductID
purchase.ConsumptionState = parsed.ProductLineItem[0].ProductOfferDetails.ConsumptionState
}
return purchase, nil
}
func (c *Client) ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error {
if c == nil {
return xerr.New(xerr.Unavailable, "google play client is not configured")
}
if !c.consumeEnabled {
return xerr.New(xerr.Unavailable, "google play consume is disabled")
}
packageName = c.normalizePackageName(packageName)
productID = strings.TrimSpace(productID)
purchaseToken = strings.TrimSpace(purchaseToken)
if packageName == "" || productID == "" || purchaseToken == "" {
return xerr.New(xerr.InvalidArgument, "google play consume request is incomplete")
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s:consume",
c.apiBaseURL,
url.PathEscape(packageName),
url.PathEscape(productID),
url.PathEscape(purchaseToken),
)
_, err := c.doJSON(ctx, http.MethodPost, endpoint, bytes.NewReader(nil))
return err
}
func (c *Client) normalizePackageName(value string) string {
value = strings.TrimSpace(value)
if value != "" {
return value
}
return c.packageName
}
func (c *Client) doJSON(ctx context.Context, method string, endpoint string, body io.Reader) ([]byte, error) {
token, err := c.token(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if method != http.MethodGet {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, xerr.New(xerr.Unavailable, "google play api request failed")
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
return nil, xerr.New(xerr.InvalidArgument, "google play purchase token is invalid")
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return nil, xerr.New(xerr.Unavailable, "google play api is not authorized")
}
return nil, xerr.New(xerr.Unavailable, "google play api returned non-success")
}
return respBody, nil
}
func (c *Client) token(ctx context.Context) (string, error) {
c.mu.Lock()
if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
token := c.accessToken
c.mu.Unlock()
return token, nil
}
c.mu.Unlock()
now := time.Now()
claims := jwt.MapClaims{
"iss": c.email,
"scope": androidPublisherScope,
"aud": c.tokenURL,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
assertion, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(c.privateKey)
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", xerr.New(xerr.Unavailable, "google oauth token request failed")
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return "", readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", xerr.New(xerr.Unavailable, "google oauth token request failed")
}
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
return "", err
}
if tokenResp.AccessToken == "" {
return "", xerr.New(xerr.Unavailable, "google oauth token response is incomplete")
}
expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second
if expiresIn <= 0 {
expiresIn = time.Hour
}
c.mu.Lock()
c.accessToken = tokenResp.AccessToken
c.expiresAt = now.Add(expiresIn)
c.mu.Unlock()
return tokenResp.AccessToken, nil
}
type productPurchaseV2 struct {
ProductLineItem []productLineItem `json:"productLineItem"`
OrderID string `json:"orderId"`
PurchaseStateContext purchaseState `json:"purchaseStateContext"`
PurchaseCompletionTime string `json:"purchaseCompletionTime"`
AcknowledgementState string `json:"acknowledgementState"`
}
type purchaseState struct {
PurchaseState string `json:"purchaseState"`
}
type productLineItem struct {
ProductID string `json:"productId"`
ProductOfferDetails productOfferDetails `json:"productOfferDetails"`
}
type productOfferDetails struct {
ConsumptionState string `json:"consumptionState"`
}

View File

@ -25,6 +25,8 @@ type Config struct {
RedPacketExpiryWorker RedPacketExpiryWorkerConfig `yaml:"red_packet_expiry_worker"`
// RocketMQ 控制 wallet_outbox 事实发布。
RocketMQ RocketMQConfig `yaml:"rocketmq"`
// GooglePlay 控制 Google Play Developer API 购买校验。
GooglePlay GooglePlayConfig `yaml:"google_play"`
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
@ -73,6 +75,18 @@ type OutboxWorkerConfig struct {
MaxBackoff time.Duration `yaml:"max_backoff"`
}
// GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。
type GooglePlayConfig struct {
Enabled bool `yaml:"enabled"`
PackageName string `yaml:"package_name"`
ServiceAccountFile string `yaml:"service_account_file"`
ServiceAccountJSON string `yaml:"service_account_json"`
APIBaseURL string `yaml:"api_base_url"`
TokenURL string `yaml:"token_url"`
HTTPTimeout time.Duration `yaml:"http_timeout"`
ConsumeEnabled bool `yaml:"consume_enabled"`
}
// Default 返回本地开发默认配置。
func Default() Config {
return Config{
@ -90,6 +104,13 @@ func Default() Config {
BatchSize: 50,
},
RocketMQ: defaultRocketMQConfig(),
GooglePlay: GooglePlayConfig{
Enabled: false,
APIBaseURL: "https://androidpublisher.googleapis.com",
TokenURL: "https://oauth2.googleapis.com/token",
HTTPTimeout: 10 * time.Second,
ConsumeEnabled: true,
},
OutboxWorker: OutboxWorkerConfig{
Enabled: false,
PollInterval: time.Second,
@ -167,6 +188,28 @@ func Load(path string) (Config, error) {
return Config{}, err
}
cfg.RocketMQ = rocketMQ
cfg.GooglePlay.PackageName = strings.TrimSpace(cfg.GooglePlay.PackageName)
cfg.GooglePlay.ServiceAccountFile = strings.TrimSpace(cfg.GooglePlay.ServiceAccountFile)
cfg.GooglePlay.ServiceAccountJSON = strings.TrimSpace(cfg.GooglePlay.ServiceAccountJSON)
cfg.GooglePlay.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GooglePlay.APIBaseURL), "/")
if cfg.GooglePlay.APIBaseURL == "" {
cfg.GooglePlay.APIBaseURL = Default().GooglePlay.APIBaseURL
}
cfg.GooglePlay.TokenURL = strings.TrimSpace(cfg.GooglePlay.TokenURL)
if cfg.GooglePlay.TokenURL == "" {
cfg.GooglePlay.TokenURL = Default().GooglePlay.TokenURL
}
if cfg.GooglePlay.HTTPTimeout <= 0 {
cfg.GooglePlay.HTTPTimeout = Default().GooglePlay.HTTPTimeout
}
if cfg.GooglePlay.Enabled {
if cfg.GooglePlay.PackageName == "" {
return Config{}, errors.New("google_play package_name is required")
}
if cfg.GooglePlay.ServiceAccountFile == "" && cfg.GooglePlay.ServiceAccountJSON == "" {
return Config{}, errors.New("google_play service account is required")
}
}
if cfg.OutboxWorker.PollInterval <= 0 {
cfg.OutboxWorker.PollInterval = Default().OutboxWorker.PollInterval
}

View File

@ -32,6 +32,16 @@ const (
RechargeChannelGoogle = "google"
// RechargeChannelApple 是 iOS 平台内购渠道标识。
RechargeChannelApple = "apple"
// PaymentProviderGooglePlay 是 Google Play 一次性内购支付渠道。
PaymentProviderGooglePlay = "google_play"
// PaymentStatusCredited 表示支付已完成校验并入账。
PaymentStatusCredited = "credited"
// PaymentConsumeStatePending 表示已入账但 Google consume 尚未确认完成。
PaymentConsumeStatePending = "consume_pending"
// PaymentConsumeStateConsumed 表示 Google consume 已确认完成。
PaymentConsumeStateConsumed = "consumed"
// GooglePurchaseStatePurchased 是 Google Play 已支付完成状态。
GooglePurchaseStatePurchased = "PURCHASED"
// WithdrawalStatusPending 表示主播美元余额提现已提交,等待后台人工审核和线下转账。
WithdrawalStatusPending = "pending"
@ -316,6 +326,45 @@ type RechargeProductCommand struct {
OperatorUserID int64
}
// GooglePaymentCommand 是 App 完成 Google Play 支付后提交给后端确认和入账的命令。
type GooglePaymentCommand struct {
AppCode string
CommandID string
UserID int64
RegionID int64
ProductID int64
ProductCode string
PackageName string
PurchaseToken string
OrderID string
PurchaseTimeMS int64
}
// GooglePlayPurchase 是 Google Play Developer API 返回的一次性商品购买状态快照。
type GooglePlayPurchase struct {
PackageName string
ProductID string
OrderID string
PurchaseState string
ConsumptionState string
AcknowledgementState string
PurchaseCompletionTime string
RawJSON string
}
// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。
type GooglePaymentReceipt struct {
PaymentOrderID string
TransactionID string
Status string
ProductID int64
ProductCode string
CoinAmount int64
Balance AssetBalance
IdempotentReplay bool
ConsumeState string
}
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
type DiamondExchangeRule struct {
ExchangeType string

View File

@ -37,6 +37,9 @@ type Repository interface {
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)
GetRechargeProduct(ctx context.Context, appCode string, productID int64) (ledger.RechargeProduct, error)
ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error)
MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error
CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error
@ -81,10 +84,17 @@ type ActivityBadgeClient interface {
ConsumeAchievementEvent(ctx context.Context, req *activityv1.ConsumeAchievementEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error)
}
// GooglePlayClient 隔离 Google Play Developer APIservice 只依赖购买校验和消耗能力。
type GooglePlayClient interface {
GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error)
ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error
}
// Service 承载钱包账务用例。
type Service struct {
repository Repository
activity ActivityBadgeClient
googlePlay GooglePlayClient
now func() time.Time
}
@ -97,6 +107,11 @@ func New(repository Repository, activity ...ActivityBadgeClient) *Service {
return &Service{repository: repository, activity: activityClient, now: time.Now}
}
// SetGooglePlayClient 注入 Google Play Developer API 客户端;未配置时 confirm 返回依赖不可用。
func (s *Service) SetGooglePlayClient(client GooglePlayClient) {
s.googlePlay = client
}
// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。
func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" {
@ -491,6 +506,86 @@ func (s *Service) DeleteRechargeProduct(ctx context.Context, appCode string, pro
return s.repository.DeleteRechargeProduct(ctx, appcode.FromContext(ctx), productID)
}
// ConfirmGooglePayment 校验 Google Play purchase token原子入账并记录支付审计。
func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.GooglePaymentReceipt, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.CommandID = strings.TrimSpace(command.CommandID)
command.ProductCode = strings.TrimSpace(command.ProductCode)
command.PackageName = strings.TrimSpace(command.PackageName)
command.PurchaseToken = strings.TrimSpace(command.PurchaseToken)
command.OrderID = strings.TrimSpace(command.OrderID)
if command.CommandID == "" || command.UserID <= 0 || command.RegionID <= 0 || command.ProductID <= 0 || command.PackageName == "" || command.PurchaseToken == "" {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is incomplete")
}
if len(command.CommandID) > 128 || len(command.PackageName) > 256 || len(command.PurchaseToken) > 4096 || len(command.OrderID) > 128 {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is too long")
}
if s.repository == nil {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.googlePlay == nil {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "google play client is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not active")
}
if product.Platform != ledger.RechargeProductPlatformAndroid || product.Channel != ledger.RechargeChannelGoogle {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "recharge product is not google play product")
}
if command.ProductCode != "" && command.ProductCode != product.ProductCode {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "product_code does not match")
}
if !rechargeProductSupportsRegion(product, command.RegionID) {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not available in user region")
}
purchase, err := s.googlePlay.GetProductPurchase(ctx, command.PackageName, command.PurchaseToken)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if purchase.PackageName == "" {
purchase.PackageName = command.PackageName
}
if purchase.PackageName != command.PackageName {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "package_name does not match")
}
if purchase.PurchaseState != ledger.GooglePurchaseStatePurchased {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase is not purchased")
}
if purchase.ProductID != "" && purchase.ProductID != product.ProductCode {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google product_id does not match recharge product")
}
if command.OrderID != "" && purchase.OrderID != "" && command.OrderID != purchase.OrderID {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google order_id does not match")
}
receipt, err := s.repository.ConfirmGooglePayment(ctx, command, product, purchase)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed {
return receipt, nil
}
consumeProductID := product.ProductCode
if purchase.ProductID != "" {
consumeProductID = purchase.ProductID
}
if err := s.googlePlay.ConsumeProduct(ctx, command.PackageName, consumeProductID, command.PurchaseToken); err != nil {
return receipt, nil
}
if err := s.repository.MarkGooglePaymentConsumed(ctx, command.AppCode, receipt.PaymentOrderID); err != nil {
return receipt, nil
}
receipt.ConsumeState = ledger.PaymentConsumeStateConsumed
return receipt, nil
}
// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。
func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) {
if userID <= 0 {
@ -669,3 +764,12 @@ func validateRechargeProductCommand(command ledger.RechargeProductCommand, requi
}
return nil
}
func rechargeProductSupportsRegion(product ledger.RechargeProduct, regionID int64) bool {
for _, productRegionID := range product.RegionIDs {
if productRegionID == regionID {
return true
}
}
return false
}

View File

@ -1417,6 +1417,31 @@ func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
}
}
func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
ctx := context.Background()
command := resourcedomain.ResourceCommand{
ResourceCode: "duplicate_badge",
ResourceType: resourcedomain.TypeBadge,
Name: "Duplicate Badge",
Status: resourcedomain.StatusActive,
Grantable: true,
ManagerGrantEnabled: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
OperatorUserID: 90001,
}
if _, err := svc.CreateResource(ctx, command); err != nil {
t.Fatalf("first CreateResource failed: %v", err)
}
command.Name = "Duplicate Badge Again"
if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("duplicate resource_code should be conflict, got %v", err)
}
}
// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
repository := mysqltest.NewRepository(t)

View File

@ -0,0 +1,358 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
// ConfirmGooglePayment 将 Google Play 已购买 token 落为平台支付订单、钱包交易和充值审计记录。
func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error) {
if r == nil || r.db == nil {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
tokenHash := googlePurchaseTokenHash(command.PurchaseToken)
if existing, exists, err := r.lookupGooglePaymentOrderByToken(ctx, tx, tokenHash); err != nil || exists {
if err != nil || !exists {
return ledger.GooglePaymentReceipt{}, err
}
if existing.UserID != command.UserID || existing.ProductID != product.ProductID {
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase token belongs to another payment order")
}
return r.receiptForGooglePaymentOrder(ctx, tx, existing.PaymentOrderID, true)
}
requestHash := googlePaymentRequestHash(command, product, purchase, tokenHash)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeGooglePlayRecharge); err != nil || exists {
if err != nil || !exists {
return ledger.GooglePaymentReceipt{}, err
}
return r.receiptForGooglePaymentTransaction(ctx, tx, txRow.TransactionID, true)
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
paymentOrderID := googlePaymentOrderID(command.AppCode, tokenHash)
balanceAfter := account.AvailableAmount + product.CoinAmount
rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.UserID, transactionID, product.CoinAmount, product.AmountMicro, nowMs)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
metadata := googlePaymentMetadata{
AppCode: command.AppCode,
PaymentOrderID: paymentOrderID,
UserID: command.UserID,
RegionID: command.RegionID,
ProductID: product.ProductID,
ProductCode: product.ProductCode,
ProductName: product.ProductName,
PackageName: command.PackageName,
Provider: ledger.PaymentProviderGooglePlay,
Channel: ledger.RechargeChannelGoogle,
ProviderOrderID: purchase.OrderID,
ClientOrderID: command.OrderID,
PurchaseTokenHash: tokenHash,
PurchaseState: purchase.PurchaseState,
ConsumptionState: purchase.ConsumptionState,
AcknowledgementState: purchase.AcknowledgementState,
PurchaseCompletionTime: purchase.PurchaseCompletionTime,
CoinAmount: product.CoinAmount,
CurrencyCode: product.CurrencyCode,
AmountMicro: product.AmountMicro,
BalanceAfter: balanceAfter,
RechargeSequence: rechargeSequence,
CreatedAtMS: nowMs,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGooglePlayRecharge, requestHash, externalGooglePaymentRef(purchase, tokenHash), metadata, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, product.CoinAmount, 0, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: product.CoinAmount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.insertRechargeRecord(ctx, tx, transactionID, coinSellerTransferMetadata{
AppCode: command.AppCode,
TargetUserID: command.UserID,
TargetRegionID: command.RegionID,
Amount: product.CoinAmount,
TargetAssetType: ledger.AssetCoin,
TargetBalanceAfter: balanceAfter,
RechargeSequence: rechargeSequence,
RechargeUSDMinor: product.AmountMicro,
RechargeCurrencyCode: product.CurrencyCode,
RechargePolicyVersion: product.PolicyVersion,
RechargePolicyCoinAmount: product.CoinAmount,
RechargePolicyUSDMinorAmount: product.AmountMicro,
}, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.insertGooglePaymentOrder(ctx, tx, paymentOrderID, command, product, purchase, tokenHash, transactionID, nowMs); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, product.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
googlePaymentCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
rechargeRecordedEvent(transactionID, command.CommandID, command.UserID, product.CoinAmount, metadata, nowMs),
}); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
return ledger.GooglePaymentReceipt{
PaymentOrderID: paymentOrderID,
TransactionID: transactionID,
Status: ledger.PaymentStatusCredited,
ProductID: product.ProductID,
ProductCode: product.ProductCode,
CoinAmount: product.CoinAmount,
Balance: ledger.AssetBalance{
AppCode: command.AppCode,
UserID: command.UserID,
AssetType: ledger.AssetCoin,
AvailableAmount: balanceAfter,
FrozenAmount: account.FrozenAmount,
},
ConsumeState: ledger.PaymentConsumeStatePending,
}, nil
}
func (r *Repository) MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
paymentOrderID = strings.TrimSpace(paymentOrderID)
if paymentOrderID == "" {
return xerr.New(xerr.InvalidArgument, "payment_order_id is required")
}
_, err := r.db.ExecContext(ctx, `
UPDATE payment_orders
SET consume_state = ?, updated_at_ms = ?
WHERE app_code = ? AND payment_order_id = ? AND provider = ?`,
ledger.PaymentConsumeStateConsumed,
time.Now().UnixMilli(),
appcode.FromContext(ctx),
paymentOrderID,
ledger.PaymentProviderGooglePlay,
)
return err
}
type googlePaymentOrderRow struct {
PaymentOrderID string
UserID int64
ProductID int64
TransactionID string
}
func (r *Repository) lookupGooglePaymentOrderByToken(ctx context.Context, tx *sql.Tx, tokenHash string) (googlePaymentOrderRow, bool, error) {
row := tx.QueryRowContext(ctx, `
SELECT payment_order_id, user_id, product_id, wallet_transaction_id
FROM payment_orders
WHERE app_code = ? AND provider = ? AND purchase_token_hash = ?
FOR UPDATE`,
appcode.FromContext(ctx),
ledger.PaymentProviderGooglePlay,
tokenHash,
)
var payment googlePaymentOrderRow
if err := row.Scan(&payment.PaymentOrderID, &payment.UserID, &payment.ProductID, &payment.TransactionID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return googlePaymentOrderRow{}, false, nil
}
return googlePaymentOrderRow{}, false, err
}
return payment, true, nil
}
func (r *Repository) insertGooglePaymentOrder(ctx context.Context, tx *sql.Tx, paymentOrderID string, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase, tokenHash string, transactionID string, nowMs int64) error {
payloadJSON := purchase.RawJSON
if strings.TrimSpace(payloadJSON) == "" {
payloadJSON = mustJSON(purchase)
}
_, err := tx.ExecContext(ctx, `
INSERT INTO payment_orders (
app_code, payment_order_id, provider, channel, status, command_id, user_id,
product_id, product_code, package_name, provider_order_id, purchase_token_hash,
purchase_state, consume_state, wallet_transaction_id, coin_amount, currency_code,
amount_micro, provider_payload, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.AppCode,
paymentOrderID,
ledger.PaymentProviderGooglePlay,
ledger.RechargeChannelGoogle,
ledger.PaymentStatusCredited,
command.CommandID,
command.UserID,
product.ProductID,
product.ProductCode,
command.PackageName,
purchase.OrderID,
tokenHash,
purchase.PurchaseState,
ledger.PaymentConsumeStatePending,
transactionID,
product.CoinAmount,
product.CurrencyCode,
product.AmountMicro,
payloadJSON,
nowMs,
nowMs,
)
if err != nil && isMySQLDuplicateError(err) {
return xerr.New(xerr.Conflict, "google payment order already exists")
}
return err
}
func (r *Repository) receiptForGooglePaymentTransaction(ctx context.Context, tx *sql.Tx, transactionID string, idempotentReplay bool) (ledger.GooglePaymentReceipt, error) {
var paymentOrderID string
if err := tx.QueryRowContext(ctx, `
SELECT payment_order_id
FROM payment_orders
WHERE app_code = ? AND wallet_transaction_id = ?
LIMIT 1`,
appcode.FromContext(ctx),
transactionID,
).Scan(&paymentOrderID); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
return r.receiptForGooglePaymentOrder(ctx, tx, paymentOrderID, idempotentReplay)
}
func (r *Repository) receiptForGooglePaymentOrder(ctx context.Context, tx *sql.Tx, paymentOrderID string, idempotentReplay bool) (ledger.GooglePaymentReceipt, error) {
row := tx.QueryRowContext(ctx, `
SELECT payment_order_id, wallet_transaction_id, status, user_id, product_id, product_code,
coin_amount, consume_state
FROM payment_orders
WHERE app_code = ? AND payment_order_id = ?`,
appcode.FromContext(ctx),
paymentOrderID,
)
var receipt ledger.GooglePaymentReceipt
var userID int64
if err := row.Scan(
&receipt.PaymentOrderID,
&receipt.TransactionID,
&receipt.Status,
&userID,
&receipt.ProductID,
&receipt.ProductCode,
&receipt.CoinAmount,
&receipt.ConsumeState,
); err != nil {
return ledger.GooglePaymentReceipt{}, err
}
balance, err := r.balanceAfterTransaction(ctx, tx, receipt.TransactionID, userID, ledger.AssetCoin)
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
receipt.Balance = balance
receipt.IdempotentReplay = idempotentReplay
return receipt, nil
}
type googlePaymentMetadata struct {
AppCode string `json:"app_code"`
PaymentOrderID string `json:"payment_order_id"`
UserID int64 `json:"user_id"`
RegionID int64 `json:"region_id"`
ProductID int64 `json:"product_id"`
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
PackageName string `json:"package_name"`
Provider string `json:"provider"`
Channel string `json:"channel"`
ProviderOrderID string `json:"provider_order_id"`
ClientOrderID string `json:"client_order_id"`
PurchaseTokenHash string `json:"purchase_token_hash"`
PurchaseState string `json:"purchase_state"`
ConsumptionState string `json:"consumption_state"`
AcknowledgementState string `json:"acknowledgement_state"`
PurchaseCompletionTime string `json:"purchase_completion_time"`
CoinAmount int64 `json:"coin_amount"`
CurrencyCode string `json:"currency_code"`
AmountMicro int64 `json:"amount_micro"`
BalanceAfter int64 `json:"balance_after"`
RechargeSequence int64 `json:"recharge_sequence"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func googlePaymentCreditedEvent(transactionID string, commandID string, metadata googlePaymentMetadata, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletGooglePaymentCredited", metadata.UserID, ledger.AssetCoin),
EventType: "WalletGooglePaymentCredited",
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.UserID,
AssetType: ledger.AssetCoin,
AvailableDelta: metadata.CoinAmount,
FrozenDelta: 0,
Payload: metadata,
CreatedAtMS: nowMs,
}
}
func googlePaymentRequestHash(command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase, tokenHash string) string {
return stableHash(fmt.Sprintf("google_play|%s|%d|%d|%d|%s|%s|%s|%s|%s|%d|%d",
appcode.Normalize(command.AppCode),
command.UserID,
command.RegionID,
product.ProductID,
product.ProductCode,
command.PackageName,
tokenHash,
purchase.OrderID,
purchase.PurchaseState,
product.CoinAmount,
product.AmountMicro,
))
}
func googlePurchaseTokenHash(token string) string {
return stableHash(strings.TrimSpace(token))
}
func googlePaymentOrderID(appCode string, tokenHash string) string {
return "gpay_" + stableHash(appcode.Normalize(appCode) + "|" + tokenHash)[:48]
}
func externalGooglePaymentRef(purchase ledger.GooglePlayPurchase, tokenHash string) string {
if strings.TrimSpace(purchase.OrderID) != "" {
return purchase.OrderID
}
return "token:" + tokenHash[:32]
}

View File

@ -28,6 +28,7 @@ const (
bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
bizTypeGooglePlayRecharge = "google_play_recharge"
bizTypeGameDebit = "game_debit"
bizTypeGameCredit = "game_credit"
bizTypeGameRefund = "game_refund"

View File

@ -146,7 +146,7 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.
nowMs,
)
if err != nil {
return resourcedomain.Resource{}, err
return resourcedomain.Resource{}, mapResourceWriteError(err)
}
resourceID, err := result.LastInsertId()
if err != nil {
@ -225,7 +225,7 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.
command.ResourceID,
)
if err != nil {
return resourcedomain.Resource{}, err
return resourcedomain.Resource{}, mapResourceWriteError(err)
}
if affected, err := result.RowsAffected(); err != nil {
return resourcedomain.Resource{}, err
@ -2215,6 +2215,13 @@ func resourceOffset(page int32, pageSize int32) int32 {
return (page - 1) * pageSize
}
func mapResourceWriteError(err error) error {
if isMySQLDuplicateError(err) {
return xerr.New(xerr.Conflict, "resource_code already exists")
}
return err
}
func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
command.ResourceCode = strings.TrimSpace(command.ResourceCode)
command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)

View File

@ -140,6 +140,37 @@ func (s *Server) ListRechargeProducts(ctx context.Context, req *walletv1.ListRec
return resp, nil
}
// ConfirmGooglePayment 校验 Google Play purchase token 并完成钱包入账。
func (s *Server) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.ConfirmGooglePayment(ctx, ledger.GooglePaymentCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
UserID: req.GetUserId(),
RegionID: req.GetRegionId(),
ProductID: req.GetProductId(),
ProductCode: req.GetProductCode(),
PackageName: req.GetPackageName(),
PurchaseToken: req.GetPurchaseToken(),
OrderID: req.GetOrderId(),
PurchaseTimeMS: req.GetPurchaseTimeMs(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.ConfirmGooglePaymentResponse{
PaymentOrderId: receipt.PaymentOrderID,
TransactionId: receipt.TransactionID,
Status: receipt.Status,
ProductId: receipt.ProductID,
ProductCode: receipt.ProductCode,
CoinAmount: receipt.CoinAmount,
Balance: balanceToProto(receipt.Balance),
IdempotentReplay: receipt.IdempotentReplay,
ConsumeState: receipt.ConsumeState,
}, nil
}
// ListAdminRechargeProducts 返回后台内购配置列表。
func (s *Server) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())