hyapp-server/server/admin/internal/modules/payment/recharge_bill_stats.go
2026-07-09 19:57:39 +08:00

385 lines
14 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"
"sort"
"strings"
"time"
"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"
"github.com/gin-gonic/gin"
)
type rechargeBillSummaryBucketDTO struct {
BillCount int64 `json:"billCount"`
CoinAmount int64 `json:"coinAmount"`
USDMinorAmount int64 `json:"usdMinorAmount"`
}
type rechargeBillSummaryDTO struct {
Total rechargeBillSummaryBucketDTO `json:"total"`
GooglePlay rechargeBillSummaryBucketDTO `json:"googlePlay"`
ThirdParty rechargeBillSummaryBucketDTO `json:"thirdParty"`
CoinSeller rechargeBillSummaryBucketDTO `json:"coinSeller"`
}
type rechargeBillRegionDTO struct {
RegionID int64 `json:"regionId"`
RegionCode string `json:"regionCode"`
Name string `json:"name"`
}
type googleRechargePaidRefreshRequest struct {
TransactionIDs []string `json:"transactionIds"`
}
type googleRechargePaidDTO struct {
TransactionID string `json:"transactionId"`
CurrencyCode string `json:"currencyCode"`
PaidAmountMicro int64 `json:"paidAmountMicro"`
TaxMicro int64 `json:"taxMicro"`
NetMicro int64 `json:"netMicro"`
SyncedAtMS int64 `json:"syncedAtMs"`
Error string `json:"error,omitempty"`
}
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
options := shared.ListOptions(c)
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// legacy 币商充值列表可能来自 dashboard 自然日聚合,默认沿用财务页中国时区。
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
Page: options.Page,
PageSize: options.PageSize,
})
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
// GetRechargeBillSummary 返回与账单列表同一筛选口径的充值总和、Google 充值与三方充值聚合。
func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// legacy finance 的币商补充来自 dashboard 自然日聚合,默认沿用财务页中国时区。
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")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
}
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.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
}
response.OK(c, summary)
return
}
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
}
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,
UserId: userID,
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
})
if err != nil {
writeWalletError(c, err, "获取充值汇总失败")
return
}
summary = rechargeBillSummaryDTO{
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()),
}
}
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"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
TargetUserID: sellerUserID,
HasUserOnlyFilter: userID > 0 && sellerUserID == 0,
}, &summary); err != nil {
response.ServerError(c, "获取充值汇总失败")
return
}
response.OK(c, summary)
}
func rechargeBillSummaryBucketFromProto(bucket *walletv1.RechargeBillSummaryBucket) rechargeBillSummaryBucketDTO {
if bucket == nil {
return rechargeBillSummaryBucketDTO{}
}
return rechargeBillSummaryBucketDTO{
BillCount: bucket.GetBillCount(),
CoinAmount: bucket.GetCoinAmount(),
USDMinorAmount: bucket.GetUsdMinorAmount(),
}
}
func normalizedRechargeType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func nonNegativeRechargeBillBucket(bucket rechargeBillSummaryBucketDTO) rechargeBillSummaryBucketDTO {
return rechargeBillSummaryBucketDTO{
BillCount: nonNegativeInt64(bucket.BillCount),
CoinAmount: nonNegativeInt64(bucket.CoinAmount),
USDMinorAmount: nonNegativeInt64(bucket.USDMinorAmount),
}
}
func nonNegativeInt64(value int64) int64 {
if value < 0 {
return 0
}
return value
}
// RefreshGoogleRechargePaidDetails 对指定账单触发 Google Orders API 实付明细同步,供财务补齐历史订单实付。
func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
var request googleRechargePaidRefreshRequest
if err := c.ShouldBindJSON(&request); err != nil || len(request.TransactionIDs) == 0 {
response.BadRequest(c, "交易号参数不正确")
return
}
if len(request.TransactionIDs) > 100 {
response.BadRequest(c, "单次最多刷新 100 笔账单")
return
}
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
if !source.SupportsGooglePaidSync() {
response.BadRequest(c, "该 App 未配置 Google Play 包名和服务账号,无法同步谷歌实付")
return
}
results, err := source.RefreshGooglePaidDetails(c.Request.Context(), request.TransactionIDs)
if err != nil {
response.ServerError(c, "查询谷歌实付明细失败")
return
}
shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "admin_legacy_google_paid_details", "success", appCode)
response.OK(c, gin.H{"items": results})
return
}
resp, err := h.wallet.RefreshGooglePaymentPrices(c.Request.Context(), &walletv1.RefreshGooglePaymentPricesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
TransactionIds: request.TransactionIDs,
})
if err != nil {
writeWalletError(c, err, "查询谷歌实付明细失败")
return
}
items := make([]googleRechargePaidDTO, 0, len(resp.GetPrices()))
for _, price := range resp.GetPrices() {
items = append(items, googleRechargePaidDTO{
TransactionID: price.GetTransactionId(),
CurrencyCode: price.GetCurrencyCode(),
PaidAmountMicro: price.GetPaidAmountMicro(),
TaxMicro: price.GetTaxMicro(),
NetMicro: price.GetNetMicro(),
SyncedAtMS: price.GetSyncedAtMs(),
Error: price.GetError(),
})
}
shared.OperationLog(c, h.audit, "refresh-google-recharge-paid", "payment_orders", "success", appCode)
response.OK(c, gin.H{"items": items})
}
type rechargeBillAppDTO struct {
AppCode string `json:"appCode"`
AppName string `json:"appName"`
}
// ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。
func (h *Handler) ListRechargeBillApps(c *gin.Context) {
items := []rechargeBillAppDTO{}
seen := map[string]struct{}{}
if h.userDB != nil {
rows, err := h.userDB.QueryContext(c.Request.Context(), `
SELECT app_code, app_name
FROM apps
WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC`)
if err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
defer rows.Close()
for rows.Next() {
var item rechargeBillAppDTO
if err := rows.Scan(&item.AppCode, &item.AppName); err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
item.AppCode = appctx.Normalize(item.AppCode)
seen[item.AppCode] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
}
legacyApps := make([]rechargeBillAppDTO, 0, len(h.billSources))
for _, source := range h.billSources {
appCode := appctx.Normalize(source.AppCode())
if _, ok := seen[appCode]; ok {
continue
}
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName()})
}
sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName })
items = append(items, legacyApps...)
response.OK(c, gin.H{"items": items, "total": len(items)})
}
// ListRechargeBillRegions 返回当前 App 的区域目录,用于充值详情按区域筛选;不受财务范围授权限制。
func (h *Handler) ListRechargeBillRegions(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode)
if err != nil {
response.ServerError(c, "获取区域列表失败")
return
}
response.OK(c, gin.H{"items": regions, "total": len(regions)})
}
func (h *Handler) listRechargeBillRegions(ctx context.Context, appCode string) ([]rechargeBillRegionDTO, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
items := []rechargeBillRegionDTO{}
seen := map[int64]struct{}{}
if h.userDB != nil {
rows, err := h.userDB.QueryContext(ctx, `
SELECT region_id, region_code, name
FROM regions
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, name ASC`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var item rechargeBillRegionDTO
if err := rows.Scan(&item.RegionID, &item.RegionCode, &item.Name); err != nil {
return nil, err
}
seen[item.RegionID] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
}
// Yumi/Aslan 这类 legacy App 的区域目录在线上 likei Mongo这里只取目录展示账单筛选仍以 wallet 的 target_region_id 为准。
for _, source := range h.moneyRegionSources {
_, sourceRegions, _, err := source.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return nil, err
}
for _, region := range sourceRegions {
if appctx.Normalize(region.AppCode) != appCode {
continue
}
if _, ok := seen[region.RegionID]; ok {
continue
}
seen[region.RegionID] = struct{}{}
items = append(items, rechargeBillRegionDTO{RegionID: region.RegionID, RegionCode: region.RegionCode, Name: region.Name})
}
}
return items, nil
}