Yumi和 Aslan的财务数据

This commit is contained in:
zhx 2026-07-03 17:35:47 +08:00
parent 27773290b5
commit 786b36afb2
26 changed files with 5184 additions and 3160 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1063,6 +1063,8 @@ message ListRechargeBillsRequest {
int64 end_at_ms = 10;
int32 page = 11;
int32 page_size = 12;
// paid_state=unsynced
string paid_state = 13;
}
message ListRechargeBillsResponse {
@ -1097,6 +1099,53 @@ message GetRechargeBillSummaryResponse {
RechargeBillSummaryBucket coin_seller = 4;
}
message GetRechargeBillOverviewRequest {
string request_id = 1;
string app_code = 2;
int64 region_id = 3;
string recharge_type = 4;
string status = 5;
int64 start_at_ms = 6;
int64 end_at_ms = 7;
// tz_offset_minutes 480
int32 tz_offset_minutes = 8;
}
message RechargeBillDailyBucket {
// date tz_offset_minutes YYYY-MM-DD
string date = 1;
int64 google_usd_minor = 2;
int64 third_party_usd_minor = 3;
int64 coin_seller_usd_minor = 4;
int64 google_coin_amount = 5;
int64 third_party_coin_amount = 6;
int64 coin_seller_coin_amount = 7;
}
message RechargeBillRegionBucket {
int64 region_id = 1;
int64 bill_count = 2;
int64 usd_minor_amount = 3;
}
// RechargeBillGooglePaidStats
// est_* × USD
message RechargeBillGooglePaidStats {
int64 google_bill_count = 1;
int64 synced_count = 2;
int64 unsynced_count = 3;
int64 covered_usd_minor = 4;
int64 est_fee_usd_minor = 5;
int64 est_tax_usd_minor = 6;
int64 est_net_usd_minor = 7;
}
message GetRechargeBillOverviewResponse {
repeated RechargeBillDailyBucket daily = 1;
repeated RechargeBillRegionBucket regions = 2;
RechargeBillGooglePaidStats google_paid = 3;
}
message RefreshGooglePaymentPricesRequest {
string request_id = 1;
string app_code = 2;
@ -2222,6 +2271,7 @@ service WalletService {
rpc PurchaseResourceShopItem(PurchaseResourceShopItemRequest) returns (PurchaseResourceShopItemResponse);
rpc ListRechargeBills(ListRechargeBillsRequest) returns (ListRechargeBillsResponse);
rpc GetRechargeBillSummary(GetRechargeBillSummaryRequest) returns (GetRechargeBillSummaryResponse);
rpc GetRechargeBillOverview(GetRechargeBillOverviewRequest) returns (GetRechargeBillOverviewResponse);
rpc RefreshGooglePaymentPrices(RefreshGooglePaymentPricesRequest) returns (RefreshGooglePaymentPricesResponse);
rpc GetWalletOverview(GetWalletOverviewRequest) returns (GetWalletOverviewResponse);
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);

View File

@ -249,6 +249,7 @@ const (
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_GetRechargeBillOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetRechargeBillOverview"
WalletService_RefreshGooglePaymentPrices_FullMethodName = "/hyapp.wallet.v1.WalletService/RefreshGooglePaymentPrices"
WalletService_GetWalletOverview_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletOverview"
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
@ -353,6 +354,7 @@ type WalletServiceClient interface {
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)
GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, 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)
@ -891,6 +893,16 @@ func (c *walletServiceClient) GetRechargeBillSummary(ctx context.Context, in *Ge
return out, nil
}
func (c *walletServiceClient) GetRechargeBillOverview(ctx context.Context, in *GetRechargeBillOverviewRequest, opts ...grpc.CallOption) (*GetRechargeBillOverviewResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetRechargeBillOverviewResponse)
err := c.cc.Invoke(ctx, WalletService_GetRechargeBillOverview_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)
@ -1425,6 +1437,7 @@ type WalletServiceServer interface {
PurchaseResourceShopItem(context.Context, *PurchaseResourceShopItemRequest) (*PurchaseResourceShopItemResponse, error)
ListRechargeBills(context.Context, *ListRechargeBillsRequest) (*ListRechargeBillsResponse, error)
GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error)
GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error)
RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error)
GetWalletOverview(context.Context, *GetWalletOverviewRequest) (*GetWalletOverviewResponse, error)
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
@ -1627,6 +1640,9 @@ func (UnimplementedWalletServiceServer) ListRechargeBills(context.Context, *List
func (UnimplementedWalletServiceServer) GetRechargeBillSummary(context.Context, *GetRechargeBillSummaryRequest) (*GetRechargeBillSummaryResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillSummary not implemented")
}
func (UnimplementedWalletServiceServer) GetRechargeBillOverview(context.Context, *GetRechargeBillOverviewRequest) (*GetRechargeBillOverviewResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRechargeBillOverview not implemented")
}
func (UnimplementedWalletServiceServer) RefreshGooglePaymentPrices(context.Context, *RefreshGooglePaymentPricesRequest) (*RefreshGooglePaymentPricesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RefreshGooglePaymentPrices not implemented")
}
@ -2656,6 +2672,24 @@ func _WalletService_GetRechargeBillSummary_Handler(srv interface{}, ctx context.
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetRechargeBillOverview_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRechargeBillOverviewRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).GetRechargeBillOverview(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_GetRechargeBillOverview_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).GetRechargeBillOverview(ctx, req.(*GetRechargeBillOverviewRequest))
}
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 {
@ -3719,6 +3753,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetRechargeBillSummary",
Handler: _WalletService_GetRechargeBillSummary_Handler,
},
{
MethodName: "GetRechargeBillOverview",
Handler: _WalletService_GetRechargeBillOverview_Handler,
},
{
MethodName: "RefreshGooglePaymentPrices",
Handler: _WalletService_RefreshGooglePaymentPrices_Handler,

View File

@ -19,6 +19,7 @@ import (
"hyapp-admin-server/internal/integration/activityclient"
dingtalkclient "hyapp-admin-server/internal/integration/dingtalk"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/googleorders"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/integration/userclient"
@ -201,6 +202,8 @@ func main() {
fatalRuntime("auto_migrate_failed", err)
}
}
// legacy 账单源的谷歌实付同步依赖 admin 库缓存表store 建好后再注入;凭证异常只降级该 App不阻断启动。
wireLegacyGooglePaidSync(financeBillSources, cfg.FinanceBillSources, store)
if *runBootstrap || cfg.Bootstrap.Enabled {
if err := store.Seed(cfg); err != nil {
fatalRuntime("seed_database_failed", err)
@ -475,6 +478,36 @@ func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBill
return sources, cleanup, nil
}
// wireLegacyGooglePaidSync 为配置了 Play Console 包名与服务账号的 legacy 账单源开启谷歌实付同步。
func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, configs []config.FinanceBillSourceConfig, store *repository.Store) {
configsByApp := map[string]config.FinanceBillSourceConfig{}
for _, sourceConfig := range configs {
configsByApp[strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))] = sourceConfig
}
for _, source := range sources {
mongoSource, ok := source.(*paymentmodule.MongoRechargeBillSource)
if !ok {
continue
}
sourceConfig := configsByApp[source.AppCode()]
if sourceConfig.GooglePackageName == "" ||
(sourceConfig.GoogleServiceAccountJSON == "" && sourceConfig.GoogleServiceAccountFile == "") {
continue
}
client, err := googleorders.New(googleorders.Config{
PackageName: sourceConfig.GooglePackageName,
ServiceAccountJSON: sourceConfig.GoogleServiceAccountJSON,
ServiceAccountFile: sourceConfig.GoogleServiceAccountFile,
})
if err != nil {
slog.Error("legacy_google_paid_sync_disabled", "app_code", source.AppCode(), "error", err.Error())
continue
}
mongoSource.SetGooglePaidSync(store, client)
slog.Info("legacy_google_paid_sync_enabled", "app_code", source.AppCode(), "package_name", sourceConfig.GooglePackageName)
}
}
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
for _, source := range sources {

View File

@ -41,6 +41,9 @@ finance_bill_sources:
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: "com.org.yumiparty"
google_service_account_json: |
{"type":"service_account","project_id":"pc-api-4773779885716259569-760","private_key_id":"29f9ea87a8e161c362dd5bdffc3988c4b7e1ef39","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDaaggeBncPZlq6\nKLYlUEKV5eSmu9pf6TcMUHwKm9Nyzd+pAyUPe52Ar8G7B+7My2INIimh+kQ9hCZG\ndF95gn2JNFa0AZksjtZcwOcz7YxmhRpYAdE6keSlJTE8u1a6Z2Oou+tU2okyXuEp\ngXDH1ao0S2chfFB9U8z36uy67vG2txnwwjZbXqiroDuwN59I4JrjcMRL/hGP9uu+\ntC5ekIjyatu4Jn6EJlXxc5pS9Gv74ZezUWqRLeRMiKJBbWtOJeQv+1ulF9qL7K6B\n11o/gaqphnt+llgTVVVzQ9Eh7eHeRj8XgHRgLC2NWeMpi7h3Jz2SKMg0ArNMqdgn\nhygY1tAXAgMBAAECggEAMy9fGJ4+P6smfvL0gLkW6acXFyX17r0qS+X+s8PB4Wky\nzZpxkHfROOu3dHvO8EqHf3lulUmfvWTfTWqPR1wXzFQqL4QiX+lXfiQs6qP0X8A4\npMBERrwS/8rAB7IFiKibF9t2MowGU/odPUta4VIG0buL/zJxcHV3lvAEq2g82CsR\nAV+RcQM8CMlZNe7XdAnPjgReUodSQW+NIV0X5iZ9vsgLYZRwGXmslt8/4VHi+qR/\nOX+4yO7BVReVCpfNNxOm4TALmjw7aLvQQAwWhwD9+maA66l9ElQdsHRtseZEzkY/\nh0S8EODgw5gTFfEr9rOp79Im9Flnk9SjxfuIp0QF/QKBgQDuKcYu0j2rEU4Vv7kb\n9grGm5XUIJk3x9bCruRc9ODqHF3vovaDyndPSq0ViuZRha1vlP6COxo40W86OdFv\nY7lN0OnN2ZGPlyTgXqckOcb0Rk0vCLGaBKNOdGmc02r2hDXA4h44Obgy37IXuXEP\nNlTN3bF2OIgHWoImcGkmcYh9jQKBgQDqxZ3QoyUoLFyzA+MKlrqqq9D3AVZi38ob\n9j9Ki6ysAgRAgW8w36yst4ef/+f2GW/OcW7QWzfk2w08fT12Gsw7FPAFUZUETuTX\ndzdE0F+yDj2m7upJ/8Yv9Ohi9K2g4I05oogbq48DR5b/lZqADSGqsb1FwZFKsNp3\n39Dp0BlBMwKBgQCZeOH1GhYTPruK2Fl44zxeb7RFVhxmDakfG4SdQlANjOobmnAw\nzS/FMOIIl9GDhxkUZnb7hQqIwq1iYA/OL/0hYBbKSAG8/jENRPGALps+nm7ueDO6\nhHKYA/xqyvKKmPfqq8u9f7RrVCt3jlCE9QYBA3NwM021L2XfT2DzHQZPoQKBgFvf\nBUjV7v5vjb8H8Fr+bQHIxrdCMLn0dTTIAjB7xBBzoZJUlFx9yyazk0FLdUxa2+Pf\ng8vJRnAqQF3BbMHA7tbX9K1AJZ5P+UFQB7LIEAqvg/TFXa2jh7zQi/fdY+ymst0w\n+y5IzmgsJazSsGkXumr/rt+TRfYCixuJ3EkDBD79AoGBAJzrUkc748L/Cx8/FHOc\nR6OOedNFpQBM+6OfodBmv2rQzM5IUsANzlVXLctuZkIev63SmhrQ/Zl1WEQHsfwu\nz35wdrc+KT/FcjgswJmCAbg5U1D+dp3Up753Bm5uariI6b+S8oi+7hC4IQMpWmBR\npeYgkq63zzxK+0ZbnnWytuia\n-----END PRIVATE KEY-----\n","client_email":"aswat-service-pay@pc-api-4773779885716259569-760.iam.gserviceaccount.com","client_id":"112164268081938208775","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/aswat-service-pay%40pc-api-4773779885716259569-760.iam.gserviceaccount.com","universe_domain":"googleapis.com"}
- enabled: true
app_code: "aslan"
app_name: "Aslan"
@ -49,6 +52,9 @@ finance_bill_sources:
mongo_database: "atyou"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: "com.chat.auu"
google_service_account_json: |
{"type":"service_account","project_id":"aslan-494908","private_key_id":"f3ab2df01b3f92b9f71f4b7d1ebdba3cff5ac93e","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDkCOxtBRVNg5CF\nIfgkT5gv1f2hSRnf6/MSTDYkgXy1sVSbLEXbsZGQdnsYSfAxNXURyJ0QXJOZV551\n+0XOlRIb+HLN++37olcFVm4GPLxQiVCCEbFOBwGPZTMi4RYgriDHEp2umY9Cns8Z\nHYlkYgbLxKLICpzyDJWDsyT3Tc4rBhRGv7+dulxSuPpF5AaLzbV/AjQTwee6qnV4\n8VEPHx/r+3IAJMVLt+VrgFwujIdzJGk8TIZKSSmZ4J6iN5dKgly1PyVu66bppbX9\nHkPDEFg8nxYK5F2/yjo7/VIFurfd5bGKGS3sqf0siRTXLsj0X/wUgt0RlWOpKPeX\nbZq9jzznAgMBAAECggEAASLUPrTMRt8VbLxfFps46GAaC+An21g7FUfA60yj2Onh\nwIYncPFBBuW4NkZEBpK8GxMTST4U1Co+FVtjnSRb+zyxIbqUFHFaGqI0GR7bV1Ff\nz84Two5BYTwBVbamXBJSAnviwjhsoMnWwUrG4POmEgTQRMvcvU33vri5Qewmz0sN\njXTNh/GIUxA+IbOUTaaTDBLSshp7JBOPjuF0ttsd/0zG1AFbaWWtZ1h8YCOhIw+g\np7/CdS3gO/uttgI8PiLj8AImlOcRz5FnFGTcHZqR7pk08W7Olm89ik3NZg+5IozP\nrJ8LOU7uQ7F50zpWRNvpBNaa4m35Ns6bOHTLEV4BXQKBgQD0q8hn1bddwC92RzMw\naBb0Qt0Najg2R/SDWaeSO6Tz3KTflGZXDFtalAOq5KXT47N7QdoIjlXGP50B5iV7\nK/I25f+GONcACPtnnLWyNvjq2OuMz0FIGEoUxY9DFP6aNkyNR5FiE2t2y2XiKmrV\nCRt+P4x/b/KTvdVI+caUSfXezQKBgQDul/F46Uj1evZZToA4XENXRN8oSb4I08cX\nBQK2WUs+KJsT9sWNaT1V5hQ8wXfuN2rhI0kqR0EURY3ycaA41rdLIeYqvAnmR5qC\nSO8HlC2+zQzjSrr7lyt8dcTughW8/9lCf+jANBNYBZrBN2ta3vlQ8eA8rFUThYnC\n8+PFo8MigwKBgFbbGJSL0MFONUsWsXxQpz1k8xYNDBFw78MlM5B87ezH+huIkd/6\n+f8opjinXJrgrVlnIiCBbr+m23TOH6YfDqggc9pRGTng9mZswi+WxjyQbuYYuQL/\n5GSFUXst28gg2IIa0uhvHmoYgH2OM0iXKBRkONsQgZui+zEhwjXoH4lNAoGAKVMJ\n0M5fA52Lg4ZUMO7R/xB/skOrdW3wwqzsflbS8G4qBfgs2URMCk+yW5+KvSi+C0aI\nSplSzUcKwd4qSQ3va0Twz6AH+umV+lDVjbN9hNmRDOEJp7/UGVdwh3ridvy9TYZH\n8tpSK2G1HxgRMQkDl6B9HSUgCySK6shBQB8QEi8CgYBAbUxCJn20RFmwU4bgRASe\ngoDGkN5Dw1SdauuWn+WySTyrsxdxvRcuyy4pQSDBadhnv24/30mza1ToeJeEsyJ/\nJuysBhWBjJKoYVC/qikgS6EuAnqlAKKg8jLIqERmNaFol0Squ03kTvMYx2aGa1dt\nwwMCwljNodGSgZRA3RlwOA==\n-----END PRIVATE KEY-----\n","client_email":"pay-purchase-verify@aslan-494908.iam.gserviceaccount.com","client_id":"100290296879529721706","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/pay-purchase-verify%40aslan-494908.iam.gserviceaccount.com","universe_domain":"googleapis.com"}
mysql_auto_migrate: false
migrations:
enabled: true

View File

@ -41,6 +41,9 @@ finance_bill_sources:
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: ""
google_service_account_json: ""
google_service_account_file: ""
- enabled: false
app_code: "aslan"
app_name: "Aslan"
@ -49,6 +52,9 @@ finance_bill_sources:
mongo_database: "atyou"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
google_package_name: ""
google_service_account_json: ""
google_service_account_file: ""
mysql_auto_migrate: true
migrations:
enabled: true

View File

@ -144,6 +144,12 @@ type FinanceBillSourceConfig struct {
MongoDatabase string `yaml:"mongo_database"`
MongoCollection string `yaml:"mongo_collection"`
RequestTimeout time.Duration `yaml:"request_timeout"`
// Google 实付同步(可选):配置该 App 在 Play Console 的包名与服务账号后,
// 财务页“查询谷歌实付”按钮即可对 legacy 谷歌账单拉取实付币种/总额/税/净收入。
// 服务账号需要对应 Play Console 的“查看财务数据”权限JSON 优先于文件路径。
GooglePackageName string `yaml:"google_package_name"`
GoogleServiceAccountJSON string `yaml:"google_service_account_json"`
GoogleServiceAccountFile string `yaml:"google_service_account_file"`
}
type TencentCOSConfig struct {
@ -527,6 +533,9 @@ func (cfg *Config) Normalize() {
if source.MongoCollection == "" {
source.MongoCollection = "in_app_purchase_details"
}
source.GooglePackageName = strings.TrimSpace(source.GooglePackageName)
source.GoogleServiceAccountJSON = strings.TrimSpace(source.GoogleServiceAccountJSON)
source.GoogleServiceAccountFile = strings.TrimSpace(source.GoogleServiceAccountFile)
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
@ -866,6 +875,16 @@ func (cfg *Config) applyFinanceBillSourceEnvOverrides() {
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_PACKAGE_NAME")); value != "" {
source.GooglePackageName = value
}
// 服务账号 JSON 含私钥;生产优先用环境变量注入,仓库 YAML 只放文件路径或占位。
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_SERVICE_ACCOUNT_JSON")); value != "" {
source.GoogleServiceAccountJSON = value
}
if value := strings.TrimSpace(os.Getenv("HYAPP_ADMIN_" + appKey + "_GOOGLE_SERVICE_ACCOUNT_FILE")); value != "" {
source.GoogleServiceAccountFile = value
}
}
}

