2026-07-03 13:09:01 +08:00

274 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 的充值账单投影,后台不能直接读写业务库内部语义。
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)
where, args := rechargeBillsWhereSQL(query)
total, err := r.countRechargeBillRows(ctx, where, args...)
if err != nil {
return nil, 0, err
}
// 充值事实仍以 wallet_recharge_records 为主表external_recharge_orders 补 H5 三方订单的实付币种和金额,
// payment_orders 补 Google 订单经 Orders API 同步的实付明细,两个 LEFT JOIN 都走 wallet_transaction_id 索引。
rows, err := r.db.QueryContext(ctx, `
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), rr.created_at_ms,
COALESCE(eo.provider_code, ''), COALESCE(eo.currency_code, ''), COALESCE(CAST(eo.provider_payload AS CHAR), ''),
COALESCE(po.paid_currency_code, ''), COALESCE(po.paid_amount_micro, 0), COALESCE(po.paid_tax_micro, 0),
COALESCE(po.paid_net_micro, 0), COALESCE(po.paid_synced_at_ms, 0)
FROM wallet_recharge_records rr
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
LEFT JOIN 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
`+where+`
ORDER BY rr.created_at_ms DESC, rr.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
}
// 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 == 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/accAmountMiFaPay 与 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))
}
// 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)
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 ledger.RechargeBillSummary{}, err
}
defer rows.Close()
var summary ledger.RechargeBillSummary
for rows.Next() {
var bizType string
var bucket ledger.RechargeBillSummaryBucket
if err := rows.Scan(&bizType, &bucket.BillCount, &bucket.CoinAmount, &bucket.USDMinorAmount); err != nil {
return ledger.RechargeBillSummary{}, err
}
summary.Total.BillCount += bucket.BillCount
summary.Total.CoinAmount += bucket.CoinAmount
summary.Total.USDMinorAmount += bucket.USDMinorAmount
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
}
}
if err := rows.Err(); err != nil {
return ledger.RechargeBillSummary{}, err
}
return summary, nil
}
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.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)
}
if query.RechargeType != "" {
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)
}
return where, args
}