# 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>_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 <token>' \
  -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 = <task_id>;

SELECT shard_no, status, shard_offset, shard_limit, row_count, file_key
FROM tb_export_shard_task
WHERE task_id = <task_id>
ORDER BY shard_no;
```
