feat: 实现物联网卡独立管理和批量导入功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m42s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m42s
新增物联网卡独立管理模块,支持单卡查询、批量导入和状态管理。主要变更包括: 功能特性: - 新增物联网卡 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>
This commit is contained in:
275
internal/service/iot_card_import/service.go
Normal file
275
internal/service/iot_card_import/service.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package iot_card_import
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
importTaskStore *postgres.IotCardImportTaskStore
|
||||
carrierStore carrierGetter
|
||||
queueClient *queue.Client
|
||||
}
|
||||
|
||||
type carrierGetter interface {
|
||||
GetByID(ctx context.Context, id uint) (*model.Carrier, error)
|
||||
}
|
||||
|
||||
type CarrierStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCarrierStore(db *gorm.DB) *CarrierStore {
|
||||
return &CarrierStore{db: db}
|
||||
}
|
||||
|
||||
func (s *CarrierStore) GetByID(ctx context.Context, id uint) (*model.Carrier, error) {
|
||||
var carrier model.Carrier
|
||||
if err := s.db.WithContext(ctx).First(&carrier, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &carrier, nil
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, importTaskStore *postgres.IotCardImportTaskStore, queueClient *queue.Client) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
importTaskStore: importTaskStore,
|
||||
carrierStore: NewCarrierStore(db),
|
||||
queueClient: queueClient,
|
||||
}
|
||||
}
|
||||
|
||||
type IotCardImportPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRequest, csvReader io.Reader, fileName string) (*dto.ImportIotCardResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
carrier, err := s.carrierStore.GetByID(ctx, req.CarrierID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "运营商不存在")
|
||||
}
|
||||
|
||||
parseResult, err := utils.ParseICCIDFromCSV(csvReader)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "CSV 解析失败: "+err.Error())
|
||||
}
|
||||
|
||||
if parseResult.TotalCount == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "CSV 文件中没有有效的 ICCID")
|
||||
}
|
||||
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
CarrierID: req.CarrierID,
|
||||
CarrierType: carrier.CarrierType,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
TotalCount: parseResult.TotalCount,
|
||||
SuccessCount: 0,
|
||||
SkipCount: 0,
|
||||
FailCount: 0,
|
||||
ICCIDList: model.ICCIDListJSON(parseResult.ICCIDs),
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
|
||||
if err := s.importTaskStore.Create(ctx, task); err != nil {
|
||||
return nil, fmt.Errorf("创建导入任务失败: %w", err)
|
||||
}
|
||||
|
||||
payload := IotCardImportPayload{TaskID: task.ID}
|
||||
err = s.queueClient.EnqueueTask(ctx, constants.TaskTypeIotCardImport, payload)
|
||||
if err != nil {
|
||||
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
return nil, fmt.Errorf("任务入队失败: %w", err)
|
||||
}
|
||||
|
||||
return &dto.ImportIotCardResponse{
|
||||
TaskID: task.ID,
|
||||
TaskNo: taskNo,
|
||||
Message: fmt.Sprintf("导入任务已创建,共 %d 条 ICCID 待处理", parseResult.TotalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dto.ListImportTaskResponse, error) {
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
|
||||
opts := &store.QueryOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
filters := make(map[string]interface{})
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
if req.CarrierID != nil {
|
||||
filters["carrier_id"] = *req.CarrierID
|
||||
}
|
||||
if req.BatchNo != "" {
|
||||
filters["batch_no"] = req.BatchNo
|
||||
}
|
||||
if req.StartTime != nil {
|
||||
filters["start_time"] = *req.StartTime
|
||||
}
|
||||
if req.EndTime != nil {
|
||||
filters["end_time"] = *req.EndTime
|
||||
}
|
||||
|
||||
tasks, total, err := s.importTaskStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
carrierMap := s.loadCarriers(ctx, tasks)
|
||||
|
||||
list := make([]*dto.ImportTaskResponse, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
list = append(list, s.toTaskResponse(task, carrierMap))
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &dto.ListImportTaskResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.ImportTaskDetailResponse, error) {
|
||||
task, err := s.importTaskStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "导入任务不存在")
|
||||
}
|
||||
|
||||
carrierMap := make(map[uint]string)
|
||||
var carrier model.Carrier
|
||||
if s.db.WithContext(ctx).First(&carrier, task.CarrierID).Error == nil {
|
||||
carrierMap[carrier.ID] = carrier.CarrierName
|
||||
}
|
||||
|
||||
resp := &dto.ImportTaskDetailResponse{
|
||||
ImportTaskResponse: *s.toTaskResponse(task, carrierMap),
|
||||
SkippedItems: make([]*dto.ImportResultItemDTO, 0),
|
||||
FailedItems: make([]*dto.ImportResultItemDTO, 0),
|
||||
}
|
||||
|
||||
for _, item := range task.SkippedItems {
|
||||
resp.SkippedItems = append(resp.SkippedItems, &dto.ImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
ICCID: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range task.FailedItems {
|
||||
resp.FailedItems = append(resp.FailedItems, &dto.ImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
ICCID: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCarriers(ctx context.Context, tasks []*model.IotCardImportTask) map[uint]string {
|
||||
carrierIDs := make([]uint, 0)
|
||||
carrierIDSet := make(map[uint]bool)
|
||||
for _, task := range tasks {
|
||||
if task.CarrierID > 0 && !carrierIDSet[task.CarrierID] {
|
||||
carrierIDs = append(carrierIDs, task.CarrierID)
|
||||
carrierIDSet[task.CarrierID] = true
|
||||
}
|
||||
}
|
||||
|
||||
carrierMap := make(map[uint]string)
|
||||
if len(carrierIDs) > 0 {
|
||||
var carriers []model.Carrier
|
||||
s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers)
|
||||
for _, c := range carriers {
|
||||
carrierMap[c.ID] = c.CarrierName
|
||||
}
|
||||
}
|
||||
return carrierMap
|
||||
}
|
||||
|
||||
func (s *Service) toTaskResponse(task *model.IotCardImportTask, carrierMap map[uint]string) *dto.ImportTaskResponse {
|
||||
var startedAt, completedAt *time.Time
|
||||
if task.StartedAt != nil {
|
||||
startedAt = task.StartedAt
|
||||
}
|
||||
if task.CompletedAt != nil {
|
||||
completedAt = task.CompletedAt
|
||||
}
|
||||
|
||||
return &dto.ImportTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
CarrierID: task.CarrierID,
|
||||
CarrierName: carrierMap[task.CarrierID],
|
||||
BatchNo: task.BatchNo,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func getStatusText(status int) string {
|
||||
switch status {
|
||||
case model.ImportTaskStatusPending:
|
||||
return "待处理"
|
||||
case model.ImportTaskStatusProcessing:
|
||||
return "处理中"
|
||||
case model.ImportTaskStatusCompleted:
|
||||
return "已完成"
|
||||
case model.ImportTaskStatusFailed:
|
||||
return "失败"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user