# Export Scene Template Use this reference when adding a new export scene. ## Minimal Checklist - Add `ExportTaskSceneXxx` in `pkg/constants/constants.go`. - Add the scene to `CreateExportTaskRequest.Scene` and `ListExportTaskRequest.Scene` validation/description. - Create `internal/exporter/_scene.go`. - Register `NewXxxDataSource(db)` in `internal/exporter/registry.go`. - Update `IsSupportedScene`. - Run `gofmt` on changed Go files. - Run `go build ./internal/exporter/...`, `go build ./internal/task/...`, and `go build ./...`. - Manually create a task and validate database rows plus downloaded file. ## Skeleton ```go package exporter import ( "context" "strconv" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" ) // XxxDataSource Xxx 导出数据源。 type XxxDataSource struct { db *gorm.DB } // NewXxxDataSource 创建 Xxx 导出数据源。 func NewXxxDataSource(db *gorm.DB) *XxxDataSource { return &XxxDataSource{db: db} } // Scene 返回导出场景编码。 func (s *XxxDataSource) Scene() string { return constants.ExportTaskSceneXxx } // Count 统计 Xxx 导出行数。 func (s *XxxDataSource) Count(ctx context.Context, params ExportParams) (int, error) { var total int64 query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Xxx{}), params) if err := query.Count(&total).Error; err != nil { return 0, err } return int(total), nil } // Headers 返回 Xxx 导出表头。 func (s *XxxDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { return []string{"ID", "名称", "状态", "店铺ID", "创建时间"}, nil } // Fetch 按 offset/limit 查询 Xxx 导出数据。 func (s *XxxDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { return [][]string{}, nil } var items []model.Xxx query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Xxx{}), params). Order("id ASC"). Limit(limit). Offset(offset) if err := query.Find(&items).Error; err != nil { return nil, err } rows := make([][]string, 0, len(items)) for _, item := range items { rows = append(rows, []string{ strconv.FormatUint(uint64(item.ID), 10), item.Name, strconv.Itoa(item.Status), formatOptionalUint(item.ShopID), item.CreatedAt.Format(exportTimeLayout), }) } return rows, nil } func (s *XxxDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "shop_id") if status, ok := filterInt(params.Filters, "status"); ok { query = query.Where("status = ?", status) } if shopID, ok := filterUint(params.Filters, "shop_id"); ok { query = query.Where("shop_id = ?", shopID) } if start, ok := filterTime(params.Filters, "created_at_start"); ok { query = query.Where("created_at >= ?", start) } if end, ok := filterTime(params.Filters, "created_at_end"); ok { query = query.Where("created_at <= ?", end) } return query } ``` ## JOIN Skeleton Use this shape for multi-table exports: ```go type xxxExportRow struct { ID uint Name string Status int ShopID *uint ExtraName string CreatedAt time.Time } func (s *XxxDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { if limit <= 0 { return [][]string{}, nil } var items []xxxExportRow query := s.applyFilters(s.db.WithContext(ctx).Table("tb_xxx AS x"), params, "x."). Select(` x.id, x.name, x.status, x.shop_id, COALESCE(e.name, '') AS extra_name, x.created_at `). Joins("LEFT JOIN tb_extra AS e ON e.xxx_id = x.id AND e.deleted_at IS NULL"). Where("x.deleted_at IS NULL"). Order("x.id ASC"). Limit(limit). Offset(offset) if err := query.Scan(&items).Error; err != nil { return nil, err } rows := make([][]string, 0, len(items)) for _, item := range items { rows = append(rows, []string{ strconv.FormatUint(uint64(item.ID), 10), item.Name, strconv.Itoa(item.Status), formatOptionalUint(item.ShopID), item.ExtraName, item.CreatedAt.Format(exportTimeLayout), }) } return rows, nil } func (s *XxxDataSource) applyFilters(query *gorm.DB, params ExportParams, prefix string) *gorm.DB { query = applyExportShopScope(query, params, prefix+"shop_id") if status, ok := filterInt(params.Filters, "status"); ok { query = query.Where(prefix+"status = ?", status) } return query } ``` If the JOIN multiplies rows, update `Count` to count the same exported row set. Do not count only the base table unless `Fetch` also returns one row per base record. ## Registry Patch ```go func NewDefaultRegistry(db *gorm.DB) *Registry { return NewRegistry( NewDeviceDataSource(db), NewIotCardDataSource(db), NewXxxDataSource(db), ) } func IsSupportedScene(scene string) bool { switch scene { case constants.ExportTaskSceneDevice, constants.ExportTaskSceneIotCard, constants.ExportTaskSceneXxx: return true default: return false } } ``` ## Manual API Smoke ```bash curl -X POST 'http://localhost:端口/api/admin/export-tasks' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "scene": "xxx", "format": "csv", "query": { "filters": { "status": 1 } } }' ``` Check database: ```sql SELECT id, scene, format, status, total_rows, total_shards, query_json FROM tb_export_task WHERE id = ; SELECT shard_no, status, shard_offset, shard_limit, row_count, file_key FROM tb_export_shard_task WHERE task_id = ORDER BY shard_no; ```