财务口径

This commit is contained in:
zhx 2026-07-09 19:57:39 +08:00
parent ccc2a8b108
commit 07302f0aff
9 changed files with 1006 additions and 342 deletions

View File

@ -0,0 +1,681 @@
package payment
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
type financeCoinSellerRechargeQuery struct {
RechargeType string
Status string
PaidState string
Keyword string
RegionID int64
StartAtMS int64
EndAtMS int64
TzOffsetMinutes int32
TargetUserID int64
HasUserOnlyFilter bool
Page int
PageSize int
MaxPageSize int
RequestID string
UserID int64
}
type financeCoinSellerRechargeStats struct {
Total rechargeBillSummaryBucketDTO
Daily map[string]rechargeBillSummaryBucketDTO
Regions []rechargeBillRegionBucketDTO
}
const (
financeCoinSellerRechargeMaxPageSize = 100
financeRechargeMergeMaxPrefetch = 5000
)
func (h *Handler) applyFinanceCoinSellerSummary(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, summary *rechargeBillSummaryDTO) error {
if summary == nil {
return nil
}
rechargeType := normalizedRechargeType(query.RechargeType)
if rechargeType != "" && rechargeType != "coin_seller" {
summary.CoinSeller = rechargeBillSummaryBucketDTO{}
legacyRecomputeSummaryTotal(summary)
return nil
}
stats, err := h.financeCoinSellerRechargeStats(ctx, appCode, query)
if err != nil {
return err
}
summary.CoinSeller = stats.Total
if rechargeType == "coin_seller" {
summary.GooglePlay = rechargeBillSummaryBucketDTO{}
summary.ThirdParty = rechargeBillSummaryBucketDTO{}
}
legacyRecomputeSummaryTotal(summary)
return nil
}
func (h *Handler) applyFinanceCoinSellerOverview(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, overview *rechargeBillOverviewDTO) error {
if overview == nil {
return nil
}
rechargeType := normalizedRechargeType(query.RechargeType)
if rechargeType != "" && rechargeType != "coin_seller" {
for i := range overview.Daily {
overview.Daily[i].CoinSellerUsdMinor = 0
overview.Daily[i].CoinSellerCoinAmount = 0
}
return nil
}
stats, err := h.financeCoinSellerRechargeStats(ctx, appCode, query)
if err != nil {
return err
}
byDate := map[string]int{}
for i := range overview.Daily {
byDate[overview.Daily[i].Date] = i
// 财务工作台的币商充值只认后台币商订单 + H5 币商身份充值wallet 库存流水和 legacy dashboard 旧聚合先清掉再重填。
overview.Daily[i].CoinSellerUsdMinor = 0
overview.Daily[i].CoinSellerCoinAmount = 0
}
for date, bucket := range stats.Daily {
if date == "" {
continue
}
index, ok := byDate[date]
if !ok {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{Date: date})
index = len(overview.Daily) - 1
byDate[date] = index
}
overview.Daily[index].CoinSellerUsdMinor = bucket.USDMinorAmount
overview.Daily[index].CoinSellerCoinAmount = bucket.CoinAmount
}
sort.Slice(overview.Daily, func(i, j int) bool {
return overview.Daily[i].Date < overview.Daily[j].Date
})
if rechargeType == "coin_seller" {
overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, stats.Regions)
return nil
}
if rechargeType == "" {
overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, mergeRechargeRegionBuckets(overview.Regions, stats.Regions))
}
return nil
}
func (h *Handler) financeCoinSellerRechargeStats(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) (financeCoinSellerRechargeStats, error) {
stats := financeCoinSellerRechargeStats{Daily: map[string]rechargeBillSummaryBucketDTO{}}
rechargeType := normalizedRechargeType(query.RechargeType)
if rechargeType != "" && rechargeType != "coin_seller" {
return stats, nil
}
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return stats, nil
}
if h != nil && h.store != nil {
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode,
RegionID: query.RegionID,
TargetUserID: query.TargetUserID,
Keyword: query.Keyword,
CreatedFromMS: query.StartAtMS,
CreatedToMS: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
})
if err != nil {
return stats, err
}
stats.addBucket(rechargeBillSummaryBucketDTO{
BillCount: repoStats.VerifiedCount,
CoinAmount: repoStats.VerifiedCoinAmount,
USDMinorAmount: repoStats.VerifiedUSDMinor,
})
for date, bucket := range repoStats.VerifiedDaily {
stats.addDaily(date, rechargeBillSummaryBucketDTO{BillCount: bucket.Count, CoinAmount: bucket.CoinAmount, USDMinorAmount: bucket.USDMinor})
}
for _, row := range repoStats.VerifiedRegionBuckets {
stats.addRegion(row.RegionID, row.Count, row.USDMinor)
}
}
if h != nil && h.walletDB != nil {
if err := h.addH5CoinSellerRechargeStats(ctx, appCode, query, &stats); err != nil {
return stats, err
}
}
sort.Slice(stats.Regions, func(i, j int) bool {
return stats.Regions[i].UsdMinorAmount > stats.Regions[j].UsdMinorAmount
})
return stats, nil
}
func (s *financeCoinSellerRechargeStats) addBucket(bucket rechargeBillSummaryBucketDTO) {
if s == nil {
return
}
s.Total.BillCount += bucket.BillCount
s.Total.CoinAmount += bucket.CoinAmount
s.Total.USDMinorAmount += bucket.USDMinorAmount
}
func (s *financeCoinSellerRechargeStats) addDaily(date string, bucket rechargeBillSummaryBucketDTO) {
if s == nil || date == "" {
return
}
if s.Daily == nil {
s.Daily = map[string]rechargeBillSummaryBucketDTO{}
}
current := s.Daily[date]
current.BillCount += bucket.BillCount
current.CoinAmount += bucket.CoinAmount
current.USDMinorAmount += bucket.USDMinorAmount
s.Daily[date] = current
}
func (s *financeCoinSellerRechargeStats) addRegion(regionID int64, billCount int64, usdMinor int64) {
if s == nil || regionID <= 0 {
return
}
for i := range s.Regions {
if s.Regions[i].RegionID == regionID {
s.Regions[i].BillCount += billCount
s.Regions[i].UsdMinorAmount += usdMinor
return
}
}
s.Regions = append(s.Regions, rechargeBillRegionBucketDTO{RegionID: regionID, BillCount: billCount, UsdMinorAmount: usdMinor})
}
func (h *Handler) addH5CoinSellerRechargeStats(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, stats *financeCoinSellerRechargeStats) error {
where, args := h5CoinSellerRechargeWhere(appCode, query)
var total rechargeBillSummaryBucketDTO
if err := h.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(eo.coin_amount), 0), COALESCE(SUM(eo.usd_minor_amount), 0)
FROM external_recharge_orders eo
JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id
`+where, args...).Scan(&total.BillCount, &total.CoinAmount, &total.USDMinorAmount); err != nil {
return err
}
stats.addBucket(total)
tzOffsetMS := int64(query.TzOffsetMinutes) * 60_000
dailyArgs := append([]any{tzOffsetMS}, args...)
rows, err := h.walletDB.QueryContext(ctx, `
SELECT DATE_FORMAT(FROM_UNIXTIME((eo.updated_at_ms + ?) / 1000), '%Y-%m-%d') AS date,
COUNT(*), COALESCE(SUM(eo.coin_amount), 0), COALESCE(SUM(eo.usd_minor_amount), 0)
FROM external_recharge_orders eo
JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id
`+where+`
GROUP BY date
ORDER BY date ASC`, dailyArgs...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var date string
var bucket rechargeBillSummaryBucketDTO
if err := rows.Scan(&date, &bucket.BillCount, &bucket.CoinAmount, &bucket.USDMinorAmount); err != nil {
return err
}
stats.addDaily(date, bucket)
}
if err := rows.Err(); err != nil {
return err
}
regionRows, err := h.walletDB.QueryContext(ctx, `
SELECT eo.target_region_id, COUNT(*), COALESCE(SUM(eo.usd_minor_amount), 0)
FROM external_recharge_orders eo
JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id
`+where+`
GROUP BY eo.target_region_id
ORDER BY 3 DESC`, args...)
if err != nil {
return err
}
defer regionRows.Close()
for regionRows.Next() {
var regionID, billCount, usdMinor int64
if err := regionRows.Scan(&regionID, &billCount, &usdMinor); err != nil {
return err
}
stats.addRegion(regionID, billCount, usdMinor)
}
return regionRows.Err()
}
func (h *Handler) listFinanceCoinSellerRechargeBills(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) {
if !financeCoinSellerSucceededStatus(query.Status) || strings.TrimSpace(query.PaidState) != "" || query.HasUserOnlyFilter {
return []rechargeBillDTO{}, 0, nil
}
page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*2)
var total int64
if h != nil && h.store != nil {
orders, orderTotal, err := h.store.ListCoinSellerRechargeOrders(repository.CoinSellerRechargeOrderListOptions{
Page: 1,
PageSize: prefetch,
PageSizeLimit: prefetch,
AppCode: appCode,
VerifyStatus: model.CoinSellerRechargeVerifyStatusVerified,
Keyword: query.Keyword,
TargetUserID: query.TargetUserID,
RegionID: query.RegionID,
CreatedFromMS: query.StartAtMS,
CreatedToMS: query.EndAtMS,
})
if err != nil {
return nil, 0, err
}
total += orderTotal
for _, order := range orders {
if query.RegionID > 0 && order.TargetRegionID != query.RegionID {
continue
}
items = append(items, financeCoinSellerRechargeOrderBill(order))
}
}
if h != nil && h.walletDB != nil {
h5Items, h5Total, err := h.listH5CoinSellerRechargeBills(ctx, appCode, query, prefetch)
if err != nil {
return nil, 0, err
}
total += h5Total
items = append(items, h5Items...)
}
sortRechargeBills(items)
offset := (page - 1) * pageSize
if offset >= len(items) {
return []rechargeBillDTO{}, total, nil
}
end := offset + pageSize
if end > len(items) {
end = len(items)
}
return items[offset:end], total, nil
}
func (h *Handler) listFinanceAllRechargeBills(ctx context.Context, appCode string, source RechargeBillSource, query financeCoinSellerRechargeQuery) ([]rechargeBillDTO, int64, error) {
page, pageSize := financeCoinSellerPage(query.Page, query.PageSize, query.MaxPageSize)
prefetch := financeRechargePrefetch(page, pageSize, query.MaxPageSize)
items := make([]rechargeBillDTO, 0, prefetch*3)
var total int64
if source != nil {
if !query.HasUserOnlyFilter && query.TargetUserID == 0 {
// legacy 普通充值源没有 user/seller 过滤能力;有用户筛选时宁可不展示普通源,避免 all 视图返回未过滤全量。
ordinary, ordinaryTotal, err := source.ListRechargeBills(ctx, legacyRechargeBillQuery{
Keyword: query.Keyword,
RechargeType: "",
PaidState: query.PaidState,
RegionID: query.RegionID,
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
Page: 1,
PageSize: prefetch,
})
if err != nil {
return nil, 0, err
}
items = append(items, ordinary...)
total += ordinaryTotal
}
} else if h != nil && h.wallet != nil {
// wallet 的空 recharge_type 会并入 coin_seller_stock_recordsall 视图必须拆成普通三方和 Google 两路,币商改走财务新口径。
for _, rechargeType := range []string{"third_party", "google_play_recharge"} {
resp, err := h.wallet.ListRechargeBills(ctx, &walletv1.ListRechargeBillsRequest{
RequestId: query.RequestID,
AppCode: appCode,
UserId: query.UserID,
SellerUserId: query.TargetUserID,
RegionId: query.RegionID,
RechargeType: rechargeType,
PaidState: query.PaidState,
Status: query.Status,
Keyword: query.Keyword,
StartAtMs: query.StartAtMS,
EndAtMs: query.EndAtMS,
Page: 1,
PageSize: int32(prefetch),
})
if err != nil {
return nil, 0, err
}
total += resp.GetTotal()
for _, item := range resp.GetBills() {
items = append(items, rechargeBillFromProto(item))
}
}
}
coinItems, coinTotal, err := h.listFinanceCoinSellerRechargeBills(ctx, appCode, financeCoinSellerRechargeQuery{
RechargeType: "coin_seller",
Status: query.Status,
PaidState: query.PaidState,
Keyword: query.Keyword,
RegionID: query.RegionID,
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
TargetUserID: query.TargetUserID,
HasUserOnlyFilter: query.HasUserOnlyFilter,
Page: 1,
PageSize: prefetch,
MaxPageSize: prefetch,
})
if err != nil {
return nil, 0, err
}
items = append(items, coinItems...)
total += coinTotal
sortRechargeBills(items)
offset := (page - 1) * pageSize
if offset >= len(items) {
return []rechargeBillDTO{}, total, nil
}
end := offset + pageSize
if end > len(items) {
end = len(items)
}
return items[offset:end], total, nil
}
func (h *Handler) walletOrdinaryRechargeOverviewRegions(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery) ([]rechargeBillRegionBucketDTO, error) {
if h == nil || h.wallet == nil {
return []rechargeBillRegionBucketDTO{}, nil
}
if !financeWalletOverviewSupportsOrdinaryFilters(query) {
// wallet overview proto 没有用户、币商、关键词和 paid_state 字段;过滤条件存在时返回空区域,避免展示未过滤的全量区域。
return []rechargeBillRegionBucketDTO{}, nil
}
var out []rechargeBillRegionBucketDTO
for _, rechargeType := range []string{"third_party", "google_play_recharge"} {
resp, err := h.wallet.GetRechargeBillOverview(ctx, &walletv1.GetRechargeBillOverviewRequest{
RequestId: query.RequestID,
AppCode: appCode,
RegionId: query.RegionID,
RechargeType: rechargeType,
Status: query.Status,
StartAtMs: query.StartAtMS,
EndAtMs: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
})
if err != nil {
return nil, err
}
for _, bucket := range resp.GetRegions() {
out = append(out, rechargeBillRegionBucketDTO{
RegionID: bucket.GetRegionId(),
BillCount: bucket.GetBillCount(),
UsdMinorAmount: bucket.GetUsdMinorAmount(),
})
}
}
return mergeRechargeRegionBuckets(out), nil
}
func financeWalletOverviewSupportsOrdinaryFilters(query financeCoinSellerRechargeQuery) bool {
return query.UserID == 0 && query.TargetUserID == 0 && strings.TrimSpace(query.Keyword) == "" && strings.TrimSpace(query.PaidState) == ""
}
func (h *Handler) listH5CoinSellerRechargeBills(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, limit int) ([]rechargeBillDTO, int64, error) {
where, args := h5CoinSellerRechargeWhere(appCode, query)
var total int64
if err := h.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM external_recharge_orders eo
JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id
`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
if limit < 1 {
return []rechargeBillDTO{}, total, nil
}
rows, err := h.walletDB.QueryContext(ctx, `
SELECT eo.app_code, eo.order_id, wt.command_id, eo.target_user_id, eo.target_region_id,
eo.product_id, eo.product_code, eo.product_name, eo.coin_amount, eo.usd_minor_amount,
eo.provider_code, eo.currency_code, eo.provider_amount_minor, eo.provider_order_id,
eo.wallet_transaction_id, eo.updated_at_ms, eo.created_at_ms
FROM external_recharge_orders eo
JOIN wallet_transactions wt ON wt.app_code = eo.app_code AND wt.transaction_id = eo.wallet_transaction_id
`+where+`
ORDER BY eo.updated_at_ms DESC, eo.order_id DESC
LIMIT ?`, append(args, limit)...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]rechargeBillDTO, 0, limit)
for rows.Next() {
var item rechargeBillDTO
var productID int64
var productCode, productName, providerOrderID, walletTransactionID string
var createdAtMS int64
if err := rows.Scan(
&item.AppCode,
&item.ExternalRef,
&item.CommandID,
&item.SellerUserID,
&item.SellerRegionID,
&productID,
&productCode,
&productName,
&item.CoinAmount,
&item.USDMinorAmount,
&item.ProviderCode,
&item.UserPaidCurrencyCode,
&item.ProviderAmountMinor,
&providerOrderID,
&walletTransactionID,
&item.CreatedAtMS,
&createdAtMS,
); err != nil {
return nil, 0, err
}
item.TransactionID = firstNonEmptyPaymentString(walletTransactionID, item.ExternalRef)
item.RechargeType = "coin_seller"
item.Status = "succeeded"
item.TargetRegionID = item.SellerRegionID
item.PolicyID = productID
item.PolicyVersion = productCode
item.CurrencyCode = "USD"
item.UserPaidAmountMicro = item.ProviderAmountMinor * 10_000
item.PaidSyncedAtMS = item.CreatedAtMS
if item.UserPaidCurrencyCode == "" {
item.UserPaidCurrencyCode = "USD"
item.UserPaidAmountMicro = item.USDMinorAmount * 10_000
}
if providerOrderID != "" {
item.ExternalRef = providerOrderID
}
if item.CreatedAtMS == 0 {
item.CreatedAtMS = createdAtMS
}
items = append(items, item)
}
return items, total, rows.Err()
}
func financeCoinSellerRechargeOrderBill(order model.CoinSellerRechargeOrder) rechargeBillDTO {
currency := firstNonEmptyPaymentString(order.ProviderCurrencyCode, "USD")
providerAmountMinor := order.ProviderAmountMinor
userPaidAmountMicro := providerAmountMinor * 10_000
if providerAmountMinor == 0 {
userPaidAmountMicro = order.USDMinorAmount * 10_000
}
return rechargeBillDTO{
AppCode: order.AppCode,
TransactionID: firstNonEmptyPaymentString(order.WalletTransactionID, fmt.Sprintf("coin-seller-order:%d", order.ID)),
CommandID: order.WalletCommandID,
RechargeType: "coin_seller",
Status: "succeeded",
ExternalRef: firstNonEmptyPaymentString(order.ProviderOrderID, order.ExternalOrderNo),
SellerUserID: order.TargetUserID,
SellerRegionID: order.TargetRegionID,
TargetRegionID: order.TargetRegionID,
CurrencyCode: "USD",
CoinAmount: order.CoinAmount,
USDMinorAmount: order.USDMinorAmount,
ProviderAmountMinor: providerAmountMinor,
CreatedAtMS: order.CreatedAtMS,
ProviderCode: order.ProviderCode,
UserPaidCurrencyCode: currency,
UserPaidAmountMicro: userPaidAmountMicro,
PaidSyncedAtMS: order.CreatedAtMS,
}
}
func h5CoinSellerRechargeWhere(appCode string, query financeCoinSellerRechargeQuery) (string, []any) {
where := []string{
"eo.app_code = ?",
"eo.audience_type = 'coin_seller'",
"eo.status = 'credited'",
"eo.wallet_transaction_id <> ''",
"wt.biz_type = 'coin_seller_recharge'",
"wt.status = 'succeeded'",
}
args := []any{appCode}
if query.TargetUserID > 0 {
where = append(where, "eo.target_user_id = ?")
args = append(args, query.TargetUserID)
}
if query.RegionID > 0 {
where = append(where, "eo.target_region_id = ?")
args = append(args, query.RegionID)
}
if query.StartAtMS > 0 {
where = append(where, "eo.updated_at_ms >= ?")
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where = append(where, "eo.updated_at_ms < ?")
args = append(args, query.EndAtMS)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
where = append(where, "(eo.order_id LIKE ? OR eo.provider_order_id LIKE ? OR eo.wallet_transaction_id LIKE ? OR eo.product_code LIKE ? OR eo.product_name LIKE ? OR eo.tx_hash LIKE ? OR CAST(eo.target_user_id AS CHAR) LIKE ?)")
args = append(args, like, like, like, like, like, like, like)
}
return "WHERE " + strings.Join(where, " AND "), args
}
func financeCoinSellerSucceededStatus(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "", "all", "succeeded", "success":
return true
default:
return false
}
}
func financeCoinSellerPage(page int, pageSize int, maxPageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
if maxPageSize <= 0 {
maxPageSize = financeCoinSellerRechargeMaxPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return page, pageSize
}
func financeRechargePrefetch(page int, pageSize int, maxPageSize int) int {
prefetch := page * pageSize
limit := financeRechargeMergeMaxPrefetch
if maxPageSize > limit {
limit = maxPageSize
}
if prefetch > limit {
return limit
}
return prefetch
}
func sortRechargeBills(items []rechargeBillDTO) {
sort.Slice(items, func(i, j int) bool {
if items[i].CreatedAtMS == items[j].CreatedAtMS {
return items[i].TransactionID > items[j].TransactionID
}
return items[i].CreatedAtMS > items[j].CreatedAtMS
})
}
func mergeRechargeRegionBuckets(groups ...[]rechargeBillRegionBucketDTO) []rechargeBillRegionBucketDTO {
byRegion := map[int64]*rechargeBillRegionBucketDTO{}
for _, group := range groups {
for _, row := range group {
if row.RegionID <= 0 {
continue
}
bucket, ok := byRegion[row.RegionID]
if !ok {
copy := row
byRegion[row.RegionID] = &copy
continue
}
bucket.BillCount += row.BillCount
bucket.UsdMinorAmount += row.UsdMinorAmount
if bucket.Name == "" {
bucket.Name = row.Name
}
}
}
out := make([]rechargeBillRegionBucketDTO, 0, len(byRegion))
for _, bucket := range byRegion {
out = append(out, *bucket)
}
sort.Slice(out, func(i, j int) bool {
return out[i].UsdMinorAmount > out[j].UsdMinorAmount
})
return out
}
func (h *Handler) fillFinanceCoinSellerRegionNames(ctx context.Context, appCode string, buckets []rechargeBillRegionBucketDTO) []rechargeBillRegionBucketDTO {
if len(buckets) == 0 {
return buckets
}
names := map[int64]string{}
if h != nil {
if regions, err := h.listRechargeBillRegions(ctx, appCode); err == nil {
for _, region := range regions {
name := firstNonEmptyPaymentString(region.Name, region.RegionCode, strconv.FormatInt(region.RegionID, 10))
names[region.RegionID] = name
}
}
}
for i := range buckets {
if name := names[buckets[i].RegionID]; name != "" {
buckets[i].Name = name
continue
}
buckets[i].Name = strconv.FormatInt(buckets[i].RegionID, 10)
}
return buckets
}
func firstNonEmptyPaymentString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -77,10 +77,7 @@ func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *re
func (h *Handler) ListRechargeBills(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
h.listLegacyRechargeBills(c, source)
return
}
rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType"))
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
@ -95,13 +92,58 @@ func (h *Handler) ListRechargeBills(c *gin.Context) {
response.OK(c, response.Page{Items: []rechargeBillDTO{}, Page: options.Page, PageSize: options.PageSize, Total: 0})
return
}
source, hasSource := h.billSources[appCode]
normalizedType := normalizedRechargeType(rechargeType)
if normalizedType == "" || normalizedType == "coin_seller" {
query := financeCoinSellerRechargeQuery{
RechargeType: rechargeType,
Status: options.Status,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
Page: options.Page,
PageSize: options.PageSize,
RequestID: middleware.CurrentRequestID(c),
UserID: userID,
}
var items []rechargeBillDTO
var total int64
var err error
if normalizedType == "coin_seller" {
items, total, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query)
} else {
var legacySource RechargeBillSource
if hasSource {
legacySource = source
}
items, total, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, legacySource, query)
}
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "获取账单用户资料失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
return
}
if hasSource {
h.listLegacyRechargeBills(c, source)
return
}
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
RechargeType: rechargeType,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Status: options.Status,
Keyword: options.Keyword,

