Files
junhong_cmp_fiber/internal/task/polling_status_import.go
邹进 4139732719
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m31s
fix: 不轮询清单审查问题
- 写清单 ON CONFLICT 的部分索引谓词改为字面量:预编译语句的通用执行计划下参数化谓词匹配不到部分唯一索引,第 6 次起关闭轮询全部失败
- 轮询开关审计允许 Worker、开放接口、C 端与支付回调来源;导入与自动恢复补审计操作者
- 文件导入按 19/20 位 ICCID 匹配
- 钱包重复支付不再恢复轮询

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:18:54 +09:00

207 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package task
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"io"
"os"
"strings"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/storage"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// PollingToggler 改一个资产的轮询开关并返回逐项结果(由资产轮询服务实现)。
type PollingToggler interface {
ToggleAsset(ctx context.Context, assetType string, assetID uint, enable, scoped bool) dto.PollingToggleItem
}
// pollingStatusImportPayload 与 API 侧 PollingStatusImportPayload 同一 JSON 契约。
type pollingStatusImportPayload struct {
TaskID uint `json:"task_id"`
}
// PollingStatusImportHandler 轮询开关文件导入:逐行独立处理(规则同勾选批量),部分成功不回滚,逐行记录结果。
type PollingStatusImportHandler struct {
db *gorm.DB
storage *storage.Service
toggler PollingToggler
logger *zap.Logger
}
// NewPollingStatusImportHandler 创建轮询开关导入任务处理器。
func NewPollingStatusImportHandler(db *gorm.DB, storageService *storage.Service, toggler PollingToggler, logger *zap.Logger) *PollingStatusImportHandler {
if logger == nil {
logger = zap.NewNop()
}
return &PollingStatusImportHandler{db: db, storage: storageService, toggler: toggler, logger: logger}
}
// Handle 处理一个导入任务;任务只会从待处理被领取一次。
func (h *PollingStatusImportHandler) Handle(ctx context.Context, t *asynq.Task) error {
var payload pollingStatusImportPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil || payload.TaskID == 0 {
h.logger.Warn("轮询开关导入任务载荷无效", zap.ByteString("payload", t.Payload()))
return nil
}
now := time.Now()
claimed := h.db.WithContext(ctx).Model(&model.PollingStatusImportTask{}).
Where("id = ? AND status = ?", payload.TaskID, model.ImportTaskStatusPending).
Updates(map[string]any{"status": model.ImportTaskStatusProcessing, "started_at": now, "updated_at": now})
if claimed.Error != nil {
return claimed.Error
}
if claimed.RowsAffected == 0 {
return nil
}
var task model.PollingStatusImportTask
if err := h.db.WithContext(ctx).First(&task, payload.TaskID).Error; err != nil {
return err
}
identifiers, err := h.downloadAndParse(ctx, task.StorageKey)
if err != nil {
h.fail(ctx, task.ID, err.Error())
return nil
}
rowCtx := pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceImport, Reason: task.Reason,
ImportTaskID: task.ID, OperatorID: task.Creator})
rowCtx = auditcontext.With(rowCtx, auditcontext.Context{ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePollingStatusImport,
ActorName: "轮询开关导入任务", Source: constants.AuditSourceWorker, CorrelationID: task.TaskNo})
results := make(model.PollingStatusImportResults, 0, len(identifiers))
success, skipped, failed := 0, 0, 0
for index, identifier := range identifiers {
item := h.processRow(rowCtx, task, index+1, identifier)
switch item.Status {
case constants.PollingToggleItemSuccess:
success++
case constants.PollingToggleItemSkipped:
skipped++
default:
failed++
}
results = append(results, item)
}
completed := time.Now()
if err := h.db.WithContext(context.WithoutCancel(ctx)).Model(&model.PollingStatusImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{
"status": model.ImportTaskStatusCompleted, "total_count": len(identifiers), "success_count": success, "skip_count": skipped,
"fail_count": failed, "result_items": results, "completed_at": completed, "updated_at": completed,
}).Error; err != nil {
h.logger.Error("保存轮询开关导入结果失败", zap.Uint("task_id", task.ID), zap.Error(err))
return err
}
h.logger.Info("轮询开关导入完成", zap.Uint("task_id", task.ID), zap.Int("total", len(identifiers)), zap.Int("success", success),
zap.Int("skipped", skipped), zap.Int("failed", failed))
return nil
}
func (h *PollingStatusImportHandler) processRow(ctx context.Context, task model.PollingStatusImportTask, line int, identifier string) model.PollingStatusImportResultItem {
item := model.PollingStatusImportResultItem{Line: line, Identifier: identifier}
if identifier == "" {
item.Status, item.Reason = constants.PollingToggleItemFailed, "标识为空"
return item
}
assetID, err := h.resolve(ctx, task.AssetType, identifier)
if err != nil {
item.Status, item.Reason = constants.PollingToggleItemForbidden, "资产不存在"
if !stderrors.Is(err, gorm.ErrRecordNotFound) {
item.Status, item.Reason = constants.PollingToggleItemFailed, "查询资产失败"
}
return item
}
item.AssetID = assetID
result := h.toggler.ToggleAsset(ctx, task.AssetType, assetID, task.EnablePolling, false)
item.Status, item.Reason = result.Status, result.Reason
return item
}
// resolve 按 ICCID(卡)或 IMEI / 虚拟号(设备)查找资产。
func (h *PollingStatusImportHandler) resolve(ctx context.Context, assetType, identifier string) (uint, error) {
if assetType == constants.AssetTypeDevice {
var device model.Device
err := h.db.WithContext(ctx).Select("id").Where("imei = ? OR virtual_no = ?", identifier, identifier).Order("id").First(&device).Error
return device.ID, err
}
// 与 IotCardStore.GetByICCID 同口径:19 位按 iccid_19、20 位按 iccid_20 匹配(联通卡两种位数都可识别)。
column := "iccid"
switch len(identifier) {
case 19:
column = "iccid_19"
case 20:
column = "iccid_20"
}
var card model.IotCard
err := h.db.WithContext(ctx).Select("id").Where(column+" = ?", identifier).First(&card).Error
return card.ID, err
}
// downloadAndParse 下载 CSV 并取每行首列作为资产标识;首行为表头,空行忽略。
func (h *PollingStatusImportHandler) downloadAndParse(ctx context.Context, key string) ([]string, error) {
if h.storage == nil {
return nil, stderrors.New("对象存储服务未配置")
}
localPath, cleanup, err := h.storage.DownloadToTemp(ctx, key)
if err != nil {
return nil, stderrors.New("下载导入文件失败")
}
defer cleanup()
data, err := os.ReadFile(localPath)
if err != nil {
return nil, stderrors.New("读取导入文件失败")
}
decoded, err := utils.DecodeTextToUTF8(data)
if err != nil {
return nil, stderrors.New("导入文件编码无法识别,请使用 UTF-8 或 GBK")
}
reader := csv.NewReader(bytes.NewReader(decoded))
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
identifiers := make([]string, 0)
header := true
for {
record, readErr := reader.Read()
if readErr == io.EOF {
break
}
if readErr != nil {
return nil, stderrors.New("导入文件不是有效的 CSV")
}
if header {
header = false
continue
}
if len(record) == 0 || strings.TrimSpace(strings.Join(record, "")) == "" {
continue
}
identifiers = append(identifiers, strings.TrimSpace(strings.TrimPrefix(record[0], "\uFEFF")))
if len(identifiers) > constants.PollingStatusImportMaxRows {
return nil, stderrors.New("导入文件超过 50000 行")
}
}
if len(identifiers) == 0 {
return nil, stderrors.New("导入文件没有数据行")
}
return identifiers, nil
}
func (h *PollingStatusImportHandler) fail(ctx context.Context, taskID uint, message string) {
now := time.Now()
if err := h.db.WithContext(context.WithoutCancel(ctx)).Model(&model.PollingStatusImportTask{}).Where("id = ?", taskID).Updates(map[string]any{
"status": model.ImportTaskStatusFailed, "error_message": message, "completed_at": now, "updated_at": now,
}).Error; err != nil {
h.logger.Error("标记轮询开关导入任务失败时出错", zap.Uint("task_id", taskID), zap.Error(err))
}
}