fix: continuously sync Google paid orders

This commit is contained in:
zhx 2026-07-11 01:17:49 +08:00
parent d02d9ada63
commit c2bbfa87f5
27 changed files with 962 additions and 80 deletions

View File

@ -0,0 +1,7 @@
-- Durable Google paid-detail retry state for wallet-service.
-- Existing paid_synced_at_ms=0 rows become immediately eligible for the 15-second worker.
ALTER TABLE payment_orders
ADD COLUMN paid_sync_attempt_count INT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步连续失败次数' AFTER paid_synced_at_ms,
ADD COLUMN paid_sync_last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Google 实付同步最近错误' AFTER paid_sync_attempt_count,
ADD COLUMN paid_sync_next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步下次重试时间UTC epoch ms' AFTER paid_sync_last_error,
ADD INDEX idx_payment_orders_paid_sync (provider, paid_synced_at_ms, paid_sync_next_retry_at_ms, created_at_ms);

View File

@ -216,6 +216,13 @@ func main() {
}
// legacy 账单源的谷歌实付同步依赖 admin 库缓存表store 建好后再注入;凭证异常只降级该 App不阻断启动。
wireLegacyGooglePaidSync(financeBillSources, cfg.FinanceBillSources, store)
if cfg.FinanceGooglePaidSync.Enabled {
// Polling is process-owned rather than HTTP-owned: startup immediately drains one batch, then
// every 15 seconds prioritizes newest orders while gradually repairing the complete backlog.
googlePaidSyncWorker := paymentmodule.NewLegacyGooglePaidSyncWorker(financeBillSources, store, cfg.FinanceGooglePaidSync)
googlePaidSyncWorker.Start()
defer googlePaidSyncWorker.Close()
}
if *runBootstrap || cfg.Bootstrap.Enabled {
if err := store.Seed(cfg); err != nil {
fatalRuntime("seed_database_failed", err)

View File

@ -55,6 +55,11 @@ finance_bill_sources:
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"}
finance_google_paid_sync:
enabled: true
poll_interval: "15s"
batch_size: 100
request_timeout: "2m"
mysql_auto_migrate: false
migrations:
enabled: true

View File

@ -55,6 +55,11 @@ finance_bill_sources:
google_package_name: ""
google_service_account_json: ""
google_service_account_file: ""
finance_google_paid_sync:
enabled: true
poll_interval: "15s"
batch_size: 100
request_timeout: "2m"
mysql_auto_migrate: true
migrations:
enabled: true

View File

@ -13,17 +13,19 @@ import (
)
type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
// FinanceGooglePaidSync controls the durable polling loop that fills Google Orders API paid details for legacy bill sources.
FinanceGooglePaidSync FinanceGooglePaidSyncConfig `yaml:"finance_google_paid_sync"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
@ -173,6 +175,15 @@ type FinanceBillSourceConfig struct {
GoogleServiceAccountFile string `yaml:"google_service_account_file"`
}
// FinanceGooglePaidSyncConfig keeps legacy Google paid-detail polling independent from HTTP requests.
// New successful Mongo orders are picked every PollInterval; failed orders use persisted retry backoff.
type FinanceGooglePaidSyncConfig struct {
Enabled bool `yaml:"enabled"`
PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -361,6 +372,12 @@ func Default() Config {
RequestTimeout: 3 * time.Second,
},
},
FinanceGooglePaidSync: FinanceGooglePaidSyncConfig{
Enabled: true,
PollInterval: 15 * time.Second,
BatchSize: 100,
RequestTimeout: 2 * time.Minute,
},
TencentCOS: TencentCOSConfig{
Enabled: false,
ObjectPrefix: "admin",
@ -607,6 +624,19 @@ func (cfg *Config) Normalize() {
}
}
cfg.applyFinanceBillSourceEnvOverrides()
// The sync feature is enabled by default; normalization only bounds operator-provided cadence and batch size.
if cfg.FinanceGooglePaidSync.PollInterval <= 0 {
cfg.FinanceGooglePaidSync.PollInterval = Default().FinanceGooglePaidSync.PollInterval
}
if cfg.FinanceGooglePaidSync.BatchSize <= 0 {
cfg.FinanceGooglePaidSync.BatchSize = Default().FinanceGooglePaidSync.BatchSize
}
if cfg.FinanceGooglePaidSync.BatchSize > 1000 {
cfg.FinanceGooglePaidSync.BatchSize = 1000
}
if cfg.FinanceGooglePaidSync.RequestTimeout <= 0 {
cfg.FinanceGooglePaidSync.RequestTimeout = Default().FinanceGooglePaidSync.RequestTimeout
}
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)

View File

@ -121,40 +121,71 @@ func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) {
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)
body, err := c.getJSON(ctx, endpoint)
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
}
return orderFromResource(parsed), nil
}
// GetOrders uses Orders batchGet for backlog repair: one request can fetch up to 1000 distinct orders.
// The caller falls back to single-order requests if Google rejects the whole batch because of one stale ID.
func (c *Client) GetOrders(ctx context.Context, orderIDs []string) (map[string]Order, error) {
if c == nil {
return nil, errors.New("google orders client is not configured")
}
cleaned := make([]string, 0, len(orderIDs))
seen := make(map[string]struct{}, len(orderIDs))
for _, orderID := range orderIDs {
orderID = strings.TrimSpace(orderID)
if orderID == "" {
continue
}
if _, ok := seen[orderID]; ok {
continue
}
seen[orderID] = struct{}{}
cleaned = append(cleaned, orderID)
}
if len(cleaned) == 0 {
return map[string]Order{}, nil
}
if len(cleaned) > 1000 {
return nil, errors.New("too many google order ids")
}
query := url.Values{}
for _, orderID := range cleaned {
query.Add("orderIds", orderID)
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders:batchGet?%s",
defaultAPIBaseURL, url.PathEscape(c.packageName), query.Encode())
body, err := c.getJSON(ctx, endpoint)
if err != nil {
return nil, err
}
var parsed struct {
Orders []orderResource `json:"orders"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
orders := make(map[string]Order, len(parsed.Orders))
for _, item := range parsed.Orders {
order := orderFromResource(item)
if order.OrderID != "" {
orders[order.OrderID] = order
}
}
return orders, nil
}
func orderFromResource(parsed orderResource) Order {
order := Order{
OrderID: parsed.OrderID,
State: parsed.State,
@ -167,7 +198,39 @@ func (c *Client) GetOrder(ctx context.Context, orderID string) (Order, error) {
if order.CurrencyCode == "" {
order.CurrencyCode = parsed.Tax.CurrencyCode
}
return order, nil
return order
}
func (c *Client) getJSON(ctx context.Context, endpoint string) ([]byte, error) {
token, err := c.token(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, errors.New("google play api request failed")
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
switch resp.StatusCode {
case http.StatusNotFound, http.StatusBadRequest:
return nil, errors.New("google order is not found")
case http.StatusUnauthorized, http.StatusForbidden:
return nil, errors.New("google play api is not authorized (需要查看财务数据权限)")
default:
return nil, fmt.Errorf("google play api returned %d", resp.StatusCode)
}
}
return body, nil
}
func (c *Client) token(ctx context.Context) (string, error) {

View File

@ -551,6 +551,22 @@ func (LegacyGooglePaidDetail) TableName() string {
return "admin_legacy_google_paid_details"
}
// LegacyGooglePaidSyncAttempt persists retry state separately from paid details.
// A row here never means "synced"; successful synchronization deletes it after the paid-detail upsert commits.
type LegacyGooglePaidSyncAttempt struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
TransactionID string `gorm:"size:96;primaryKey;column:transaction_id" json:"transactionId"`
AttemptCount int `gorm:"not null;default:0" json:"attemptCount"`
LastError string `gorm:"size:512;not null;default:''" json:"lastError"`
NextRetryAtMS int64 `gorm:"column:next_retry_at_ms;not null;default:0;index:idx_legacy_google_paid_retry,priority:2" json:"nextRetryAtMs"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli;index:idx_legacy_google_paid_retry,priority:3" json:"updatedAtMs"`
}
func (LegacyGooglePaidSyncAttempt) TableName() string {
return "admin_legacy_google_paid_sync_attempts"
}
type FinanceApplication struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_finance_app_app_status;not null" json:"appCode"`

View File

@ -0,0 +1,141 @@
package payment
import (
"context"
"log/slog"
"strings"
"sync"
"time"
"hyapp-admin-server/internal/config"
)
type legacyGooglePaidSyncSource interface {
AppCode() string
SupportsGooglePaidSync() bool
ListPendingGooglePaidTransactionIDs(ctx context.Context, limit int) ([]string, error)
RefreshGooglePaidDetails(ctx context.Context, transactionIDs []string) ([]googleRechargePaidDTO, error)
}
type legacyGooglePaidSyncAttemptStore interface {
RecordLegacyGooglePaidSyncFailure(appCode string, transactionID string, message string, nowMS int64) error
ClearLegacyGooglePaidSyncFailure(appCode string, transactionID string) error
}
// LegacyGooglePaidSyncWorker polls every 15 seconds independently of finance-page traffic.
// Successful rows leave the pending set through the paid cache; failures remain durable with backoff.
type LegacyGooglePaidSyncWorker struct {
batchSize int
pollInterval time.Duration
requestTimeout time.Duration
sources []legacyGooglePaidSyncSource
store legacyGooglePaidSyncAttemptStore
closeOnce sync.Once
done chan struct{}
stop chan struct{}
}
func NewLegacyGooglePaidSyncWorker(sources []RechargeBillSource, store legacyGooglePaidSyncAttemptStore, cfg config.FinanceGooglePaidSyncConfig) *LegacyGooglePaidSyncWorker {
narrow := make([]legacyGooglePaidSyncSource, 0, len(sources))
for _, source := range sources {
if candidate, ok := source.(legacyGooglePaidSyncSource); ok && candidate.SupportsGooglePaidSync() {
narrow = append(narrow, candidate)
}
}
return newLegacyGooglePaidSyncWorker(narrow, store, cfg)
}
func newLegacyGooglePaidSyncWorker(sources []legacyGooglePaidSyncSource, store legacyGooglePaidSyncAttemptStore, cfg config.FinanceGooglePaidSyncConfig) *LegacyGooglePaidSyncWorker {
return &LegacyGooglePaidSyncWorker{
batchSize: cfg.BatchSize,
pollInterval: cfg.PollInterval,
requestTimeout: cfg.RequestTimeout,
sources: sources,
store: store,
done: make(chan struct{}),
stop: make(chan struct{}),
}
}
func (w *LegacyGooglePaidSyncWorker) Start() {
if w == nil || w.store == nil || len(w.sources) == 0 {
return
}
go w.loop()
}
func (w *LegacyGooglePaidSyncWorker) Close() {
if w == nil || w.store == nil || len(w.sources) == 0 {
return
}
w.closeOnce.Do(func() {
close(w.stop)
<-w.done
})
}
func (w *LegacyGooglePaidSyncWorker) loop() {
defer close(w.done)
ticker := time.NewTicker(w.pollInterval)
defer ticker.Stop()
w.runOnce(context.Background())
for {
select {
case <-ticker.C:
w.runOnce(context.Background())
case <-w.stop:
return
}
}
}
func (w *LegacyGooglePaidSyncWorker) runOnce(parent context.Context) {
for _, source := range w.sources {
// Ten full batches per tick drain historical data quickly without turning the steady-state
// 15-second poller into an unbounded loop when a provider keeps returning pathological data.
for drainBatch := 0; drainBatch < 10; drainBatch++ {
ctx, cancel := context.WithTimeout(parent, w.requestTimeout)
transactionIDs, err := source.ListPendingGooglePaidTransactionIDs(ctx, w.batchSize)
if err != nil {
cancel()
slog.Error("legacy_google_paid_sync_scan_failed", "app_code", source.AppCode(), "error", err.Error())
break
}
if len(transactionIDs) == 0 {
cancel()
break
}
results, err := source.RefreshGooglePaidDetails(ctx, transactionIDs)
cancel()
if err != nil {
slog.Error("legacy_google_paid_sync_batch_failed", "app_code", source.AppCode(), "count", len(transactionIDs), "error", err.Error())
break
}
nowMS := time.Now().UnixMilli()
succeeded := 0
failed := 0
for _, result := range results {
transactionID := strings.TrimSpace(result.TransactionID)
if transactionID == "" {
continue
}
if strings.TrimSpace(result.Error) == "" {
if err := w.store.ClearLegacyGooglePaidSyncFailure(source.AppCode(), transactionID); err != nil {
slog.Error("legacy_google_paid_sync_retry_clear_failed", "app_code", source.AppCode(), "transaction_id", transactionID, "error", err.Error())
}
succeeded++
continue
}
failed++
if err := w.store.RecordLegacyGooglePaidSyncFailure(source.AppCode(), transactionID, result.Error, nowMS); err != nil {
slog.Error("legacy_google_paid_sync_retry_store_failed", "app_code", source.AppCode(), "transaction_id", transactionID, "error", err.Error())
}
}
slog.Info("legacy_google_paid_sync_batch_completed", "app_code", source.AppCode(), "claimed", len(transactionIDs), "succeeded", succeeded, "failed", failed)
if len(transactionIDs) < w.batchSize {
break
}
}
}
}

View File

@ -0,0 +1,59 @@
package payment
import (
"context"
"testing"
"time"
"hyapp-admin-server/internal/config"
)
type fakeLegacyGooglePaidSyncSource struct {
pending []string
results []googleRechargePaidDTO
}
func (s *fakeLegacyGooglePaidSyncSource) AppCode() string { return "yumi" }
func (s *fakeLegacyGooglePaidSyncSource) SupportsGooglePaidSync() bool { return true }
func (s *fakeLegacyGooglePaidSyncSource) ListPendingGooglePaidTransactionIDs(context.Context, int) ([]string, error) {
return append([]string(nil), s.pending...), nil
}
func (s *fakeLegacyGooglePaidSyncSource) RefreshGooglePaidDetails(context.Context, []string) ([]googleRechargePaidDTO, error) {
return append([]googleRechargePaidDTO(nil), s.results...), nil
}
type fakeLegacyGooglePaidAttemptStore struct {
cleared []string
failed []string
}
func (s *fakeLegacyGooglePaidAttemptStore) RecordLegacyGooglePaidSyncFailure(_ string, transactionID string, _ string, _ int64) error {
s.failed = append(s.failed, transactionID)
return nil
}
func (s *fakeLegacyGooglePaidAttemptStore) ClearLegacyGooglePaidSyncFailure(_ string, transactionID string) error {
s.cleared = append(s.cleared, transactionID)
return nil
}
func TestLegacyGooglePaidSyncWorkerPersistsPerOrderOutcome(t *testing.T) {
source := &fakeLegacyGooglePaidSyncSource{
pending: []string{"ok-1", "bad-1"},
results: []googleRechargePaidDTO{{TransactionID: "ok-1", SyncedAtMS: 1}, {TransactionID: "bad-1", Error: "temporary"}},
}
store := &fakeLegacyGooglePaidAttemptStore{}
worker := newLegacyGooglePaidSyncWorker([]legacyGooglePaidSyncSource{source}, store, config.FinanceGooglePaidSyncConfig{
PollInterval: 15 * time.Second,
BatchSize: 100,
RequestTimeout: time.Second,
})
worker.runOnce(context.Background())
if len(store.cleared) != 1 || store.cleared[0] != "ok-1" {
t.Fatalf("cleared = %v, want ok-1", store.cleared)
}
if len(store.failed) != 1 || store.failed[0] != "bad-1" {
t.Fatalf("failed = %v, want bad-1", store.failed)
}
}

View File

@ -9,6 +9,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"hyapp-admin-server/internal/appctx"
@ -20,6 +21,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义legacy 源不适用。
@ -48,6 +50,8 @@ type RechargeBillSource interface {
SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error)
// SupportsGooglePaidSync 为 true 时RefreshGooglePaidDetails 用该 App 的 Play Console 服务账号同步实付明细。
SupportsGooglePaidSync() bool
// ListPendingGooglePaidTransactionIDs returns newest successful Google bills that still need paid-detail synchronization.
ListPendingGooglePaidTransactionIDs(ctx context.Context, limit int) ([]string, error)
RefreshGooglePaidDetails(ctx context.Context, transactionIDs []string) ([]googleRechargePaidDTO, error)
// Overview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度。
Overview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (rechargeBillOverviewDTO, error)
@ -64,6 +68,7 @@ type LegacyGooglePaidStore interface {
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)
LegacyGooglePaidRetryBlockedTransactionIDs(appCode string, nowMS int64) ([]string, error)
}
// legacyGoogleOrdersClient 按 legacy App 各自的 Play Console 服务账号查订单实付。
@ -71,6 +76,10 @@ type legacyGoogleOrdersClient interface {
GetOrder(ctx context.Context, orderID string) (googleorders.Order, error)
}
type legacyGoogleOrdersBatchClient interface {
GetOrders(ctx context.Context, orderIDs []string) (map[string]googleorders.Order, error)
}
// errLegacyGooglePaidUnsupported 表示该账单源未配置谷歌包名/服务账号或实付缓存存储。
var errLegacyGooglePaidUnsupported = errors.New("legacy google paid sync is not configured")
@ -143,6 +152,83 @@ func (s *MongoRechargeBillSource) SupportsGooglePaidSync() bool {
return s != nil && s.paidStore != nil && s.googleOrders != nil
}
// ListPendingGooglePaidTransactionIDs scans the read-only legacy Mongo source and subtracts both
// successful cache rows and failures still inside persisted backoff. Sorting newest-first keeps the
// 15-second loop current while successful removals naturally drain the complete historical backlog.
func (s *MongoRechargeBillSource) ListPendingGooglePaidTransactionIDs(ctx context.Context, limit int) ([]string, error) {
if !s.SupportsGooglePaidSync() {
return nil, errLegacyGooglePaidUnsupported
}
if limit <= 0 {
return []string{}, nil
}
if limit > 1000 {
limit = 1000
}
filter, _, matchable, err := s.billFilter(ctx, legacyRechargeBillQuery{RechargeType: "google_play_recharge"})
if err != nil || !matchable {
return nil, err
}
syncedIDs, err := s.paidStore.LegacyGooglePaidTransactionIDs(s.config.AppCode)
if err != nil {
return nil, fmt.Errorf("load synced legacy google transactions for %s: %w", s.config.AppCode, err)
}
blockedIDs, err := s.paidStore.LegacyGooglePaidRetryBlockedTransactionIDs(s.config.AppCode, time.Now().UnixMilli())
if err != nil {
return nil, fmt.Errorf("load blocked legacy google transactions for %s: %w", s.config.AppCode, err)
}
excluded := append(syncedIDs, blockedIDs...)
if values := legacyTransactionIDFilterValues(excluded); len(values) > 0 {
filter["_id"] = bson.M{"$nin": values}
}
cursor, err := s.collection.Find(ctx, filter, options.Find().
SetProjection(bson.M{"_id": 1}).
SetSort(bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}).
SetLimit(int64(limit)))
if err != nil {
return nil, fmt.Errorf("list pending legacy google bills for %s: %w", s.config.AppCode, err)
}
defer cursor.Close(ctx)
ids := make([]string, 0, limit)
for cursor.Next(ctx) {
var row struct {
ID any `bson:"_id"`
}
if err := cursor.Decode(&row); err != nil {
return nil, fmt.Errorf("decode pending legacy google bill for %s: %w", s.config.AppCode, err)
}
if transactionID := legacyDocumentID(row.ID); transactionID != "" {
ids = append(ids, transactionID)
}
}
if err := cursor.Err(); err != nil {
return nil, fmt.Errorf("iterate pending legacy google bills for %s: %w", s.config.AppCode, err)
}
return ids, nil
}
// legacyTransactionIDFilterValues covers both string IDs and ObjectID-backed collections.
// The paid cache stores a stable string representation, so both BSON shapes must be excluded.
func legacyTransactionIDFilterValues(transactionIDs []string) bson.A {
values := make(bson.A, 0, len(transactionIDs)*2)
seen := make(map[string]struct{}, len(transactionIDs))
for _, transactionID := range transactionIDs {
transactionID = strings.TrimSpace(transactionID)
if transactionID == "" {
continue
}
if _, ok := seen[transactionID]; ok {
continue
}
seen[transactionID] = struct{}{}
values = append(values, transactionID)
if objectID, err := bson.ObjectIDFromHex(transactionID); err == nil {
values = append(values, objectID)
}
}
return values
}
func (s *MongoRechargeBillSource) AppCode() string {
return s.config.AppCode
}
@ -503,14 +589,59 @@ func (s *MongoRechargeBillSource) RefreshGooglePaidDetails(ctx context.Context,
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))
// Backfills use batchGet whenever every order can be resolved in one Google request. Google rejects
// an entire batch containing one stale ID, so failures fall back to bounded concurrent single gets.
prefetched := map[string]googleorders.Order{}
batchReady := false
if batchClient, ok := s.googleOrders.(legacyGoogleOrdersBatchClient); ok {
orderIDs := make([]string, 0, len(bills))
for _, transactionID := range cleaned {
if document, exists := bills[transactionID]; exists {
if orderID := strings.TrimSpace(document.OrderID); orderID != "" {
orderIDs = append(orderIDs, orderID)
}
}
}
if len(orderIDs) > 0 {
if orders, batchErr := batchClient.GetOrders(ctx, orderIDs); batchErr == nil {
prefetched = orders
batchReady = true
}
}
}
results := make([]googleRechargePaidDTO, len(cleaned))
if batchReady {
for index, transactionID := range cleaned {
results[index] = s.refreshGooglePaidDetailWithOrders(ctx, transactionID, bills, prefetched)
}
return results, nil
}
const fallbackConcurrency = 8
semaphore := make(chan struct{}, fallbackConcurrency)
var wait sync.WaitGroup
for index, transactionID := range cleaned {
wait.Add(1)
go func(index int, transactionID string) {
defer wait.Done()
select {
case semaphore <- struct{}{}:
defer func() { <-semaphore }()
case <-ctx.Done():
results[index] = googleRechargePaidDTO{TransactionID: transactionID, Error: ctx.Err().Error()}
return
}
results[index] = s.refreshGooglePaidDetail(ctx, transactionID, bills)
}(index, transactionID)
}
wait.Wait()
return results, nil
}
func (s *MongoRechargeBillSource) refreshGooglePaidDetail(ctx context.Context, transactionID string, bills map[string]legacyPurchaseDocument) googleRechargePaidDTO {
return s.refreshGooglePaidDetailWithOrders(ctx, transactionID, bills, nil)
}
func (s *MongoRechargeBillSource) refreshGooglePaidDetailWithOrders(ctx context.Context, transactionID string, bills map[string]legacyPurchaseDocument, prefetched map[string]googleorders.Order) googleRechargePaidDTO {
result := googleRechargePaidDTO{TransactionID: transactionID}
document, ok := bills[transactionID]
if !ok {
@ -526,11 +657,19 @@ func (s *MongoRechargeBillSource) refreshGooglePaidDetail(ctx context.Context, t
result.Error = "缺少谷歌订单号"
return result
}
order, err := s.googleOrders.GetOrder(ctx, orderID)
if err != nil {
result.Error = err.Error()
order, prefetchedOK := prefetched[orderID]
if prefetched != nil && !prefetchedOK {
result.Error = "google order is not found"
return result
}
if !prefetchedOK {
var err error
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() {
@ -703,11 +842,7 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
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)
}
if idValues := legacyTransactionIDFilterValues(syncedIDs); len(idValues) > 0 {
filter["_id"] = bson.M{"$nin": idValues}
}
}

View File

@ -2,12 +2,18 @@ package repository
import (
"strings"
"time"
"hyapp-admin-server/internal/model"
"gorm.io/gorm/clause"
)
const (
legacyGooglePaidRetryBaseMS = int64((15 * time.Second) / time.Millisecond)
legacyGooglePaidRetryMaxMS = int64((15 * time.Minute) / time.Millisecond)
)
// UpsertLegacyGooglePaidDetail 写入或覆盖 legacy 谷歌账单实付明细;同一账单重复同步以最新结果为准。
func (s *Store) UpsertLegacyGooglePaidDetail(detail model.LegacyGooglePaidDetail) error {
detail.AppCode = strings.ToLower(strings.TrimSpace(detail.AppCode))
@ -67,6 +73,47 @@ func (s *Store) LegacyGooglePaidTransactionIDs(appCode string) ([]string, error)
return ids, err
}
// LegacyGooglePaidRetryBlockedTransactionIDs returns failures whose persisted backoff has not expired.
// Due attempts are intentionally omitted so the next 15-second scan can retry them without manual intervention.
func (s *Store) LegacyGooglePaidRetryBlockedTransactionIDs(appCode string, nowMS int64) ([]string, error) {
ids := []string{}
err := s.db.Model(&model.LegacyGooglePaidSyncAttempt{}).
Where("app_code = ? AND next_retry_at_ms > ?", strings.ToLower(strings.TrimSpace(appCode)), nowMS).
Pluck("transaction_id", &ids).Error
return ids, err
}
// RecordLegacyGooglePaidSyncFailure atomically advances retry state with exponential backoff.
// The error text is bounded before persistence so provider responses cannot inflate the admin database.
func (s *Store) RecordLegacyGooglePaidSyncFailure(appCode string, transactionID string, message string, nowMS int64) error {
appCode = strings.ToLower(strings.TrimSpace(appCode))
transactionID = strings.TrimSpace(transactionID)
message = strings.TrimSpace(message)
if len(message) > 512 {
message = message[:512]
}
return s.db.Exec(`
INSERT INTO admin_legacy_google_paid_sync_attempts (
app_code, transaction_id, attempt_count, last_error, next_retry_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, 1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_error = VALUES(last_error),
next_retry_at_ms = VALUES(updated_at_ms) + LEAST(?, ? * POW(2, LEAST(attempt_count, 6))),
attempt_count = attempt_count + 1,
updated_at_ms = VALUES(updated_at_ms)`,
appCode, transactionID, message, nowMS+legacyGooglePaidRetryBaseMS, nowMS, nowMS,
legacyGooglePaidRetryMaxMS, legacyGooglePaidRetryBaseMS,
).Error
}
// ClearLegacyGooglePaidSyncFailure removes obsolete retry state after the paid-detail upsert succeeds.
func (s *Store) ClearLegacyGooglePaidSyncFailure(appCode string, transactionID string) error {
return s.db.Where(
"app_code = ? AND transaction_id = ?",
strings.ToLower(strings.TrimSpace(appCode)), strings.TrimSpace(transactionID),
).Delete(&model.LegacyGooglePaidSyncAttempt{}).Error
}
// LegacyGooglePaidDetails 按交易号批量读取实付明细,供财务明细列表按页回填。
func (s *Store) LegacyGooglePaidDetails(appCode string, transactionIDs []string) (map[string]model.LegacyGooglePaidDetail, error) {
details := map[string]model.LegacyGooglePaidDetail{}

View File

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

View File

@ -0,0 +1,15 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- Google paid-detail failures are not paid facts. Keep retry state in a separate table so the
-- finance list/overview can continue treating admin_legacy_google_paid_details as successful cache only.
CREATE TABLE IF NOT EXISTS admin_legacy_google_paid_sync_attempts (
app_code VARCHAR(32) NOT NULL COMMENT 'legacy App code',
transaction_id VARCHAR(96) NOT NULL COMMENT 'in_app_purchase_details._id',
attempt_count INT NOT NULL DEFAULT 0 COMMENT 'consecutive failed attempts',
last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'last sanitized synchronization error',
next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'next retry time, UTC epoch ms',
created_at_ms BIGINT NOT NULL COMMENT 'created time, UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT 'updated time, UTC epoch ms',
PRIMARY KEY (app_code, transaction_id),
KEY idx_legacy_google_paid_retry (app_code, next_retry_at_ms, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='legacy Google paid-detail retry state';

View File

@ -51,7 +51,11 @@ google_play:
token_url: "https://oauth2.googleapis.com/token"
http_timeout: "10s"
consume_enabled: true
google_paid_sync_worker:
enabled: true
poll_interval: "15s"
batch_size: 100
request_timeout: "2m"
mifapay:
# MiFaPay 是 H5 统一三方支付入口;商家公钥只用于记录商户侧配置,真实下单签名必须继续使用商户私钥。
enabled: true

View File

@ -51,6 +51,11 @@ google_play:
token_url: "https://oauth2.googleapis.com/token"
http_timeout: "10s"
consume_enabled: true
google_paid_sync_worker:
enabled: true
poll_interval: "15s"
batch_size: 100
request_timeout: "2m"
mifapay:
# MiFaPay 是 H5 统一三方支付入口;商家公钥只用于记录商户侧配置,真实下单签名必须继续使用商户私钥。
enabled: true

View File

@ -51,6 +51,11 @@ google_play:
token_url: "https://oauth2.googleapis.com/token"
http_timeout: "10s"
consume_enabled: true
google_paid_sync_worker:
enabled: true
poll_interval: "15s"
batch_size: 100
request_timeout: "2m"
mifapay:
# MiFaPay 是 H5 统一三方支付入口;商家公钥只用于记录商户侧配置,真实下单签名必须继续使用商户私钥。
enabled: true

View File

@ -887,6 +887,9 @@ CREATE TABLE IF NOT EXISTS payment_orders (
paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)',
paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)',
paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间UTC epoch ms0 表示未同步',
paid_sync_attempt_count INT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步连续失败次数',
paid_sync_last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Google 实付同步最近错误',
paid_sync_next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步下次重试时间UTC epoch ms',
provider_payload JSON NULL COMMENT 'Google Play 校验返回快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -895,7 +898,8 @@ CREATE TABLE IF NOT EXISTS payment_orders (
UNIQUE KEY uk_payment_orders_command (app_code, command_id),
KEY idx_payment_orders_user_time (app_code, user_id, created_at_ms),
KEY idx_payment_orders_provider_order (app_code, provider, provider_order_id),
KEY idx_payment_orders_wallet_tx (app_code, wallet_transaction_id)
KEY idx_payment_orders_wallet_tx (app_code, wallet_transaction_id),
KEY idx_payment_orders_paid_sync (provider, paid_synced_at_ms, paid_sync_next_retry_at_ms, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单与 provider 审计表';
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (

View File

@ -44,6 +44,7 @@ type App struct {
outboxWorkerCfg config.OutboxWorkerConfig
realtimeOutboxWorkerCfg config.OutboxWorkerConfig
projectionWorkerCfg config.ProjectionWorkerConfig
googlePaidSyncWorkerCfg config.GooglePaidSyncWorkerConfig
externalRechargeReconcileWorkerCfg config.ExternalRechargeReconcileWorkerConfig
walletOutboxTopic string
realtimeWalletOutboxTopic string
@ -185,6 +186,9 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close()
return nil, err
}
googlePaidSyncWorkerCfg := cfg.GooglePaidSyncWorker
// A disabled Google client cannot make progress; suppress the worker instead of emitting a permanent error loop in local environments.
googlePaidSyncWorkerCfg.Enabled = googlePaidSyncWorkerCfg.Enabled && cfg.GooglePlay.Enabled
return &App{
server: server,
@ -200,6 +204,7 @@ func New(cfg config.Config) (*App, error) {
outboxWorkerCfg: cfg.OutboxWorker,
realtimeOutboxWorkerCfg: cfg.RealtimeOutboxWorker,
projectionWorkerCfg: cfg.ProjectionWorker,
googlePaidSyncWorkerCfg: googlePaidSyncWorkerCfg,
externalRechargeReconcileWorkerCfg: cfg.ExternalRechargeReconcileWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
realtimeWalletOutboxTopic: cfg.RocketMQ.RealtimeOutbox.Topic,

View File

@ -22,6 +22,9 @@ func (a *App) runBackgroundWorkers() {
if a.externalRechargeReconcileWorkerCfg.Enabled {
a.startExternalRechargeReconcileWorker(ctx)
}
if a.googlePaidSyncWorkerCfg.Enabled {
a.startGooglePaidSyncWorker(ctx)
}
if a.redPacketExpiryWorkerCfg.Enabled {
a.startRedPacketExpiryWorker(ctx)
}

View File

@ -0,0 +1,45 @@
package app
import (
"context"
"errors"
"log/slog"
"time"
"hyapp/pkg/logx"
)
func (a *App) startGooglePaidSyncWorker(ctx context.Context) {
workerID := "google-paid-sync-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.googlePaidSyncWorkerCfg.PollInterval)
defer ticker.Stop()
for {
requestCtx, cancel := context.WithTimeout(ctx, a.googlePaidSyncWorkerCfg.RequestTimeout)
result, err := a.walletSvc.ProcessGooglePaymentPaidSyncBatch(requestCtx, a.googlePaidSyncWorkerCfg.BatchSize)
cancel()
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "google_paid_sync_batch_failed", err, slog.String("worker_id", workerID))
}
if result.Claimed > 0 {
logx.Info(ctx, "google_paid_sync_batch_completed",
slog.String("worker_id", workerID),
slog.Int("claimed", result.Claimed),
slog.Int("succeeded", result.Succeeded),
slog.Int("failed", result.Failed),
)
}
// A full batch means historical backlog remains. Drain immediately; once caught up, poll every 15 seconds.
if err == nil && result.Claimed >= a.googlePaidSyncWorkerCfg.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}

View File

@ -155,6 +155,62 @@ func (c *Client) GetOrder(ctx context.Context, packageName string, orderID strin
if err := json.Unmarshal(body, &parsed); err != nil {
return ledger.GooglePlayOrder{}, err
}
return googlePlayOrderFromResource(parsed, string(body)), nil
}
// GetOrders fetches up to 1000 paid-detail snapshots with one Orders batchGet request.
// Service code falls back to single gets when Google rejects a mixed batch because one ID is stale.
func (c *Client) GetOrders(ctx context.Context, packageName string, orderIDs []string) (map[string]ledger.GooglePlayOrder, error) {
if c == nil {
return nil, xerr.New(xerr.Unavailable, "google play client is not configured")
}
packageName = c.normalizePackageName(packageName)
cleaned := make([]string, 0, len(orderIDs))
seen := make(map[string]struct{}, len(orderIDs))
for _, orderID := range orderIDs {
orderID = strings.TrimSpace(orderID)
if orderID == "" {
continue
}
if _, ok := seen[orderID]; ok {
continue
}
seen[orderID] = struct{}{}
cleaned = append(cleaned, orderID)
}
if packageName == "" || len(cleaned) == 0 {
return map[string]ledger.GooglePlayOrder{}, nil
}
if len(cleaned) > 1000 {
return nil, xerr.New(xerr.InvalidArgument, "too many google order ids")
}
query := url.Values{}
for _, orderID := range cleaned {
query.Add("orderIds", orderID)
}
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/orders:batchGet?%s",
c.apiBaseURL, url.PathEscape(packageName), query.Encode())
body, err := c.doJSON(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
var parsed struct {
Orders []orderResource `json:"orders"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
orders := make(map[string]ledger.GooglePlayOrder, len(parsed.Orders))
for _, item := range parsed.Orders {
order := googlePlayOrderFromResource(item, "")
if order.OrderID != "" {
orders[order.OrderID] = order
}
}
return orders, nil
}
func googlePlayOrderFromResource(parsed orderResource, rawJSON string) ledger.GooglePlayOrder {
order := ledger.GooglePlayOrder{
OrderID: parsed.OrderID,
State: parsed.State,
@ -163,12 +219,12 @@ func (c *Client) GetOrder(ctx context.Context, packageName string, orderID strin
TotalAmountMicro: parsed.Total.micro(),
TaxAmountMicro: parsed.Tax.micro(),
NetAmountMicro: parsed.DeveloperRevenueInBuyerCurrency.micro(),
RawJSON: string(body),
RawJSON: rawJSON,
}
if order.CurrencyCode == "" {
order.CurrencyCode = parsed.Tax.CurrencyCode
}
return order, nil
return order
}
func (c *Client) ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error {

View File

@ -29,6 +29,8 @@ type Config struct {
RocketMQ RocketMQConfig `yaml:"rocketmq"`
// GooglePlay 控制 Google Play Developer API 购买校验。
GooglePlay GooglePlayConfig `yaml:"google_play"`
// GooglePaidSyncWorker controls durable paid-detail retries after Google purchase confirmation.
GooglePaidSyncWorker GooglePaidSyncWorkerConfig `yaml:"google_paid_sync_worker"`
// MifaPay 控制 H5 MiFaPay 站外支付下单和回调验签。
MifaPay MifaPayConfig `yaml:"mifapay"`
// V5Pay 控制 H5 V5Pay 站外支付下单、查询和回调验签。
@ -120,6 +122,14 @@ type GooglePlayConfig struct {
ConsumeEnabled bool `yaml:"consume_enabled"`
}
// GooglePaidSyncWorkerConfig polls unsynchronized payment_orders independently from the request goroutine.
type GooglePaidSyncWorkerConfig struct {
Enabled bool `yaml:"enabled"`
PollInterval time.Duration `yaml:"poll_interval"`
BatchSize int `yaml:"batch_size"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// MifaPayConfig 保存 MiFaPay 商户签名、验签和网关配置。
type MifaPayConfig struct {
Enabled bool `yaml:"enabled"`
@ -273,6 +283,12 @@ func Default() Config {
HTTPTimeout: 10 * time.Second,
ConsumeEnabled: true,
},
GooglePaidSyncWorker: GooglePaidSyncWorkerConfig{
Enabled: true,
PollInterval: 15 * time.Second,
BatchSize: 100,
RequestTimeout: 2 * time.Minute,
},
MifaPay: MifaPayConfig{
Enabled: true,
APIBaseURL: "https://platformtest.xqdmipay.com",
@ -438,6 +454,18 @@ func Load(path string) (Config, error) {
return Config{}, errors.New("google_play service account is required")
}
}
if cfg.GooglePaidSyncWorker.PollInterval <= 0 {
cfg.GooglePaidSyncWorker.PollInterval = Default().GooglePaidSyncWorker.PollInterval
}
if cfg.GooglePaidSyncWorker.BatchSize <= 0 {
cfg.GooglePaidSyncWorker.BatchSize = Default().GooglePaidSyncWorker.BatchSize
}
if cfg.GooglePaidSyncWorker.BatchSize > 1000 {
cfg.GooglePaidSyncWorker.BatchSize = 1000
}
if cfg.GooglePaidSyncWorker.RequestTimeout <= 0 {
cfg.GooglePaidSyncWorker.RequestTimeout = Default().GooglePaidSyncWorker.RequestTimeout
}
cfg.MifaPay = normalizeMifaPayConfig(cfg.MifaPay)
// MiFaPay 功能开关默认打开;商户密钥属于环境敏感配置,允许本地缺省启动,启动装配时再按凭证完整性决定是否接入真实网关。
cfg.V5Pay = normalizeV5PayConfig(cfg.V5Pay)

View File

@ -110,11 +110,13 @@ type GooglePaymentPaidDetails struct {
// GooglePaymentOrderRef 是按钱包交易反查 Google 支付订单、发起 Orders API 查询所需的最小定位信息。
type GooglePaymentOrderRef struct {
PaymentOrderID string
TransactionID string
PackageName string
ProviderOrderID string
PaidSyncedAtMS int64
AppCode string
PaymentOrderID string
TransactionID string
PackageName string
ProviderOrderID string
PaidSyncedAtMS int64
SyncAttemptCount int
}
// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。

View File

@ -99,7 +99,9 @@ type RechargeStore interface {
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)
ListPendingGooglePaymentOrderRefs(ctx context.Context, limit int, nowMS int64) ([]ledger.GooglePaymentOrderRef, error)
UpdateGooglePaymentPaidDetails(ctx context.Context, appCode string, paymentOrderID string, details ledger.GooglePaymentPaidDetails) error
MarkGooglePaymentPaidSyncFailure(ctx context.Context, ref ledger.GooglePaymentOrderRef, message string, nowMS int64) error
ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error)
ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error)
GetRechargeProduct(ctx context.Context, appCode string, productID int64) (ledger.RechargeProduct, error)

View File

@ -2,11 +2,15 @@ package wallet
import (
"context"
"log/slog"
"sync"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
"time"
)
// ListRechargeBills 返回充值账单只读列表;所有充值来源必须先在 wallet-service 落充值记录。
@ -51,6 +55,16 @@ type GooglePaymentPriceResult struct {
Err string
}
type GooglePaymentPaidSyncBatchResult struct {
Claimed int
Succeeded int
Failed int
}
type googlePlayOrdersBatchClient interface {
GetOrders(ctx context.Context, packageName string, orderIDs []string) (map[string]ledger.GooglePlayOrder, error)
}
const maxGooglePaymentPriceRefreshBatch = 100
// RefreshGooglePaymentPrices 用 Play Orders API 拉取账单的用户实付币种、总额、税额和净收入并写回 payment_orders。
@ -92,16 +106,18 @@ func (s *Service) RefreshGooglePaymentPrices(ctx context.Context, appCode string
}
func (s *Service) refreshGooglePaymentPrice(ctx context.Context, appCode string, ref ledger.GooglePaymentOrderRef) GooglePaymentPriceResult {
result := GooglePaymentPriceResult{TransactionID: ref.TransactionID}
if strings.TrimSpace(ref.ProviderOrderID) == "" {
result.Err = "google order id is missing"
return result
return s.failedGooglePaymentPrice(ctx, ref, "google order id is missing")
}
order, err := s.googlePlay.GetOrder(ctx, ref.PackageName, ref.ProviderOrderID)
if err != nil {
result.Err = err.Error()
return result
return s.failedGooglePaymentPrice(ctx, ref, err.Error())
}
return s.refreshGooglePaymentPriceFromOrder(ctx, appCode, ref, order)
}
func (s *Service) refreshGooglePaymentPriceFromOrder(ctx context.Context, appCode string, ref ledger.GooglePaymentOrderRef, order ledger.GooglePlayOrder) GooglePaymentPriceResult {
result := GooglePaymentPriceResult{TransactionID: ref.TransactionID}
details := ledger.GooglePaymentPaidDetails{
CurrencyCode: order.CurrencyCode,
PaidAmountMicro: order.TotalAmountMicro,
@ -110,8 +126,7 @@ func (s *Service) refreshGooglePaymentPrice(ctx context.Context, appCode string,
SyncedAtMS: s.now().UnixMilli(),
}
if err := s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, ref.PaymentOrderID, details); err != nil {
result.Err = err.Error()
return result
return s.failedGooglePaymentPrice(ctx, ref, err.Error())
}
result.CurrencyCode = details.CurrencyCode
result.PaidAmountMicro = details.PaidAmountMicro
@ -121,6 +136,102 @@ func (s *Service) refreshGooglePaymentPrice(ctx context.Context, appCode string,
return result
}
func (s *Service) failedGooglePaymentPrice(ctx context.Context, ref ledger.GooglePaymentOrderRef, message string) GooglePaymentPriceResult {
result := GooglePaymentPriceResult{TransactionID: ref.TransactionID, Err: strings.TrimSpace(message)}
if err := s.repository.MarkGooglePaymentPaidSyncFailure(ctx, ref, result.Err, s.now().UnixMilli()); err != nil {
logx.Warn(ctx, "google_paid_sync_failure_persist_failed",
slog.String("app_code", ref.AppCode),
slog.String("payment_order_id", ref.PaymentOrderID),
slog.String("error", err.Error()),
)
}
return result
}
// ProcessGooglePaymentPaidSyncBatch is the durable worker entrypoint. It uses batchGet for the common
// path and bounded single-order fallback when Google rejects a batch containing one stale order ID.
func (s *Service) ProcessGooglePaymentPaidSyncBatch(ctx context.Context, batchSize int) (GooglePaymentPaidSyncBatchResult, error) {
if s.repository == nil {
return GooglePaymentPaidSyncBatchResult{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if s.googlePlay == nil {
return GooglePaymentPaidSyncBatchResult{}, xerr.New(xerr.Unavailable, "google play client is not configured")
}
refs, err := s.repository.ListPendingGooglePaymentOrderRefs(ctx, batchSize, s.now().UnixMilli())
if err != nil {
return GooglePaymentPaidSyncBatchResult{}, err
}
result := GooglePaymentPaidSyncBatchResult{Claimed: len(refs)}
if len(refs) == 0 {
return result, nil
}
prices := s.refreshGooglePaymentRefBatch(ctx, refs)
for _, price := range prices {
if price.Err == "" {
result.Succeeded++
} else {
result.Failed++
}
}
return result, nil
}
func (s *Service) refreshGooglePaymentRefBatch(ctx context.Context, refs []ledger.GooglePaymentOrderRef) []GooglePaymentPriceResult {
results := make([]GooglePaymentPriceResult, len(refs))
groups := make(map[string][]int)
for index, ref := range refs {
groups[strings.TrimSpace(ref.PackageName)] = append(groups[strings.TrimSpace(ref.PackageName)], index)
}
batchClient, supportsBatch := s.googlePlay.(googlePlayOrdersBatchClient)
for packageName, indexes := range groups {
if supportsBatch && packageName != "" {
orderIDs := make([]string, 0, len(indexes))
for _, index := range indexes {
if orderID := strings.TrimSpace(refs[index].ProviderOrderID); orderID != "" {
orderIDs = append(orderIDs, orderID)
}
}
if len(orderIDs) == len(indexes) {
if orders, err := batchClient.GetOrders(ctx, packageName, orderIDs); err == nil {
for _, index := range indexes {
ref := refs[index]
order, ok := orders[ref.ProviderOrderID]
if !ok {
results[index] = s.failedGooglePaymentPrice(ctx, ref, "google order is not found")
continue
}
results[index] = s.refreshGooglePaymentPriceFromOrder(ctx, ref.AppCode, ref, order)
}
continue
}
}
}
s.refreshGooglePaymentRefSingles(ctx, refs, indexes, results)
}
return results
}
func (s *Service) refreshGooglePaymentRefSingles(ctx context.Context, refs []ledger.GooglePaymentOrderRef, indexes []int, results []GooglePaymentPriceResult) {
const concurrency = 8
semaphore := make(chan struct{}, concurrency)
var wait sync.WaitGroup
for _, index := range indexes {
wait.Add(1)
go func(index int) {
defer wait.Done()
select {
case semaphore <- struct{}{}:
defer func() { <-semaphore }()
case <-ctx.Done():
results[index] = s.failedGooglePaymentPrice(ctx, refs[index], ctx.Err().Error())
return
}
results[index] = s.refreshGooglePaymentPrice(ctx, refs[index].AppCode, refs[index])
}(index)
}
wait.Wait()
}
// ListRechargeProducts 返回区域化充值档位region_id 必须由 gateway 从 user-service 资料解析。
func (s *Service) ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string, audienceType string) ([]ledger.RechargeProduct, []string, error) {
if userID <= 0 || regionID <= 0 {
@ -274,7 +385,7 @@ func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.Googl
if err != nil {
return ledger.GooglePaymentReceipt{}, err
}
// 入账已完成,实付明细只影响财务展示:异步拉 Orders API失败静默后台可手动刷新补数
// 入账已完成,实付明细只影响财务展示:先即时异步拉取;失败会持久化并由 15 秒 worker 补偿
s.scheduleGooglePaymentPriceRefresh(command.AppCode, receipt.PaymentOrderID, command.PackageName, purchase.OrderID)
if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed {
return receipt, nil
@ -299,21 +410,22 @@ func (s *Service) scheduleGooglePaymentPriceRefresh(appCode string, paymentOrder
strings.TrimSpace(paymentOrderID) == "" || strings.TrimSpace(orderID) == "" {
return
}
syncedAt := s.now().UnixMilli()
go func() {
ctx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appCode), 10*time.Second)
defer cancel()
order, err := s.googlePlay.GetOrder(ctx, packageName, orderID)
if err != nil {
return
}
_ = s.repository.UpdateGooglePaymentPaidDetails(ctx, appCode, paymentOrderID, ledger.GooglePaymentPaidDetails{
CurrencyCode: order.CurrencyCode,
PaidAmountMicro: order.TotalAmountMicro,
TaxAmountMicro: order.TaxAmountMicro,
NetAmountMicro: order.NetAmountMicro,
SyncedAtMS: syncedAt,
result := s.refreshGooglePaymentPrice(ctx, appCode, ledger.GooglePaymentOrderRef{
AppCode: appCode,
PaymentOrderID: paymentOrderID,
PackageName: packageName,
ProviderOrderID: orderID,
})
if result.Err != "" {
logx.Warn(ctx, "google_paid_sync_immediate_failed",
slog.String("app_code", appCode),
slog.String("payment_order_id", paymentOrderID),
slog.String("error", result.Err),
)
}
}()
}

View File

@ -13,6 +13,11 @@ import (
"hyapp/services/wallet-service/internal/domain/ledger"
)
const (
googlePaidSyncRetryBase = 15 * time.Second
googlePaidSyncRetryMax = 15 * time.Minute
)
// ConfirmGooglePayment 将 Google Play 已购买 token 落为平台支付订单、钱包交易和充值审计记录。
func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error) {
if r == nil || r.db == nil {
@ -321,7 +326,8 @@ func (r *Repository) ListGooglePaymentOrderRefs(ctx context.Context, appCode str
args = append(args, transactionID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT payment_order_id, wallet_transaction_id, package_name, provider_order_id, paid_synced_at_ms
SELECT app_code, payment_order_id, wallet_transaction_id, package_name, provider_order_id,
paid_synced_at_ms, paid_sync_attempt_count
FROM payment_orders
WHERE app_code = ? AND provider = ? AND wallet_transaction_id IN (`+placeholders+`)`,
args...,
@ -334,7 +340,7 @@ func (r *Repository) ListGooglePaymentOrderRefs(ctx context.Context, appCode str
refs := make(map[string]ledger.GooglePaymentOrderRef, len(cleaned))
for rows.Next() {
var ref ledger.GooglePaymentOrderRef
if err := rows.Scan(&ref.PaymentOrderID, &ref.TransactionID, &ref.PackageName, &ref.ProviderOrderID, &ref.PaidSyncedAtMS); err != nil {
if err := rows.Scan(&ref.AppCode, &ref.PaymentOrderID, &ref.TransactionID, &ref.PackageName, &ref.ProviderOrderID, &ref.PaidSyncedAtMS, &ref.SyncAttemptCount); err != nil {
return nil, err
}
refs[ref.TransactionID] = ref
@ -342,6 +348,43 @@ func (r *Repository) ListGooglePaymentOrderRefs(ctx context.Context, appCode str
return refs, rows.Err()
}
// ListPendingGooglePaymentOrderRefs claims no rows: paid-detail synchronization is idempotent and the
// retry columns prevent failed orders from monopolizing every 15-second scan. Duplicate workers may
// read the same row during a rolling deploy, but both write the same provider fact.
func (r *Repository) ListPendingGooglePaymentOrderRefs(ctx context.Context, limit int, nowMS int64) ([]ledger.GooglePaymentOrderRef, error) {
if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if limit <= 0 {
return []ledger.GooglePaymentOrderRef{}, nil
}
if limit > 1000 {
limit = 1000
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, payment_order_id, wallet_transaction_id, package_name, provider_order_id,
paid_synced_at_ms, paid_sync_attempt_count
FROM payment_orders
WHERE provider = ? AND paid_synced_at_ms = 0 AND paid_sync_next_retry_at_ms <= ?
ORDER BY created_at_ms DESC, payment_order_id DESC
LIMIT ?`,
ledger.PaymentProviderGooglePlay, nowMS, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
refs := make([]ledger.GooglePaymentOrderRef, 0, limit)
for rows.Next() {
var ref ledger.GooglePaymentOrderRef
if err := rows.Scan(&ref.AppCode, &ref.PaymentOrderID, &ref.TransactionID, &ref.PackageName, &ref.ProviderOrderID, &ref.PaidSyncedAtMS, &ref.SyncAttemptCount); err != nil {
return nil, err
}
refs = append(refs, ref)
}
return refs, rows.Err()
}
// UpdateGooglePaymentPaidDetails 把 Orders API 返回的实付明细写回 payment_orders同步时间必须由调用方给出。
func (r *Repository) UpdateGooglePaymentPaidDetails(ctx context.Context, appCode string, paymentOrderID string, details ledger.GooglePaymentPaidDetails) error {
if r == nil || r.db == nil {
@ -354,7 +397,8 @@ func (r *Repository) UpdateGooglePaymentPaidDetails(ctx context.Context, appCode
}
_, err := r.db.ExecContext(ctx, `
UPDATE payment_orders
SET paid_currency_code = ?, paid_amount_micro = ?, paid_tax_micro = ?, paid_net_micro = ?, paid_synced_at_ms = ?, updated_at_ms = ?
SET paid_currency_code = ?, paid_amount_micro = ?, paid_tax_micro = ?, paid_net_micro = ?, paid_synced_at_ms = ?,
paid_sync_attempt_count = 0, paid_sync_last_error = '', paid_sync_next_retry_at_ms = 0, updated_at_ms = ?
WHERE app_code = ? AND payment_order_id = ? AND provider = ?`,
strings.ToUpper(strings.TrimSpace(details.CurrencyCode)),
details.PaidAmountMicro,
@ -369,6 +413,34 @@ func (r *Repository) UpdateGooglePaymentPaidDetails(ctx context.Context, appCode
return err
}
// MarkGooglePaymentPaidSyncFailure stores sanitized diagnostics and exponential retry time.
// It never overwrites a concurrently completed synchronization because paid_synced_at_ms=0 is required.
func (r *Repository) MarkGooglePaymentPaidSyncFailure(ctx context.Context, ref ledger.GooglePaymentOrderRef, message string, nowMS int64) error {
if r == nil || r.db == nil {
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
message = strings.TrimSpace(message)
if len(message) > 512 {
message = message[:512]
}
backoff := googlePaidSyncRetryBase
for attempt := 0; attempt < ref.SyncAttemptCount && backoff < googlePaidSyncRetryMax; attempt++ {
backoff *= 2
if backoff > googlePaidSyncRetryMax {
backoff = googlePaidSyncRetryMax
}
}
_, err := r.db.ExecContext(ctx, `
UPDATE payment_orders
SET paid_sync_attempt_count = paid_sync_attempt_count + 1,
paid_sync_last_error = ?, paid_sync_next_retry_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND payment_order_id = ? AND provider = ? AND paid_synced_at_ms = 0`,
message, nowMS+backoff.Milliseconds(), nowMS,
strings.ToLower(strings.TrimSpace(ref.AppCode)), strings.TrimSpace(ref.PaymentOrderID), ledger.PaymentProviderGooglePlay,
)
return err
}
type googlePaymentMetadata struct {
AppCode string `json:"app_code"`
PaymentOrderID string `json:"payment_order_id"`

View File

@ -172,6 +172,9 @@ func ensureGooglePaymentPaidDetailsSchema(ctx context.Context, db *sql.DB) error
`ALTER TABLE payment_orders ADD COLUMN paid_tax_micro BIGINT NOT NULL DEFAULT 0 COMMENT '订单税额微单位(实付币种)' AFTER paid_amount_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_net_micro BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 扣费后开发者净收入微单位(实付币种)' AFTER paid_tax_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_synced_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '实付明细同步时间UTC epoch ms0 表示未同步' AFTER paid_net_micro`,
`ALTER TABLE payment_orders ADD COLUMN paid_sync_attempt_count INT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步连续失败次数' AFTER paid_synced_at_ms`,
`ALTER TABLE payment_orders ADD COLUMN paid_sync_last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Google 实付同步最近错误' AFTER paid_sync_attempt_count`,
`ALTER TABLE payment_orders ADD COLUMN paid_sync_next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 实付同步下次重试时间UTC epoch ms' AFTER paid_sync_last_error`,
}
for _, stmt := range alters {
if _, err := db.ExecContext(ctx, stmt); err != nil && !isDuplicateColumnError(err) {
@ -183,6 +186,11 @@ func ensureGooglePaymentPaidDetailsSchema(ctx context.Context, db *sql.DB) error
ADD INDEX idx_payment_orders_wallet_tx (app_code, wallet_transaction_id)`); err != nil && !isDuplicateKeyNameError(err) {
return err
}
if _, err := db.ExecContext(ctx, `
ALTER TABLE payment_orders
ADD INDEX idx_payment_orders_paid_sync (provider, paid_synced_at_ms, paid_sync_next_retry_at_ms, created_at_ms)`); err != nil && !isDuplicateKeyNameError(err) {
return err
}
return nil
}