财务模块

This commit is contained in:
zhx 2026-07-03 13:09:01 +08:00
parent a1e7514af3
commit 0480304483
21 changed files with 4557 additions and 3022 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1037,6 +1037,17 @@ message RechargeBill {
int64 exchange_usd_minor_amount = 17;
int64 created_at_ms = 18;
int64 provider_amount_minor = 19;
// provider_code google_play/mifapay/v5pay/usdt_trc20
string provider_code = 20;
// user_paid_* Google Play Orders API
string user_paid_currency_code = 21;
int64 user_paid_amount_micro = 22;
// provider_fee/tax/net 0
int64 provider_fee_micro = 23;
int64 provider_tax_micro = 24;
int64 provider_net_micro = 25;
// paid_synced_at_ms Google Orders API 0
int64 paid_synced_at_ms = 26;
}
message ListRechargeBillsRequest {
@ -1059,6 +1070,51 @@ message ListRechargeBillsResponse {
int64 total = 2;
}
message GetRechargeBillSummaryRequest {
string request_id = 1;
string app_code = 2;
int64 user_id = 3;
int64 seller_user_id = 4;
int64 region_id = 5;
string recharge_type = 6;
string status = 7;
string keyword = 8;
int64 start_at_ms = 9;
int64 end_at_ms = 10;
}
message RechargeBillSummaryBucket {
int64 bill_count = 1;
int64 coin_amount = 2;
int64 usd_minor_amount = 3;
}
message GetRechargeBillSummaryResponse {
RechargeBillSummaryBucket total = 1;
RechargeBillSummaryBucket google_play = 2;
RechargeBillSummaryBucket third_party = 3;
}
message RefreshGooglePaymentPricesRequest {
string request_id = 1;
string app_code = 2;
repeated string transaction_ids = 3;
}
message GooglePaymentPrice {
string transaction_id = 1;
string currency_code = 2;
int64 paid_amount_micro = 3;
int64 tax_micro = 4;
int64 net_micro = 5;
int64 synced_at_ms = 6;
string error = 7;
}
message RefreshGooglePaymentPricesResponse {
repeated GooglePaymentPrice prices = 1;
}
// WalletFeatureFlags 使 wallet-service
message WalletFeatureFlags {
bool recharge_enabled = 1;
@ -2163,6 +2219,8 @@ service WalletService {
rpc ListResourceShopPurchaseOrders(ListResourceShopPurchaseOrdersRequest) returns (ListResourceShopPurchaseOrdersResponse);
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetRechargeBillSummary(GetRechargeBillSummaryRequest) returns (GetRechargeBillSummaryResponse);
rpc RefreshGooglePaymentPrices(RefreshGooglePaymentPricesRequest) returns (RefreshGooglePaymentPricesResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);

View File

@ -248,6 +248,8 @@ const (
WalletService_ListResourceShopPurchaseOrders_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceShopPurchaseOrders"
WalletService_PurchaseResourceShopItem_FullMethodName = "/hyapp.wallet.v1.WalletService/PurchaseResourceShopItem"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
WalletService_GetRechargeBillSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillSummary"
WalletService_RefreshGooglePaymentPrices_FullMethodName = "/hyapp.wallet.v1.WalletService/RefreshGooglePaymentPrices"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
@ -350,6 +352,8 @@ type WalletServiceClient interface {
ListResourceShopPurchaseOrders(ctx context.Context, in *ListResourceShopPurchaseOrdersRequest, opts ...grpc.CallOption) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(ctx context.Context, in *PurchaseResourceShopItemRequest, opts ...grpc.CallOption) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(ctx context.Context, in *ListRechargeBillsRequest, opts ...grpc.CallOption) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(ctx context.Context, in *GetRechargeBillSummaryRequest, opts ...grpc.CallOption) (*GetRechargeBillSummaryResponse, error)
RefreshGooglePaymentPrices(ctx context.Context, in *RefreshGooglePaymentPricesRequest, opts ...grpc.CallOption) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error)
@ -877,6 +881,26 @@ func (c *walletServiceClient) ListRechargeBills(ctx context.Context, in *ListRec
return out, nil
}
func (c *walletServiceClient) GetRechargeBillSummary(ctx context.Context, in *GetRechargeBillSummaryRequest, opts ...grpc.CallOption) (*GetRechargeBillSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRechargeBillSummaryResponse)
err := c.cc.Invoke(ctx, WalletService_GetRechargeBillSummary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) RefreshGooglePaymentPrices(ctx context.Context, in *RefreshGooglePaymentPricesRequest, opts ...grpc.CallOption) (*RefreshGooglePaymentPricesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RefreshGooglePaymentPricesResponse)
err := c.cc.Invoke(ctx, WalletService_RefreshGooglePaymentPrices_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetWalletOverview(ctx context.Context, in *GetWalletOverviewRequest, opts ...grpc.CallOption) (*GetWalletOverviewResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetWalletOverviewResponse)
@ -1400,6 +1424,8 @@ type WalletServiceServer interface {
ListResourceShopPurchaseOrders(context.Context, *ListResourceShopPurchaseOrdersRequest) (*ListResourceShopPurchaseOrdersResponse, error)
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error)
RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
@ -1598,6 +1624,12 @@ func (UnimplementedWalletServiceServer) PurchaseResourceShopItem(context.Context
func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRechargeBills not implemented")
}
func (UnimplementedWalletServiceServer) GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillSummary not implemented")
}
func (UnimplementedWalletServiceServer) RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RefreshGooglePaymentPrices not implemented")
}
func (UnimplementedWalletServiceServer) GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWalletOverview not implemented")
}
@ -2606,6 +2638,42 @@ func _WalletService_ListRechargeBills_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRechargeBillSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRechargeBillSummaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetRechargeBillSummary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetRechargeBillSummary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetRechargeBillSummary(ctx, req.(*GetRechargeBillSummaryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_RefreshGooglePaymentPrices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RefreshGooglePaymentPricesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).RefreshGooglePaymentPrices(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_RefreshGooglePaymentPrices_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).RefreshGooglePaymentPrices(ctx, req.(*RefreshGooglePaymentPricesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetWalletOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetWalletOverviewRequest)
if err := dec(in); err != nil {
@ -3647,6 +3715,14 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRechargeBills",
Handler: _WalletService_ListRechargeBills_Handler,
},
{
MethodName: "GetRechargeBillSummary",
Handler: _WalletService_GetRechargeBillSummary_Handler,
},
{
MethodName: "RefreshGooglePaymentPrices",
Handler: _WalletService_RefreshGooglePaymentPrices_Handler,
},
{
MethodName: "GetWalletOverview",
Handler: _WalletService_GetWalletOverview_Handler,

View File

@ -0,0 +1,12 @@
-- 财务系统 APP 充值详情payment_orders 补 Google Play Orders API 实付明细列。
-- 与 wallet-service 启动迁移 ensureGooglePaymentPaidDetailsSchema 等价,供手工升级线上库使用。
ALTER TABLE payment_orders
ADD COLUMN paid_currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '用户实付币种,来自 Google Orders API' AFTER amount_micro,
ADD COLUMN paid_amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '用户实付总额微单位(实付币种)' AFTER paid_currency_code,
ADD COLUMN paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)' AFTER paid_amount_micro,
ADD COLUMN paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)' AFTER paid_tax_micro,
ADD COLUMN paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间UTC epoch ms0 表示未同步' AFTER paid_net_micro;
ALTER TABLE payment_orders
ADD INDEX idx_payment_orders_wallet_tx (app_code, wallet_transaction_id);

View File

@ -41,6 +41,8 @@ type Client interface {
SettleSalaryWithdrawal(ctx context.Context, req *walletv1.SettleSalaryWithdrawalRequest) (*walletv1.SettleSalaryWithdrawalResponse, error)
ReleaseSalaryWithdrawal(ctx context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error)
RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error)
ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error)
CreateTemporaryRechargeOrder(ctx context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
GetTemporaryRechargeOrder(ctx context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error)
@ -194,6 +196,14 @@ func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRe
return c.client.ListRechargeBills(ctx, req)
}
func (c *GRPCClient) GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error) {
return c.client.GetRechargeBillSummary(ctx, req)
}
func (c *GRPCClient) RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error) {
return c.client.RefreshGooglePaymentPrices(ctx, req)
}
func (c *GRPCClient) ListThirdPartyPaymentChannels(ctx context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
return c.client.ListThirdPartyPaymentChannels(ctx, req)
}

