hyapp-server/server/admin/internal/modules/payment/recharge_bill_export.go
2026-07-03 15:53:36 +08:00

267 lines
8.6 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 (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"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"
)
const (
rechargeBillExportPageSize = 500
// rechargeBillExportMaxRows 限制单次导出行数,避免财务误选超大时间范围拖垮 admin 与 wallet-service。
rechargeBillExportMaxRows = 50_000
)
// rechargeBillExportTimezone 与页面展示一致,导出统一使用中国时区时间。
var rechargeBillExportTimezone = time.FixedZone("GMT+8", 8*3600)
// ExportRechargeBills 按当前筛选口径导出充值明细 CSVhyapp 与 legacy 账单源共用同一套列。
func (h *Handler) ExportRechargeBills(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
bills, ok := h.collectRechargeBillsForExport(c, appCode)
if !ok {
return
}
truncated := len(bills) >= rechargeBillExportMaxRows
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
}
}
buffer := &bytes.Buffer{}
// UTF-8 BOM 让 Excel 直接双击打开时正确识别中文。
buffer.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(buffer)
_ = writer.Write([]string{
"充值时间", "APP", "充值来源", "交易号", "业务单号", "外部订单号",
"金币数量", "美金金额(USD)", "账单币种", "账单金额",
"实付币种", "实付金额", "手续费", "税费", "净收入", "扣款占比",
"充值用户ID", "充值用户昵称", "币商用户ID", "区域", "状态",
})
for _, item := range bills {
_ = writer.Write(rechargeBillExportRow(item, regionNames))
}
if truncated {
_ = writer.Write([]string{fmt.Sprintf("-- 导出达到上限 %d 行,请缩小时间范围后重新导出 --", rechargeBillExportMaxRows)})
}
writer.Flush()
if err := writer.Error(); err != nil {
response.ServerError(c, "生成导出文件失败")
return
}
fileName := fmt.Sprintf("recharge-bills-%s-%s.csv", appCode, time.Now().In(rechargeBillExportTimezone).Format("20060102-150405"))
shared.OperationLog(c, h.audit, "export-recharge-bills", "wallet_recharge_records", "success", fmt.Sprintf("%s:%d rows", appCode, len(bills)))
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Writer.WriteHeader(200)
_, _ = c.Writer.Write(buffer.Bytes())
}
// collectRechargeBillsForExport 复用列表筛选逐页拉取账单;返回 ok=false 表示已写出错误响应。
func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) ([]rechargeBillDTO, bool) {
options := shared.ListOptions(c)
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,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: page,
PageSize: rechargeBillExportPageSize,
})
if err != nil {
response.ServerError(c, "导出账单失败")
return nil, false
}
bills = append(bills, items...)
if len(items) < rechargeBillExportPageSize || int64(len(bills)) >= total {
break
}
}
return capRechargeBillExportRows(bills), true
}
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
}
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
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")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
Page: int32(page),
PageSize: rechargeBillExportPageSize,
})
if err != nil {
writeWalletError(c, err, "导出账单失败")
return nil, false
}
items := make([]rechargeBillDTO, 0, len(resp.GetBills()))
for _, item := range resp.GetBills() {
items = append(items, rechargeBillFromProto(item))
}
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "导出账单用户资料失败")
return nil, false
}
bills = append(bills, items...)
if len(items) < rechargeBillExportPageSize || int64(len(bills)) >= resp.GetTotal() {
break
}
}
return capRechargeBillExportRows(bills), true
}
func capRechargeBillExportRows(bills []rechargeBillDTO) []rechargeBillDTO {
if len(bills) > rechargeBillExportMaxRows {
return bills[:rechargeBillExportMaxRows]
}
return bills
}
func rechargeBillExportRow(item rechargeBillDTO, regionNames map[int64]string) []string {
deductionMicro := item.ProviderFeeMicro + item.ProviderTaxMicro
deductionRatio := ""
if deductionMicro > 0 && item.UserPaidAmountMicro > 0 {
deductionRatio = fmt.Sprintf("%.2f%%", float64(deductionMicro)/float64(item.UserPaidAmountMicro)*100)
}
billAmountMinor := item.USDMinorAmount
if item.ProviderAmountMinor > 0 {
billAmountMinor = item.ProviderAmountMinor
}
chargeUserID := ""
if item.User.UserID != "" {
chargeUserID = item.User.UserID
} else if item.UserID > 0 {
chargeUserID = strconv.FormatInt(item.UserID, 10)
}
sellerUserID := ""
if item.SellerUserID > 0 {
sellerUserID = strconv.FormatInt(item.SellerUserID, 10)
}
regionID := item.TargetRegionID
if regionID == 0 {
regionID = item.SellerRegionID
}
regionText := ""
if regionID > 0 {
if name, ok := regionNames[regionID]; ok && name != "" {
regionText = name
} else {
regionText = strconv.FormatInt(regionID, 10)
}
}
return []string{
time.UnixMilli(item.CreatedAtMS).In(rechargeBillExportTimezone).Format("2006-01-02 15:04:05"),
item.AppCode,
rechargeSourceLabel(item.RechargeType),
item.TransactionID,
item.CommandID,
item.ExternalRef,
strconv.FormatInt(item.CoinAmount, 10),
csvMinorAmount(item.USDMinorAmount),
item.CurrencyCode,
csvMinorAmount(billAmountMinor),
item.UserPaidCurrencyCode,
csvMicroAmount(item.UserPaidAmountMicro),
csvMicroAmount(item.ProviderFeeMicro),
csvMicroAmount(item.ProviderTaxMicro),
csvMicroAmount(item.ProviderNetMicro),
deductionRatio,
chargeUserID,
item.User.Username,
sellerUserID,
regionText,
item.Status,
}
}
// rechargeSourceLabel 与前端 rechargeTypeLabel 保持同一套中文口径,导出文件可直接给财务阅读。
func rechargeSourceLabel(rechargeType string) string {
switch rechargeType {
case "google_play_recharge":
return "谷歌充值"
case "coin_seller_stock_purchase":
return "币商进货"
case "coin_seller_stock_deduction":
return "币商冲回"
case "coin_seller_transfer":
return "币商充值"
case "mifapay":
return "三方充值-MiFaPay"
case "v5pay":
return "三方充值-V5Pay"
case "usdt_trc20":
return "三方充值-USDT"
case "apple_recharge":
return "苹果充值"
case "huawei_recharge":
return "华为充值"
case "telegram_recharge":
return "Telegram充值"
case "web_recharge":
return "三方充值-Web"
default:
return rechargeType
}
}
func csvMinorAmount(minor int64) string {
return strconv.FormatFloat(float64(minor)/100, 'f', 2, 64)
}
func csvMicroAmount(micro int64) string {
if micro == 0 {
return "0"
}
negative := micro < 0
if negative {
micro = -micro
}
text := formatUSDTMicro(micro)
if negative {
return "-" + text
}
return text
}