fix: continuously sync Google paid orders
This commit is contained in:
parent
d02d9ada63
commit
c2bbfa87f5
7
scripts/mysql/063_google_paid_sync_retry.sql
Normal file
7
scripts/mysql/063_google_paid_sync_retry.sql
Normal 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);
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"`
|
||||
|
||||
141
server/admin/internal/modules/payment/google_paid_sync_worker.go
Normal file
141
server/admin/internal/modules/payment/google_paid_sync_worker.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -87,6 +87,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.UserMoneyScope{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
&model.LegacyGooglePaidSyncAttempt{},
|
||||
&model.FinanceApplication{},
|
||||
&model.CoinSellerRechargeOrder{},
|
||||
&model.UserWithdrawalApplication{},
|
||||
|
||||
@ -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';
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ms;0 表示未同步',
|
||||
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 (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 的稳定入账回执。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 ms;0 表示未同步' 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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user