View File

@ -28,6 +28,13 @@ type rechargeBillDTO struct {
ExchangeUSDMinorAmount int64 `json:"exchangeUsdMinorAmount"`
ProviderAmountMinor int64 `json:"providerAmountMinor,omitempty"`
CreatedAtMS int64 `json:"createdAtMs"`
ProviderCode string `json:"providerCode"`
UserPaidCurrencyCode string `json:"userPaidCurrencyCode"`
UserPaidAmountMicro int64 `json:"userPaidAmountMicro"`
ProviderFeeMicro int64 `json:"providerFeeMicro"`
ProviderTaxMicro int64 `json:"providerTaxMicro"`
ProviderNetMicro int64 `json:"providerNetMicro"`
PaidSyncedAtMS int64 `json:"paidSyncedAtMs"`
User rechargeBillUserDTO `json:"user"`
Seller rechargeBillUserDTO `json:"seller"`
}
@ -168,6 +175,13 @@ func rechargeBillFromProto(item *walletv1.RechargeBill) rechargeBillDTO {
ExchangeUSDMinorAmount: item.GetExchangeUsdMinorAmount(),
ProviderAmountMinor: item.GetProviderAmountMinor(),
CreatedAtMS: item.GetCreatedAtMs(),
ProviderCode: item.GetProviderCode(),
UserPaidCurrencyCode: item.GetUserPaidCurrencyCode(),
UserPaidAmountMicro: item.GetUserPaidAmountMicro(),
ProviderFeeMicro: item.GetProviderFeeMicro(),
ProviderTaxMicro: item.GetProviderTaxMicro(),
ProviderNetMicro: item.GetProviderNetMicro(),
PaidSyncedAtMS: item.GetPaidSyncedAtMs(),
}
}

View File

