267 lines
8.6 KiB
Go
267 lines
8.6 KiB
Go
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 按当前筛选口径导出充值明细 CSV;hyapp 与 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
|
||
}
|