增加钱包确认
This commit is contained in:
parent
ad4a755998
commit
0b428fc9c7
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
268
docs/ExploreTab-Flutter-App对接文档.md
Normal file
268
docs/ExploreTab-Flutter-App对接文档.md
Normal 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;没有缓存时展示空态。
|
||||
|
||||
@ -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 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -154,6 +154,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
|
||||
@ -361,6 +362,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)
|
||||
|
||||
@ -346,6 +346,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
|
||||
@ -1041,6 +1043,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 {
|
||||
@ -3624,6 +3637,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",
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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",
|
||||
|
||||
281
services/wallet-service/internal/client/googleplay/client.go
Normal file
281
services/wallet-service/internal/client/googleplay/client.go
Normal 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"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 API,service 只依赖购买校验和消耗能力。
|
||||
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
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user