增加钱包确认

This commit is contained in:
zhx 2026-05-26 01:02:12 +08:00
parent ad4a755998
commit 0b428fc9c7
26 changed files with 2262 additions and 363 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ type WalletClient interface {
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error)
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error)
@ -66,6 +67,10 @@ func (c *grpcWalletClient) ListRechargeProducts(ctx context.Context, req *wallet
return c.client.ListRechargeProducts(ctx, req)
}
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
return c.client.ConfirmGooglePayment(ctx, req)
}
func (c *grpcWalletClient) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
return c.client.GetDiamondExchangeConfig(ctx, req)
}

View File

@ -154,6 +154,7 @@ type WalletHandlers struct {
GetWalletOverview http.HandlerFunc
GetMyBalances http.HandlerFunc
ListRechargeProducts http.HandlerFunc
ConfirmGooglePayment http.HandlerFunc
GetDiamondExchangeConfig http.HandlerFunc
ApplyWithdrawal http.HandlerFunc
ListCoinTransactions http.HandlerFunc
@ -361,6 +362,7 @@ func (r routes) registerWalletRoutes() {
r.profile("/wallet/me/overview", http.MethodGet, h.GetWalletOverview)
r.profile("/wallet/me/balances", "", h.GetMyBalances)
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)

View File

@ -346,6 +346,8 @@ type fakeWalletClient struct {
giftWallResp *walletv1.GetUserGiftWallResponse
lastRechargeProducts *walletv1.ListRechargeProductsRequest
rechargeProductsResp *walletv1.ListRechargeProductsResponse
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
lastTransactions *walletv1.ListWalletTransactionsRequest
@ -1041,6 +1043,17 @@ func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1
return &walletv1.ListRechargeProductsResponse{}, nil
}
func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
f.lastGoogleConfirm = req
if f.err != nil {
return nil, f.err
}
if f.googleConfirmResp != nil {
return f.googleConfirmResp, nil
}
return &walletv1.ConfirmGooglePaymentResponse{}, nil
}
func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
f.lastDiamondExchange = req
if f.err != nil {
@ -3624,6 +3637,60 @@ func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) {
}
}
func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T) {
walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{
PaymentOrderId: "gpay_abc",
TransactionId: "wtx_abc",
Status: "credited",
ProductId: 11,
ProductCode: "iap_android_11",
CoinAmount: 1500,
Balance: &walletv1.AssetBalance{
AssetType: "COIN",
AvailableAmount: 2500,
},
ConsumeState: "consumed",
}}
profileClient := &fakeUserProfileClient{regionID: 2002}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetWalletClient(walletClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := `{"command_id":"pay-cmd-1","product_id":11,"product_code":"iap_android_11","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}`
request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Request-ID", "req-google-confirm")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet)
}
if walletClient.lastGoogleConfirm == nil ||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
walletClient.lastGoogleConfirm.GetProductId() != 11 ||
walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" ||
walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-1" ||
walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm") {
t.Fatalf("google confirm request mismatch: %+v", walletClient.lastGoogleConfirm)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["payment_order_id"] != "gpay_abc" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(1500) {
t.Fatalf("google confirm response mismatch: %+v", response)
}
}
func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
TransactionId: "wtx-coin-seller",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -243,6 +243,35 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_product_regions (
KEY idx_wallet_recharge_product_regions_region (app_code, region_id, product_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值商品区域表';
CREATE TABLE IF NOT EXISTS payment_orders (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
payment_order_id VARCHAR(96) NOT NULL COMMENT '平台支付订单 ID',
provider VARCHAR(32) NOT NULL COMMENT '支付提供方',
channel VARCHAR(32) NOT NULL COMMENT '充值渠道',
status VARCHAR(32) NOT NULL COMMENT '订单状态',
command_id VARCHAR(128) NOT NULL COMMENT '客户端确认命令幂等 ID',
user_id BIGINT NOT NULL COMMENT '用户 ID',
product_id BIGINT NOT NULL COMMENT '后台充值商品 ID',
product_code VARCHAR(128) NOT NULL COMMENT 'Google Play 商品 ID',
package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT 'Google Play 包名',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Google Play orderId',
purchase_token_hash VARCHAR(128) NOT NULL COMMENT 'Google purchase token SHA256不落明文 token',
purchase_state VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Google purchase state',
consume_state VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Google consume 状态',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '商品币种编码',
amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '商品金额微单位',
provider_payload JSON NULL COMMENT 'Google Play 校验返回快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, payment_order_id),
UNIQUE KEY uk_payment_orders_token (app_code, provider, purchase_token_hash),
UNIQUE KEY uk_payment_orders_command (app_code, command_id),
KEY idx_payment_orders_user_time (app_code, user_id, created_at_ms),
KEY idx_payment_orders_provider_order (app_code, provider, provider_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单与 provider 审计表';
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
stock_id VARCHAR(96) NOT NULL COMMENT '库存 ID',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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