View File

@ -87,6 +87,34 @@ func TestListRechargeBillsReturnsProviderAmountMinor(t *testing.T) {
}
}
func TestGetRechargeBillSummaryKeepsOrdinaryThirdParty(t *testing.T) {
wallet := &mockPaymentWallet{rechargeSummaryResp: &walletv1.GetRechargeBillSummaryResponse{
Total: &walletv1.RechargeBillSummaryBucket{BillCount: 31, CoinAmount: 310000, UsdMinorAmount: 12345},
ThirdParty: &walletv1.RechargeBillSummaryBucket{BillCount: 31, CoinAmount: 310000, UsdMinorAmount: 12345},
}}
router := newPaymentHandlerTestRouter(New(wallet, nil, nil, nil, nil))
request := httptest.NewRequest(http.MethodGet, "/admin/payment/recharge-bills/summary?recharge_type=third_party&status=succeeded", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response adminPaymentTestResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
thirdParty := response.Data["thirdParty"].(map[string]any)
total := response.Data["total"].(map[string]any)
if thirdParty["billCount"] != float64(31) || thirdParty["usdMinorAmount"] != float64(12345) {
t.Fatalf("ordinary third party summary should be preserved: %+v", response.Data)
}
if total["billCount"] != float64(31) || total["usdMinorAmount"] != float64(12345) {
t.Fatalf("total should be recomputed from ordinary third party bucket: %+v", response.Data)
}
}
func TestListThirdPartyPaymentChannelsForwardsIncludeDisabledMethods(t *testing.T) {
wallet := &mockPaymentWallet{thirdPartyChannelsResp: &walletv1.ListThirdPartyPaymentChannelsResponse{
Channels: []*walletv1.ThirdPartyPaymentChannel{{
@ -609,6 +637,7 @@ func newPaymentHandlerTestRouter(handler *Handler) *gin.Engine {
c.Next()
})
router.GET("/admin/payment/recharge-bills", handler.ListRechargeBills)
router.GET("/admin/payment/recharge-bills/summary", handler.GetRechargeBillSummary)
router.GET("/admin/payment/third-party-channels", handler.ListThirdPartyPaymentChannels)
router.GET("/admin/payment/temporary-links", handler.ListTemporaryPaymentLinks)
router.POST("/admin/payment/temporary-links", handler.CreateTemporaryPaymentLink)
@ -755,6 +784,7 @@ type mockPaymentWallet struct {
lastRechargeBills *walletv1.ListRechargeBillsRequest
rechargeBillsResp *walletv1.ListRechargeBillsResponse
rechargeSummaryResp *walletv1.GetRechargeBillSummaryResponse
lastThirdPartyChannels *walletv1.ListThirdPartyPaymentChannelsRequest
thirdPartyChannelsResp *walletv1.ListThirdPartyPaymentChannelsResponse
lastTemporaryOrders *walletv1.ListTemporaryRechargeOrdersRequest
@ -779,6 +809,13 @@ func (m *mockPaymentWallet) ListRechargeBills(_ context.Context, req *walletv1.L
return &walletv1.ListRechargeBillsResponse{}, nil
}
func (m *mockPaymentWallet) GetRechargeBillSummary(_ context.Context, req *walletv1.GetRechargeBillSummaryRequest) (*walletv1.GetRechargeBillSummaryResponse, error) {
if m.rechargeSummaryResp != nil {
return m.rechargeSummaryResp, nil
}
return &walletv1.GetRechargeBillSummaryResponse{}, nil
}
func (m *mockPaymentWallet) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
m.lastThirdPartyChannels = req
if m.thirdPartyChannelsResp != nil {

View File

@ -248,7 +248,8 @@ func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query l
return nil, 0, fmt.Errorf("legacy bill source is not configured")
}
if legacyQueryRechargeType(query) == "coin_seller" {
return s.listCoinSellerRechargeBills(ctx, query)
// 财务工作台币商充值已切到后台币商订单 + H5 币商身份订单legacy dashboard 进货聚合不再作为账单来源。
return []rechargeBillDTO{}, 0, nil
}
if s.collection == nil {
return nil, 0, fmt.Errorf("legacy bill source is not configured")
@ -565,12 +566,7 @@ func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, qu
return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured")
}
if legacyQueryRechargeType(query) == "coin_seller" {
summary := rechargeBillSummaryDTO{}
if err := s.mergeCoinSellerSummary(ctx, query, legacySummaryTZOffset(query), &summary); err != nil {
return rechargeBillSummaryDTO{}, err
}
summary.Total = summary.CoinSeller
return summary, nil
return rechargeBillSummaryDTO{}, nil
}
filter, regionCodes, matchable, err := s.billFilter(ctx, query)
if err != nil {
@ -635,11 +631,6 @@ func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, qu
if err := cursor.Err(); err != nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("iterate legacy recharge summary for %s: %w", s.config.AppCode, err)
}
if legacyQueryRechargeType(query) == "" {
if err := s.mergeCoinSellerSummary(ctx, query, legacySummaryTZOffset(query), &summary); err != nil {
return rechargeBillSummaryDTO{}, err
}
}
legacyRecomputeSummaryTotal(&summary)
return summary, nil
}
@ -907,9 +898,6 @@ func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRech
return overview, err
}
if legacyQueryRechargeType(query) == "coin_seller" {
if err := s.mergeCoinSellerOverview(ctx, query, tzOffsetMinutes, &overview); err != nil {
return overview, err
}
return overview, nil
}
filter, regionCodes, matchable, err := s.billFilter(ctx, query)
@ -930,11 +918,6 @@ func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRech
if err := s.overviewGooglePaid(ctx, filter, query, &overview); err != nil {
return overview, err
}
if legacyQueryRechargeType(query) == "" {
if err := s.mergeCoinSellerOverview(ctx, query, tzOffsetMinutes, &overview); err != nil {
return overview, err
}
}
return overview, nil
}

View File

@ -234,7 +234,7 @@ func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Cont
return s.bills, s.billTotal, nil
}
func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
func TestLegacyCoinSellerListDoesNotUseDashboardBillSource(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "aslan",
billTotal: 2,
@ -283,37 +283,11 @@ func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
if err != nil {
t.Fatalf("ListRechargeBills: %v", err)
}
if total != 2 || len(items) != 2 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
if total != 0 || len(items) != 0 {
t.Fatalf("legacy dashboard coin seller bills should be disabled: total=%d items=%+v", total, items)
}
item := items[0]
if item.RechargeType != "coin_seller_stock_purchase" || item.ProviderCode != "coin_seller" {
t.Fatalf("unexpected coin seller mapping: %+v", item)
}
if item.TransactionID != "aslan-freight-purchase-99" || item.SellerUserID != 2069828597070528514 || item.USDMinorAmount != 132790 {
t.Fatalf("unexpected bill fields: %+v", item)
}
if item.SellerRegionID != 301 || item.TargetRegionID != 301 {
t.Fatalf("region mapping failed: %+v", item)
}
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
t.Fatalf("paid facts mismatch: %+v", item)
}
deduction := items[1]
if deduction.RechargeType != "coin_seller_stock_deduction" || deduction.ProviderCode != "coin_seller" {
t.Fatalf("unexpected deduction mapping: %+v", deduction)
}
if deduction.TransactionID != "aslan-freight-deduction-100" || deduction.SellerUserID != 2069828597070528515 || deduction.USDMinorAmount != -100000 {
t.Fatalf("unexpected deduction fields: %+v", deduction)
}
if deduction.SellerRegionID != 302 || deduction.TargetRegionID != 302 {
t.Fatalf("deduction region mapping failed: %+v", deduction)
}
if deduction.UserPaidCurrencyCode != "USD" || deduction.UserPaidAmountMicro != -1_000_000_000 {
t.Fatalf("deduction paid facts mismatch: %+v", deduction)
}
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 || dashboardSource.billQuery.StatTZ != "Asia/Shanghai" {
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
if dashboardSource.billCalls != 0 {
t.Fatalf("dashboard bill source should not be called: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
}
}

View File

@ -84,12 +84,61 @@ func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string)
// legacy 币商充值导出必须和页面列表使用同一个自然日解释方式。
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType"))
normalizedType := normalizedRechargeType(rechargeType)
if normalizedType == "" || normalizedType == "coin_seller" {
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return nil, false
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return nil, false
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
return []rechargeBillDTO{}, true
}
query := financeCoinSellerRechargeQuery{
RechargeType: rechargeType,
Status: options.Status,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
Page: 1,
PageSize: rechargeBillExportMaxRows,
MaxPageSize: rechargeBillExportMaxRows,
RequestID: middleware.CurrentRequestID(c),
UserID: userID,
}
var items []rechargeBillDTO
var err error
if normalizedType == "coin_seller" {
items, _, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query)
} else {
items, _, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, h.billSources[appCode], query)
}
if err != nil {
response.ServerError(c, "导出账单失败")
return nil, false
}
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "导出账单失败")
return nil, false
}
return capRechargeBillExportRows(items), true
}
if source, ok := h.billSources[appCode]; ok {
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
RechargeType: rechargeType,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
@ -230,6 +279,8 @@ func rechargeSourceLabel(rechargeType string) string {
switch rechargeType {
case "google_play_recharge":
return "谷歌充值"
case "coin_seller":
return "币商充值"
case "coin_seller_stock_purchase":
return "币商进货"
case "coin_seller_stock_deduction":

View File

@ -1,14 +1,11 @@
package payment
import (
"context"
"sort"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
@ -69,6 +66,20 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
}
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
response.OK(c, rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}})
return
}
if source, ok := h.billSources[appCode]; ok {
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
@ -78,22 +89,26 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
return
}
overview, err := source.Overview(c.Request.Context(), query, tzOffsetMinutes)
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
if userFilter.IsEmpty() && sellerFilter.IsEmpty() {
var err error
overview, err = source.Overview(c.Request.Context(), query, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
if err := h.applyFinanceThirdPartyOverview(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
}
if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
PaidState: query.PaidState,
Keyword: options.Keyword,
RegionID: query.RegionID,
StartAtMS: startAtMS,
EndAtMS: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
}, &overview); err != nil {
response.ServerError(c, "获取财务概览失败")
return
@ -103,11 +118,30 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
return
}
overview := rechargeBillOverviewDTO{
Daily: []rechargeBillDailyBucketDTO{},
Regions: []rechargeBillRegionBucketDTO{},
}
financeQuery := financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
RequestID: middleware.CurrentRequestID(c),
UserID: userID,
}
if financeWalletOverviewSupportsOrdinaryFilters(financeQuery) {
resp, err := h.wallet.GetRechargeBillOverview(c.Request.Context(), &walletv1.GetRechargeBillOverviewRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
RechargeType: financeQuery.RechargeType,
Status: options.Status,
StartAtMs: startAtMS,
EndAtMs: endAtMS,
@ -117,11 +151,7 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
writeWalletError(c, err, "获取财务概览失败")
return
}
overview := rechargeBillOverviewDTO{
Daily: make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily())),
Regions: make([]rechargeBillRegionBucketDTO, 0, len(resp.GetRegions())),
}
overview.Daily = make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily()))
for _, bucket := range resp.GetDaily() {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
Date: bucket.GetDate(),
@ -133,25 +163,6 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
CoinSellerCoinAmount: bucket.GetCoinSellerCoinAmount(),
})
}
// 区域名从后台区域目录补齐;目录缺失时回退区域 ID 文本,避免概览页出现空白区块。
regionNames := map[int64]string{}
if regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode); err == nil {
for _, region := range regions {
name := region.Name
if name == "" {
name = region.RegionCode
}
regionNames[region.RegionID] = name
}
}
for _, bucket := range resp.GetRegions() {
overview.Regions = append(overview.Regions, rechargeBillRegionBucketDTO{
RegionID: bucket.GetRegionId(),
Name: regionNames[bucket.GetRegionId()],
BillCount: bucket.GetBillCount(),
UsdMinorAmount: bucket.GetUsdMinorAmount(),
})
}
if stats := resp.GetGooglePaid(); stats != nil {
overview.GooglePaid = rechargeBillGooglePaidStatsDTO{
GoogleBillCount: stats.GetGoogleBillCount(),
@ -167,15 +178,16 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
}
if err := h.applyFinanceThirdPartyOverview(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
TzOffsetMinutes: tzOffsetMinutes,
}, &overview); err != nil {
}
if normalizedRechargeType(financeQuery.RechargeType) == "" {
ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(c.Request.Context(), appCode, financeQuery)
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
}
overview.Regions = h.fillFinanceCoinSellerRegionNames(c.Request.Context(), appCode, ordinaryRegions)
}
if err := h.applyFinanceCoinSellerOverview(c.Request.Context(), appCode, financeQuery, &overview); err != nil {
response.ServerError(c, "获取财务概览失败")
return
}
@ -183,73 +195,6 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
response.OK(c, overview)
}
func (h *Handler) applyFinanceThirdPartyOverview(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, overview *rechargeBillOverviewDTO) error {
if overview == nil {
return nil
}
stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query)
if err != nil {
return err
}
rechargeType := normalizedRechargeType(query.RechargeType)
byDate := map[string]int{}
for i := range overview.Daily {
byDate[overview.Daily[i].Date] = i
if rechargeType == "" || rechargeType == "third_party" {
// 覆写而不是累加ordinary 三方支付仍可存在于钱包账单,但不能进入 finance thirdParty 统计。
overview.Daily[i].ThirdPartyUsdMinor = 0
overview.Daily[i].ThirdPartyCoinAmount = 0
}
}
if rechargeType == "" || rechargeType == "third_party" {
for date, bucket := range stats.VerifiedDaily {
if date == "" {
continue
}
index, ok := byDate[date]
if !ok {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{Date: date})
index = len(overview.Daily) - 1
byDate[date] = index
}
overview.Daily[index].ThirdPartyUsdMinor = bucket.USDMinorAmount
overview.Daily[index].ThirdPartyCoinAmount = bucket.CoinAmount
}
}
sort.Slice(overview.Daily, func(i, j int) bool {
return overview.Daily[i].Date < overview.Daily[j].Date
})
if rechargeType == "third_party" {
// thirdParty 区域分布来自工单 target_region_id/target_country_id 快照,不能复用普通支付账单区域。
overview.Regions = financeThirdPartyRegionBuckets(stats.VerifiedRegions)
}
return nil
}
func financeThirdPartyRegionBuckets(rows []repository.CoinSellerRechargeOrderRegionStatsBucket) []rechargeBillRegionBucketDTO {
byRegion := map[int64]*rechargeBillRegionBucketDTO{}
for _, row := range rows {
if row.RegionID <= 0 {
continue
}
bucket, ok := byRegion[row.RegionID]
if !ok {
bucket = &rechargeBillRegionBucketDTO{RegionID: row.RegionID}
byRegion[row.RegionID] = bucket
}
bucket.BillCount += row.Count
bucket.UsdMinorAmount += row.USDMinor
}
out := make([]rechargeBillRegionBucketDTO, 0, len(byRegion))
for _, bucket := range byRegion {
out = append(out, *bucket)
}
sort.Slice(out, func(i, j int) bool {
return out[i].UsdMinorAmount > out[j].UsdMinorAmount
})
return out
}
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
return true

