Yumi和 Aslan的财务数据
This commit is contained in:
parent
27773290b5
commit
786b36afb2
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
258
server/admin/internal/integration/googleorders/client.go
Normal file
258
server/admin/internal/integration/googleorders/client.go
Normal file
@ -0,0 +1,258 @@
|
||||
// Package googleorders 提供 Play Developer API Orders 资源的最小只读客户端。
|
||||
// hyapp 自有 App(Lalu 等)的实付同步走 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -417,6 +417,28 @@ func (TemporaryPaymentLinkOwner) TableName() string {
|
||||
return "admin_temporary_payment_link_owners"
|
||||
}
|
||||
|
||||
// LegacyGooglePaidDetail 缓存 legacy App(Yumi/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"`
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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 解析账单的区域归属国家:优先账单自身 countryCode(Aslan 的 MifaPay 单会带),
|
||||
// 为空时回退付款用户 user_run_profile.countryCode(Yumi/Aslan 的谷歌单都不带国家码);codes 为空时不追加任何阶段。
|
||||
func legacyRegionLookupStages(codes []string) mongo.Pipeline {
|
||||
if len(codes) == 0 {
|
||||
return mongo.Pipeline{}
|
||||
}
|
||||
// legacyCountryResolveStages 解析账单的区域归属国家:优先账单自身 countryCode(Aslan 的 MifaPay 单会带),
|
||||
// 为空时回退付款用户 user_run_profile.countryCode(Yumi/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)
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"),
|
||||
|
||||
134
server/admin/internal/modules/payment/recharge_bill_overview.go
Normal file
134
server/admin/internal/modules/payment/recharge_bill_overview.go
Normal 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)
|
||||
}
|
||||
@ -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{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -81,6 +81,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.DataScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
&model.FinanceApplication{},
|
||||
&model.UserWithdrawalApplication{},
|
||||
&model.AdminJob{},
|
||||
|
||||
20
server/admin/migrations/074_legacy_google_paid_details.sql
Normal file
20
server/admin/migrations/074_legacy_google_paid_details.sql
Normal file
@ -0,0 +1,20 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- legacy App(Yumi/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 内购明细内部订单 ID(in_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 谷歌账单实付明细缓存';
|
||||
@ -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 固定返回,后续可迁移到配置表。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(®ionID, &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
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user