View File

@ -0,0 +1,258 @@
// Package googleorders 提供 Play Developer API Orders 资源的最小只读客户端。
// hyapp 自有 AppLalu 等)的实付同步走 wallet-service这里只服务 legacy 账单源Yumi/Aslan
// 它们的支付发生在外部平台admin 需要用各自 Play Console 的服务账号直接查订单实付。
package googleorders
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"
defaultAPIBaseURL = "https://androidpublisher.googleapis.com"
defaultTokenURL = "https://oauth2.googleapis.com/token"
)
// Order 是 orders.get 的实付快照;金额统一为实付币种微单位。
type Order struct {
OrderID string
State string
BuyerCountry string
CurrencyCode string
TotalAmountMicro int64
TaxAmountMicro int64
// NetAmountMicro 是 Google 扣除服务费和代缴税后的开发者净收入;接口未返回时为 0。
NetAmountMicro int64
}
// Config 描述一个 Play Console 服务账号ServiceAccountJSON 优先于 ServiceAccountFile。
type Config struct {
PackageName string
ServiceAccountJSON string
ServiceAccountFile string
HTTPTimeout time.Duration
}
type serviceAccount struct {
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
TokenURI string `json:"token_uri"`
}
// Client 按服务账号缓存 OAuth token一个 legacy App 一个实例。
type Client struct {
httpClient *http.Client
packageName string
tokenURL string
email string
privateKey *rsa.PrivateKey
mu sync.Mutex
accessToken string
expiresAt time.Time
}
// New 创建 legacy Google Orders 客户端;凭证不完整时返回错误,由调用方决定是否降级为“未配置”。
func New(cfg Config) (*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)
}
if body == "" {
return nil, errors.New("google service account is not configured")
}
var account serviceAccount
if err := json.Unmarshal([]byte(body), &account); err != nil {
return nil, fmt.Errorf("parse google service account: %w", err)
}
account.ClientEmail = strings.TrimSpace(account.ClientEmail)
account.PrivateKey = strings.TrimSpace(account.PrivateKey)
if account.ClientEmail == "" || account.PrivateKey == "" {
return nil, errors.New("google service account is incomplete")
}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey))
if err != nil {
return nil, fmt.Errorf("parse google private key: %w", err)
}
tokenURL := strings.TrimSpace(account.TokenURI)
if tokenURL == "" {
tokenURL = defaultTokenURL
}
timeout := cfg.HTTPTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
packageName := strings.TrimSpace(cfg.PackageName)
if packageName == "" {
return nil, errors.New("google package name is required")
}
return &Client{
httpClient: &http.Client{Timeout: timeout},
packageName: packageName,
tokenURL: tokenURL,
email: account.ClientEmail,
privateKey: privateKey,
}, nil
}
// GetOrder 查询单笔订单实付明细;服务账号需要该 Play Console 的“查看财务数据”权限。
func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) {
if c == nil {
return Order{}, errors.New("google orders client is not configured")
}
orderID = strings.TrimSpace(orderID)
if orderID == "" {
return Order{}, errors.New("google order id is required")
}
token, err := c.token(ctx)
if err != nil {
return Order{}, err
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders/%s",
defaultAPIBaseURL, url.PathEscape(c.packageName), url.PathEscape(orderID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Order{}, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return Order{}, errors.New("google play api request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return Order{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
switch resp.StatusCode {
case http.StatusNotFound, http.StatusBadRequest:
return Order{}, errors.New("google order is not found")
case http.StatusUnauthorized, http.StatusForbidden:
return Order{}, errors.New("google play api is not authorized (需要查看财务数据权限)")
default:
return Order{}, fmt.Errorf("google play api returned %d", resp.StatusCode)
}
}
var parsed orderResource
if err := json.Unmarshal(body, &parsed); err != nil {
return Order{}, err
}
order := Order{
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(),
}
if order.CurrencyCode == "" {
order.CurrencyCode = parsed.Tax.CurrencyCode
}
return order, 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 "", errors.New("google oauth token request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", errors.New("google oauth token request failed")
}
var tokenResp struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", err
}
if tokenResp.AccessToken == "" {
return "", errors.New("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 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

@ -42,6 +42,7 @@ type Client interface {
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)
GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, 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)
@ -200,6 +201,10 @@ func (c *GRPCClient) GetRechargeBillSummary(ctx context.Context, req *walletv1.G
return c.client.GetRechargeBillSummary(ctx, req)
}
func (c *GRPCClient) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
return c.client.GetRechargeBillOverview(ctx, req)
}
func (c *GRPCClient) RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error) {
return c.client.RefreshGooglePaymentPrices(ctx, req)
}

View File

@ -417,6 +417,28 @@ func (TemporaryPaymentLinkOwner) TableName() string {
return "admin_temporary_payment_link_owners"
}
// LegacyGooglePaidDetail 缓存 legacy AppYumi/Aslan谷歌账单经 Play Orders API 同步的实付明细;
// legacy 平台的 Mongo 对 admin 只读,实付事实只能落在 admin 自己的库里,按账单交易号回填展示。
type LegacyGooglePaidDetail struct {
AppCode string `gorm:"size:32;primaryKey;index:idx_legacy_google_paid_bill_time,priority:1" json:"appCode"`
TransactionID string `gorm:"size:96;primaryKey;column:transaction_id" json:"transactionId"`
ProviderOrderID string `gorm:"size:128;not null;default:''" json:"providerOrderId"`
PaidCurrencyCode string `gorm:"size:8;not null;default:''" json:"paidCurrencyCode"`
PaidAmountMicro int64 `gorm:"not null;default:0" json:"paidAmountMicro"`
PaidTaxMicro int64 `gorm:"not null;default:0" json:"paidTaxMicro"`
PaidNetMicro int64 `gorm:"not null;default:0" json:"paidNetMicro"`
// BillUSDMinor/BillCreatedAtMS 是同步时从 legacy 账单带过来的快照,让概览统计不用再回查 Mongo。
BillUSDMinor int64 `gorm:"column:bill_usd_minor;not null;default:0" json:"billUsdMinor"`
BillCreatedAtMS int64 `gorm:"column:bill_created_at_ms;not null;default:0;index:idx_legacy_google_paid_bill_time,priority:2" json:"billCreatedAtMs"`
SyncedAtMS int64 `gorm:"column:synced_at_ms;not null" json:"syncedAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (LegacyGooglePaidDetail) TableName() string {
return "admin_legacy_google_paid_details"
}
type FinanceApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`

View File

@ -102,6 +102,7 @@ func (h *Handler) ListRechargeBills(c *gin.Context) {
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),

View File

@ -2,14 +2,19 @@ package payment
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/googleorders"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
@ -18,7 +23,11 @@ import (
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义legacy 源不适用。
type legacyRechargeBillQuery struct {
Keyword string
Keyword string
// RechargeType 支持 google_play_recharge / third_party 两个口径legacy 平台没有币商分支。
RechargeType string
// PaidState=unsynced 只看谷歌实付未同步的账单。
PaidState string
RegionID int64
StartAtMS int64
EndAtMS int64
@ -33,6 +42,11 @@ type RechargeBillSource interface {
AppName() string
ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error)
SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error)
// SupportsGooglePaidSync 为 true 时RefreshGooglePaidDetails 用该 App 的 Play Console 服务账号同步实付明细。
SupportsGooglePaidSync() bool
RefreshGooglePaidDetails(ctx context.Context, transactionIDs []string) ([]googleRechargePaidDTO, error)
// Overview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度。
Overview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (rechargeBillOverviewDTO, error)
}
// legacyRegionCountryResolver 复用财务范围的区域→国家映射MongoMoneyRegionSource 已实现)。
@ -40,11 +54,30 @@ type legacyRegionCountryResolver interface {
CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error)
}
// LegacyGooglePaidStore 缓存 legacy 谷歌账单的 Orders API 实付明细repository.Store 实现)。
type LegacyGooglePaidStore interface {
UpsertLegacyGooglePaidDetail(detail model.LegacyGooglePaidDetail) error
LegacyGooglePaidDetails(appCode string, transactionIDs []string) (map[string]model.LegacyGooglePaidDetail, error)
LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64, endAtMS int64) (repository.LegacyGooglePaidStats, error)
LegacyGooglePaidTransactionIDs(appCode string) ([]string, error)
}
// legacyGoogleOrdersClient 按 legacy App 各自的 Play Console 服务账号查订单实付。
type legacyGoogleOrdersClient interface {
GetOrder(ctx context.Context, orderID string) (googleorders.Order, error)
}
// errLegacyGooglePaidUnsupported 表示该账单源未配置谷歌包名/服务账号或实付缓存存储。
var errLegacyGooglePaidUnsupported = errors.New("legacy google paid sync is not configured")
// MongoRechargeBillSource 读取 likei Mongo 的 in_app_purchase_details 集合作为充值账单事实。
type MongoRechargeBillSource struct {
collection *mongo.Collection
config config.FinanceBillSourceConfig
regionResolvers []legacyRegionCountryResolver
regionSources []MoneyRegionSource
paidStore LegacyGooglePaidStore
googleOrders legacyGoogleOrdersClient
}
// NewMongoRechargeBillSource 创建 legacy 账单源regionSources 用于把后台区域 ID 解析成国家码过滤条件。
@ -61,13 +94,31 @@ func NewMongoRechargeBillSource(database *mongo.Database, sourceConfig config.Fi
sourceConfig.SysOrigin = strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
source := &MongoRechargeBillSource{collection: database.Collection(collectionName), config: sourceConfig}
for _, regionSource := range regionSources {
if resolver, ok := regionSource.(legacyRegionCountryResolver); ok && resolver != nil {
if regionSource == nil {
continue
}
source.regionSources = append(source.regionSources, regionSource)
if resolver, ok := regionSource.(legacyRegionCountryResolver); ok {
source.regionResolvers = append(source.regionResolvers, resolver)
}
}
return source
}
// SetGooglePaidSync 注入实付缓存存储与该 App 的 Google Orders 客户端;未注入时“查询谷歌实付”对该源不可用。
func (s *MongoRechargeBillSource) SetGooglePaidSync(paidStore LegacyGooglePaidStore, googleOrders legacyGoogleOrdersClient) {
if s == nil {
return
}
s.paidStore = paidStore
s.googleOrders = googleOrders
}
// SupportsGooglePaidSync 供 handler 判定该源能否响应谷歌实付刷新。
func (s *MongoRechargeBillSource) SupportsGooglePaidSync() bool {
return s != nil && s.paidStore != nil && s.googleOrders != nil
}
func (s *MongoRechargeBillSource) AppCode() string {
return s.config.AppCode
}
@ -206,9 +257,151 @@ func (s *MongoRechargeBillSource) decodeBillCursor(ctx context.Context, cursor *
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)
}
if err := s.fillGooglePaidDetails(items); err != nil {
return nil, err
}
return items, nil
}
// fillGooglePaidDetails 把已同步的 Orders API 实付明细回填到当页谷歌账单:实付币种/总额/税/净收入,
// 手续费与 Lalu 同口径反推(实付 净收入 税)。未同步的谷歌账单保持 paidSyncedAtMs=0前端提示可查询。
func (s *MongoRechargeBillSource) fillGooglePaidDetails(items []rechargeBillDTO) error {
if s.paidStore == nil {
return nil
}
googleTxIDs := make([]string, 0, len(items))
for _, item := range items {
if item.RechargeType == "google_play_recharge" {
googleTxIDs = append(googleTxIDs, item.TransactionID)
}
}
if len(googleTxIDs) == 0 {
return nil
}
details, err := s.paidStore.LegacyGooglePaidDetails(s.config.AppCode, googleTxIDs)
if err != nil {
return fmt.Errorf("load legacy google paid details for %s: %w", s.config.AppCode, err)
}
for index := range items {
detail, ok := details[items[index].TransactionID]
if !ok || items[index].RechargeType != "google_play_recharge" {
continue
}
items[index].UserPaidCurrencyCode = detail.PaidCurrencyCode
items[index].UserPaidAmountMicro = detail.PaidAmountMicro
items[index].ProviderTaxMicro = detail.PaidTaxMicro
items[index].ProviderNetMicro = detail.PaidNetMicro
if detail.PaidAmountMicro > 0 && detail.PaidNetMicro > 0 {
if fee := detail.PaidAmountMicro - detail.PaidNetMicro - detail.PaidTaxMicro; fee > 0 {
items[index].ProviderFeeMicro = fee
}
}
items[index].PaidSyncedAtMS = detail.SyncedAtMS
}
return nil
}
const maxLegacyGooglePaidRefreshBatch = 100
// RefreshGooglePaidDetails 用该 App 的 Play Console 服务账号逐单拉取实付明细并缓存;单笔失败不阻断整批。
func (s *MongoRechargeBillSource) RefreshGooglePaidDetails(ctx context.Context, transactionIDs []string) ([]googleRechargePaidDTO, error) {
if !s.SupportsGooglePaidSync() {
return nil, errLegacyGooglePaidUnsupported
}
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 []googleRechargePaidDTO{}, nil
}
if len(cleaned) > maxLegacyGooglePaidRefreshBatch {
return nil, fmt.Errorf("too many transaction_ids")
}
idValues := make(bson.A, 0, len(cleaned))
for _, transactionID := range cleaned {
idValues = append(idValues, transactionID)
}
cursor, err := s.collection.Find(ctx, bson.M{
"_id": bson.M{"$in": idValues},
"sysOrigin": s.config.SysOrigin,
})
if err != nil {
return nil, fmt.Errorf("load legacy bills for google paid refresh: %w", err)
}
defer cursor.Close(ctx)
bills := map[string]legacyPurchaseDocument{}
for cursor.Next(ctx) {
var document legacyPurchaseDocument
if err := cursor.Decode(&document); err != nil {
return nil, fmt.Errorf("decode legacy bill for google paid refresh: %w", err)
}
bills[legacyDocumentID(document.ID)] = document
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy bills for google paid refresh: %w", err)
}
results := make([]googleRechargePaidDTO, 0, len(cleaned))
for _, transactionID := range cleaned {
results = append(results, s.refreshGooglePaidDetail(ctx, transactionID, bills))
}
return results, nil
}
func (s *MongoRechargeBillSource) refreshGooglePaidDetail(ctx context.Context, transactionID string, bills map[string]legacyPurchaseDocument) googleRechargePaidDTO {
result := googleRechargePaidDTO{TransactionID: transactionID}
document, ok := bills[transactionID]
if !ok {
result.Error = "账单不存在"
return result
}
if strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode)) != "GOOGLE" {
result.Error = "不是谷歌账单"
return result
}
orderID := strings.TrimSpace(document.OrderID)
if orderID == "" {
result.Error = "缺少谷歌订单号"
return result
}
order, err := s.googleOrders.GetOrder(ctx, orderID)
if err != nil {
result.Error = err.Error()
return result
}
syncedAt := time.Now().UnixMilli()
billCreatedAtMS := int64(0)
if !document.CreateTime.IsZero() {
billCreatedAtMS = document.CreateTime.UnixMilli()
} else if !document.PurchaseDateMS.IsZero() {
billCreatedAtMS = document.PurchaseDateMS.UnixMilli()
}
if err := s.paidStore.UpsertLegacyGooglePaidDetail(model.LegacyGooglePaidDetail{
AppCode: s.config.AppCode,
TransactionID: transactionID,
ProviderOrderID: orderID,
PaidCurrencyCode: strings.ToUpper(strings.TrimSpace(order.CurrencyCode)),
PaidAmountMicro: order.TotalAmountMicro,
PaidTaxMicro: order.TaxAmountMicro,
PaidNetMicro: order.NetAmountMicro,
BillUSDMinor: legacyDecimalToMinor(document.AmountUSD),
BillCreatedAtMS: billCreatedAtMS,
SyncedAtMS: syncedAt,
}); err != nil {
result.Error = err.Error()
return result
}
result.CurrencyCode = strings.ToUpper(strings.TrimSpace(order.CurrencyCode))
result.PaidAmountMicro = order.TotalAmountMicro
result.TaxMicro = order.TaxAmountMicro
result.NetMicro = order.NetAmountMicro
result.SyncedAtMS = syncedAt
return result
}
func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error) {
if s == nil || s.collection == nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured")
@ -310,6 +503,31 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
bson.M{"orderId": keyword},
}
}
switch strings.ToLower(strings.TrimSpace(query.RechargeType)) {
case "google_play_recharge":
filter["factory.factoryCode"] = "GOOGLE"
case "third_party":
filter["factory.factoryCode"] = bson.M{"$ne": "GOOGLE"}
case "coin_seller":
// legacy 平台没有币商进货口径。
return nil, nil, false, nil
}
if strings.ToLower(strings.TrimSpace(query.PaidState)) == "unsynced" {
filter["factory.factoryCode"] = "GOOGLE"
if s.paidStore != nil {
syncedIDs, err := s.paidStore.LegacyGooglePaidTransactionIDs(s.config.AppCode)
if err != nil {
return nil, nil, false, fmt.Errorf("load synced legacy google tx ids for %s: %w", s.config.AppCode, err)
}
if len(syncedIDs) > 0 {
idValues := make(bson.A, 0, len(syncedIDs))
for _, transactionID := range syncedIDs {
idValues = append(idValues, transactionID)
}
filter["_id"] = bson.M{"$nin": idValues}
}
}
}
if query.RegionID > 0 {
codes, handled, err := s.regionCountryCodes(ctx, query.RegionID)
if err != nil {
@ -325,12 +543,9 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
const legacyUserProfileCollection = "user_run_profile"
// legacyRegionLookupStages 解析账单的区域归属国家:优先账单自身 countryCodeAslan 的 MifaPay 单会带),
// 为空时回退付款用户 user_run_profile.countryCodeYumi/Aslan 的谷歌单都不带国家码codes 为空时不追加任何阶段。
func legacyRegionLookupStages(codes []string) mongo.Pipeline {
if len(codes) == 0 {
return mongo.Pipeline{}
}
// legacyCountryResolveStages 解析账单的区域归属国家:优先账单自身 countryCodeAslan 的 MifaPay 单会带),
// 为空时回退付款用户 user_run_profile.countryCodeYumi/Aslan 的谷歌单都不带国家码)。
func legacyCountryResolveStages() mongo.Pipeline {
return mongo.Pipeline{
bson.D{{Key: "$lookup", Value: bson.M{
"from": legacyUserProfileCollection,
@ -345,10 +560,19 @@ func legacyRegionLookupStages(codes []string) mongo.Pipeline {
bson.M{"$ifNull": bson.A{bson.M{"$first": "$legacyBillUser.countryCode"}, ""}},
}}},
}}},
bson.D{{Key: "$match", Value: bson.M{"legacyBillCountry": bson.M{"$in": codes}}}},
}
}
// legacyRegionLookupStages 在国家解析的基础上按区域国家清单过滤codes 为空时不追加任何阶段。
func legacyRegionLookupStages(codes []string) mongo.Pipeline {
if len(codes) == 0 {
return mongo.Pipeline{}
}
return append(legacyCountryResolveStages(),
bson.D{{Key: "$match", Value: bson.M{"legacyBillCountry": bson.M{"$in": codes}}}},
)
}
// clonePipeline 复制聚合管道,避免 append 复用底层数组导致 count/分页两条管道互相踩踏。
func clonePipeline(pipeline mongo.Pipeline) mongo.Pipeline {
cloned := make(mongo.Pipeline, len(pipeline))
@ -398,9 +622,13 @@ func legacyRechargeBillDTO(sourceConfig config.FinanceBillSourceConfig, document
ProviderCode: legacyProviderCode(factoryCode),
UserPaidCurrencyCode: strings.ToUpper(strings.TrimSpace(document.Currency)),
UserPaidAmountMicro: legacyDecimalToMicro(document.Amount),
// likei 平台没有可反推的三方扣款数据;标记已同步避免前端把 legacy 谷歌账单当成待查询。
// 非谷歌渠道MifaPay 等)没有可反推的三方扣款数据,标记已同步;
// 谷歌账单保持未同步0由 Orders API 实付缓存回填后才算同步完成。
PaidSyncedAtMS: createdAtMS,
}
if factoryCode == "GOOGLE" {
dto.PaidSyncedAtMS = 0
}
return dto
}
@ -456,6 +684,213 @@ func legacyGoldCoinAmount(products []legacyPurchaseProduct) int64 {
return total
}
// Overview 返回 legacy 账单的按日趋势、区域分布与谷歌实付覆盖度tzOffsetMinutes 决定日边界。
func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (rechargeBillOverviewDTO, error) {
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
if s == nil || s.collection == nil {
return overview, fmt.Errorf("legacy bill source is not configured")
}
filter, regionCodes, matchable, err := s.billFilter(ctx, query)
if err != nil {
return overview, err
}
if !matchable {
return overview, nil
}
base := append(mongo.Pipeline{bson.D{{Key: "$match", Value: filter}}}, legacyRegionLookupStages(regionCodes)...)
if err := s.overviewDaily(ctx, base, tzOffsetMinutes, &overview); err != nil {
return overview, err
}
if err := s.overviewRegions(ctx, base, &overview); err != nil {
return overview, err
}
if err := s.overviewGooglePaid(ctx, filter, query, &overview); err != nil {
return overview, err
}
return overview, nil
}
func (s *MongoRechargeBillSource) overviewDaily(ctx context.Context, base mongo.Pipeline, tzOffsetMinutes int32, overview *rechargeBillOverviewDTO) error {
coinExpr := bson.M{"$reduce": bson.M{
"input": bson.M{"$filter": bson.M{
"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
"cond": bson.M{"$in": bson.A{"$$this.name", bson.A{"GOLD", "CANDY"}}},
}},
"initialValue": 0,
"in": bson.M{"$add": bson.A{
"$$value",
bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": 0, "onNull": 0}},
}},
}}
isGoogle := bson.M{"$eq": bson.A{"$factory.factoryCode", "GOOGLE"}}
pipeline := append(clonePipeline(base),
bson.D{{Key: "$group", Value: bson.M{
"_id": bson.M{"$dateToString": bson.M{"format": "%Y-%m-%d", "date": "$createTime", "timezone": legacyTimezoneOffset(tzOffsetMinutes)}},
"googleUsd": bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, bson.M{"$ifNull": bson.A{"$amountUsd", 0}}, 0}}},
"thirdUsd": bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, 0, bson.M{"$ifNull": bson.A{"$amountUsd", 0}}}}},
"googleCoin": bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, coinExpr, 0}}},
"thirdCoin": bson.M{"$sum": bson.M{"$cond": bson.A{isGoogle, 0, coinExpr}}},
}}},
bson.D{{Key: "$sort", Value: bson.M{"_id": 1}}},
)
cursor, err := s.collection.Aggregate(ctx, pipeline)
if err != nil {
return fmt.Errorf("aggregate legacy daily overview for %s: %w", s.config.AppCode, err)
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
var row struct {
Date string `bson:"_id"`
GoogleUSD any `bson:"googleUsd"`
ThirdUSD any `bson:"thirdUsd"`
GoogleCoin int64 `bson:"googleCoin"`
ThirdCoin int64 `bson:"thirdCoin"`
}
if err := cursor.Decode(&row); err != nil {
return fmt.Errorf("decode legacy daily overview for %s: %w", s.config.AppCode, err)
}
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
Date: row.Date,
GoogleUsdMinor: legacyDecimalToMinor(row.GoogleUSD),
ThirdPartyUsdMinor: legacyDecimalToMinor(row.ThirdUSD),
GoogleCoinAmount: row.GoogleCoin,
ThirdPartyCoinAmount: row.ThirdCoin,
})
}
return cursor.Err()
}
func (s *MongoRechargeBillSource) overviewRegions(ctx context.Context, base mongo.Pipeline, overview *rechargeBillOverviewDTO) error {
pipeline := clonePipeline(base)
// 区域分布需要国家解析;若区域筛选未触发 lookup这里补上重复 addFields 无害但避免重复 lookup
if len(pipeline) == 1 {
pipeline = append(pipeline, legacyCountryResolveStages()...)
}
pipeline = append(pipeline,
bson.D{{Key: "$group", Value: bson.M{
"_id": "$legacyBillCountry",
"billCount": bson.M{"$sum": 1},
"usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
}}},
)
cursor, err := s.collection.Aggregate(ctx, pipeline)
if err != nil {
return fmt.Errorf("aggregate legacy region overview for %s: %w", s.config.AppCode, err)
}
defer cursor.Close(ctx)
countryToRegion := s.legacyCountryRegionIndex(ctx)
buckets := map[int64]*rechargeBillRegionBucketDTO{}
for cursor.Next(ctx) {
var row struct {
Country string `bson:"_id"`
BillCount int64 `bson:"billCount"`
USD any `bson:"usd"`
}
if err := cursor.Decode(&row); err != nil {
return fmt.Errorf("decode legacy region overview for %s: %w", s.config.AppCode, err)
}
region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.Country))]
if !ok {
continue
}
bucket, exists := buckets[region.RegionID]
if !exists {
bucket = &rechargeBillRegionBucketDTO{RegionID: region.RegionID, Name: region.Name}
buckets[region.RegionID] = bucket
}
bucket.BillCount += row.BillCount
bucket.UsdMinorAmount += legacyDecimalToMinor(row.USD)
}
if err := cursor.Err(); err != nil {
return err
}
for _, bucket := range buckets {
overview.Regions = append(overview.Regions, *bucket)
}
sort.Slice(overview.Regions, func(i, j int) bool {
return overview.Regions[i].UsdMinorAmount > overview.Regions[j].UsdMinorAmount
})
return nil
}
type legacyRegionRef struct {
RegionID int64
Name string
}
// legacyCountryRegionIndex 从区域目录构建国家→区域映射;目录读取失败时区域分布降级为空,不阻断概览其他数据。
func (s *MongoRechargeBillSource) legacyCountryRegionIndex(ctx context.Context) map[string]legacyRegionRef {
index := map[string]legacyRegionRef{}
for _, regionSource := range s.regionSources {
_, regions, _, err := regionSource.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
continue
}
for _, region := range regions {
if appctx.Normalize(region.AppCode) != s.config.AppCode {
continue
}
for _, country := range region.Countries {
country = strings.ToUpper(strings.TrimSpace(country))
if country == "" {
continue
}
if _, exists := index[country]; !exists {
index[country] = legacyRegionRef{RegionID: region.RegionID, Name: region.Name}
}
}
}
}
return index
}
func (s *MongoRechargeBillSource) overviewGooglePaid(ctx context.Context, filter bson.M, query legacyRechargeBillQuery, overview *rechargeBillOverviewDTO) error {
rechargeType := strings.ToLower(strings.TrimSpace(query.RechargeType))
if rechargeType != "" && rechargeType != "google_play_recharge" {
return nil
}
googleFilter := bson.M{}
for key, value := range filter {
googleFilter[key] = value
}
googleFilter["factory.factoryCode"] = "GOOGLE"
googleCount, err := s.collection.CountDocuments(ctx, googleFilter)
if err != nil {
return fmt.Errorf("count legacy google bills for %s: %w", s.config.AppCode, err)
}
overview.GooglePaid.GoogleBillCount = googleCount
if s.paidStore == nil {
overview.GooglePaid.UnsyncedCount = googleCount
return nil
}
stats, err := s.paidStore.LegacyGooglePaidStatsByBillTime(s.config.AppCode, query.StartAtMS, query.EndAtMS)
if err != nil {
return fmt.Errorf("load legacy google paid stats for %s: %w", s.config.AppCode, err)
}
overview.GooglePaid.SyncedCount = stats.SyncedCount
if unsynced := googleCount - stats.SyncedCount; unsynced > 0 {
overview.GooglePaid.UnsyncedCount = unsynced
}
overview.GooglePaid.CoveredUsdMinor = stats.CoveredUSDMinor
overview.GooglePaid.EstFeeUsdMinor = stats.EstFeeUSDMinor
overview.GooglePaid.EstTaxUsdMinor = stats.EstTaxUSDMinor
overview.GooglePaid.EstNetUsdMinor = stats.EstNetUSDMinor
return nil
}
// legacyTimezoneOffset 把分钟偏移转成 Mongo $dateToString 的时区串,如 480 → "+08:00"。
func legacyTimezoneOffset(tzOffsetMinutes int32) string {
sign := "+"
minutes := tzOffsetMinutes
if minutes < 0 {
sign = "-"
minutes = -minutes
}
return fmt.Sprintf("%s%02d:%02d", sign, minutes/60, minutes%60)
}
func legacyDecimalToMicro(value any) int64 {
return legacyDecimalScaled(value, 1_000_000)
}