@ -0,0 +1,196 @@
package payment
import (
"context"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type rechargeBillSummaryBucketDTO struct {
BillCount int64 `json:"billCount"`
CoinAmount int64 `json:"coinAmount"`
USDMinorAmount int64 `json:"usdMinorAmount"`
}
type rechargeBillSummaryDTO struct {
Total rechargeBillSummaryBucketDTO `json:"total"`
GooglePlay rechargeBillSummaryBucketDTO `json:"googlePlay"`
ThirdParty rechargeBillSummaryBucketDTO `json:"thirdParty"`
}
type rechargeBillRegionDTO struct {
RegionID int64 `json:"regionId"`
RegionCode string `json:"regionCode"`
Name string `json:"name"`
}
type googleRechargePaidRefreshRequest struct {
TransactionIDs []string `json:"transactionIds"`
}
type googleRechargePaidDTO struct {
TransactionID string `json:"transactionId"`
CurrencyCode string `json:"currencyCode"`
PaidAmountMicro int64 `json:"paidAmountMicro"`
TaxMicro int64 `json:"taxMicro"`
NetMicro int64 `json:"netMicro"`
SyncedAtMS int64 `json:"syncedAtMs"`
Error string `json:"error,omitempty"`
}
// GetRechargeBillSummary 返回与账单列表同一筛选口径的充值总和、Google 充值与三方充值聚合。
func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
response.OK(c, rechargeBillSummaryDTO{})
return
}
resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
})
if err != nil {
writeWalletError(c, err, "获取充值汇总失败")
return
}
response.OK(c, rechargeBillSummaryDTO{
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
})
}
func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBucket) rechargeBillSummaryBucketDTO {
if bucket == nil {
return rechargeBillSummaryBucketDTO{}
}
return rechargeBillSummaryBucketDTO{
BillCount: bucket.GetBillCount(),
CoinAmount: bucket.GetCoinAmount(),
USDMinorAmount: bucket.GetUsdMinorAmount(),
}
}
// RefreshGoogleRechargePaidDetails 对指定账单触发 Google Orders API 实付明细同步,供财务补齐历史订单实付。
func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
var request googleRechargePaidRefreshRequest
if err := c.ShouldBindJSON(&request); err != nil || len(request.TransactionIDs) == 0 {
response.BadRequest(c, "交易号参数不正确")
return
}
if len(request.TransactionIDs) > 100 {
response.BadRequest(c, "单次最多刷新 100 笔账单")
return
}
appCode := appctx.FromContext(c.Request.Context())
resp, err := h.wallet.RefreshGooglePaymentPrices(c.Request.Context(), &walletv1.RefreshGooglePaymentPricesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
TransactionIds: request.TransactionIDs,
})
if err != nil {
writeWalletError(c, err, "查询谷歌实付明细失败")
return
}
items := make([]googleRechargePaidDTO, 0, len(resp.GetPrices()))
for _, price := range resp.GetPrices() {
items = append(items, googleRechargePaidDTO{
TransactionID: price.GetTransactionId(),
CurrencyCode: price.GetCurrencyCode(),
PaidAmountMicro: price.GetPaidAmountMicro(),
TaxMicro: price.GetTaxMicro(),
NetMicro: price.GetNetMicro(),
SyncedAtMS: price.GetSyncedAtMs(),
Error: price.GetError(),
})
}
shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "payment_orders", "success", appCode)
response.OK(c, gin.H{"items": items})
}
// ListRechargeBillRegions 返回当前 App 的区域目录,用于充值详情按区域筛选;不受财务范围授权限制。
func (h *Handler) ListRechargeBillRegions(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode)
if err != nil {
response.ServerError(c, "获取区域列表失败")
return
}
response.OK(c, gin.H{"items": regions, "total": len(regions)})
}
func (h *Handler) listRechargeBillRegions(ctx context.Context, appCode string) ([]rechargeBillRegionDTO, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
items := []rechargeBillRegionDTO{}
seen := map[int64]struct{}{}
if h.userDB != nil {
rows, err := h.userDB.QueryContext(ctx, `
SELECT region_id, region_code, name
FROM regions
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, name ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var item rechargeBillRegionDTO
if err := rows.Scan(&item.RegionID, &item.RegionCode, &item.Name); err != nil {
return nil, err
}
seen[item.RegionID] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
// Yumi/Aslan 这类 legacy App 的区域目录在线上 likei Mongo这里只取目录展示账单筛选仍以 wallet 的 target_region_id 为准。
for _, source := range h.moneyRegionSources {
_, sourceRegions, _, err := source.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return nil, err
}
for _, region := range sourceRegions {
if appctx.Normalize(region.AppCode) != appCode {
continue
}
if _, ok := seen[region.RegionID]; ok {
continue
}
seen[region.RegionID] = struct{}{}
items = append(items, rechargeBillRegionDTO{RegionID: region.RegionID, RegionCode: region.RegionCode, Name: region.Name})
}
}
return items, nil
}

View File

@ -12,6 +12,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
protected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:view"), h.RefreshGoogleRechargePaidDetails)
protected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)

View File

@ -836,6 +836,11 @@ CREATE TABLE IF NOT EXISTS payment_orders (
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '商品币种编码',
amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '商品金额微单位',
paid_currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '用户实付币种,来自 Google Orders API',
paid_amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '用户实付总额微单位(实付币种)',
paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)',
paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)',
paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间UTC epoch ms0 表示未同步',
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',
@ -843,7 +848,8 @@ CREATE TABLE IF NOT EXISTS payment_orders (
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)
KEY idx_payment_orders_provider_order (app_code, provider, provider_order_id),
KEY idx_payment_orders_wallet_tx (app_code, wallet_transaction_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单与 provider 审计表';
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (

View File

@ -131,6 +131,46 @@ func (c *Client) GetProductPurchase(ctx context.Context, packageName string, pur
return purchase, nil
}
// GetOrder 拉取 Play Developer API Orders 资源,取回用户实付币种、实付总额、税额和开发者净收入。
// 需要服务账号在 Play Console 拥有“查看财务数据”权限,否则 Google 返回 403。
func (c *Client) GetOrder(ctx context.Context, packageName string, orderID string) (ledger.GooglePlayOrder, error) {
if c == nil {
return ledger.GooglePlayOrder{}, xerr.New(xerr.Unavailable, "google play client is not configured")
}
packageName = c.normalizePackageName(packageName)
orderID = strings.TrimSpace(orderID)
if packageName == "" || orderID == "" {
return ledger.GooglePlayOrder{}, xerr.New(xerr.InvalidArgument, "google play order request is incomplete")
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders/%s",
c.apiBaseURL,
url.PathEscape(packageName),
url.PathEscape(orderID),
)
body, err := c.doJSON(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ledger.GooglePlayOrder{}, err
}
var parsed orderResource
if err := json.Unmarshal(body, &parsed); err != nil {
return ledger.GooglePlayOrder{}, err
}
order := ledger.GooglePlayOrder{
OrderID: parsed.OrderID,
State: parsed.State,
BuyerCountry: parsed.BuyerAddress.BuyerCountry,
CurrencyCode: parsed.Total.CurrencyCode,
TotalAmountMicro: parsed.Total.micro(),
TaxAmountMicro: parsed.Tax.micro(),
NetAmountMicro: parsed.DeveloperRevenueInBuyerCurrency.micro(),
RawJSON: string(body),
}
if order.CurrencyCode == "" {
order.CurrencyCode = parsed.Tax.CurrencyCode
}
return order, 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")
@ -279,3 +319,28 @@ type productLineItem struct {
type productOfferDetails struct {
ConsumptionState string `json:"consumptionState"`
}
type orderResource struct {
OrderID string `json:"orderId"`
State string `json:"state"`
BuyerAddress buyerAddress `json:"buyerAddress"`
Total moneyAmount `json:"total"`
Tax moneyAmount `json:"tax"`
DeveloperRevenueInBuyerCurrency moneyAmount `json:"developerRevenueInBuyerCurrency"`
}
type buyerAddress struct {
BuyerCountry string `json:"buyerCountry"`
}
// moneyAmount 是 google.type.Money 的 JSON 形态units 是字符串形式的整数金额nanos 是 10^-9 小数部分。
type moneyAmount struct {
CurrencyCode string `json:"currencyCode"`
Units json.Number `json:"units"`
Nanos int64 `json:"nanos"`
}
func (m moneyAmount) micro() int64 {
units, _ := m.Units.Int64()
return units*1_000_000 + m.Nanos/1_000
}

View File

@ -84,6 +84,37 @@ type GooglePlayPurchase struct {
RawJSON string
}
// GooglePlayOrder 是 Play Developer API Orders 接口返回的订单实付快照;金额统一收敛为实付币种微单位。
type GooglePlayOrder struct {
OrderID string
State string
BuyerCountry string
CurrencyCode string
TotalAmountMicro int64
TaxAmountMicro int64
// NetAmountMicro 是 Google 扣除服务费和代缴税后的开发者净收入(实付币种);接口未返回时为 0。
NetAmountMicro int64
RawJSON string
}
// GooglePaymentPaidDetails 是写回 payment_orders 的实付明细命令。
type GooglePaymentPaidDetails struct {
CurrencyCode string
PaidAmountMicro int64
TaxAmountMicro int64
NetAmountMicro int64
SyncedAtMS int64
}
// GooglePaymentOrderRef 是按钱包交易反查 Google 支付订单、发起 Orders API 查询所需的最小定位信息。
type GooglePaymentOrderRef struct {
PaymentOrderID string
TransactionID string
PackageName string
ProviderOrderID string
PaidSyncedAtMS int64
}
// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。
type GooglePaymentReceipt struct {
PaymentOrderID string

View File

@ -25,6 +25,31 @@ type RechargeBill struct {
ExchangeUSDMinorAmount int64
ProviderAmountMinor int64
CreatedAtMS int64
// ProviderCode 标识实际支付渠道google_play/mifapay/v5pay/usdt_trc20。
ProviderCode string
// UserPaid* 是用户实付事实:外部订单来自三方下单/回调快照Google 订单来自 Play Orders API。
UserPaidCurrencyCode string
UserPaidAmountMicro int64
// ProviderFee/Tax/Net 是三方扣款事实实付币种微单位0 表示渠道未提供。
ProviderFeeMicro int64
ProviderTaxMicro int64
ProviderNetMicro int64
// PaidSyncedAtMS 仅对 Google 账单有意义Orders API 实付明细的同步时间0 表示未同步。
PaidSyncedAtMS int64
}
// RechargeBillSummaryBucket 是充值账单的聚合桶:笔数、金币总数与美元最小单位总额。
type RechargeBillSummaryBucket struct {
BillCount int64
CoinAmount int64
USDMinorAmount int64
}
// RechargeBillSummary 拆分财务系统关心的三个口径总和、Google 充值与三方充值。
type RechargeBillSummary struct {
Total RechargeBillSummaryBucket
GooglePlay RechargeBillSummaryBucket
ThirdParty RechargeBillSummaryBucket
}
// ListRechargeBillsQuery 是后台查询所有充值渠道账单的筛选条件。

View File

@ -13,10 +13,11 @@ type ActivityBadgeClient interface {
ConsumeAchievementEvent(ctx context.Context, req *activityv1.ConsumeAchievementEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error)
}
// GooglePlayClient 隔离 Google Play Developer APIservice 只依赖购买校验和消耗能力。
// 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
GetOrder(ctx context.Context, packageName string, orderID string) (ledger.GooglePlayOrder, error)
}
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。

View File

@ -94,6 +94,9 @@ type GameLedgerStore interface {
// RechargeStore 管理内购充值档位、Google 支付和充值账单。
type RechargeStore interface {
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
SummarizeRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error)
ListGooglePaymentOrderRefs(ctx context.Context, appCode string, transactionIDs []string) (map[string]ledger.GooglePaymentOrderRef, error)
UpdateGooglePaymentPaidDetails(ctx context.Context, appCode string, paymentOrderID string, details ledger.GooglePaymentPaidDetails) error
ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType 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)

View File

@ -6,6 +6,7 @@ import (
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
"time"
)
// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。
@ -19,6 +20,97 @@ func (s *Service) ListRechargeBills(ctx context.Context, query ledger.ListRechar
return s.repository.ListRechargeBills(ctx, query)
}
// GetRechargeBillSummary 按与账单列表一致的筛选口径聚合充值总和、Google 充值与三方充值。
func (s *Service) GetRechargeBillSummary(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error) {
if s.repository == nil {
return ledger.RechargeBillSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.SummarizeRechargeBills(ctx, query)
}
// GooglePaymentPriceResult 是单笔 Google 账单实付明细刷新结果Err 非空表示该笔刷新失败但不影响其他账单。
type GooglePaymentPriceResult struct {
TransactionID string
CurrencyCode string
PaidAmountMicro int64
TaxAmountMicro int64
NetAmountMicro int64
SyncedAtMS int64
Err string
}
const maxGooglePaymentPriceRefreshBatch = 100
// RefreshGooglePaymentPrices 用 Play Orders API 拉取账单的用户实付币种、总额、税额和净收入并写回 payment_orders。
// 服务账号需要 Play Console 的“查看财务数据”权限;单笔失败只标记该笔的 Err整批继续。
func (s *Service) RefreshGooglePaymentPrices(ctx context.Context, appCode string, transactionIDs []string) ([]GooglePaymentPriceResult, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.googlePlay == nil {
return nil, xerr.New(xerr.Unavailable, "google play client is not configured")
}
if len(transactionIDs) == 0 {
return []GooglePaymentPriceResult{}, nil
}
if len(transactionIDs) > maxGooglePaymentPriceRefreshBatch {
return nil, xerr.New(xerr.InvalidArgument, "too many transaction_ids")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
refs, err := s.repository.ListGooglePaymentOrderRefs(ctx, appCode, transactionIDs)
if err != nil {
return nil, err
}
results := make([]GooglePaymentPriceResult, 0, len(transactionIDs))
for _, transactionID := range transactionIDs {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
continue
}
ref, ok := refs[transactionID]
if !ok {
results = append(results, GooglePaymentPriceResult{TransactionID: transactionID, Err: "not a google payment bill"})
continue
}
result := s.refreshGooglePaymentPrice(ctx, appCode, ref)
results = append(results, result)
}
return results, nil
}
func (s *Service) refreshGooglePaymentPrice(ctx context.Context, appCode string, ref ledger.GooglePaymentOrderRef) GooglePaymentPriceResult {
result := GooglePaymentPriceResult{TransactionID: ref.TransactionID}
if strings.TrimSpace(ref.ProviderOrderID) == "" {
result.Err = "google order id is missing"
return result
}
order, err := s.googlePlay.GetOrder(ctx, ref.PackageName, ref.ProviderOrderID)
if err != nil {
result.Err = err.Error()
return result
}
details := ledger.GooglePaymentPaidDetails{
CurrencyCode: order.CurrencyCode,
PaidAmountMicro: order.TotalAmountMicro,
TaxAmountMicro: order.TaxAmountMicro,
NetAmountMicro: order.NetAmountMicro,
SyncedAtMS: s.now().UnixMilli(),
}
if err := s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, ref.PaymentOrderID, details); err != nil {
result.Err = err.Error()
return result
}
result.CurrencyCode = details.CurrencyCode
result.PaidAmountMicro = details.PaidAmountMicro
result.TaxAmountMicro = details.TaxAmountMicro
result.NetAmountMicro = details.NetAmountMicro
result.SyncedAtMS = details.SyncedAtMS
return result
}
// ListRechargeProducts 返回区域化充值档位region_id 必须由 gateway 从 user-service 资料解析。
func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) {
if userID <= 0 || regionID <= 0 {
@ -172,6 +264,8 @@ func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.Googl
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
// 入账已完成,实付明细只影响财务展示:异步拉 Orders API失败静默后台可手动刷新补数。
s.scheduleGooglePaymentPriceRefresh(command.AppCode, receipt.PaymentOrderID, command.PackageName, purchase.OrderID)
if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed {
return receipt, nil
}
@ -189,6 +283,30 @@ func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.Googl
return receipt, nil
}
// scheduleGooglePaymentPriceRefresh 在支付确认返回后异步同步实付明细,不给支付主链路增加延迟或失败面。
func (s *Service) scheduleGooglePaymentPriceRefresh(appCode string, paymentOrderID string, packageName string, orderID string) {
if s.googlePlay == nil || s.repository == nil ||
strings.TrimSpace(paymentOrderID) == "" || strings.TrimSpace(orderID) == "" {
return
}
syncedAt := s.now().UnixMilli()
go func() {
ctx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appCode), 10*time.Second)
defer cancel()
order, err := s.googlePlay.GetOrder(ctx, packageName, orderID)
if err != nil {
return
}
_ = s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, paymentOrderID, ledger.GooglePaymentPaidDetails{
CurrencyCode: order.CurrencyCode,
PaidAmountMicro: order.TotalAmountMicro,
TaxAmountMicro: order.TaxAmountMicro,
NetAmountMicro: order.NetAmountMicro,
SyncedAtMS: syncedAt,
})
}()
}
func (s *Service) googleRechargeProductForPayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.RechargeProduct, error) {
if command.ProductID > 0 {
return s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)

View File

@ -6192,6 +6192,11 @@ func (f *fakeGooglePlayClient) ConsumeProduct(_ context.Context, _ string, produ
return nil
}
func (f *fakeGooglePlayClient) GetOrder(_ context.Context, _ string, _ string) (ledger.GooglePlayOrder, error) {
// 实付明细刷新是入账后的异步补数动作fake 直接失败,让确认链路测试不产生额外副作用。
return ledger.GooglePlayOrder{}, xerr.New(xerr.Unavailable, "orders api is not faked")
}
type fakeMifaPayClient struct {
createReq walletservice.MifaPayCreateOrderRequest
createResp walletservice.MifaPayCreateOrderResponse

View File

@ -299,6 +299,76 @@ func (r *Repository) receiptForGooglePaymentOrder(ctx context.Context, tx *sql.T
return receipt, nil
}
// ListGooglePaymentOrderRefs 按钱包交易 ID 批量反查 Google 支付订单定位信息;只返回命中的交易。
func (r *Repository) ListGooglePaymentOrderRefs(ctx context.Context, appCode string, transactionIDs []string) (map[string]ledger.GooglePaymentOrderRef, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, appCode)
cleaned := make([]string, 0, len(transactionIDs))
for _, transactionID := range transactionIDs {
if transactionID = strings.TrimSpace(transactionID); transactionID != "" {
cleaned = append(cleaned, transactionID)
}
}
if len(cleaned) == 0 {
return map[string]ledger.GooglePaymentOrderRef{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(cleaned)), ",")
args := make([]any, 0, len(cleaned)+2)
args = append(args, appcode.FromContext(ctx), ledger.PaymentProviderGooglePlay)
for _, transactionID := range cleaned {
args = append(args, transactionID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT payment_order_id, wallet_transaction_id, package_name, provider_order_id, paid_synced_at_ms
FROM payment_orders
WHERE app_code = ? AND provider = ? AND wallet_transaction_id IN (`+placeholders+`)`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
refs := make(map[string]ledger.GooglePaymentOrderRef, len(cleaned))
for rows.Next() {
var ref ledger.GooglePaymentOrderRef
if err := rows.Scan(&ref.PaymentOrderID, &ref.TransactionID, &ref.PackageName, &ref.ProviderOrderID, &ref.PaidSyncedAtMS); err != nil {
return nil, err
}
refs[ref.TransactionID] = ref
}
return refs, rows.Err()
}
// UpdateGooglePaymentPaidDetails 把 Orders API 返回的实付明细写回 payment_orders同步时间必须由调用方给出。
func (r *Repository) UpdateGooglePaymentPaidDetails(ctx context.Context, appCode string, paymentOrderID string, details ledger.GooglePaymentPaidDetails) 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 paid_currency_code = ?, paid_amount_micro = ?, paid_tax_micro = ?, paid_net_micro = ?, paid_synced_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND payment_order_id = ? AND provider = ?`,
strings.ToUpper(strings.TrimSpace(details.CurrencyCode)),
details.PaidAmountMicro,
details.TaxAmountMicro,
details.NetAmountMicro,
details.SyncedAtMS,
time.Now().UnixMilli(),
appcode.FromContext(ctx),
paymentOrderID,
ledger.PaymentProviderGooglePlay,
)
return err
}
type googlePaymentMetadata struct {
AppCode string `json:"app_code"`
PaymentOrderID string `json:"payment_order_id"`

View File

@ -2,6 +2,9 @@ package mysql
import (
"context"
"encoding/json"
"math"
"strconv"
"strings"
"hyapp/pkg/appcode"
@ -23,16 +26,21 @@ func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRec
if err != nil {
return nil, 0, err
}
// 充值事实仍以 wallet_recharge_records 为主表external_recharge_orders 只补 H5 三方订单的实付金额展示,使用 wallet_transaction_id 索引命中,避免列表查询回扫订单表。
// 充值事实仍以 wallet_recharge_records 为主表external_recharge_orders 补 H5 三方订单的实付币种和金额,
// payment_orders 补 Google 订单经 Orders API 同步的实付明细,两个 LEFT JOIN 都走 wallet_transaction_id 索引。
rows, err := r.db.QueryContext(ctx, `
SELECT rr.app_code, rr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref,
rr.user_id, rr.seller_user_id, rr.seller_region_id, rr.target_region_id,
rr.policy_id, rr.policy_version, rr.currency_code,
rr.coin_amount, rr.usd_minor_amount, rr.exchange_coin_amount, rr.exchange_usd_minor_amount,
COALESCE(eo.provider_amount_minor, 0), rr.created_at_ms
COALESCE(eo.provider_amount_minor, 0), rr.created_at_ms,
COALESCE(eo.provider_code, ''), COALESCE(eo.currency_code, ''), COALESCE(CAST(eo.provider_payload AS CHAR), ''),
COALESCE(po.paid_currency_code, ''), COALESCE(po.paid_amount_micro, 0), COALESCE(po.paid_tax_micro, 0),
COALESCE(po.paid_net_micro, 0), COALESCE(po.paid_synced_at_ms, 0)
FROM wallet_recharge_records rr
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
LEFT JOIN external_recharge_orders eo ON eo.app_code = rr.app_code AND eo.wallet_transaction_id = rr.transaction_id
LEFT JOIN payment_orders po ON po.app_code = rr.app_code AND po.wallet_transaction_id = rr.transaction_id
`+where+`
ORDER BY rr.created_at_ms DESC, rr.transaction_id DESC
LIMIT ? OFFSET ?`,
@ -46,6 +54,9 @@ func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRec
items := make([]ledger.RechargeBill, 0, query.PageSize)
for rows.Next() {
var item ledger.RechargeBill
var externalProviderCode, externalCurrencyCode, externalPayloadJSON string
var paidCurrencyCode string
var paidAmountMicro, paidTaxMicro, paidNetMicro, paidSyncedAtMS int64
if err := rows.Scan(
&item.AppCode,
&item.TransactionID,
@ -66,9 +77,19 @@ func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRec
&item.ExchangeUSDMinorAmount,
&item.ProviderAmountMinor,
&item.CreatedAtMS,
&externalProviderCode,
&externalCurrencyCode,
&externalPayloadJSON,
&paidCurrencyCode,
&paidAmountMicro,
&paidTaxMicro,
&paidNetMicro,
&paidSyncedAtMS,
); err != nil {
return nil, 0, err
}
fillRechargeBillPaidFacts(&item, externalProviderCode, externalCurrencyCode, externalPayloadJSON,
paidCurrencyCode, paidAmountMicro, paidTaxMicro, paidNetMicro, paidSyncedAtMS)
items = append(items, item)
}
if err := rows.Err(); err != nil {
@ -77,6 +98,115 @@ func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRec
return items, total, nil
}
// fillRechargeBillPaidFacts 把三方订单快照和 Google 实付明细收敛成账单统一的实付字段。
func fillRechargeBillPaidFacts(item *ledger.RechargeBill, externalProviderCode string, externalCurrencyCode string, externalPayloadJSON string,
paidCurrencyCode string, paidAmountMicro int64, paidTaxMicro int64, paidNetMicro int64, paidSyncedAtMS int64) {
if item.RechargeType == bizTypeGooglePlayRecharge {
item.ProviderCode = "google_play"
item.UserPaidCurrencyCode = paidCurrencyCode
item.UserPaidAmountMicro = paidAmountMicro
item.ProviderTaxMicro = paidTaxMicro
item.ProviderNetMicro = paidNetMicro
item.PaidSyncedAtMS = paidSyncedAtMS
// Google 不单独返回服务费,只能由 实付总额 - 净收入 - 税 推出;任一缺失则保持 0。
if paidAmountMicro > 0 && paidNetMicro > 0 {
if fee := paidAmountMicro - paidNetMicro - paidTaxMicro; fee > 0 {
item.ProviderFeeMicro = fee
}
}
return
}
item.ProviderCode = externalProviderCode
item.UserPaidCurrencyCode = externalCurrencyCode
if item.ProviderAmountMinor > 0 {
item.UserPaidAmountMicro = item.ProviderAmountMinor * 10_000
}
// V5Pay 的回调和查单快照带 fee/tax/accAmountMiFaPay 与 USDT 没有该数据,解析不到就保持 0。
fee, tax, net := externalRechargeSettlementFromPayload(externalPayloadJSON)
item.ProviderFeeMicro = fee
item.ProviderTaxMicro = tax
item.ProviderNetMicro = net
}
// externalRechargeSettlementFromPayload 从 provider_payload 快照解析三方结算字段(实付币种微单位)。
func externalRechargeSettlementFromPayload(payloadJSON string) (feeMicro int64, taxMicro int64, netMicro int64) {
payloadJSON = strings.TrimSpace(payloadJSON)
if payloadJSON == "" || !strings.Contains(payloadJSON, "accAmount") && !strings.Contains(payloadJSON, "\"fee\"") {
return 0, 0, 0
}
var payload struct {
Fee string `json:"fee"`
Tax string `json:"tax"`
AccAmount string `json:"accAmount"`
}
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
return 0, 0, 0
}
return decimalAmountToMicro(payload.Fee), decimalAmountToMicro(payload.Tax), decimalAmountToMicro(payload.AccAmount)
}
// decimalAmountToMicro 把三方返回的十进制金额字符串(如 "10.50")转成微单位;非法或负数一律按 0 处理。
func decimalAmountToMicro(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 {
return 0
}
return int64(math.Round(parsed * 1_000_000))
}
// SummarizeRechargeBills 按与列表一致的筛选口径聚合充值账单,拆分 Google 与三方两个渠道桶。
func (r *Repository) SummarizeRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error) {
if r == nil || r.db == nil {
return ledger.RechargeBillSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, query.AppCode)
query = normalizeRechargeBillsQuery(query)
query.AppCode = appcode.FromContext(ctx)
where, args := rechargeBillsWhereSQL(query)
rows, err := r.db.QueryContext(ctx, `
SELECT wt.biz_type, COUNT(*), COALESCE(SUM(rr.coin_amount), 0), COALESCE(SUM(rr.usd_minor_amount), 0)
FROM wallet_recharge_records rr
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
`+where+`
GROUP BY wt.biz_type`,
args...,
)
if err != nil {
return ledger.RechargeBillSummary{}, err
}
defer rows.Close()
var summary ledger.RechargeBillSummary
for rows.Next() {
var bizType string
var bucket ledger.RechargeBillSummaryBucket
if err := rows.Scan(&bizType, &bucket.BillCount, &bucket.CoinAmount, &bucket.USDMinorAmount); err != nil {
return ledger.RechargeBillSummary{}, err
}
summary.Total.BillCount += bucket.BillCount
summary.Total.CoinAmount += bucket.CoinAmount
summary.Total.USDMinorAmount += bucket.USDMinorAmount
if bizType == bizTypeGooglePlayRecharge {
summary.GooglePlay.BillCount += bucket.BillCount
summary.GooglePlay.CoinAmount += bucket.CoinAmount
summary.GooglePlay.USDMinorAmount += bucket.USDMinorAmount
} else {
summary.ThirdParty.BillCount += bucket.BillCount
summary.ThirdParty.CoinAmount += bucket.CoinAmount
summary.ThirdParty.USDMinorAmount += bucket.USDMinorAmount
}
}
if err := rows.Err(); err != nil {
return ledger.RechargeBillSummary{}, err
}
return summary, nil
}
func (r *Repository) countRechargeBillRows(ctx context.Context, where string, args ...any) (int64, error) {
var total int64
err := r.db.QueryRowContext(ctx, `

View File

@ -57,6 +57,10 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
_ = db.Close()
return nil, err
}
if err := ensureGooglePaymentPaidDetailsSchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return &Repository{db: db}, nil
}

