package mysql import ( "context" "encoding/json" "math" "strconv" "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/wallet-service/internal/domain/ledger" ) // ListRechargeBills 查询 wallet-service 的充值账单投影,后台不能直接读写业务库内部语义。 // 结果集是两类事实的并集:wallet_recharge_records(用户充值)与 coin_seller_stock_records(币商进货/冲回)。 func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query = normalizeRechargeBillsQuery(query) query.AppCode = appcode.FromContext(ctx) includeRecords, includeStock := rechargeBillBranches(query) if !includeRecords && !includeStock { return []ledger.RechargeBill{}, 0, nil } selects := make([]string, 0, 2) args := make([]any, 0, 16) var total int64 if includeRecords { where, whereArgs := rechargeBillsWhereSQL(query) branchTotal, err := r.countRechargeBillRows(ctx, where, whereArgs...) if err != nil { return nil, 0, err } total += branchTotal selects = append(selects, rechargeBillRecordsSelectSQL+where) args = append(args, whereArgs...) } if includeStock { where, whereArgs := rechargeBillStockWhereSQL(query) branchTotal, err := r.countRechargeBillStockRows(ctx, where, whereArgs...) if err != nil { return nil, 0, err } total += branchTotal selects = append(selects, rechargeBillStockSelectSQL+where) args = append(args, whereArgs...) } rows, err := r.db.QueryContext(ctx, ` SELECT * FROM (`+strings.Join(selects, ` UNION ALL `)+`) bills ORDER BY created_at_ms DESC, transaction_id DESC LIMIT ? OFFSET ?`, append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))..., ) if err != nil { return nil, 0, err } defer rows.Close() items := make([]ledger.RechargeBill, 0, query.PageSize) for rows.Next() { var item ledger.RechargeBill var externalProviderCode, externalCurrencyCode, externalPayloadJSON string var paidCurrencyCode string var paidAmountMicro, paidTaxMicro, paidNetMicro, paidSyncedAtMS int64 if err := rows.Scan( &item.AppCode, &item.TransactionID, &item.CommandID, &item.RechargeType, &item.Status, &item.ExternalRef, &item.UserID, &item.SellerUserID, &item.SellerRegionID, &item.TargetRegionID, &item.PolicyID, &item.PolicyVersion, &item.CurrencyCode, &item.CoinAmount, &item.USDMinorAmount, &item.ExchangeCoinAmount, &item.ExchangeUSDMinorAmount, &item.ProviderAmountMinor, &item.CreatedAtMS, &externalProviderCode, &externalCurrencyCode, &externalPayloadJSON, &paidCurrencyCode, &paidAmountMicro, &paidTaxMicro, &paidNetMicro, &paidSyncedAtMS, ); err != nil { return nil, 0, err } fillRechargeBillPaidFacts(&item, externalProviderCode, externalCurrencyCode, externalPayloadJSON, paidCurrencyCode, paidAmountMicro, paidTaxMicro, paidNetMicro, paidSyncedAtMS) items = append(items, item) } if err := rows.Err(); err != nil { return nil, 0, err } return items, total, nil } // rechargeBillRecordsSelectSQL 是用户充值分支:external_recharge_orders 补 H5 三方实付,payment_orders 补 Google 实付明细。 const rechargeBillRecordsSelectSQL = ` SELECT rr.app_code, rr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref, rr.user_id, rr.seller_user_id, rr.seller_region_id, rr.target_region_id, rr.policy_id, rr.policy_version, rr.currency_code, rr.coin_amount, rr.usd_minor_amount, rr.exchange_coin_amount, rr.exchange_usd_minor_amount, COALESCE(eo.provider_amount_minor, 0) AS provider_amount_minor, rr.created_at_ms, COALESCE(eo.provider_code, '') AS external_provider_code, COALESCE(eo.currency_code, '') AS external_currency_code, COALESCE(CAST(eo.provider_payload AS CHAR), '') AS external_payload_json, COALESCE(po.paid_currency_code, '') AS paid_currency_code, COALESCE(po.paid_amount_micro, 0) AS paid_amount_micro, COALESCE(po.paid_tax_micro, 0) AS paid_tax_micro, COALESCE(po.paid_net_micro, 0) AS paid_net_micro, COALESCE(po.paid_synced_at_ms, 0) AS paid_synced_at_ms FROM wallet_recharge_records rr JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id LEFT JOIN external_recharge_orders eo ON eo.app_code = rr.app_code AND eo.wallet_transaction_id = rr.transaction_id LEFT JOIN payment_orders po ON po.app_code = rr.app_code AND po.wallet_transaction_id = rr.transaction_id ` // rechargeBillStockSelectSQL 是币商进货分支:列结构与用户充值分支对齐;实付事实来自库存记录本身, // 美元口径只认 USDT/USD 付款(micro DIV 10000 = 美分),冲回单的金额与美元都为负数。 const rechargeBillStockSelectSQL = ` SELECT sr.app_code, sr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref, 0 AS user_id, sr.seller_user_id, COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(sr.metadata_json, '$.seller_region_id')) AS SIGNED), 0) AS seller_region_id, 0 AS target_region_id, 0 AS policy_id, '' AS policy_version, sr.paid_currency_code AS currency_code, sr.coin_amount, CASE WHEN sr.paid_currency_code IN ('USDT', 'USD') THEN sr.paid_amount_micro DIV 10000 ELSE 0 END AS usd_minor_amount, 0 AS exchange_coin_amount, 0 AS exchange_usd_minor_amount, 0 AS provider_amount_minor, sr.created_at_ms, '' AS external_provider_code, '' AS external_currency_code, '' AS external_payload_json, sr.paid_currency_code AS paid_currency_code, sr.paid_amount_micro AS paid_amount_micro, 0 AS paid_tax_micro, 0 AS paid_net_micro, sr.created_at_ms AS paid_synced_at_ms FROM coin_seller_stock_records sr JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id ` // rechargeBillBranches 判定筛选条件命中的账单分支:用户筛选和普通渠道只在充值记录分支,币商进货走库存分支。 func rechargeBillBranches(query ledger.ListRechargeBillsQuery) (includeRecords bool, includeStock bool) { switch query.RechargeType { case rechargeTypeFilterCoinSeller: includeStock = true case "": includeRecords = true includeStock = true default: includeRecords = true } // 库存记录没有充值用户维度;按充值用户筛选时币商分支注定无结果。 if query.UserID > 0 { includeStock = false } // 实付同步状态是谷歌账单专属维度。 if query.PaidState == rechargeBillPaidStateUnsynced { includeStock = false } return includeRecords, includeStock } const ( // rechargeTypeFilterCoinSeller/ThirdParty 是后台筛选哨兵值,不对应单一 biz_type: // coin_seller 命中币商进货/冲回分支,third_party 命中除 Google 外的所有用户充值渠道。 rechargeTypeFilterCoinSeller = "coin_seller" rechargeTypeFilterThirdParty = "third_party" providerCodeCoinSeller = "coin_seller" ) // fillRechargeBillPaidFacts 把三方订单快照、Google 实付明细和币商进货付款收敛成账单统一的实付字段。 func fillRechargeBillPaidFacts(item *ledger.RechargeBill, externalProviderCode string, externalCurrencyCode string, externalPayloadJSON string, paidCurrencyCode string, paidAmountMicro int64, paidTaxMicro int64, paidNetMicro int64, paidSyncedAtMS int64) { if item.RechargeType == bizTypeCoinSellerStockPurchase || item.RechargeType == bizTypeCoinSellerStockDeduction { item.ProviderCode = providerCodeCoinSeller item.UserPaidCurrencyCode = paidCurrencyCode item.UserPaidAmountMicro = paidAmountMicro item.PaidSyncedAtMS = paidSyncedAtMS return } if item.RechargeType == bizTypeGooglePlayRecharge { item.ProviderCode = "google_play" item.UserPaidCurrencyCode = paidCurrencyCode item.UserPaidAmountMicro = paidAmountMicro item.ProviderTaxMicro = paidTaxMicro item.ProviderNetMicro = paidNetMicro item.PaidSyncedAtMS = paidSyncedAtMS // Google 不单独返回服务费,只能由 实付总额 - 净收入 - 税 推出;任一缺失则保持 0。 if paidAmountMicro > 0 && paidNetMicro > 0 { if fee := paidAmountMicro - paidNetMicro - paidTaxMicro; fee > 0 { item.ProviderFeeMicro = fee } } return } item.ProviderCode = externalProviderCode item.UserPaidCurrencyCode = externalCurrencyCode if item.ProviderAmountMinor > 0 { item.UserPaidAmountMicro = item.ProviderAmountMinor * 10_000 } // V5Pay 的回调和查单快照带 fee/tax/accAmount;MiFaPay 与 USDT 没有该数据,解析不到就保持 0。 fee, tax, net := externalRechargeSettlementFromPayload(externalPayloadJSON) item.ProviderFeeMicro = fee item.ProviderTaxMicro = tax item.ProviderNetMicro = net } // externalRechargeSettlementFromPayload 从 provider_payload 快照解析三方结算字段(实付币种微单位)。 func externalRechargeSettlementFromPayload(payloadJSON string) (feeMicro int64, taxMicro int64, netMicro int64) { payloadJSON = strings.TrimSpace(payloadJSON) if payloadJSON == "" || !strings.Contains(payloadJSON, "accAmount") && !strings.Contains(payloadJSON, "\"fee\"") { return 0, 0, 0 } var payload struct { Fee string `json:"fee"` Tax string `json:"tax"` AccAmount string `json:"accAmount"` } if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { return 0, 0, 0 } return decimalAmountToMicro(payload.Fee), decimalAmountToMicro(payload.Tax), decimalAmountToMicro(payload.AccAmount) } // decimalAmountToMicro 把三方返回的十进制金额字符串(如 "10.50")转成微单位;非法或负数一律按 0 处理。 func decimalAmountToMicro(value string) int64 { value = strings.TrimSpace(value) if value == "" { return 0 } parsed, err := strconv.ParseFloat(value, 64) if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 { return 0 } return int64(math.Round(parsed * 1_000_000)) } // rechargeBillStockWhereSQL 构造币商进货分支的筛选;与列表分支同一套查询参数语义。 func rechargeBillStockWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any) { // 只有 counts_as_seller_recharge 的进货/冲回属于财务充值口径;金币补偿等运营动作不进账单。 where := `WHERE sr.app_code = ? AND sr.counts_as_seller_recharge = TRUE` args := []any{query.AppCode} if query.SellerUserID > 0 { where += ` AND sr.seller_user_id = ?` args = append(args, query.SellerUserID) } if query.RegionID > 0 { where += ` AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(sr.metadata_json, '$.seller_region_id')) AS SIGNED), 0) = ?` args = append(args, query.RegionID) } if query.Status != "" { where += ` AND wt.status = ?` args = append(args, query.Status) } if query.StartAtMS > 0 { where += ` AND sr.created_at_ms >= ?` args = append(args, query.StartAtMS) } if query.EndAtMS > 0 { where += ` AND sr.created_at_ms < ?` args = append(args, query.EndAtMS) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where += ` AND (sr.transaction_id LIKE ? OR wt.command_id LIKE ? OR COALESCE(sr.payment_ref, '') LIKE ? OR sr.evidence_ref LIKE ?)` args = append(args, like, like, like, like) } return where, args } func (r *Repository) countRechargeBillStockRows(ctx context.Context, where string, args ...any) (int64, error) { var total int64 err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM coin_seller_stock_records sr JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id `+where, args..., ).Scan(&total) return total, err } // SummarizeRechargeBills 按与列表一致的筛选口径聚合充值账单:Google、三方与币商进货三个渠道桶。 func (r *Repository) SummarizeRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error) { if r == nil || r.db == nil { return ledger.RechargeBillSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } ctx = contextWithCommandApp(ctx, query.AppCode) query = normalizeRechargeBillsQuery(query) query.AppCode = appcode.FromContext(ctx) includeRecords, includeStock := rechargeBillBranches(query) var summary ledger.RechargeBillSummary if includeRecords { if err := r.summarizeRechargeRecordBills(ctx, query, &summary); err != nil { return ledger.RechargeBillSummary{}, err } } if includeStock { if err := r.summarizeCoinSellerStockBills(ctx, query, &summary); err != nil { return ledger.RechargeBillSummary{}, err } } summary.Total = ledger.RechargeBillSummaryBucket{ BillCount: summary.GooglePlay.BillCount + summary.ThirdParty.BillCount + summary.CoinSeller.BillCount, CoinAmount: summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount + summary.CoinSeller.CoinAmount, USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount + summary.CoinSeller.USDMinorAmount, } return summary, nil } func (r *Repository) summarizeRechargeRecordBills(ctx context.Context, query ledger.ListRechargeBillsQuery, summary *ledger.RechargeBillSummary) error { where, args := rechargeBillsWhereSQL(query) rows, err := r.db.QueryContext(ctx, ` SELECT wt.biz_type, COUNT(*), COALESCE(SUM(rr.coin_amount), 0), COALESCE(SUM(rr.usd_minor_amount), 0) FROM wallet_recharge_records rr JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id `+where+` GROUP BY wt.biz_type`, args..., ) if err != nil { return err } defer rows.Close() for rows.Next() { var bizType string var bucket ledger.RechargeBillSummaryBucket if err := rows.Scan(&bizType, &bucket.BillCount, &bucket.CoinAmount, &bucket.USDMinorAmount); err != nil { return err } if bizType == bizTypeGooglePlayRecharge { summary.GooglePlay.BillCount += bucket.BillCount summary.GooglePlay.CoinAmount += bucket.CoinAmount summary.GooglePlay.USDMinorAmount += bucket.USDMinorAmount } else { summary.ThirdParty.BillCount += bucket.BillCount summary.ThirdParty.CoinAmount += bucket.CoinAmount summary.ThirdParty.USDMinorAmount += bucket.USDMinorAmount } } return rows.Err() } func (r *Repository) summarizeCoinSellerStockBills(ctx context.Context, query ledger.ListRechargeBillsQuery, summary *ledger.RechargeBillSummary) error { where, args := rechargeBillStockWhereSQL(query) // 币商口径是进货减冲回的净额:冲回记录的金币与付款额本身就是负数,直接求和。 err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*), COALESCE(SUM(sr.coin_amount), 0), COALESCE(SUM(CASE WHEN sr.paid_currency_code IN ('USDT', 'USD') THEN sr.paid_amount_micro DIV 10000 ELSE 0 END), 0) FROM coin_seller_stock_records sr JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id `+where, args..., ).Scan(&summary.CoinSeller.BillCount, &summary.CoinSeller.CoinAmount, &summary.CoinSeller.USDMinorAmount) return err } func (r *Repository) countRechargeBillRows(ctx context.Context, where string, args ...any) (int64, error) { var total int64 err := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM wallet_recharge_records rr JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id `+where, args..., ).Scan(&total) return total, err } func normalizeRechargeBillsQuery(query ledger.ListRechargeBillsQuery) ledger.ListRechargeBillsQuery { query.AppCode = appcode.Normalize(query.AppCode) query.RechargeType = strings.ToLower(strings.TrimSpace(query.RechargeType)) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.PaidState = strings.ToLower(strings.TrimSpace(query.PaidState)) query.Keyword = strings.TrimSpace(query.Keyword) query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) if query.EndAtMS > 0 && query.StartAtMS > query.EndAtMS { query.StartAtMS, query.EndAtMS = query.EndAtMS, query.StartAtMS } return query } func rechargeBillsWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any) { // 财务系统的 APP 充值详情只展示用户主动向平台充值的账单;币商给用户转普通金币虽然会落充值事实供活动/统计消费, // 但它本质是币商出货,不应混入财务充值明细和分页总数。 where := `WHERE rr.app_code = ? AND wt.biz_type <> ?` args := []any{query.AppCode, bizTypeCoinSellerTransfer} if query.UserID > 0 { where += ` AND rr.user_id = ?` args = append(args, query.UserID) } if query.SellerUserID > 0 { where += ` AND rr.seller_user_id = ?` args = append(args, query.SellerUserID) } if query.RegionID > 0 { where += ` AND rr.target_region_id = ?` args = append(args, query.RegionID) } switch query.RechargeType { case "", rechargeTypeFilterCoinSeller: // 空值或币商哨兵不追加渠道条件:分支取舍由 rechargeBillBranches 决定。 case rechargeTypeFilterThirdParty: where += ` AND wt.biz_type <> ?` args = append(args, bizTypeGooglePlayRecharge) default: where += ` AND wt.biz_type = ?` args = append(args, query.RechargeType) } if query.Status != "" { where += ` AND wt.status = ?` args = append(args, query.Status) } if query.StartAtMS > 0 { where += ` AND rr.created_at_ms >= ?` args = append(args, query.StartAtMS) } if query.EndAtMS > 0 { where += ` AND rr.created_at_ms < ?` args = append(args, query.EndAtMS) } if query.Keyword != "" { like := "%" + query.Keyword + "%" where += ` AND (rr.transaction_id LIKE ? OR wt.command_id LIKE ? OR wt.external_ref LIKE ? OR rr.policy_version LIKE ?)` args = append(args, like, like, like, like) } if query.PaidState == rechargeBillPaidStateUnsynced { // “实付未同步”只对谷歌账单有意义;用 NOT EXISTS 表达,列表与计数共用同一片段,不依赖外层 join。 where += ` AND wt.biz_type = ? AND NOT EXISTS ( SELECT 1 FROM payment_orders po2 WHERE po2.app_code = rr.app_code AND po2.wallet_transaction_id = rr.transaction_id AND po2.paid_synced_at_ms > 0)` args = append(args, bizTypeGooglePlayRecharge) } return where, args } const rechargeBillPaidStateUnsynced = "unsynced"