View File

@ -67,9 +67,19 @@ func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) {
if dto.UserPaidCurrencyCode != "TRY" || dto.UserPaidAmountMicro != 164_990_000 {
t.Fatalf("unexpected user paid: %+v", dto)
}
if dto.CreatedAtMS != createTime.UnixMilli() || dto.PaidSyncedAtMS != dto.CreatedAtMS {
// 谷歌账单初始必须是未同步0等 Orders API 实付缓存回填后才算同步完成。
if dto.CreatedAtMS != createTime.UnixMilli() || dto.PaidSyncedAtMS != 0 {
t.Fatalf("unexpected timestamps: %+v", dto)
}
mifapayDTO := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{
ID: "175100000000054321",
Factory: legacyPurchaseFactory{Platform: "H5", FactoryCode: "MIFA_PAY"},
CreateTime: createTime,
})
if mifapayDTO.RechargeType != "mifapay" || mifapayDTO.PaidSyncedAtMS != mifapayDTO.CreatedAtMS {
t.Fatalf("unexpected mifapay mapping: %+v", mifapayDTO)
}
}
func TestLegacyRechargeTypeMapping(t *testing.T) {

View File

@ -83,12 +83,14 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string)
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: page,
PageSize: rechargeBillExportPageSize,
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: page,
PageSize: rechargeBillExportPageSize,
})
if err != nil {
response.ServerError(c, "导出账单失败")
@ -125,6 +127,7 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string)
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),