View File

@ -49,26 +49,6 @@ type googleRechargePaidDTO struct {
Error string `json:"error,omitempty"`
}
type financeThirdPartyRechargeStats struct {
Verified rechargeBillSummaryBucketDTO
GrantedOverlap rechargeBillSummaryBucketDTO
VerifiedDaily map[string]rechargeBillSummaryBucketDTO
GrantedDaily map[string]rechargeBillSummaryBucketDTO
VerifiedRegions []repository.CoinSellerRechargeOrderRegionStatsBucket
}
type financeThirdPartyRechargeQuery struct {
RechargeType string
Status string
Keyword string
RegionID int64
StartAtMS int64
EndAtMS int64
TzOffsetMinutes int32
TargetUserID int64
HasUserOnlyFilter bool
}
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
options := shared.ListOptions(c)
@ -105,6 +85,20 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
if source, ok := h.billSources[appCode]; ok {
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
response.OK(c, rechargeBillSummaryDTO{})
return
}
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
@ -114,22 +108,28 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
}
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
return
}
summary, err := source.SummarizeRechargeBills(c.Request.Context(), query)
summary := rechargeBillSummaryDTO{}
if !userFilter.IsEmpty() || !sellerFilter.IsEmpty() {
// legacy 普通充值汇总没有 user/seller 过滤能力;有用户筛选时只保留后续可精确计算的财务币商桶。
} else {
var err error
summary, err = source.SummarizeRechargeBills(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取充值汇总失败")
return
}
if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
}
if err := h.applyFinanceCoinSellerSummary(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
PaidState: query.PaidState,
Keyword: options.Keyword,
RegionID: query.RegionID,
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
TzOffsetMinutes: tzOffsetMinutes,
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
}, &summary); err != nil {
response.ServerError(c, "获取充值汇总失败")
return
@ -151,6 +151,9 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
response.OK(c, rechargeBillSummaryDTO{})
return
}
paidState := strings.TrimSpace(firstQuery(c, "paid_state", "paidState"))
var summary rechargeBillSummaryDTO
if paidState == "" {
resp, err := h.wallet.GetRechargeBillSummary(c.Request.Context(), &walletv1.GetRechargeBillSummaryRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
@ -167,15 +170,17 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
writeWalletError(c, err, "获取充值汇总失败")
return
}
summary := rechargeBillSummaryDTO{
summary = rechargeBillSummaryDTO{
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()),
}
if err := h.applyFinanceThirdPartySummary(c.Request.Context(), appCode, financeThirdPartyRechargeQuery{
}
if err := h.applyFinanceCoinSellerSummary(c.Request.Context(), appCode, financeCoinSellerRechargeQuery{
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
PaidState: paidState,
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
@ -201,81 +206,6 @@ func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBuck
}
}
func (h *Handler) applyFinanceThirdPartySummary(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery, summary *rechargeBillSummaryDTO) error {
if summary == nil {
return nil
}
stats, err := h.financeThirdPartyRechargeStats(ctx, appCode, query)
if err != nil {
return err
}
rechargeType := normalizedRechargeType(query.RechargeType)
if rechargeType == "" || rechargeType == "third_party" {
// 财务页 thirdParty 只表示后台工单校验通过的币商充值外部订单;普通 MifaPay/V5Pay/USDT H5 支付不能进入该桶。
summary.ThirdParty = stats.Verified
} else {
summary.ThirdParty = rechargeBillSummaryBucketDTO{}
}
if rechargeType != "" && rechargeType != "coin_seller" {
summary.CoinSeller = rechargeBillSummaryBucketDTO{}
}
if rechargeType != "" && rechargeType != "google_play_recharge" {
summary.GooglePlay = rechargeBillSummaryBucketDTO{}
}
if rechargeType == "" {
// coinSeller 桶保持全量展示total 扣掉已落账重叠部分,避免同一财务工单同时经 thirdParty 与 coinSeller 重复计入总额。
summary.Total = nonNegativeRechargeBillBucket(rechargeBillSummaryBucketDTO{
BillCount: summary.GooglePlay.BillCount + summary.CoinSeller.BillCount + summary.ThirdParty.BillCount - stats.GrantedOverlap.BillCount,
CoinAmount: summary.GooglePlay.CoinAmount + summary.CoinSeller.CoinAmount + summary.ThirdParty.CoinAmount - stats.GrantedOverlap.CoinAmount,
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.CoinSeller.USDMinorAmount + summary.ThirdParty.USDMinorAmount - stats.GrantedOverlap.USDMinorAmount,
})
return nil
}
legacyRecomputeSummaryTotal(summary)
return nil
}
func (h *Handler) financeThirdPartyRechargeStats(ctx context.Context, appCode string, query financeThirdPartyRechargeQuery) (financeThirdPartyRechargeStats, error) {
stats := financeThirdPartyRechargeStats{
VerifiedDaily: map[string]rechargeBillSummaryBucketDTO{},
GrantedDaily: map[string]rechargeBillSummaryBucketDTO{},
}
rechargeType := normalizedRechargeType(query.RechargeType)
if rechargeType != "" && rechargeType != "third_party" && rechargeType != "coin_seller" {
return stats, nil
}
if h == nil || h.store == nil || query.HasUserOnlyFilter {
return stats, nil
}
repoStats, err := h.store.CoinSellerRechargeOrderStats(repository.CoinSellerRechargeOrderStatsOptions{
AppCode: appCode,
Status: query.Status,
RegionID: query.RegionID,
TargetUserID: query.TargetUserID,
Keyword: query.Keyword,
CreatedFromMS: query.StartAtMS,
CreatedToMS: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
})
if err != nil {
return stats, err
}
stats.Verified = rechargeBillSummaryBucketDTO{BillCount: repoStats.VerifiedCount, CoinAmount: repoStats.VerifiedCoinAmount, USDMinorAmount: repoStats.VerifiedUSDMinor}
stats.GrantedOverlap = rechargeBillSummaryBucketDTO{BillCount: repoStats.GrantedCount, CoinAmount: repoStats.GrantedCoinAmount, USDMinorAmount: repoStats.GrantedUSDMinor}
stats.VerifiedDaily = financeThirdPartyDailyStats(repoStats.VerifiedDaily)
stats.GrantedDaily = financeThirdPartyDailyStats(repoStats.GrantedDaily)
stats.VerifiedRegions = repoStats.VerifiedRegionBuckets
return stats, nil
}
func financeThirdPartyDailyStats(in map[string]repository.CoinSellerRechargeOrderStatsBucket) map[string]rechargeBillSummaryBucketDTO {
out := make(map[string]rechargeBillSummaryBucketDTO, len(in))
for date, bucket := range in {
out[date] = rechargeBillSummaryBucketDTO{BillCount: bucket.Count, CoinAmount: bucket.CoinAmount, USDMinorAmount: bucket.USDMinor}
}
return out
}
func normalizedRechargeType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@ -19,6 +19,7 @@ var (
type CoinSellerRechargeOrderListOptions struct {
Page int
PageSize int
PageSizeLimit int
AppCode string
Status string
VerifyStatus string
@ -26,6 +27,7 @@ type CoinSellerRechargeOrderListOptions struct {
ProviderCode string
Keyword string
TargetUserID int64
RegionID int64
OperatorID uint
CreatedFromMS int64
CreatedToMS int64
@ -111,7 +113,7 @@ func (s *Store) GetCoinSellerRechargeOrder(id uint) (*model.CoinSellerRechargeOr
}
func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderListOptions) ([]model.CoinSellerRechargeOrder, int64, error) {
page, pageSize := normalizePage(options.Page, options.PageSize)
page, pageSize := normalizeCoinSellerRechargeOrderPage(options.Page, options.PageSize, options.PageSizeLimit)
query := applyCoinSellerRechargeOrderFilters(s.db.Model(&model.CoinSellerRechargeOrder{}), options)
var total int64
if err := query.Count(&total).Error; err != nil {
@ -126,6 +128,22 @@ func (s *Store) ListCoinSellerRechargeOrders(options CoinSellerRechargeOrderList
return orders, total, err
}
func normalizeCoinSellerRechargeOrderPage(page int, pageSize int, pageSizeLimit int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
if pageSizeLimit <= 0 {
pageSizeLimit = 100
}
if pageSize > pageSizeLimit {
pageSize = pageSizeLimit
}
return page, pageSize
}
func (s *Store) CoinSellerRechargeOrderStats(options CoinSellerRechargeOrderStatsOptions) (CoinSellerRechargeOrderStats, error) {
stats := CoinSellerRechargeOrderStats{
VerifiedDaily: map[string]CoinSellerRechargeOrderStatsBucket{},
@ -350,6 +368,9 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha
if options.TargetUserID > 0 {
query = query.Where("target_user_id = ?", options.TargetUserID)
}
if options.RegionID > 0 {
query = query.Where("target_region_id = ?", options.RegionID)
}
if options.OperatorID > 0 {
query = query.Where("operator_user_id = ?", options.OperatorID)
}
@ -361,7 +382,7 @@ func applyCoinSellerRechargeOrderFilters(query *gorm.DB, options CoinSellerRecha
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("external_order_no LIKE ? OR target_display_user_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like)
query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like, like)
}
return query
}
@ -392,7 +413,7 @@ func applyCoinSellerRechargeOrderStatsFilters(query *gorm.DB, options CoinSeller
}
if keyword := strings.TrimSpace(options.Keyword); keyword != "" {
like := "%" + keyword + "%"
query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like)
query = query.Where("external_order_no LIKE ? OR provider_order_id LIKE ? OR target_display_user_id LIKE ? OR wallet_transaction_id LIKE ? OR operator_name LIKE ? OR verified_by_name LIKE ? OR granted_by_name LIKE ? OR remark LIKE ? OR CAST(target_user_id AS CHAR) LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like, like, like, like, like)
}
return query
}