407 lines
15 KiB
Go
407 lines
15 KiB
Go
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
|
||
}
|