View File

@ -0,0 +1,134 @@
package payment
import (
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type rechargeBillDailyBucketDTO struct {
Date string `json:"date"`
GoogleUsdMinor int64 `json:"googleUsdMinor"`
ThirdPartyUsdMinor int64 `json:"thirdPartyUsdMinor"`
CoinSellerUsdMinor int64 `json:"coinSellerUsdMinor"`
GoogleCoinAmount int64 `json:"googleCoinAmount"`
ThirdPartyCoinAmount int64 `json:"thirdPartyCoinAmount"`
CoinSellerCoinAmount int64 `json:"coinSellerCoinAmount"`
}
type rechargeBillRegionBucketDTO struct {
RegionID int64 `json:"regionId"`
Name string `json:"name"`
BillCount int64 `json:"billCount"`
UsdMinorAmount int64 `json:"usdMinorAmount"`
}
type rechargeBillGooglePaidStatsDTO struct {
GoogleBillCount int64 `json:"googleBillCount"`
SyncedCount int64 `json:"syncedCount"`
UnsyncedCount int64 `json:"unsyncedCount"`
CoveredUsdMinor int64 `json:"coveredUsdMinor"`
EstFeeUsdMinor int64 `json:"estFeeUsdMinor"`
EstTaxUsdMinor int64 `json:"estTaxUsdMinor"`
EstNetUsdMinor int64 `json:"estNetUsdMinor"`
}
type rechargeBillOverviewDTO struct {
Daily []rechargeBillDailyBucketDTO `json:"daily"`
Regions []rechargeBillRegionBucketDTO `json:"regions"`
GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"`
}
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// 财务系统统一按中国时区切日边界。
tzOffsetMinutes = 480
}
if source, ok := h.billSources[appCode]; ok {
overview, err := source.Overview(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
}, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
response.OK(c, overview)
return
}
resp, err := h.wallet.GetRechargeBillOverview(c.Request.Context(), &walletv1.GetRechargeBillOverviewRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
})
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
}
overview := rechargeBillOverviewDTO{
Daily: make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily())),
Regions: make([]rechargeBillRegionBucketDTO, 0, len(resp.GetRegions())),
}
for _, bucket := range resp.GetDaily() {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
Date: bucket.GetDate(),
GoogleUsdMinor: bucket.GetGoogleUsdMinor(),
ThirdPartyUsdMinor: bucket.GetThirdPartyUsdMinor(),
CoinSellerUsdMinor: bucket.GetCoinSellerUsdMinor(),
GoogleCoinAmount: bucket.GetGoogleCoinAmount(),
ThirdPartyCoinAmount: bucket.GetThirdPartyCoinAmount(),
CoinSellerCoinAmount: bucket.GetCoinSellerCoinAmount(),
})
}
// 区域名从后台区域目录补齐;目录缺失时回退区域 ID 文本,避免概览页出现空白区块。
regionNames := map[int64]string{}
if regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode); err == nil {
for _, region := range regions {
name := region.Name
if name == "" {
name = region.RegionCode
}
regionNames[region.RegionID] = name
}
}
for _, bucket := range resp.GetRegions() {
overview.Regions = append(overview.Regions, rechargeBillRegionBucketDTO{
RegionID: bucket.GetRegionId(),
Name: regionNames[bucket.GetRegionId()],
BillCount: bucket.GetBillCount(),
UsdMinorAmount: bucket.GetUsdMinorAmount(),
})
}
if stats := resp.GetGooglePaid(); stats != nil {
overview.GooglePaid = rechargeBillGooglePaidStatsDTO{
GoogleBillCount: stats.GetGoogleBillCount(),
SyncedCount: stats.GetSyncedCount(),
UnsyncedCount: stats.GetUnsyncedCount(),
CoveredUsdMinor: stats.GetCoveredUsdMinor(),
EstFeeUsdMinor: stats.GetEstFeeUsdMinor(),
EstTaxUsdMinor: stats.GetEstTaxUsdMinor(),
EstNetUsdMinor: stats.GetEstNetUsdMinor(),
}
}
response.OK(c, overview)
}

