Files
junhong_cmp_fiber/internal/task/export_dispatch.go
break 62419d4b17
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 13m40s
feat(导出时间筛选): AUG26-014 统一时间筛选与临期导出,归档并同步主 Spec 与证据链
统一时间筛选:新增共享严格解析器 pkg/utils/time_range.go,只接受带显式时区的 RFC3339 秒级时间(拒绝小数秒、无时区、date-only、空格分隔、±hhmm、未补零、非法日期与越界偏移),闭区间含两端、归一为 UTC 瞬时,创建期与执行期共用同一份实现。

端点改造(13 个入口):IoT 卡导入任务、设备导入任务、导出任务列表、订单列表参数名不变仅收紧解析;换货、分配记录、代理充值、临期列表改名 start_time/end_time(旧参数名显式拒绝);提现记录两处删除解析失败静默跳过,非法参数一律 1001;授权记录由起始闭结束开改为闭区间含两端;临期列表改按当前生效主套餐最终到期时刻比较,保留剩余天数上下限与既有粗放窗口。

临期导出新建:新场景 expiring_asset 与受控入口 POST /api/admin/expiring-assets/export,复用列表候选预筛与最终到期推算,一行一资产、加油包不单独成行,列序与 111 §18.1 逐列一致,店铺/业务员/用户组按执行时当前归属补充且不超出创建时冻结范围。

佣金明细导出新增按创建时间闭区间筛选(原佣金与回溯两条分支各自创建时间列),记录粒度、列定义与余额口径不变。

冻结与遗留任务:创建期把筛选与时间边界规范化为 UTC RFC3339 秒级串写入既有 query_json,无新列无迁移;执行期只按冻结值严格解析,非法冻结值在任何分片与文件动作前落任务失败并写安全摘要,不放行全量;重试沿用原快照。达量预警导出执行期同样纳入严格解析(其入口契约、列定义与触发快照口径不变)。

归档 add-export-time-filter-standards 并新建主 Spec openspec/specs/export-time-filter/spec.md,同步 requirement-evidence.json 与入口能力矩阵,README 导出场景清单更新为 11 个场景。

验证:junhong_cmp_test + 本地隔离 Redis(DB7,测试部署共享队列 DB6 未被占用)实跑 85 PASS / 0 FAIL(接受/拒绝集合、区间与顺序语义、列表与导出同筛选行集一致、代理 HTTP 全链路与范围冻结、遗留旧格式任务安全失败、列与余额口径回归、表头逐字),门禁 gofmt/go build/gendocs 两次一致/openspec validate/doctor/context-health 全绿;无 Schema 变更、无迁移、无运行时开关。
2026-09-17 18:37:02 +08:00

268 lines
8.2 KiB
Go

