hyapp-server/server/admin/internal/modules/payment/recharge_bill_overview.go
2026-07-12 00:47:20 +08:00

407 lines
15 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 payment
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
type rechargeBillDailyBucketDTO struct {
Date string `json:"date"`
GoogleUsdMinor int64 `json:"googleUsdMinor"`
ThirdPartyUsdMinor int64 `json:"thirdPartyUsdMinor"`
CoinSellerUsdMinor int64 `json:"coinSellerUsdMinor"`
GoogleCoinAmount int64 `json:"googleCoinAmount"`
ThirdPartyCoinAmount int64 `json:"thirdPartyCoinAmount"`
CoinSellerCoinAmount int64 `json:"coinSellerCoinAmount"`
}
type rechargeBillRegionBucketDTO struct {
RegionID int64 `json:"regionId"`
Name string `json:"name"`
BillCount int64 `json:"billCount"`
UsdMinorAmount int64 `json:"usdMinorAmount"`
}
type rechargeBillGooglePaidStatsDTO struct {
GoogleBillCount int64 `json:"googleBillCount"`
SyncedCount int64 `json:"syncedCount"`
UnsyncedCount int64 `json:"unsyncedCount"`
CoveredUsdMinor int64 `json:"coveredUsdMinor"`
EstFeeUsdMinor int64 `json:"estFeeUsdMinor"`
EstTaxUsdMinor int64 `json:"estTaxUsdMinor"`
EstNetUsdMinor int64 `json:"estNetUsdMinor"`
}
// rechargeBillWithdrawalStatsDTO 是“用户提现”合并口径:工资转币商(钱包账本)+ 审核通过的提现申请admin 库)。
type rechargeBillWithdrawalStatsDTO struct {
TransferCount int64 `json:"transferCount"`
TransferUsdMinor int64 `json:"transferUsdMinor"`
ApprovedCount int64 `json:"approvedCount"`
ApprovedUsdMinor int64 `json:"approvedUsdMinor"`
TotalUsdMinor int64 `json:"totalUsdMinor"`
}
type rechargeBillOverviewDTO struct {
Daily []rechargeBillDailyBucketDTO `json:"daily"`
Regions []rechargeBillRegionBucketDTO `json:"regions"`
GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"`
Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"`
}
// FinanceRechargeOverviewQuery 是供其他后台模块复用财务充值口径的只读查询。
// ScopeRegionIDs 非空时只汇总这些区域,避免数据范围受限的 BI 查询先读全量再裁剪。
type FinanceRechargeOverviewQuery struct {
AppCode string
StartAtMS int64
EndAtMS int64
ScopeRegionIDs []int64
IncludeRegions bool
TZOffsetMinutes int32
}
// FinanceRechargeDailyBucket 与 Finance 页面趋势使用同一组渠道字段和切日口径。
type FinanceRechargeDailyBucket struct {
Date string
GoogleUSDMinor int64
ThirdPartyUSDMinor int64
CoinSellerUSDMinor int64
}
// FinanceRechargeRegionOverview 在全量查询时只返回 Finance 页面已有的区域总额;
// 仅显式区域范围查询才补齐逐日渠道,避免默认概览对所有区域做 N+1 聚合。
type FinanceRechargeRegionOverview struct {
RegionID int64
TotalUSDMinor int64
DailyAvailable bool
Daily []FinanceRechargeDailyBucket
}
type FinanceRechargeOverview struct {
Daily []FinanceRechargeDailyBucket
Regions []FinanceRechargeRegionOverview
}
// FinanceRechargeOverviewSource 让 Social BI 依赖 Finance 的业务口径,而不是依赖 payment HTTP handler。
type FinanceRechargeOverviewSource interface {
FinanceRechargeOverview(context.Context, FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error)
}
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// 财务系统统一按中国时区切日边界。
tzOffsetMinutes = 480
}
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
}
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,
}
// HTTP 页面和 Social BI 都走同一个装配函数;后续口径调整只允许在这里发生一次。
overview, err := h.loadFinanceRechargeOverview(c.Request.Context(), appCode, financeQuery, userFilter.IsEmpty() && sellerFilter.IsEmpty(), true)
if err != nil {
writeWalletError(c, err, "获取财务概览失败")
return
}
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
response.OK(c, overview)
}
// FinanceRechargeOverview 暴露 Finance 页面自身使用的充值聚合,供 Social BI 复用。
// 默认只复用 Finance 已经返回的全局逐日与区域总额;显式区域筛选才按区域补逐日渠道。
func (h *Handler) FinanceRechargeOverview(ctx context.Context, query FinanceRechargeOverviewQuery) (FinanceRechargeOverview, error) {
appCode := appctx.Normalize(query.AppCode)
if appCode == "" {
return FinanceRechargeOverview{}, fmt.Errorf("app_code is required")
}
tzOffsetMinutes := query.TZOffsetMinutes
if tzOffsetMinutes == 0 {
tzOffsetMinutes = 480
}
regionIDs := uniquePositiveInt64s(query.ScopeRegionIDs)
baseQuery := financeCoinSellerRechargeQuery{
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
TzOffsetMinutes: tzOffsetMinutes,
}
if len(regionIDs) > 0 {
regions, err := h.loadFinanceRechargeRegions(ctx, appCode, baseQuery, regionIDs)
if err != nil {
return FinanceRechargeOverview{}, err
}
return FinanceRechargeOverview{Daily: mergeFinanceRechargeRegionDaily(regions), Regions: regions}, nil
}
overview, err := h.loadFinanceRechargeOverview(ctx, appCode, baseQuery, true, query.IncludeRegions)
if err != nil {
return FinanceRechargeOverview{}, err
}
result := FinanceRechargeOverview{Daily: financeRechargeDailyBuckets(overview.Daily)}
if !query.IncludeRegions {
return result, nil
}
for _, region := range overview.Regions {
if region.RegionID <= 0 {
continue
}
result.Regions = append(result.Regions, FinanceRechargeRegionOverview{
RegionID: region.RegionID,
TotalUSDMinor: region.UsdMinorAmount,
})
}
sort.Slice(result.Regions, func(i, j int) bool { return result.Regions[i].RegionID < result.Regions[j].RegionID })
return result, nil
}
func (h *Handler) loadFinanceRechargeRegions(ctx context.Context, appCode string, baseQuery financeCoinSellerRechargeQuery, regionIDs []int64) ([]FinanceRechargeRegionOverview, error) {
type regionResult struct {
region FinanceRechargeRegionOverview
err error
}
results := make(chan regionResult, len(regionIDs))
semaphore := make(chan struct{}, 4)
var wg sync.WaitGroup
for _, regionID := range uniquePositiveInt64s(regionIDs) {
regionID := regionID
wg.Add(1)
go func() {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
regionQuery := baseQuery
regionQuery.RegionID = regionID
// 单区域默认趋势已经同时返回 Google/三方渠道;这里不再额外查询两次渠道区域分布。
overview, err := h.loadFinanceRechargeOverview(ctx, appCode, regionQuery, true, false)
daily := financeRechargeDailyBuckets(overview.Daily)
results <- regionResult{
region: FinanceRechargeRegionOverview{
RegionID: regionID,
TotalUSDMinor: financeRechargeDailyTotal(daily),
DailyAvailable: true,
Daily: daily,
},
err: err,
}
}()
}
wg.Wait()
close(results)
regions := make([]FinanceRechargeRegionOverview, 0, len(regionIDs))
for result := range results {
if result.err != nil {
return nil, result.err
}
regions = append(regions, result.region)
}
sort.Slice(regions, func(i, j int) bool { return regions[i].RegionID < regions[j].RegionID })
return regions, nil
}
func financeRechargeDailyTotal(rows []FinanceRechargeDailyBucket) int64 {
var total int64
for _, row := range rows {
total += row.GoogleUSDMinor + row.ThirdPartyUSDMinor + row.CoinSellerUSDMinor
}
return total
}
// loadFinanceRechargeOverview 是 Finance HTTP 页面与模块内复用入口的唯一装配路径。
// ordinary 与币商统计仍分别由 wallet/legacy source 和 financeCoinSellerRechargeStats 提供,禁止回退旧库存币商口径。
func (h *Handler) loadFinanceRechargeOverview(ctx context.Context, appCode string, query financeCoinSellerRechargeQuery, includeOrdinary bool, includeRegions bool) (rechargeBillOverviewDTO, error) {
overview := rechargeBillOverviewDTO{Daily: []rechargeBillDailyBucketDTO{}, Regions: []rechargeBillRegionBucketDTO{}}
if source, ok := h.billSources[appCode]; ok {
if includeOrdinary {
var err error
overview, err = source.Overview(ctx, legacyRechargeBillQuery{
Keyword: query.Keyword,
RechargeType: query.RechargeType,
PaidState: query.PaidState,
RegionID: query.RegionID,
StartAtMS: query.StartAtMS,
EndAtMS: query.EndAtMS,
}, query.TzOffsetMinutes)
if err != nil {
return rechargeBillOverviewDTO{}, err
}
}
if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil {
return rechargeBillOverviewDTO{}, err
}
return overview, nil
}
if includeOrdinary && financeWalletOverviewSupportsOrdinaryFilters(query) {
if h.wallet == nil {
return rechargeBillOverviewDTO{}, fmt.Errorf("wallet recharge overview is not configured")
}
resp, err := h.wallet.GetRechargeBillOverview(ctx, &walletv1.GetRechargeBillOverviewRequest{
RequestId: query.RequestID,
AppCode: appCode,
RegionId: query.RegionID,
RechargeType: query.RechargeType,
Status: query.Status,
StartAtMs: query.StartAtMS,
EndAtMs: query.EndAtMS,
TzOffsetMinutes: query.TzOffsetMinutes,
})
if err != nil {
return rechargeBillOverviewDTO{}, err
}
overview.Daily = make([]rechargeBillDailyBucketDTO, 0, len(resp.GetDaily()))
for _, bucket := range resp.GetDaily() {
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{
Date: bucket.GetDate(),
GoogleUsdMinor: bucket.GetGoogleUsdMinor(),
ThirdPartyUsdMinor: bucket.GetThirdPartyUsdMinor(),
CoinSellerUsdMinor: bucket.GetCoinSellerUsdMinor(),
GoogleCoinAmount: bucket.GetGoogleCoinAmount(),
ThirdPartyCoinAmount: bucket.GetThirdPartyCoinAmount(),
CoinSellerCoinAmount: bucket.GetCoinSellerCoinAmount(),
})
}
if stats := resp.GetGooglePaid(); stats != nil {
overview.GooglePaid = rechargeBillGooglePaidStatsDTO{
GoogleBillCount: stats.GetGoogleBillCount(),
SyncedCount: stats.GetSyncedCount(),
UnsyncedCount: stats.GetUnsyncedCount(),
CoveredUsdMinor: stats.GetCoveredUsdMinor(),
EstFeeUsdMinor: stats.GetEstFeeUsdMinor(),
EstTaxUsdMinor: stats.GetEstTaxUsdMinor(),
EstNetUsdMinor: stats.GetEstNetUsdMinor(),
}
}
if transfer := resp.GetSalaryTransfer(); transfer != nil {
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
}
}
if includeOrdinary && includeRegions && normalizedRechargeType(query.RechargeType) == "" {
ordinaryRegions, err := h.walletOrdinaryRechargeOverviewRegions(ctx, appCode, query)
if err != nil {
return rechargeBillOverviewDTO{}, err
}
overview.Regions = h.fillFinanceCoinSellerRegionNames(ctx, appCode, ordinaryRegions)
}
if err := h.applyFinanceCoinSellerOverview(ctx, appCode, query, &overview); err != nil {
return rechargeBillOverviewDTO{}, err
}
return overview, nil
}
func financeRechargeDailyBuckets(rows []rechargeBillDailyBucketDTO) []FinanceRechargeDailyBucket {
out := make([]FinanceRechargeDailyBucket, 0, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Date) == "" {
continue
}
out = append(out, FinanceRechargeDailyBucket{
Date: row.Date,
GoogleUSDMinor: row.GoogleUsdMinor,
ThirdPartyUSDMinor: row.ThirdPartyUsdMinor,
CoinSellerUSDMinor: row.CoinSellerUsdMinor,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
return out
}
func mergeFinanceRechargeRegionDaily(regions []FinanceRechargeRegionOverview) []FinanceRechargeDailyBucket {
byDay := map[string]FinanceRechargeDailyBucket{}
for _, region := range regions {
for _, row := range region.Daily {
current := byDay[row.Date]
current.Date = row.Date
current.GoogleUSDMinor += row.GoogleUSDMinor
current.ThirdPartyUSDMinor += row.ThirdPartyUSDMinor
current.CoinSellerUSDMinor += row.CoinSellerUSDMinor
byDay[row.Date] = current
}
}
out := make([]FinanceRechargeDailyBucket, 0, len(byDay))
for _, row := range byDay {
out = append(out, row)
}
sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date })
return out
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
return true
}
if _, _, _, err := legacyDashboardDayRange(query, tzOffsetMinutes); err != nil {
response.BadRequest(c, "币商统计仅支持明确整日时间范围")
return false
}
return true
}
// fillApprovedWithdrawalStats 把 admin 库的“审核通过提现申请”并入用户提现口径;查询失败只降级该卡片,不阻断概览。
func (h *Handler) fillApprovedWithdrawalStats(overview *rechargeBillOverviewDTO, appCode string, startAtMS int64, endAtMS int64) {
if h.store != nil {
if stats, err := h.store.ApprovedWithdrawalStats(appCode, startAtMS, endAtMS); err == nil {
overview.Withdrawal.ApprovedCount = stats.ApprovedCount
overview.Withdrawal.ApprovedUsdMinor = stats.ApprovedUSDMinor
}
}
overview.Withdrawal.TotalUsdMinor = overview.Withdrawal.TransferUsdMinor + overview.Withdrawal.ApprovedUsdMinor
}