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) tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes")) if tzOffsetMinutes == 0 { // legacy 币商充值导出必须和页面列表使用同一个自然日解释方式。 tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes } rechargeType := strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")) normalizedType := normalizedRechargeType(rechargeType) if normalizedType == "" || normalizedType == "coin_seller" { 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 } query := financeCoinSellerRechargeQuery{ RechargeType: rechargeType, Status: options.Status, PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), Keyword: options.Keyword, RegionID: queryInt64(c, "region_id", "regionId"), StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"), EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"), TargetUserID: sellerUserID, HasUserOnlyFilter: userID > 0 && sellerUserID == 0, Page: 1, PageSize: rechargeBillExportMaxRows, MaxPageSize: rechargeBillExportMaxRows, RequestID: middleware.CurrentRequestID(c), UserID: userID, } var items []rechargeBillDTO var err error if normalizedType == "coin_seller" { items, _, err = h.listFinanceCoinSellerRechargeBills(c.Request.Context(), appCode, query) } else { items, _, err = h.listFinanceAllRechargeBills(c.Request.Context(), appCode, h.billSources[appCode], query) } if err != nil { response.ServerError(c, "导出账单失败") return nil, false } if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil { response.ServerError(c, "导出账单失败") return nil, false } return capRechargeBillExportRows(items), true } 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, RechargeType: 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: 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")), PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")), 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": 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 }