package pickup import ( "bytes" "encoding/csv" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "hfb_jysh/backend/internal/auditlog" "hfb_jysh/backend/internal/middleware" "hfb_jysh/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") } }