package task
import (
"context"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/exporter"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ExportDispatchHandler 导出 dispatch 处理器。
type ExportDispatchHandler struct {
db *gorm.DB
redis *redis.Client
taskStore *postgres.ExportTaskStore
shardStore *postgres.ExportShardTaskStore
asynqClient *asynq.Client
sceneRegistry *exporter.Registry
logger *zap.Logger
}
// NewExportDispatchHandler 创建导出 dispatch 处理器。
func NewExportDispatchHandler(
db *gorm.DB,
redis *redis.Client,
taskStore *postgres.ExportTaskStore,
shardStore *postgres.ExportShardTaskStore,
asynqClient *asynq.Client,
sceneRegistry *exporter.Registry,
logger *zap.Logger,
) *ExportDispatchHandler {
return &ExportDispatchHandler{
db: db,
redis: redis,
taskStore: taskStore,
shardStore: shardStore,
asynqClient: asynqClient,
sceneRegistry: sceneRegistry,
logger: logger,
}
}
// HandleExportDispatch 处理导出 dispatch 任务。
func (h *ExportDispatchHandler) HandleExportDispatch(ctx context.Context, task *asynq.Task) error {
var payload ExportDispatchPayload
if err := parseExportPayload(task.Payload(), &payload); err != nil {
h.logger.Error("解析导出 dispatch 任务载荷失败", zap.Error(err))
return asynq.SkipRetry
}
lockKey := constants.RedisExportDispatchLockKey(payload.TaskID)
locked, err := tryAcquireLock(ctx, h.redis, lockKey, exportLockTTL)
if err != nil {
h.logger.Error("获取导出 dispatch 锁失败", zap.Uint("task_id", payload.TaskID), zap.Error(err))
return err
}
if !locked {
h.logger.Info("导出 dispatch 已在执行,跳过", zap.Uint("task_id", payload.TaskID))
return nil
}
defer releaseLock(ctx, h.redis, lockKey)
exportTask, err := h.taskStore.GetByIDForWorker(ctx, payload.TaskID)
if err != nil {
h.logger.Error("查询导出任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err))
return asynq.SkipRetry
}
if exportTask.Status == constants.ExportTaskStatusCompleted || exportTask.Status == constants.ExportTaskStatusFailed || exportTask.Status == constants.ExportTaskStatusCancelled {
return nil
}
updater := dispatchUpdater(exportTask)
if exportTask.CancelRequested {
if err := h.shardStore.CancelByTaskID(ctx, exportTask.ID, updater, "任务已取消"); err != nil {
return err
}
if err := h.taskStore.MarkCancelledByWorker(ctx, exportTask.ID, updater, "任务已取消"); err != nil {
return err
}
return nil
}
started, err := h.taskStore.TryMarkProcessingIfPending(ctx, exportTask.ID, updater)
if err != nil {
h.logger.Error("更新导出任务状态为处理中失败", zap.Uint("task_id", exportTask.ID), zap.Error(err))
return err
}
if !started {
if exportTask.Status != constants.ExportTaskStatusProcessing {
return nil
}
existingShards, err := h.shardStore.ListByTaskID(ctx, exportTask.ID)
if err != nil {
return err
}
if len(existingShards) == 0 {
return nil
}
if err := h.enqueueShardTasks(ctx, exportTask, existingShards); err != nil {
return err
}
return h.enqueueFinalizeTask(ctx, exportTask.ID)
}
strategy, ok := h.sceneRegistry.Get(exportTask.Scene)
if !ok {
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "导出场景未注册")
return asynq.SkipRetry
}
params := exporter.ParseExportParams(exportTask)
if err := exporter.ValidateTaskTimeFilters(exportTask.Scene, params.Filters); err != nil {
// 冻结值非法(含变更前遗留任务的旧格式值)必须落失败,不得忽略条件后放行全量数据。
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, constants.ExportTaskInvalidTimeFilterMessage)
h.logger.Error("导出任务冻结的时间边界非法", zap.Uint("task_id", exportTask.ID), zap.Error(err))
return asynq.SkipRetry
}
headers, err := strategy.Headers(ctx, params)
if err != nil {
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "解析导出表头失败")
h.logger.Error("解析导出表头失败", zap.Uint("task_id", exportTask.ID), zap.Error(err))
return err
}
shards, totalRows, err := h.buildShards(ctx, strategy, params, exportTask.CancelRequested, exportTask.ID, updater)
if err != nil {
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "构建导出分片失败")
h.logger.Error("构建导出分片失败", zap.Uint("task_id", exportTask.ID), zap.Error(err))
return err
}
if err := h.persistShardsAndPlan(ctx, exportTask.ID, updater, shards, totalRows, headers); err != nil {
_ = h.taskStore.MarkFailed(ctx, exportTask.ID, updater, "保存导出分片失败")
return err
}
if err := h.enqueueShardTasks(ctx, exportTask, shards); err != nil {
return err
}
if err := h.enqueueFinalizeTask(ctx, exportTask.ID); err != nil {
return err
}
h.logger.Info("导出 dispatch 完成",
zap.Uint("task_id", exportTask.ID),
zap.Int("total_rows", totalRows),
zap.Int("total_shards", len(shards)),
)
return nil
}
func (h *ExportDispatchHandler) buildShards(ctx context.Context, source exporter.DataSource, params exporter.ExportParams, cancelRequested bool, taskID uint, updater uint) ([]*model.ExportShardTask, int, error) {
shards := make([]*model.ExportShardTask, 0)
if cancelRequested {
return shards, 0, nil
}
totalRows, err := source.Count(ctx, params)
if err != nil {
return nil, 0, err
}
if totalRows <= 0 {
return shards, 0, nil
}
shardNo := 1
for offset := 0; offset < totalRows; offset += constants.ExportDefaultShardSize {
limit := constants.ExportDefaultShardSize
if remaining := totalRows - offset; remaining < limit {
limit = remaining
}
shard := &model.ExportShardTask{
TaskID: taskID,
ShardNo: shardNo,
Status: constants.ExportShardStatusPending,
ShardOffset: offset,
ShardLimit: limit,
RowCount: limit,
}
shard.Creator = updater
shard.Updater = updater
shards = append(shards, shard)
shardNo++
}
return shards, totalRows, nil
}
func (h *ExportDispatchHandler) persistShardsAndPlan(ctx context.Context, taskID uint, updater uint, shards []*model.ExportShardTask, totalRows int, headers []string) error {
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txShardStore := postgres.NewExportShardTaskStore(tx, h.redis)
txTaskStore := postgres.NewExportTaskStore(tx, h.redis)
if err := txShardStore.CreateBatch(ctx, shards); err != nil {
return err
}
if err := txTaskStore.UpdateResolvedHeaders(ctx, taskID, headers, updater); err != nil {
return err
}
if err := txTaskStore.UpdateDispatchPlan(ctx, taskID, totalRows, len(shards), updater); err != nil {
return err
}
return nil
})
}
func (h *ExportDispatchHandler) enqueueShardTasks(ctx context.Context, task *model.ExportTask, shards []*model.ExportShardTask) error {
for _, shard := range shards {
payload := ExportShardPayload{TaskID: task.ID, ShardID: shard.ID}
err := enqueueTask(
ctx,
h.asynqClient,
constants.TaskTypeExportShard,
payload,
asynq.MaxRetry(constants.ExportShardRetryMax),
asynq.Timeout(constants.ExportShardTaskTimeout),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeExportShard)),
)
if err != nil {
h.logger.Error("导出分片任务入队失败",
zap.Uint("task_id", task.ID),
zap.Uint("shard_id", shard.ID),
zap.Error(err),
)
return fmt.Errorf("enqueue shard task failed: %w", err)
}
}
return nil
}
func (h *ExportDispatchHandler) enqueueFinalizeTask(ctx context.Context, taskID uint) error {
return enqueueTask(
ctx,
h.asynqClient,
constants.TaskTypeExportFinalize,
ExportFinalizePayload{TaskID: taskID},
asynq.MaxRetry(constants.ExportFinalizeRetryMax),
asynq.Timeout(constants.ExportFinalizeTaskTimeout),
asynq.ProcessIn(5*time.Second),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeExportFinalize)),
)
}
func dispatchUpdater(task *model.ExportTask) uint {
if task.CreatorUserID > 0 {
return task.CreatorUserID
}
if task.Creator > 0 {
return task.Creator
}
return 0
}