Files
junhong_cmp_fiber/.codex/skills/export-datasource/references/export-scene-template.md
Break 1f634eb465
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
init
2026-06-16 15:15:50 +08:00

6.0 KiB

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

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:

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

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

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:

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;