diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index 86ad29e5..c5235fa5 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -46,6 +46,7 @@ var ( ErrPickupShopNameExists = errors.New("pickup shop name already exists") ErrPickupCancelled = errors.New("pickup is cancelled") ErrInvalidThirdPartyOrderNo = errors.New("invalid third party order no") + ErrPickupExportTooLarge = errors.New("pickup export too large") ) type PickupDTO struct { diff --git a/backend/internal/modules/pickup/export.go b/backend/internal/modules/pickup/export.go new file mode 100644 index 00000000..5f7a5ac1 --- /dev/null +++ b/backend/internal/modules/pickup/export.go @@ -0,0 +1,256 @@ +package pickup + +import ( + "bytes" + "context" + "encoding/csv" + "fmt" + "strings" + "time" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/timeutil" + + "gorm.io/gorm" +) + +const maxAdminPickupExportRows = 50000 + +var adminPickupExportHeader = []string{ + "提号编号", + "账号标题", + "区服", + "登录平台", + "上架编号", + "卖家手机号", + "操作人", + "账号来源", + "来源渠道", + "交易渠道", + "第三方单号", + "店铺名称", + "提号备注", + "下单金额(元)", + "押金金额(元)", + "线下利润(元)", + "结算金额(元)", + "提号状态", + "结算状态", + "创建时间", + "完成时间", + "取消时间", + "完成备注", +} + +// ListAdminForExport 按后台列表相同条件查询全部匹配提号,不返回账号快照。 +func (r *Repository) ListAdminForExport(ctx context.Context, query AdminPickupQuery) ([]PickupDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + + var rows []pickupRow + db := applyAdminPickupFilters(r.adminPickupExportQuery(ctx), query) + if err := db.Order("p.id DESC").Limit(maxAdminPickupExportRows + 1).Scan(&rows).Error; err != nil { + return nil, err + } + if len(rows) > maxAdminPickupExportRows { + return nil, ErrPickupExportTooLarge + } + + items := make([]PickupDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toDTO()) + } + return items, nil +} + +// RecordAdminPickupExport 记录敏感数据导出行为。 +func (r *Repository) RecordAdminPickupExport( + ctx context.Context, + adminID uint64, + meta auditlog.Meta, + query AdminPickupQuery, + rowCount int, +) error { + if r == nil || r.db == nil { + return ErrDependencyUnavailable + } + return auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_export", + BizType: "admin_pickup", + Meta: meta, + Detail: map[string]any{ + "row_count": rowCount, + "keyword": strings.TrimSpace(query.Keyword), + "status": strings.TrimSpace(query.Status), + "shop_name": strings.TrimSpace(query.ShopName), + "account_source": strings.TrimSpace(query.AccountSource), + "offline_settlement_status": strings.TrimSpace(query.OfflineSettlementStatus), + "operator": strings.TrimSpace(query.Operator), + "third_party_order_no": strings.TrimSpace(query.ThirdPartyOrderNo), + "start_time": formatExportTimePointer(query.StartTime), + "end_time": formatExportTimePointer(query.EndTime), + }, + }) +} + +func (r *Repository) adminPickupExportQuery(ctx context.Context) *gorm.DB { + return r.db.WithContext(ctx).Table("admin_pickups AS p"). + Select(`p.id, p.pickup_no, p.listing_id, p.account_id, p.owner_id, p.admin_id, + p.operator, p.platform, p.third_party_order_no, p.shop_name, p.raw_shop_name, + p.account_source, p.source_channel, p.settlement_mode, + p.order_amount_cent, p.deposit_amount_cent, p.profit_amount_cent, p.settle_amount_cent, + p.status, p.offline_settlement_status, p.remark, p.complete_remark, + p.created_at, p.completed_at, p.cancelled_at, + l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone, + COALESCE((SELECT SUM(fa.profit_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS profit_adjustment_cent, + COALESCE((SELECT SUM(fa.settle_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS settle_adjustment_cent`). + Joins("JOIN rental_listings AS l ON l.id = p.listing_id"). + Joins("JOIN game_accounts AS a ON a.id = p.account_id"). + Joins("JOIN users AS owner ON owner.id = p.owner_id") +} + +func buildAdminPickupCSV(items []PickupDTO) ([]byte, error) { + var buf bytes.Buffer + buf.WriteString("\xEF\xBB\xBF") + + writer := csv.NewWriter(&buf) + writer.UseCRLF = true + if err := writer.Write(adminPickupExportHeader); err != nil { + return nil, err + } + + for _, item := range items { + settleAmount := "" + if item.Status == StatusCompleted { + settleAmount = formatExportAmount(item.EffectiveSettleAmountCent) + } + record := []string{ + exportCSVText(item.PickupNo), + exportCSVText(item.AccountTitle), + exportCSVText(item.ServerRegion), + exportCSVText(item.LoginPlatform), + exportCSVText(item.ListingNo), + exportCSVText(item.OwnerPhone), + exportCSVText(item.Operator), + exportAccountSourceLabel(item.AccountSource), + exportCSVText(item.SourceChannel), + exportCSVText(item.Platform), + exportCSVText(item.ThirdPartyOrderNo), + exportCSVText(item.ShopName), + exportCSVText(item.Remark), + formatExportAmount(item.OrderAmountCent), + formatExportAmount(item.DepositAmountCent), + formatExportAmount(item.EffectiveProfitAmountCent), + settleAmount, + exportPickupStatusLabel(item.Status), + exportSettlementStatusLabel(item), + formatExportTime(item.CreatedAt), + formatExportTimePointer(item.CompletedAt), + formatExportTimePointer(item.CancelledAt), + exportCSVText(item.CompleteRemark), + } + if err := writer.Write(record); err != nil { + return nil, err + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func exportCSVText(value string) string { + if value == "" { + return "" + } + trimmed := strings.TrimLeft(value, " \t\r") + if trimmed == "" { + return value + } + switch trimmed[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + value + default: + return value + } +} + +func exportPickupStatusLabel(status string) string { + switch status { + case StatusPickingUp: + return "提号中" + case StatusCompleted: + return "已完成" + case StatusCancelled: + return "已取消" + default: + return status + } +} + +func exportAccountSourceLabel(source string) string { + switch source { + case AccountSourceExternalPlatformManaged: + return "外部上传 · 平台代管" + case AccountSourceInternal: + return "站内上传" + default: + return source + } +} + +func exportSettlementStatusLabel(item PickupDTO) string { + if item.SettlementMode != SettlementModePlatformManaged { + return "钱包已结算" + } + switch item.OfflineSettlementStatus { + case OfflineSettlementStatusPending: + return "待线下结算" + case OfflineSettlementStatusSettled: + return "已线下结算" + default: + return "待完成提号" + } +} + +func formatExportAmount(cent int64) string { + if cent < 0 { + return "-" + formatExportAmount(-cent) + } + return fmt.Sprintf("%d.%02d", cent/100, cent%100) +} + +func formatExportTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05") +} + +func formatExportTimePointer(value *time.Time) string { + if value == nil { + return "" + } + return formatExportTime(*value) +} + +func encodeExportFilename(filename string) string { + var builder strings.Builder + for _, value := range []byte(filename) { + if (value >= 'a' && value <= 'z') || + (value >= 'A' && value <= 'Z') || + (value >= '0' && value <= '9') || + value == '-' || value == '_' || value == '.' || value == '~' { + builder.WriteByte(value) + continue + } + builder.WriteByte('%') + builder.WriteString(strings.ToUpper(fmt.Sprintf("%02X", value))) + } + return builder.String() +} diff --git a/backend/internal/modules/pickup/export_test.go b/backend/internal/modules/pickup/export_test.go new file mode 100644 index 00000000..c196768d --- /dev/null +++ b/backend/internal/modules/pickup/export_test.go @@ -0,0 +1,196 @@ +package pickup + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/model" + + "github.com/gin-gonic/gin" +) + +func TestBuildAdminPickupCSVUsesExcelFriendlyEncodingAndEscaping(t *testing.T) { + createdAt := time.Date(2026, 11, 12, 10, 20, 30, 0, time.FixedZone("CST", 8*60*60)) + completedAt := createdAt.Add(2 * time.Hour) + items := []PickupDTO{ + { + PickupNo: "PK-001", + AccountTitle: "账号,带逗号", + ServerRegion: "国服", + LoginPlatform: "QQ", + ListingNo: "202611120001", + OwnerPhone: "18800000000", + Operator: "运营小张", + AccountSource: AccountSourceExternalPlatformManaged, + SourceChannel: "闲鱼", + Platform: "拼多多", + ThirdPartyOrderNo: "=1+1", + ShopName: `大锤"专营店"`, + Remark: "第一行\n第二行", + OrderAmountCent: 29500, + DepositAmountCent: 35000, + EffectiveProfitAmountCent: 1234, + EffectiveSettleAmountCent: 20000, + SettlementMode: SettlementModePlatformManaged, + OfflineSettlementStatus: OfflineSettlementStatusPending, + Status: StatusCompleted, + CreatedAt: createdAt, + CompletedAt: &completedAt, + CompleteRemark: "已完成", + }, + } + + data, err := buildAdminPickupCSV(items) + if err != nil { + t.Fatalf("buildAdminPickupCSV() error = %v", err) + } + if !bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}) { + t.Fatal("CSV 缺少 UTF-8 BOM") + } + + records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll() + if err != nil { + t.Fatalf("解析 CSV 失败: %v", err) + } + if len(records) != 2 { + t.Fatalf("CSV 行数 = %d, want 2", len(records)) + } + if len(records[0]) != len(adminPickupExportHeader) { + t.Fatalf("表头列数 = %d, want %d", len(records[0]), len(adminPickupExportHeader)) + } + + row := records[1] + if row[1] != "账号,带逗号" { + t.Fatalf("账号标题 = %q", row[1]) + } + if row[7] != "外部上传 · 平台代管" { + t.Fatalf("账号来源 = %q", row[7]) + } + if row[10] != "'=1+1" { + t.Fatalf("第三方单号未转义公式注入: %q", row[10]) + } + if row[11] != `大锤"专营店"` { + t.Fatalf("店铺名称 = %q", row[11]) + } + if row[12] != "第一行\n第二行" { + t.Fatalf("提号备注 = %q", row[12]) + } + if row[13] != "295.00" || row[14] != "350.00" || row[15] != "12.34" { + t.Fatalf("金额列 = %q/%q/%q", row[13], row[14], row[15]) + } + if row[16] != "200.00" { + t.Fatalf("结算金额 = %q", row[16]) + } + if row[17] != "已完成" || row[18] != "待线下结算" { + t.Fatalf("状态列 = %q/%q", row[17], row[18]) + } + if row[19] != "2026-11-12 10:20:30" || row[20] != "2026-11-12 12:20:30" { + t.Fatalf("时间列 = %q/%q", row[19], row[20]) + } +} + +func TestServiceExportAdminReusesFiltersAndRecordsAudit(t *testing.T) { + repo, db := newPickupTestRepo(t) + first := seedPickup(t, db, StatusPickingUp) + if err := db.Model(&first).Updates(map[string]any{ + "operator": "运营,小张", + "platform": "拼多多", + "third_party_order_no": "=1+1", + "order_amount_cent": 29500, + "deposit_amount_cent": 35000, + }).Error; err != nil { + t.Fatalf("更新第一条提号失败: %v", err) + } + + second := first + second.ID = 0 + second.PickupNo = "PK-EXPORT-002" + second.Status = StatusCompleted + second.Operator = "运营小李" + second.ThirdPartyOrderNo = "TB-002" + if err := db.Create(&second).Error; err != nil { + t.Fatalf("创建第二条提号失败: %v", err) + } + + service := NewService(repo) + data, rowCount, err := service.ExportAdmin( + t.Context(), + AdminPickupQuery{Status: StatusPickingUp}, + 99, + auditlog.Meta{RequestID: "req-pickup-export"}, + ) + if err != nil { + t.Fatalf("ExportAdmin() error = %v", err) + } + if rowCount != 1 { + t.Fatalf("导出行数 = %d, want 1", rowCount) + } + + records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll() + if err != nil { + t.Fatalf("解析导出 CSV 失败: %v", err) + } + if len(records) != 2 { + t.Fatalf("筛选导出 CSV 行数 = %d, want 2", len(records)) + } + if records[1][0] != first.PickupNo || records[1][10] != "'=1+1" { + t.Fatalf("筛选导出行 = %#v", records[1]) + } + + var audit model.AuditLog + if err := db.Where("action = ?", "pickup_export").First(&audit).Error; err != nil { + t.Fatalf("查询导出审计失败: %v", err) + } + var detail map[string]any + if err := json.Unmarshal(audit.Detail, &detail); err != nil { + t.Fatalf("解析导出审计失败: %v", err) + } + if detail["status"] != StatusPickingUp { + t.Fatalf("审计 status = %v, want %s", detail["status"], StatusPickingUp) + } + assertJSONNumber(t, "导出行数", detail["row_count"], 1) + if !strings.Contains(string(audit.Detail), "req-pickup-export") { + t.Fatalf("审计日志缺少 request_id: %s", audit.Detail) + } +} + +func TestHandlerExportWritesCSVDownloadResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + repo, db := newPickupTestRepo(t) + seedPickup(t, db, StatusPickingUp) + + handler := NewHandler(NewService(repo)) + router := gin.New() + router.GET("/export", func(c *gin.Context) { + c.Set(middleware.ContextAdminID, uint64(99)) + handler.Export(c) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/export", nil) + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("状态码 = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") { + t.Fatalf("Content-Type = %q", contentType) + } + if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, ".csv") { + t.Fatalf("Content-Disposition = %q", disposition) + } + if rowCount := recorder.Header().Get("X-Export-Row-Count"); rowCount != "1" { + t.Fatalf("X-Export-Row-Count = %q, want 1", rowCount) + } + if !bytes.HasPrefix(recorder.Body.Bytes(), []byte{0xEF, 0xBB, 0xBF}) { + t.Fatal("响应体缺少 UTF-8 BOM") + } +} diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 964f1d28..7ce5efa3 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -2,11 +2,13 @@ package pickup import ( "errors" + "fmt" "net/http" "strings" "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/timeutil" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -244,6 +246,31 @@ func (h *Handler) List(c *gin.Context) { response.OK(c, result) } +// Export 导出线下提号 CSV(管理员)。 +func (h *Handler) Export(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + query := parseAdminQuery(c) + data, rowCount, err := h.service.ExportAdmin(c.Request.Context(), query, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + + timestamp := timeutil.ShanghaiNow().Format("20060102-150405") + filename := fmt.Sprintf("线下提号_%s.csv", timestamp) + asciiFilename := fmt.Sprintf("pickup-export-%s.csv", timestamp) + c.Header( + "Content-Disposition", + fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, asciiFilename, encodeExportFilename(filename)), + ) + c.Header("X-Export-Row-Count", fmt.Sprintf("%d", rowCount)) + c.Data(http.StatusOK, "text/csv; charset=utf-8", data) +} + // ShopOptions 返回后台提号已使用的店铺名称。 func (h *Handler) ShopOptions(c *gin.Context) { items, err := h.service.ListShopNames(c.Request.Context(), c.Query("platform")) @@ -333,6 +360,13 @@ func writePickupError(c *gin.Context, err error) { response.BadRequest(c, "提号订单已取消,不能补录单号") case errors.Is(err, ErrInvalidThirdPartyOrderNo): response.BadRequest(c, "第三方单号不能为空且长度不能超过128位") + case errors.Is(err, ErrPickupExportTooLarge): + response.Error( + c, + http.StatusBadRequest, + "pickup_export_too_large", + fmt.Sprintf("导出数据超过 %d 条,请缩小筛选范围", maxAdminPickupExportRows), + ) default: response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error()) } diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index eb56674d..0d65ecec 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -141,6 +141,29 @@ func (s *Service) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pagin return s.repo.ListAdmin(ctx, query) } +func (s *Service) ExportAdmin( + ctx context.Context, + query AdminPickupQuery, + adminID uint64, + meta auditlog.Meta, +) ([]byte, int, error) { + if s.repo == nil { + return nil, 0, ErrDependencyUnavailable + } + items, err := s.repo.ListAdminForExport(ctx, query) + if err != nil { + return nil, 0, err + } + data, err := buildAdminPickupCSV(items) + if err != nil { + return nil, 0, err + } + if err := s.repo.RecordAdminPickupExport(ctx, adminID, meta, query, len(items)); err != nil { + return nil, 0, err + } + return data, len(items), nil +} + func (s *Service) ListShopNames(ctx context.Context, platform string) ([]string, error) { if s.repo == nil { return nil, ErrDependencyUnavailable @@ -210,4 +233,3 @@ func (s *Service) DeleteShopMapping(ctx context.Context, sourceName string, admi } return s.repo.DeleteShopMapping(ctx, sourceName, adminID, meta) } - diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index b0535fef..d9337b1e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -840,6 +840,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { // 管理员线下提号(独立于正常订单流程) adminRoutes.POST("/pickups", requirePerm("order:pickup"), pickupHandler.Create) adminRoutes.GET("/pickups", requirePerm("order:pickup"), pickupHandler.List) + adminRoutes.GET("/pickups/export", requirePerm("order:pickup"), pickupHandler.Export) adminRoutes.GET("/pickups/shop-options", requirePerm("order:pickup"), pickupHandler.ShopOptions) adminRoutes.GET("/pickups/shops", requirePerm("order:pickup"), pickupHandler.ListShops) adminRoutes.POST("/pickups/shops", requirePerm("order:pickup"), pickupHandler.CreateShop) diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index 48bc70ca..9f522122 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -1,3 +1,4 @@ +import axios from 'axios' import { apiClient } from '@/shared/api/client' import type { ApiResponse, PaginatedResult } from '@/shared/types/types' @@ -166,6 +167,8 @@ export interface AdminPickupListQuery { end_time?: string } +export type AdminPickupExportQuery = Omit + export interface PickupShop { id: number name: string @@ -254,11 +257,57 @@ export async function fetchAdminPickups(query: AdminPickupListQuery = {}) { } } +export async function exportAdminPickups(query: AdminPickupExportQuery = {}) { + try { + const response = await apiClient.get('/admin/pickups/export', { + params: cleanParams(query), + responseType: 'blob', + timeout: 60000, + skipErrorHandler: true, + }) + return { + blob: response.data, + filename: readExportFilename(response.headers['content-disposition']), + rowCount: Number(response.headers['x-export-row-count'] || 0), + } + } catch (error) { + if (axios.isAxiosError(error) && error.response?.data instanceof Blob) { + const text = await error.response.data.text() + let message = '' + try { + const payload = JSON.parse(text) as { message?: unknown } + if (typeof payload.message === 'string') { + message = payload.message + } + } catch { + // 非 JSON 错误响应保留原始 Axios 错误。 + } + if (message) { + error.message = message + } + } + throw error + } +} + export async function fetchAdminPickup(id: string | number) { const { data } = await apiClient.get>(`/admin/pickups/${id}`) return data.data } +function readExportFilename(contentDisposition: unknown) { + if (typeof contentDisposition !== 'string') return 'pickup-export.csv' + const encoded = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1] + if (encoded) { + try { + return decodeURIComponent(encoded) + } catch { + return encoded + } + } + return contentDisposition.match(/filename="?([^";]+)"?/i)?.[1] || 'pickup-export.csv' +} + export async function fetchAdminPickupShopOptions(platform = '') { const { data } = await apiClient.get>('/admin/pickups/shop-options', { params: cleanParams({ platform }), diff --git a/frontend/src/features/admin/views/AdminPickupView.vue b/frontend/src/features/admin/views/AdminPickupView.vue index dd57a4af..93e1a141 100644 --- a/frontend/src/features/admin/views/AdminPickupView.vue +++ b/frontend/src/features/admin/views/AdminPickupView.vue @@ -1,13 +1,14 @@