View File

@ -53,12 +53,14 @@ type googleRechargePaidDTO struct {
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
options := shared.ListOptions(c)
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: options.Page,
PageSize: options.PageSize,
})
if err != nil {
response.ServerError(c, "获取账单列表失败")
@ -146,8 +148,18 @@ func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
return
}
appCode := appctx.FromContext(c.Request.Context())
if _, ok := h.billSources[appCode]; ok {
response.BadRequest(c, "该 App 的账单来自外部系统,不支持谷歌实付同步")
if source, ok := h.billSources[appCode]; ok {
if !source.SupportsGooglePaidSync() {
response.BadRequest(c, "该 App 未配置 Google Play 包名和服务账号,无法同步谷歌实付")
return
}
results, err := source.RefreshGooglePaidDetails(c.Request.Context(), request.TransactionIDs)
if err != nil {
response.ServerError(c, "查询谷歌实付明细失败")
return
}
shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "admin_legacy_google_paid_details", "success", appCode)
response.OK(c, gin.H{"items": results})
return
}
resp, err := h.wallet.RefreshGooglePaymentPrices(c.Request.Context(), &walletv1.RefreshGooglePaymentPricesRequest{

View File

@ -13,6 +13,7 @@ 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.GET("/admin/payment/recharge-bills/overview", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillOverview)
protected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:view"), h.ExportRechargeBills)
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)

View File

@ -0,0 +1,92 @@
package repository
import (
"strings"
"hyapp-admin-server/internal/model"
"gorm.io/gorm/clause"
)
// UpsertLegacyGooglePaidDetail 写入或覆盖 legacy 谷歌账单实付明细;同一账单重复同步以最新结果为准。
func (s *Store) UpsertLegacyGooglePaidDetail(detail model.LegacyGooglePaidDetail) error {
detail.AppCode = strings.ToLower(strings.TrimSpace(detail.AppCode))
detail.TransactionID = strings.TrimSpace(detail.TransactionID)
return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_code"}, {Name: "transaction_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"provider_order_id", "paid_currency_code", "paid_amount_micro",
"paid_tax_micro", "paid_net_micro", "bill_usd_minor", "bill_created_at_ms",
"synced_at_ms", "updated_at_ms",
}),
}).Create(&detail).Error
}
// LegacyGooglePaidStats 是 legacy 谷歌实付缓存在指定账单时间段内的聚合口径。
type LegacyGooglePaidStats struct {
SyncedCount int64
CoveredUSDMinor int64
EstFeeUSDMinor int64
EstTaxUSDMinor int64
EstNetUSDMinor int64
}
// LegacyGooglePaidStatsByBillTime 按账单创建时间段聚合已同步实付:税/费/净收按实付占比折算到账单美金口径。
func (s *Store) LegacyGooglePaidStatsByBillTime(appCode string, startAtMS int64, endAtMS int64) (LegacyGooglePaidStats, error) {
where := "app_code = ? AND paid_amount_micro > 0 AND paid_net_micro > 0"
args := []any{strings.ToLower(strings.TrimSpace(appCode))}
if startAtMS > 0 {
where += " AND bill_created_at_ms >= ?"
args = append(args, startAtMS)
}
if endAtMS > 0 {
where += " AND bill_created_at_ms < ?"
args = append(args, endAtMS)
}
var stats LegacyGooglePaidStats
row := s.db.Raw(`
SELECT COUNT(*),
COALESCE(SUM(bill_usd_minor), 0),
COALESCE(SUM(ROUND(bill_usd_minor * GREATEST(paid_amount_micro - paid_net_micro - paid_tax_micro, 0) / paid_amount_micro)), 0),
COALESCE(SUM(ROUND(bill_usd_minor * paid_tax_micro / paid_amount_micro)), 0),
COALESCE(SUM(ROUND(bill_usd_minor * paid_net_micro / paid_amount_micro)), 0)
FROM admin_legacy_google_paid_details
WHERE `+where, args...).Row()
if err := row.Scan(&stats.SyncedCount, &stats.CoveredUSDMinor, &stats.EstFeeUSDMinor, &stats.EstTaxUSDMinor, &stats.EstNetUSDMinor); err != nil {
return LegacyGooglePaidStats{}, err
}
return stats, nil
}
// LegacyGooglePaidTransactionIDs 返回该 App 已同步实付的账单交易号集合,供“未同步”视图在 Mongo 侧做排除。
func (s *Store) LegacyGooglePaidTransactionIDs(appCode string) ([]string, error) {
ids := []string{}
err := s.db.Model(&model.LegacyGooglePaidDetail{}).
Where("app_code = ?", strings.ToLower(strings.TrimSpace(appCode))).
Pluck("transaction_id", &ids).Error
return ids, err
}
// LegacyGooglePaidDetails 按交易号批量读取实付明细,供财务明细列表按页回填。
func (s *Store) LegacyGooglePaidDetails(appCode string, transactionIDs []string) (map[string]model.LegacyGooglePaidDetail, error) {
details := map[string]model.LegacyGooglePaidDetail{}
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 details, nil
}
rows := []model.LegacyGooglePaidDetail{}
if err := s.db.
Where("app_code = ? AND transaction_id IN ?", strings.ToLower(strings.TrimSpace(appCode)), cleaned).
Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
details[row.TransactionID] = row
}
return details, nil
}