View File

@ -158,6 +158,29 @@ func ensureExternalRechargeSchema(ctx context.Context, db *sql.DB) error {
return seedThirdPartyPaymentDefaults(ctx, db)
}
// ensureGooglePaymentPaidDetailsSchema 给 payment_orders 补 Google Orders API 实付明细列;
// 财务列表按 wallet_transaction_id 反查 Google 支付订单,需要独立索引避免回表扫描。
func ensureGooglePaymentPaidDetailsSchema(ctx context.Context, db *sql.DB) error {
alters := []string{
`ALTER TABLE payment_orders ADD COLUMN paid_currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '用户实付币种,来自 Google Orders API' AFTER amount_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '用户实付总额微单位(实付币种)' AFTER paid_currency_code`,
`ALTER TABLE payment_orders ADD COLUMN paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)' AFTER paid_amount_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)' AFTER paid_tax_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间UTC epoch ms0 表示未同步' AFTER paid_net_micro`,
}
for _, stmt := range alters {
if _, err := db.ExecContext(ctx, stmt); err != nil && !isDuplicateColumnError(err) {
return err
}
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE payment_orders
ADD INDEX idx_payment_orders_wallet_tx (app_code, wallet_transaction_id)`); err != nil && !isDuplicateKeyNameError(err) {
return err
}
return nil
}
func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (

View File

@ -36,6 +36,60 @@ func (s *Server) ListRechargeBills(ctx context.Context, req *walletv1.ListRechar
return resp, nil
}
// GetRechargeBillSummary 暴露充值账单聚合查询,筛选口径与列表完全一致。
func (s *Server) GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
summary, err := s.svc.GetRechargeBillSummary(ctx, ledger.ListRechargeBillsQuery{
AppCode: req.GetAppCode(),
UserID: req.GetUserId(),
SellerUserID: req.GetSellerUserId(),
RegionID: req.GetRegionId(),
RechargeType: req.GetRechargeType(),
Status: req.GetStatus(),
Keyword: req.GetKeyword(),
StartAtMS: req.GetStartAtMs(),
EndAtMS: req.GetEndAtMs(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.GetRechargeBillSummaryResponse{
Total: rechargeBillSummaryBucketToProto(summary.Total),
GooglePlay: rechargeBillSummaryBucketToProto(summary.GooglePlay),
ThirdParty: rechargeBillSummaryBucketToProto(summary.ThirdParty),
}, nil
}
// RefreshGooglePaymentPrices 触发 Google Orders API 实付明细同步;单笔失败不阻断整批。
func (s *Server) RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
results, err := s.svc.RefreshGooglePaymentPrices(ctx, req.GetAppCode(), req.GetTransactionIds())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &walletv1.RefreshGooglePaymentPricesResponse{Prices: make([]*walletv1.GooglePaymentPrice, 0, len(results))}
for _, result := range results {
resp.Prices = append(resp.Prices, &walletv1.GooglePaymentPrice{
TransactionId: result.TransactionID,
CurrencyCode: result.CurrencyCode,
PaidAmountMicro: result.PaidAmountMicro,
TaxMicro: result.TaxAmountMicro,
NetMicro: result.NetAmountMicro,
SyncedAtMs: result.SyncedAtMS,
Error: result.Err,
})
}
return resp, nil
}
func rechargeBillSummaryBucketToProto(bucket ledger.RechargeBillSummaryBucket) *walletv1.RechargeBillSummaryBucket {
return &walletv1.RechargeBillSummaryBucket{
BillCount: bucket.BillCount,
CoinAmount: bucket.CoinAmount,
UsdMinorAmount: bucket.USDMinorAmount,
}
}
func rechargeBillToProto(item ledger.RechargeBill) *walletv1.RechargeBill {
return &walletv1.RechargeBill{
AppCode: item.AppCode,
@ -57,5 +111,12 @@ func rechargeBillToProto(item ledger.RechargeBill) *walletv1.RechargeBill {
ExchangeUsdMinorAmount: item.ExchangeUSDMinorAmount,
ProviderAmountMinor: item.ProviderAmountMinor,
CreatedAtMs: item.CreatedAtMS,
ProviderCode: item.ProviderCode,
UserPaidCurrencyCode: item.UserPaidCurrencyCode,
UserPaidAmountMicro: item.UserPaidAmountMicro,
ProviderFeeMicro: item.ProviderFeeMicro,
ProviderTaxMicro: item.ProviderTaxMicro,
ProviderNetMicro: item.ProviderNetMicro,
PaidSyncedAtMs: item.PaidSyncedAtMS,
}
}