增加钱包确认
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;
|
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 是后台内购配置列表查询,不参与用户充值主链路。
|
// ListAdminRechargeProductsRequest 是后台内购配置列表查询,不参与用户充值主链路。
|
||||||
message ListAdminRechargeProductsRequest {
|
message ListAdminRechargeProductsRequest {
|
||||||
string request_id = 1;
|
string request_id = 1;
|
||||||
@ -1233,6 +1259,7 @@ service WalletService {
|
|||||||
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
||||||
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);
|
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);
|
||||||
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
|
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
|
||||||
|
rpc ConfirmGooglePayment(ConfirmGooglePaymentRequest) returns (ConfirmGooglePaymentResponse);
|
||||||
rpc ListAdminRechargeProducts(ListAdminRechargeProductsRequest) returns (ListAdminRechargeProductsResponse);
|
rpc ListAdminRechargeProducts(ListAdminRechargeProductsRequest) returns (ListAdminRechargeProductsResponse);
|
||||||
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
|
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
|
||||||
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);
|
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);
|
||||||
|
|||||||
@ -50,6 +50,7 @@ const (
|
|||||||
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
||||||
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
|
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
|
||||||
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
|
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_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
|
||||||
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
|
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
|
||||||
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
|
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)
|
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
|
||||||
GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error)
|
GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error)
|
||||||
ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, 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)
|
ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error)
|
||||||
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
|
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
|
||||||
UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, 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
|
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) {
|
func (c *walletServiceClient) ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListAdminRechargeProductsResponse)
|
out := new(ListAdminRechargeProductsResponse)
|
||||||
@ -710,6 +722,7 @@ type WalletServiceServer interface {
|
|||||||
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
||||||
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
|
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
|
||||||
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
|
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
|
||||||
|
ConfirmGooglePayment(context.Context, *ConfirmGooglePaymentRequest) (*ConfirmGooglePaymentResponse, error)
|
||||||
ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error)
|
ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error)
|
||||||
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
|
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
|
||||||
UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*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) {
|
func (UnimplementedWalletServiceServer) ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListRechargeProducts not implemented")
|
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) {
|
func (UnimplementedWalletServiceServer) ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error) {
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListAdminRechargeProducts not implemented")
|
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)
|
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) {
|
func _WalletService_ListAdminRechargeProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListAdminRechargeProductsRequest)
|
in := new(ListAdminRechargeProductsRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@ -2007,6 +2041,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ListRechargeProducts",
|
MethodName: "ListRechargeProducts",
|
||||||
Handler: _WalletService_ListRechargeProducts_Handler,
|
Handler: _WalletService_ListRechargeProducts_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ConfirmGooglePayment",
|
||||||
|
Handler: _WalletService_ConfirmGooglePayment_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListAdminRechargeProducts",
|
MethodName: "ListAdminRechargeProducts",
|
||||||
Handler: _WalletService_ListAdminRechargeProducts_Handler,
|
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 端充值商品列表。
|
- `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`:刷新金币余额。
|
- `GET /api/v1/wallet/me/balances`:刷新金币余额。
|
||||||
- 钱包余额 IM 通知:`WalletBalanceChanged`。
|
- 钱包余额 IM 通知:`WalletBalanceChanged`。
|
||||||
|
- `payment_orders`:保存 Google provider 订单审计,purchase token 只落 SHA256,不落明文。
|
||||||
|
|
||||||
后端待补齐:
|
后端待补齐:
|
||||||
|
|
||||||
- `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token,后端校验并入账。
|
|
||||||
- Google Play RTDN / Pub/Sub 回调:只做补偿通知,不替代客户端确认接口。
|
- Google Play RTDN / Pub/Sub 回调:只做补偿通知,不替代客户端确认接口。
|
||||||
- `payment_orders` 和 Google provider 订单审计表。
|
|
||||||
|
|
||||||
Flutter 可以先按本文契约接商品列表和本地 Google Billing 流程;确认支付接口需要等后端补齐后联调。
|
Flutter 可以按本文契约联调商品列表、本地 Google Billing 流程和确认支付接口。
|
||||||
|
|
||||||
## 2. Flutter 依赖
|
## 2. Flutter 依赖
|
||||||
|
|
||||||
@ -200,7 +200,7 @@ void startPurchaseListener() {
|
|||||||
|
|
||||||
## 6. 确认 Google 支付
|
## 6. 确认 Google 支付
|
||||||
|
|
||||||
后端待实现接口:
|
后端已实现接口:
|
||||||
|
|
||||||
`POST /api/v1/wallet/payments/google/confirm`
|
`POST /api/v1/wallet/payments/google/confirm`
|
||||||
|
|
||||||
@ -214,7 +214,7 @@ void startPurchaseListener() {
|
|||||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
|
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
|
||||||
| `X-App-Platform` | 是 | 固定传 `android`。 |
|
| `X-App-Platform` | 是 | 固定传 `android`。 |
|
||||||
|
|
||||||
请求体建议契约:
|
请求体:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -232,7 +232,7 @@ void startPurchaseListener() {
|
|||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试必须使用同一个值。 |
|
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试建议使用同一个值。后端最终还会按 purchase token 去重。 |
|
||||||
| `product_id` | 是 | 后端商品列表返回的本地商品 ID。 |
|
| `product_id` | 是 | 后端商品列表返回的本地商品 ID。 |
|
||||||
| `product_code` | 是 | 后端商品列表返回的 Google productId。 |
|
| `product_code` | 是 | 后端商品列表返回的 Google productId。 |
|
||||||
| `package_name` | 是 | 当前 Android 包名。 |
|
| `package_name` | 是 | 当前 Android 包名。 |
|
||||||
@ -246,7 +246,7 @@ void startPurchaseListener() {
|
|||||||
final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}';
|
final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}';
|
||||||
```
|
```
|
||||||
|
|
||||||
成功响应建议契约:
|
成功响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -254,7 +254,7 @@ final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}
|
|||||||
"message": "ok",
|
"message": "ok",
|
||||||
"request_id": "req_abc",
|
"request_id": "req_abc",
|
||||||
"data": {
|
"data": {
|
||||||
"payment_order_id": "pay_google_xxx",
|
"payment_order_id": "gpay_xxx",
|
||||||
"transaction_id": "wtx_xxx",
|
"transaction_id": "wtx_xxx",
|
||||||
"status": "credited",
|
"status": "credited",
|
||||||
"product_id": 11,
|
"product_id": 11,
|
||||||
@ -276,16 +276,26 @@ Flutter 成功处理:
|
|||||||
|
|
||||||
- `status=credited`:展示充值成功,刷新 `/wallet/me/balances`。
|
- `status=credited`:展示充值成功,刷新 `/wallet/me/balances`。
|
||||||
- `idempotent_replay=true`:按成功处理,不重复提示异常。
|
- `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 状态。
|
- 后端成功后调用 `InAppPurchase.instance.completePurchase(purchaseDetails)`,结束客户端 pending 状态。
|
||||||
|
|
||||||
失败处理:
|
失败处理:
|
||||||
|
|
||||||
- `UNAUTHORIZED`:登录失效,走登录态恢复。
|
- `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`:保留订单为待确认,稍后重试。
|
- `UPSTREAM_ERROR` / `INTERNAL_ERROR`:保留订单为待确认,稍后重试。
|
||||||
- provider 校验失败:不入账,不 complete purchase;记录 `request_id` 并触发补单或客服入口。
|
- 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. 补单规则
|
## 7. 补单规则
|
||||||
|
|
||||||
Flutter 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。
|
Flutter 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。
|
||||||
|
|||||||
@ -2156,6 +2156,38 @@ paths:
|
|||||||
$ref: "#/responses/Internal"
|
$ref: "#/responses/Internal"
|
||||||
"502":
|
"502":
|
||||||
$ref: "#/responses/UpstreamError"
|
$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:
|
/api/v1/wallet/coin-seller/transfer:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@ -3800,6 +3832,75 @@ definitions:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/definitions/RechargeProductData"
|
$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:
|
CoinSellerTransferRequest:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@ -5095,6 +5196,13 @@ definitions:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: "#/definitions/RechargeProductListData"
|
$ref: "#/definitions/RechargeProductListData"
|
||||||
|
GooglePaymentConfirmEnvelope:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: "#/definitions/GooglePaymentConfirmData"
|
||||||
CoinSellerTransferEnvelope:
|
CoinSellerTransferEnvelope:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||||
|
|||||||
@ -351,6 +351,37 @@ func (h *Handler) UpdateGiftType(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
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) {
|
func (h *Handler) CreateGift(c *gin.Context) {
|
||||||
var req giftRequest
|
var req giftRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@ -122,6 +122,18 @@ type giftTypeRequest struct {
|
|||||||
SortOrder int32 `json:"sortOrder"`
|
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 {
|
type grantResourceRequest struct {
|
||||||
CommandID string `json:"commandId"`
|
CommandID string `json:"commandId"`
|
||||||
TargetUserID int64 `json:"targetUserId"`
|
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 {
|
func managerGrantEnabledOrDefault(value *bool) *bool {
|
||||||
enabled := true
|
enabled := true
|
||||||
if value != nil {
|
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/gifts", middleware.RequirePermission("gift:view"), h.ListGifts)
|
||||||
protected.GET("/admin/gift-types", middleware.RequirePermission("gift:view"), h.ListGiftTypes)
|
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.PUT("/admin/gift-types/:type_code", middleware.RequirePermission("gift:update"), h.UpdateGiftType)
|
||||||
protected.POST("/admin/gifts", middleware.RequirePermission("gift:create"), h.CreateGift)
|
protected.POST("/admin/gifts", middleware.RequirePermission("gift:create"), h.CreateGift)
|
||||||
protected.PUT("/admin/gifts/:gift_id", middleware.RequirePermission("gift:update"), h.UpdateGift)
|
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)
|
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
|
||||||
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
|
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
|
||||||
ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, 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)
|
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
|
||||||
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
|
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
|
||||||
ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, 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)
|
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) {
|
func (c *grpcWalletClient) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
|
||||||
return c.client.GetDiamondExchangeConfig(ctx, req)
|
return c.client.GetDiamondExchangeConfig(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,6 +154,7 @@ type WalletHandlers struct {
|
|||||||
GetWalletOverview http.HandlerFunc
|
GetWalletOverview http.HandlerFunc
|
||||||
GetMyBalances http.HandlerFunc
|
GetMyBalances http.HandlerFunc
|
||||||
ListRechargeProducts http.HandlerFunc
|
ListRechargeProducts http.HandlerFunc
|
||||||
|
ConfirmGooglePayment http.HandlerFunc
|
||||||
GetDiamondExchangeConfig http.HandlerFunc
|
GetDiamondExchangeConfig http.HandlerFunc
|
||||||
ApplyWithdrawal http.HandlerFunc
|
ApplyWithdrawal http.HandlerFunc
|
||||||
ListCoinTransactions 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/overview", http.MethodGet, h.GetWalletOverview)
|
||||||
r.profile("/wallet/me/balances", "", h.GetMyBalances)
|
r.profile("/wallet/me/balances", "", h.GetMyBalances)
|
||||||
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
|
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/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
|
||||||
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
|
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
|
||||||
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
|
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
|
||||||
|
|||||||
@ -346,6 +346,8 @@ type fakeWalletClient struct {
|
|||||||
giftWallResp *walletv1.GetUserGiftWallResponse
|
giftWallResp *walletv1.GetUserGiftWallResponse
|
||||||
lastRechargeProducts *walletv1.ListRechargeProductsRequest
|
lastRechargeProducts *walletv1.ListRechargeProductsRequest
|
||||||
rechargeProductsResp *walletv1.ListRechargeProductsResponse
|
rechargeProductsResp *walletv1.ListRechargeProductsResponse
|
||||||
|
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
|
||||||
|
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
||||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
||||||
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
|
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
|
||||||
lastTransactions *walletv1.ListWalletTransactionsRequest
|
lastTransactions *walletv1.ListWalletTransactionsRequest
|
||||||
@ -1041,6 +1043,17 @@ func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1
|
|||||||
return &walletv1.ListRechargeProductsResponse{}, nil
|
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) {
|
func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
|
||||||
f.lastDiamondExchange = req
|
f.lastDiamondExchange = req
|
||||||
if f.err != nil {
|
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) {
|
func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
|
||||||
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
|
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
|
||||||
TransactionId: "wtx-coin-seller",
|
TransactionId: "wtx-coin-seller",
|
||||||
|
|||||||
@ -105,6 +105,35 @@ type withdrawalApplyRequestBody struct {
|
|||||||
Reason string `json:"reason"`
|
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 {
|
type withdrawalData struct {
|
||||||
WithdrawalID string `json:"withdrawal_id"`
|
WithdrawalID string `json:"withdrawal_id"`
|
||||||
UserID int64 `json:"user_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})
|
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 返回钻石兑换配置。
|
// getDiamondExchangeConfig 返回钻石兑换配置。
|
||||||
func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.walletClient == nil {
|
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 {
|
func diamondExchangeRuleFromProto(rule *walletv1.DiamondExchangeRule) diamondExchangeRuleData {
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
return diamondExchangeRuleData{}
|
return diamondExchangeRuleData{}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
|||||||
GetWalletOverview: h.getWalletOverview,
|
GetWalletOverview: h.getWalletOverview,
|
||||||
GetMyBalances: h.getMyBalances,
|
GetMyBalances: h.getMyBalances,
|
||||||
ListRechargeProducts: h.listRechargeProducts,
|
ListRechargeProducts: h.listRechargeProducts,
|
||||||
|
ConfirmGooglePayment: h.confirmGooglePayment,
|
||||||
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
|
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
|
||||||
ApplyWithdrawal: h.applyWithdrawal,
|
ApplyWithdrawal: h.applyWithdrawal,
|
||||||
ListCoinTransactions: h.listCoinTransactions,
|
ListCoinTransactions: h.listCoinTransactions,
|
||||||
|
|||||||
@ -31,6 +31,15 @@ rocketmq:
|
|||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_wallet_outbox"
|
topic: "hyapp_wallet_outbox"
|
||||||
producer_group: "hyapp-wallet-outbox-producer"
|
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:
|
outbox_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "1s"
|
poll_interval: "1s"
|
||||||
|
|||||||
@ -31,6 +31,15 @@ rocketmq:
|
|||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_wallet_outbox"
|
topic: "hyapp_wallet_outbox"
|
||||||
producer_group: "hyapp-wallet-outbox-producer"
|
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:
|
outbox_worker:
|
||||||
enabled: true
|
enabled: true
|
||||||
poll_interval: "1s"
|
poll_interval: "1s"
|
||||||
|
|||||||
@ -31,6 +31,15 @@ rocketmq:
|
|||||||
enabled: false
|
enabled: false
|
||||||
topic: "hyapp_wallet_outbox"
|
topic: "hyapp_wallet_outbox"
|
||||||
producer_group: "hyapp-wallet-outbox-producer"
|
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:
|
outbox_worker:
|
||||||
enabled: false
|
enabled: false
|
||||||
poll_interval: "1s"
|
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)
|
KEY idx_wallet_recharge_product_regions_region (app_code, region_id, product_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值商品区域表';
|
) 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 (
|
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
|
||||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||||
stock_id VARCHAR(96) NOT NULL COMMENT '库存 ID',
|
stock_id VARCHAR(96) NOT NULL COMMENT '库存 ID',
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import (
|
|||||||
"hyapp/pkg/rocketmqx"
|
"hyapp/pkg/rocketmqx"
|
||||||
"hyapp/pkg/walletmq"
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/services/wallet-service/internal/client"
|
"hyapp/services/wallet-service/internal/client"
|
||||||
|
"hyapp/services/wallet-service/internal/client/googleplay"
|
||||||
"hyapp/services/wallet-service/internal/config"
|
"hyapp/services/wallet-service/internal/config"
|
||||||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||||||
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
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")))
|
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
|
||||||
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
|
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))
|
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
|
||||||
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
|
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
|
||||||
Name: "mysql",
|
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"`
|
RedPacketExpiryWorker RedPacketExpiryWorkerConfig `yaml:"red_packet_expiry_worker"`
|
||||||
// RocketMQ 控制 wallet_outbox 事实发布。
|
// RocketMQ 控制 wallet_outbox 事实发布。
|
||||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||||
|
// GooglePlay 控制 Google Play Developer API 购买校验。
|
||||||
|
GooglePlay GooglePlayConfig `yaml:"google_play"`
|
||||||
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
||||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||||
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
|
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
|
||||||
@ -73,6 +75,18 @@ type OutboxWorkerConfig struct {
|
|||||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
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 返回本地开发默认配置。
|
// Default 返回本地开发默认配置。
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
@ -90,6 +104,13 @@ func Default() Config {
|
|||||||
BatchSize: 50,
|
BatchSize: 50,
|
||||||
},
|
},
|
||||||
RocketMQ: defaultRocketMQConfig(),
|
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{
|
OutboxWorker: OutboxWorkerConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
PollInterval: time.Second,
|
PollInterval: time.Second,
|
||||||
@ -167,6 +188,28 @@ func Load(path string) (Config, error) {
|
|||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
cfg.RocketMQ = rocketMQ
|
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 {
|
if cfg.OutboxWorker.PollInterval <= 0 {
|
||||||
cfg.OutboxWorker.PollInterval = Default().OutboxWorker.PollInterval
|
cfg.OutboxWorker.PollInterval = Default().OutboxWorker.PollInterval
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,16 @@ const (
|
|||||||
RechargeChannelGoogle = "google"
|
RechargeChannelGoogle = "google"
|
||||||
// RechargeChannelApple 是 iOS 平台内购渠道标识。
|
// RechargeChannelApple 是 iOS 平台内购渠道标识。
|
||||||
RechargeChannelApple = "apple"
|
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 表示主播美元余额提现已提交,等待后台人工审核和线下转账。
|
||||||
WithdrawalStatusPending = "pending"
|
WithdrawalStatusPending = "pending"
|
||||||
@ -316,6 +326,45 @@ type RechargeProductCommand struct {
|
|||||||
OperatorUserID int64
|
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 是钻石兑换金币或余额的固定规则投影。
|
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
|
||||||
type DiamondExchangeRule struct {
|
type DiamondExchangeRule struct {
|
||||||
ExchangeType string
|
ExchangeType string
|
||||||
|
|||||||
@ -37,6 +37,9 @@ type Repository interface {
|
|||||||
GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error)
|
GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error)
|
||||||
ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string) ([]ledger.RechargeProduct, []string, 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)
|
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)
|
CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
|
||||||
UpdateRechargeProduct(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
|
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)
|
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 承载钱包账务用例。
|
// Service 承载钱包账务用例。
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
activity ActivityBadgeClient
|
activity ActivityBadgeClient
|
||||||
|
googlePlay GooglePlayClient
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,6 +107,11 @@ func New(repository Repository, activity ...ActivityBadgeClient) *Service {
|
|||||||
return &Service{repository: repository, activity: activityClient, now: time.Now}
|
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 做原子落账和幂等。
|
// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。
|
||||||
func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
|
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 == "" {
|
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)
|
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 支持的钻石兑换规则。
|
// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。
|
||||||
func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) {
|
func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) {
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
@ -669,3 +764,12 @@ func validateRechargeProductCommand(command ledger.RechargeProductCommand, requi
|
|||||||
}
|
}
|
||||||
return nil
|
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"
|
bizTypeCoinSellerTransfer = "coin_seller_transfer"
|
||||||
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
|
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
|
||||||
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
|
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
|
||||||
|
bizTypeGooglePlayRecharge = "google_play_recharge"
|
||||||
bizTypeGameDebit = "game_debit"
|
bizTypeGameDebit = "game_debit"
|
||||||
bizTypeGameCredit = "game_credit"
|
bizTypeGameCredit = "game_credit"
|
||||||
bizTypeGameRefund = "game_refund"
|
bizTypeGameRefund = "game_refund"
|
||||||
|
|||||||
@ -140,6 +140,37 @@ func (s *Server) ListRechargeProducts(ctx context.Context, req *walletv1.ListRec
|
|||||||
return resp, nil
|
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 返回后台内购配置列表。
|
// ListAdminRechargeProducts 返回后台内购配置列表。
|
||||||
func (s *Server) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
|
func (s *Server) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user