View File

@ -81,6 +81,7 @@ func (s *Store) AutoMigrate() error {
&model.DataScope{},
&model.UserMoneyScope{},
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},
&model.FinanceApplication{},
&model.UserWithdrawalApplication{},
&model.AdminJob{},

View File

@ -0,0 +1,20 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- legacy AppYumi/Aslan的谷歌账单实付明细缓存likei 平台 Mongo 对 admin 只读,
-- Play Orders API 同步到的实付币种/总额/税/净收入落在 admin 库,按账单交易号回填财务充值明细。
CREATE TABLE IF NOT EXISTS admin_legacy_google_paid_details (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码yumi/aslan',
transaction_id VARCHAR(96) NOT NULL COMMENT 'legacy 内购明细内部订单 IDin_app_purchase_details._id',
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Google 订单号GPA.xxx',
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 扣费后开发者净收入微单位(实付币种)',
bill_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '账单美金流水快照(美分),同步时从 legacy 账单带入',
bill_created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '账单创建时间快照UTC epoch ms',
synced_at_ms BIGINT NOT NULL COMMENT '实付明细同步时间UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, transaction_id),
KEY idx_legacy_google_paid_bill_time (app_code, bill_created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='legacy App 谷歌账单实付明细缓存';

View File

@ -62,10 +62,48 @@ type ListRechargeBillsQuery struct {
RechargeType string
Status string
Keyword string
StartAtMS int64
EndAtMS int64
Page int32
PageSize int32
// PaidState=unsynced 只保留实付明细未同步的谷歌账单。
PaidState string
StartAtMS int64
EndAtMS int64
Page int32
PageSize int32
}
// RechargeBillDailyBucket 是充值趋势的按日聚合桶;日期按查询时区切边界。
type RechargeBillDailyBucket struct {
Date string
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
CoinSellerUSDMinor int64
GoogleCoinAmount int64
ThirdPartyCoinAmount int64
CoinSellerCoinAmount int64
}
// RechargeBillRegionBucket 是按区域聚合的充值分布(用户充值按目标区域,币商按币商区域)。
type RechargeBillRegionBucket struct {
RegionID int64
BillCount int64
USDMinorAmount int64
}
// RechargeBillGooglePaidStats 是谷歌实付同步覆盖度与扣费估算USD 最小单位)。
type RechargeBillGooglePaidStats struct {
GoogleBillCount int64
SyncedCount int64
UnsyncedCount int64
CoveredUSDMinor int64
EstFeeUSDMinor int64
EstTaxUSDMinor int64
EstNetUSDMinor int64
}
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布与谷歌扣费口径。
type RechargeBillOverview struct {
Daily []RechargeBillDailyBucket
Regions []RechargeBillRegionBucket
GooglePaid RechargeBillGooglePaidStats
}
// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。

View File

@ -95,6 +95,7 @@ type GameLedgerStore interface {
type RechargeStore interface {
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
SummarizeRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error)
GetRechargeBillOverview(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMinutes int32) (ledger.RechargeBillOverview, 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)

View File

@ -30,6 +30,16 @@ func (s *Service) GetRechargeBillSummary(ctx context.Context, query ledger.ListR
return s.repository.SummarizeRechargeBills(ctx, query)
}
// GetRechargeBillOverview 汇总财务概览的按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (s *Service) GetRechargeBillOverview(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMinutes int32) (ledger.RechargeBillOverview, error) {
if s.repository == nil {
return ledger.RechargeBillOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.GetRechargeBillOverview(ctx, query, tzOffsetMinutes)
}
// GooglePaymentPriceResult 是单笔 Google 账单实付明细刷新结果Err 非空表示该笔刷新失败但不影响其他账单。
type GooglePaymentPriceResult struct {
TransactionID string

View File

@ -0,0 +1,241 @@
package mysql
import (
"context"
"sort"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
const dayMS = int64(86_400_000)
// GetRechargeBillOverview 汇总财务概览所需的按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (r *Repository) GetRechargeBillOverview(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMinutes int32) (ledger.RechargeBillOverview, error) {
if r == nil || r.db == nil {
return ledger.RechargeBillOverview{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, query.AppCode)
query = normalizeRechargeBillsQuery(query)
query.AppCode = appcode.FromContext(ctx)
tzOffsetMS := int64(tzOffsetMinutes) * 60_000
overview := ledger.RechargeBillOverview{}
dailyBuckets := map[int64]*ledger.RechargeBillDailyBucket{}
regionBuckets := map[int64]*ledger.RechargeBillRegionBucket{}
includeRecords, includeStock := rechargeBillBranches(query)
if includeRecords {
if err := r.overviewRecordDaily(ctx, query, tzOffsetMS, dailyBuckets); err != nil {
return ledger.RechargeBillOverview{}, err
}
if err := r.overviewRecordRegions(ctx, query, regionBuckets); err != nil {
return ledger.RechargeBillOverview{}, err
}
}
if includeStock {
if err := r.overviewStockDaily(ctx, query, tzOffsetMS, dailyBuckets); err != nil {
return ledger.RechargeBillOverview{}, err
}
if err := r.overviewStockRegions(ctx, query, regionBuckets); err != nil {
return ledger.RechargeBillOverview{}, err
}
}
if includeRecords && (query.RechargeType == "" || query.RechargeType == bizTypeGooglePlayRecharge) {
stats, err := r.overviewGooglePaidStats(ctx, query)
if err != nil {
return ledger.RechargeBillOverview{}, err
}
overview.GooglePaid = stats
}
dayIndexes := make([]int64, 0, len(dailyBuckets))
for dayIndex := range dailyBuckets {
dayIndexes = append(dayIndexes, dayIndex)
}
sort.Slice(dayIndexes, func(i, j int) bool { return dayIndexes[i] < dayIndexes[j] })
for _, dayIndex := range dayIndexes {
bucket := dailyBuckets[dayIndex]
// day_index 已按查询时区平移,再按 UTC 取日期即得到该时区的日历日。
bucket.Date = time.UnixMilli(dayIndex * dayMS).UTC().Format("2006-01-02")
overview.Daily = append(overview.Daily, *bucket)
}
for _, bucket := range regionBuckets {
overview.Regions = append(overview.Regions, *bucket)
}
sort.Slice(overview.Regions, func(i, j int) bool {
return overview.Regions[i].USDMinorAmount > overview.Regions[j].USDMinorAmount
})
return overview, nil
}
func (r *Repository) overviewRecordDaily(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMS int64, buckets map[int64]*ledger.RechargeBillDailyBucket) error {
where, args := rechargeBillsWhereSQL(query)
rows, err := r.db.QueryContext(ctx, `
SELECT FLOOR((rr.created_at_ms + ?) / 86400000) AS day_index,
COALESCE(SUM(CASE WHEN wt.biz_type = ? THEN rr.usd_minor_amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN wt.biz_type = ? THEN rr.coin_amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN wt.biz_type <> ? THEN rr.usd_minor_amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN wt.biz_type <> ? THEN rr.coin_amount ELSE 0 END), 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 day_index`,
append([]any{tzOffsetMS, bizTypeGooglePlayRecharge, bizTypeGooglePlayRecharge, bizTypeGooglePlayRecharge, bizTypeGooglePlayRecharge}, args...)...,
)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var dayIndex, googleUSD, googleCoin, thirdUSD, thirdCoin int64
if err := rows.Scan(&dayIndex, &googleUSD, &googleCoin, &thirdUSD, &thirdCoin); err != nil {
return err
}
bucket := overviewDailyBucket(buckets, dayIndex)
bucket.GoogleUSDMinor += googleUSD
bucket.GoogleCoinAmount += googleCoin
bucket.ThirdPartyUSDMinor += thirdUSD
bucket.ThirdPartyCoinAmount += thirdCoin
}
return rows.Err()
}
func (r *Repository) overviewStockDaily(ctx context.Context, query ledger.ListRechargeBillsQuery, tzOffsetMS int64, buckets map[int64]*ledger.RechargeBillDailyBucket) error {
where, args := rechargeBillStockWhereSQL(query)
rows, err := r.db.QueryContext(ctx, `
SELECT FLOOR((sr.created_at_ms + ?) / 86400000) AS day_index,
COALESCE(SUM(CASE WHEN sr.paid_currency_code IN ('USDT', 'USD') THEN sr.paid_amount_micro DIV 10000 ELSE 0 END), 0),
COALESCE(SUM(sr.coin_amount), 0)
FROM coin_seller_stock_records sr
JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id
`+where+`
GROUP BY day_index`,
append([]any{tzOffsetMS}, args...)...,
)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var dayIndex, usdMinor, coinAmount int64
if err := rows.Scan(&dayIndex, &usdMinor, &coinAmount); err != nil {
return err
}
bucket := overviewDailyBucket(buckets, dayIndex)
bucket.CoinSellerUSDMinor += usdMinor
bucket.CoinSellerCoinAmount += coinAmount
}
return rows.Err()
}
func overviewDailyBucket(buckets map[int64]*ledger.RechargeBillDailyBucket, dayIndex int64) *ledger.RechargeBillDailyBucket {
bucket, ok := buckets[dayIndex]
if !ok {
bucket = &ledger.RechargeBillDailyBucket{}
buckets[dayIndex] = bucket
}
return bucket
}
func (r *Repository) overviewRecordRegions(ctx context.Context, query ledger.ListRechargeBillsQuery, buckets map[int64]*ledger.RechargeBillRegionBucket) error {
where, args := rechargeBillsWhereSQL(query)
rows, err := r.db.QueryContext(ctx, `
SELECT rr.target_region_id, COUNT(*), 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 rr.target_region_id`,
args...,
)
if err != nil {
return err
}
defer rows.Close()
return scanOverviewRegionRows(rows, buckets)
}
func (r *Repository) overviewStockRegions(ctx context.Context, query ledger.ListRechargeBillsQuery, buckets map[int64]*ledger.RechargeBillRegionBucket) error {
where, args := rechargeBillStockWhereSQL(query)
rows, err := r.db.QueryContext(ctx, `
SELECT COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(sr.metadata_json, '$.seller_region_id')) AS SIGNED), 0),
COUNT(*),
COALESCE(SUM(CASE WHEN sr.paid_currency_code IN ('USDT', 'USD') THEN sr.paid_amount_micro DIV 10000 ELSE 0 END), 0)
FROM coin_seller_stock_records sr
JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id
`+where+`
GROUP BY 1`,
args...,
)
if err != nil {
return err
}
defer rows.Close()
return scanOverviewRegionRows(rows, buckets)
}
type overviewRegionRows interface {
Next() bool
Scan(dest ...any) error
Err() error
}
func scanOverviewRegionRows(rows overviewRegionRows, buckets map[int64]*ledger.RechargeBillRegionBucket) error {
for rows.Next() {
var regionID, billCount, usdMinor int64
if err := rows.Scan(&regionID, &billCount, &usdMinor); err != nil {
return err
}
if regionID <= 0 {
continue
}
bucket, ok := buckets[regionID]
if !ok {
bucket = &ledger.RechargeBillRegionBucket{RegionID: regionID}
buckets[regionID] = bucket
}
bucket.BillCount += billCount
bucket.USDMinorAmount += usdMinor
}
return rows.Err()
}
// overviewGooglePaidStats 统计谷歌实付同步覆盖度,并把已同步账单的税/费/净收按实付占比折算到 USD 口径。
// 估算只覆盖“已同步且净收入有效”的账单;负差额按 0 处理,避免退款单把手续费冲成负数。
func (r *Repository) overviewGooglePaidStats(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillGooglePaidStats, error) {
googleQuery := query
googleQuery.RechargeType = bizTypeGooglePlayRecharge
googleQuery.PaidState = ""
where, args := rechargeBillsWhereSQL(googleQuery)
row := r.db.QueryRowContext(ctx, `
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN COALESCE(po.paid_synced_at_ms, 0) > 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN po.paid_amount_micro > 0 AND po.paid_net_micro > 0 THEN rr.usd_minor_amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN po.paid_amount_micro > 0 AND po.paid_net_micro > 0
THEN ROUND(rr.usd_minor_amount * GREATEST(po.paid_amount_micro - po.paid_net_micro - po.paid_tax_micro, 0) / po.paid_amount_micro) ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN po.paid_amount_micro > 0 AND po.paid_net_micro > 0
THEN ROUND(rr.usd_minor_amount * po.paid_tax_micro / po.paid_amount_micro) ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN po.paid_amount_micro > 0 AND po.paid_net_micro > 0
THEN ROUND(rr.usd_minor_amount * po.paid_net_micro / po.paid_amount_micro) ELSE 0 END), 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 payment_orders po ON po.app_code = rr.app_code AND po.wallet_transaction_id = rr.transaction_id
`+where,
args...,
)
var stats ledger.RechargeBillGooglePaidStats
if err := row.Scan(
&stats.GoogleBillCount,
&stats.SyncedCount,
&stats.CoveredUSDMinor,
&stats.EstFeeUSDMinor,
&stats.EstTaxUSDMinor,
&stats.EstNetUSDMinor,
); err != nil {
return ledger.RechargeBillGooglePaidStats{}, err
}
stats.UnsyncedCount = stats.GoogleBillCount - stats.SyncedCount
return stats, nil
}

View File

@ -162,6 +162,10 @@ func rechargeBillBranches(query ledger.ListRechargeBillsQuery) (includeRecords b
if query.UserID > 0 {
includeStock = false
}
// 实付同步状态是谷歌账单专属维度。
if query.PaidState == rechargeBillPaidStateUnsynced {
includeStock = false
}
return includeRecords, includeStock
}
@ -378,6 +382,7 @@ func normalizeRechargeBillsQuery(query ledger.ListRechargeBillsQuery) ledger.Lis
query.AppCode = appcode.Normalize(query.AppCode)
query.RechargeType = strings.ToLower(strings.TrimSpace(query.RechargeType))
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.PaidState = strings.ToLower(strings.TrimSpace(query.PaidState))
query.Keyword = strings.TrimSpace(query.Keyword)
query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
if query.EndAtMS > 0 && query.StartAtMS > query.EndAtMS {
@ -430,5 +435,14 @@ func rechargeBillsWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any)
where += ` AND (rr.transaction_id LIKE ? OR wt.command_id LIKE ? OR wt.external_ref LIKE ? OR rr.policy_version LIKE ?)`
args = append(args, like, like, like, like)
}
if query.PaidState == rechargeBillPaidStateUnsynced {
// “实付未同步”只对谷歌账单有意义;用 NOT EXISTS 表达,列表与计数共用同一片段,不依赖外层 join。
where += ` AND wt.biz_type = ? AND NOT EXISTS (
SELECT 1 FROM payment_orders po2
WHERE po2.app_code = rr.app_code AND po2.wallet_transaction_id = rr.transaction_id AND po2.paid_synced_at_ms > 0)`
args = append(args, bizTypeGooglePlayRecharge)
}
return where, args
}
const rechargeBillPaidStateUnsynced = "unsynced"

View File

@ -20,6 +20,7 @@ func (s *Server) ListRechargeBills(ctx context.Context, req *walletv1.ListRechar
RechargeType: req.GetRechargeType(),
Status: req.GetStatus(),
Keyword: req.GetKeyword(),
PaidState: req.GetPaidState(),
StartAtMS: req.GetStartAtMs(),
EndAtMS: req.GetEndAtMs(),
Page: req.GetPage(),
@ -61,6 +62,54 @@ func (s *Server) GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRe
}, nil
}
// GetRechargeBillOverview 暴露财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度。
func (s *Server) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetRechargeBillOverviewRequest) (*walletv1.GetRechargeBillOverviewResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
overview, err := s.svc.GetRechargeBillOverview(ctx, ledger.ListRechargeBillsQuery{
AppCode: req.GetAppCode(),
RegionID: req.GetRegionId(),
RechargeType: req.GetRechargeType(),
Status: req.GetStatus(),
StartAtMS: req.GetStartAtMs(),
EndAtMS: req.GetEndAtMs(),
}, req.GetTzOffsetMinutes())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &walletv1.GetRechargeBillOverviewResponse{
Daily: make([]*walletv1.RechargeBillDailyBucket, 0, len(overview.Daily)),
Regions: make([]*walletv1.RechargeBillRegionBucket, 0, len(overview.Regions)),
GooglePaid: &walletv1.RechargeBillGooglePaidStats{
GoogleBillCount: overview.GooglePaid.GoogleBillCount,
SyncedCount: overview.GooglePaid.SyncedCount,
UnsyncedCount: overview.GooglePaid.UnsyncedCount,
CoveredUsdMinor: overview.GooglePaid.CoveredUSDMinor,
EstFeeUsdMinor: overview.GooglePaid.EstFeeUSDMinor,
EstTaxUsdMinor: overview.GooglePaid.EstTaxUSDMinor,
EstNetUsdMinor: overview.GooglePaid.EstNetUSDMinor,
},
}
for _, bucket := range overview.Daily {
resp.Daily = append(resp.Daily, &walletv1.RechargeBillDailyBucket{
Date: bucket.Date,
GoogleUsdMinor: bucket.GoogleUSDMinor,
ThirdPartyUsdMinor: bucket.ThirdPartyUSDMinor,
CoinSellerUsdMinor: bucket.CoinSellerUSDMinor,
GoogleCoinAmount: bucket.GoogleCoinAmount,
ThirdPartyCoinAmount: bucket.ThirdPartyCoinAmount,
CoinSellerCoinAmount: bucket.CoinSellerCoinAmount,
})
}
for _, bucket := range overview.Regions {
resp.Regions = append(resp.Regions, &walletv1.RechargeBillRegionBucket{
RegionId: bucket.RegionID,
BillCount: bucket.BillCount,
UsdMinorAmount: bucket.USDMinorAmount,
})
}
return resp, nil
}
// RefreshGooglePaymentPrices 触发 Google Orders API 实付明细同步;单笔失败不阻断整批。
func (s *Server) RefreshGooglePaymentPrices(ctx context.Context, req *walletv1.RefreshGooglePaymentPricesRequest) (*walletv1.RefreshGooglePaymentPricesResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())