Files
junhong_cmp_fiber/internal/task/device_import.go
huang d309951493
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
feat(import): 用 Excel 格式替换 CSV 导入
- 删除 CSV 解析代码,新增 Excel 解析器 (excelize)

- 更新 IoT 卡和设备导入任务处理器

- 更新 API 路由文档和前端接入指南

- 归档变更到 openspec/changes/archive/

- 同步 delta specs 到 main specs
2026-01-31 14:13:02 +08:00

327 lines
9.0 KiB
Go

package task
import (
"context"
stderrors "errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"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"
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"github.com/break/junhong_cmp_fiber/pkg/storage"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
const deviceBatchSize = 100
type DeviceImportPayload struct {
TaskID uint `json:"task_id"`
}
type DeviceImportHandler struct {
db *gorm.DB
redis *redis.Client
importTaskStore *postgres.DeviceImportTaskStore
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
storageService *storage.Service
logger *zap.Logger
}
func NewDeviceImportHandler(
db *gorm.DB,
redis *redis.Client,
importTaskStore *postgres.DeviceImportTaskStore,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
storageSvc *storage.Service,
logger *zap.Logger,
) *DeviceImportHandler {
return &DeviceImportHandler{
db: db,
redis: redis,
importTaskStore: importTaskStore,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
storageService: storageSvc,
logger: logger,
}
}
func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asynq.Task) error {
ctx = pkggorm.SkipDataPermission(ctx)
var payload DeviceImportPayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析设备导入任务载荷失败",
zap.Error(err),
zap.String("task_id", task.ResultWriter().TaskID()),
)
return asynq.SkipRetry
}
importTask, err := h.importTaskStore.GetByID(ctx, payload.TaskID)
if err != nil {
h.logger.Error("获取导入任务失败",
zap.Uint("task_id", payload.TaskID),
zap.Error(err),
)
return asynq.SkipRetry
}
if importTask.Status != model.ImportTaskStatusPending {
h.logger.Info("导入任务已处理,跳过",
zap.Uint("task_id", payload.TaskID),
zap.Int("status", importTask.Status),
)
return nil
}
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "")
h.logger.Info("开始处理设备导入任务",
zap.Uint("task_id", importTask.ID),
zap.String("task_no", importTask.TaskNo),
zap.String("storage_key", importTask.StorageKey),
)
rows, totalCount, err := h.downloadAndParse(ctx, importTask)
if err != nil {
h.logger.Error("下载或解析 Excel 失败",
zap.Uint("task_id", importTask.ID),
zap.Error(err),
)
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error())
return asynq.SkipRetry
}
result := h.processImport(ctx, importTask, rows, totalCount)
h.importTaskStore.UpdateResult(ctx, importTask.ID, totalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil)
if result.failCount > 0 && result.successCount == 0 {
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败")
} else {
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "")
}
h.logger.Info("设备导入任务完成",
zap.Uint("task_id", importTask.ID),
zap.Int("success_count", result.successCount),
zap.Int("skip_count", result.skipCount),
zap.Int("fail_count", result.failCount),
)
return nil
}
func (h *DeviceImportHandler) downloadAndParse(ctx context.Context, task *model.DeviceImportTask) ([]utils.DeviceRow, int, error) {
if h.storageService == nil {
return nil, 0, ErrStorageNotConfigured
}
if task.StorageKey == "" {
return nil, 0, ErrStorageKeyEmpty
}
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
if err != nil {
return nil, 0, err
}
defer cleanup()
if !strings.HasSuffix(strings.ToLower(task.FileName), ".xlsx") {
ext := filepath.Ext(task.FileName)
return nil, 0, fmt.Errorf("不支持的文件格式 %s,请上传Excel文件(.xlsx)", ext)
}
return utils.ParseDeviceExcel(localPath)
}
type deviceImportResult struct {
successCount int
skipCount int
failCount int
skippedItems model.ImportResultItems
failedItems model.ImportResultItems
}
func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.DeviceImportTask, rows []utils.DeviceRow, totalCount int) *deviceImportResult {
result := &deviceImportResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
if len(rows) == 0 {
return result
}
for i := 0; i < len(rows); i += deviceBatchSize {
end := min(i+deviceBatchSize, len(rows))
batch := rows[i:end]
h.processBatch(ctx, task, batch, result)
}
return result
}
func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.DeviceImportTask, batch []utils.DeviceRow, result *deviceImportResult) {
deviceNos := make([]string, 0, len(batch))
allICCIDs := make([]string, 0)
for _, row := range batch {
deviceNos = append(deviceNos, row.DeviceNo)
allICCIDs = append(allICCIDs, row.ICCIDs...)
}
existingDevices, err := h.deviceStore.ExistsByDeviceNoBatch(ctx, deviceNos)
if err != nil {
h.logger.Error("检查设备是否存在失败", zap.Error(err))
for _, row := range batch {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "数据库查询失败",
})
result.failCount++
}
return
}
var existingCards map[string]*model.IotCard
var boundCards map[string]bool
if len(allICCIDs) > 0 {
cards, err := h.iotCardStore.GetByICCIDs(ctx, allICCIDs)
if err != nil {
h.logger.Error("查询卡信息失败", zap.Error(err))
} else {
existingCards = make(map[string]*model.IotCard)
for _, card := range cards {
existingCards[card.ICCID] = card
}
}
boundCards, err = h.deviceSimBindingStore.GetBoundICCIDs(ctx, allICCIDs)
if err != nil {
h.logger.Error("查询卡绑定状态失败", zap.Error(err))
}
}
for _, row := range batch {
if existingDevices[row.DeviceNo] {
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "设备号已存在",
})
result.skipCount++
continue
}
var validCardIDs []uint
var cardIssues []string
for _, iccid := range row.ICCIDs {
card, exists := existingCards[iccid]
if !exists {
cardIssues = append(cardIssues, iccid+"不存在")
continue
}
if boundCards[iccid] {
cardIssues = append(cardIssues, iccid+"已绑定其他设备")
continue
}
if card.ShopID != nil {
cardIssues = append(cardIssues, iccid+"已分配给店铺,不能绑定到平台库存设备")
continue
}
validCardIDs = append(validCardIDs, card.ID)
}
if len(row.ICCIDs) > 0 && len(cardIssues) > 0 {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "卡验证失败: " + strings.Join(cardIssues, ", "),
})
result.failCount++
continue
}
err := h.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txBindingStore := postgres.NewDeviceSimBindingStore(tx, nil)
device := &model.Device{
DeviceNo: row.DeviceNo,
DeviceName: row.DeviceName,
DeviceModel: row.DeviceModel,
DeviceType: row.DeviceType,
MaxSimSlots: row.MaxSimSlots,
Manufacturer: row.Manufacturer,
BatchNo: task.BatchNo,
Status: constants.DeviceStatusInStock,
}
device.Creator = task.Creator
device.Updater = task.Creator
if err := txDeviceStore.Create(ctx, device); err != nil {
return err
}
now := time.Now()
for i, cardID := range validCardIDs {
binding := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: cardID,
SlotPosition: i + 1,
BindStatus: 1,
BindTime: &now,
}
if err := txBindingStore.Create(ctx, binding); err != nil {
return err
}
}
return nil
})
if err != nil {
h.logger.Error("创建设备失败",
zap.String("device_no", row.DeviceNo),
zap.Error(err),
)
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: row.Line,
ICCID: row.DeviceNo,
Reason: "数据库写入失败: " + err.Error(),
})
result.failCount++
continue
}
for _, iccid := range row.ICCIDs {
if card, exists := existingCards[iccid]; exists && !boundCards[iccid] && card.ShopID == nil {
boundCards[iccid] = true
}
}
result.successCount++
}
}
var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 device_no 列")