Files
junhong_cmp_fiber/internal/task/iot_card_import.go
huang a924e63e68
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m42s
feat: 实现物联网卡独立管理和批量导入功能
新增物联网卡独立管理模块,支持单卡查询、批量导入和状态管理。主要变更包括:

功能特性:
- 新增物联网卡 CRUD 接口(查询、分页列表、删除)
- 支持 CSV/Excel 批量导入物联网卡
- 实现异步导入任务处理和进度跟踪
- 新增 ICCID 号码格式校验器(支持 Luhn 算法)
- 新增 CSV 文件解析工具(支持编码检测和错误处理)

数据库变更:
- 移除 iot_card 和 device 表的 owner_id/owner_type 字段
- 新增 iot_card_import_task 导入任务表
- 为导入任务添加运营商类型字段

测试覆盖:
- 新增 IoT 卡 Store 层单元测试
- 新增 IoT 卡导入任务单元测试
- 新增 IoT 卡集成测试(包含导入流程测试)
- 新增 CSV 工具和 ICCID 校验器测试

文档更新:
- 更新 OpenAPI 文档(新增 7 个 IoT 卡接口)
- 归档 OpenSpec 变更提案
- 更新 API 文档规范和生成器指南

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-24 11:03:43 +08:00

231 lines
6.2 KiB
Go

package task
import (
"context"
"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/validator"
)
const batchSize = 1000
type IotCardImportPayload struct {
TaskID uint `json:"task_id"`
}
type IotCardImportHandler struct {
db *gorm.DB
redis *redis.Client
importTaskStore *postgres.IotCardImportTaskStore
iotCardStore *postgres.IotCardStore
logger *zap.Logger
}
func NewIotCardImportHandler(db *gorm.DB, redis *redis.Client, importTaskStore *postgres.IotCardImportTaskStore, iotCardStore *postgres.IotCardStore, logger *zap.Logger) *IotCardImportHandler {
return &IotCardImportHandler{
db: db,
redis: redis,
importTaskStore: importTaskStore,
iotCardStore: iotCardStore,
logger: logger,
}
}
func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *asynq.Task) error {
ctx = pkggorm.SkipDataPermission(ctx)
var payload IotCardImportPayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析 IoT 卡导入任务载荷失败",
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("开始处理 IoT 卡导入任务",
zap.Uint("task_id", importTask.ID),
zap.String("task_no", importTask.TaskNo),
zap.Int("total_count", importTask.TotalCount),
)
result := h.processImport(ctx, importTask)
h.importTaskStore.UpdateResult(ctx, importTask.ID, result.successCount, result.skipCount, result.failCount, result.skippedItems, result.failedItems)
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("IoT 卡导入任务完成",
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
}
type importResult struct {
successCount int
skipCount int
failCount int
skippedItems model.ImportResultItems
failedItems model.ImportResultItems
}
func (h *IotCardImportHandler) processImport(ctx context.Context, task *model.IotCardImportTask) *importResult {
result := &importResult{
skippedItems: make(model.ImportResultItems, 0),
failedItems: make(model.ImportResultItems, 0),
}
iccids := h.getICCIDsFromTask(task)
if len(iccids) == 0 {
return result
}
for i := 0; i < len(iccids); i += batchSize {
end := min(i+batchSize, len(iccids))
batch := iccids[i:end]
h.processBatch(ctx, task, batch, i+1, result)
}
return result
}
func (h *IotCardImportHandler) getICCIDsFromTask(task *model.IotCardImportTask) []string {
return []string(task.ICCIDList)
}
func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.IotCardImportTask, batch []string, startLine int, result *importResult) {
validICCIDs := make([]string, 0)
lineMap := make(map[string]int)
for i, iccid := range batch {
line := startLine + i
lineMap[iccid] = line
validationResult := validator.ValidateICCID(iccid, task.CarrierType)
if !validationResult.Valid {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: line,
ICCID: iccid,
Reason: validationResult.Message,
})
result.failCount++
continue
}
validICCIDs = append(validICCIDs, iccid)
}
if len(validICCIDs) == 0 {
return
}
existingMap, err := h.iotCardStore.ExistsByICCIDBatch(ctx, validICCIDs)
if err != nil {
h.logger.Error("批量检查 ICCID 是否存在失败",
zap.Error(err),
zap.Int("batch_size", len(validICCIDs)),
)
for _, iccid := range validICCIDs {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: lineMap[iccid],
ICCID: iccid,
Reason: "数据库查询失败",
})
result.failCount++
}
return
}
newICCIDs := make([]string, 0)
for _, iccid := range validICCIDs {
if existingMap[iccid] {
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
Line: lineMap[iccid],
ICCID: iccid,
Reason: "ICCID 已存在",
})
result.skipCount++
} else {
newICCIDs = append(newICCIDs, iccid)
}
}
if len(newICCIDs) == 0 {
return
}
cards := make([]*model.IotCard, 0, len(newICCIDs))
now := time.Now()
for _, iccid := range newICCIDs {
card := &model.IotCard{
ICCID: iccid,
CarrierID: task.CarrierID,
BatchNo: task.BatchNo,
Status: constants.IotCardStatusInStock,
CardCategory: constants.CardCategoryNormal,
ActivationStatus: constants.ActivationStatusInactive,
RealNameStatus: constants.RealNameStatusNotVerified,
NetworkStatus: constants.NetworkStatusOffline,
}
card.BaseModel.Creator = task.Creator
card.BaseModel.Updater = task.Creator
card.CreatedAt = now
card.UpdatedAt = now
cards = append(cards, card)
}
if err := h.iotCardStore.CreateBatch(ctx, cards); err != nil {
h.logger.Error("批量创建 IoT 卡失败",
zap.Error(err),
zap.Int("batch_size", len(cards)),
)
for _, iccid := range newICCIDs {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: lineMap[iccid],
ICCID: iccid,
Reason: "数据库写入失败",
})
result.failCount++
}
return
}
result.successCount += len(newICCIDs)
}