feat: 实现设备管理和设备导入功能,修复测试问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m30s
主要变更: - 实现设备管理模块(创建、查询、列表、更新状态、删除) - 实现设备批量导入功能(CSV 解析、ICCID 绑定、异步任务处理) - 添加设备-SIM 卡绑定约束(部分唯一索引防止并发问题) - 修复 fee_rate 数据库字段类型(numeric -> bigint) - 修复测试数据隔离问题(基于增量断言) - 修复集成测试中间件顺序问题 - 清理无用测试文件(PersonalCustomer、Email 相关) - 归档 enterprise-card-authorization 变更
This commit is contained in:
@@ -29,6 +29,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
||||
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
||||
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
||||
Device: admin.NewDeviceHandler(svc.Device),
|
||||
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
|
||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
||||
Storage: admin.NewStorageHandler(deps.StorageService),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||
customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account"
|
||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
|
||||
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
|
||||
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
|
||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
@@ -38,6 +40,8 @@ type services struct {
|
||||
MyCommission *myCommissionSvc.Service
|
||||
IotCard *iotCardSvc.Service
|
||||
IotCardImport *iotCardImportSvc.Service
|
||||
Device *deviceSvc.Service
|
||||
DeviceImport *deviceImportSvc.Service
|
||||
AssetAllocationRecord *assetAllocationRecordSvc.Service
|
||||
}
|
||||
|
||||
@@ -60,6 +64,8 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
|
||||
IotCard: iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord),
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
||||
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord),
|
||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ type stores struct {
|
||||
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
|
||||
IotCard *postgres.IotCardStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
Device *postgres.DeviceStore
|
||||
DeviceSimBinding *postgres.DeviceSimBindingStore
|
||||
DeviceImportTask *postgres.DeviceImportTaskStore
|
||||
AssetAllocationRecord *postgres.AssetAllocationRecordStore
|
||||
}
|
||||
|
||||
@@ -44,6 +47,9 @@ func initStores(deps *Dependencies) *stores {
|
||||
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
Device: postgres.NewDeviceStore(deps.DB, deps.Redis),
|
||||
DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
|
||||
DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis),
|
||||
AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type Handlers struct {
|
||||
MyCommission *admin.MyCommissionHandler
|
||||
IotCard *admin.IotCardHandler
|
||||
IotCardImport *admin.IotCardImportHandler
|
||||
Device *admin.DeviceHandler
|
||||
DeviceImport *admin.DeviceImportHandler
|
||||
AssetAllocationRecord *admin.AssetAllocationRecordHandler
|
||||
Storage *admin.StorageHandler
|
||||
}
|
||||
|
||||
183
internal/handler/admin/device.go
Normal file
183
internal/handler/admin/device.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||
"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/response"
|
||||
)
|
||||
|
||||
type DeviceHandler struct {
|
||||
service *deviceService.Service
|
||||
}
|
||||
|
||||
func NewDeviceHandler(service *deviceService.Service) *DeviceHandler {
|
||||
return &DeviceHandler{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) List(c *fiber.Ctx) error {
|
||||
var req dto.ListDeviceRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.List(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) GetByID(c *fiber.Ctx) error {
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
result, err := h.service.Get(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可删除设备")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) ListCards(c *fiber.Ctx) error {
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
result, err := h.service.ListBindings(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) BindCard(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可绑定卡到设备")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
var req dto.BindCardToDeviceRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
req.ID = uint(id)
|
||||
|
||||
result, err := h.service.BindCard(c.UserContext(), uint(id), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) UnbindCard(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可解绑设备的卡")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||
}
|
||||
|
||||
cardIdStr := c.Params("cardId")
|
||||
cardId, err := strconv.ParseUint(cardIdStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
|
||||
result, err := h.service.UnbindCard(c.UserContext(), uint(id), uint(cardId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) Allocate(c *fiber.Ctx) error {
|
||||
var req dto.AllocateDevicesRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
userID := middleware.GetUserIDFromContext(c.UserContext())
|
||||
shopID := middleware.GetShopIDFromContext(c.UserContext())
|
||||
|
||||
var shopIDPtr *uint
|
||||
if shopID > 0 {
|
||||
shopIDPtr = &shopID
|
||||
}
|
||||
|
||||
result, err := h.service.AllocateDevices(c.UserContext(), &req, userID, shopIDPtr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) Recall(c *fiber.Ctx) error {
|
||||
var req dto.RecallDevicesRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
userID := middleware.GetUserIDFromContext(c.UserContext())
|
||||
shopID := middleware.GetShopIDFromContext(c.UserContext())
|
||||
|
||||
var shopIDPtr *uint
|
||||
if shopID > 0 {
|
||||
shopIDPtr = &shopID
|
||||
}
|
||||
|
||||
result, err := h.service.RecallDevices(c.UserContext(), &req, userID, shopIDPtr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
86
internal/handler/admin/device_import.go
Normal file
86
internal/handler/admin/device_import.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
deviceImportService "github.com/break/junhong_cmp_fiber/internal/service/device_import"
|
||||
"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/response"
|
||||
)
|
||||
|
||||
type DeviceImportHandler struct {
|
||||
service *deviceImportService.Service
|
||||
}
|
||||
|
||||
func NewDeviceImportHandler(service *deviceImportService.Service) *DeviceImportHandler {
|
||||
return &DeviceImportHandler{
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) Import(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可导入设备")
|
||||
}
|
||||
|
||||
var req dto.ImportDeviceRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
if req.FileKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "文件路径不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.CreateImportTask(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) List(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可查看导入任务")
|
||||
}
|
||||
|
||||
var req dto.ListDeviceImportTaskRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.List(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) GetByID(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可查看导入任务详情")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的任务ID")
|
||||
}
|
||||
|
||||
result, err := h.service.GetByID(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
43
internal/model/device_import_task.go
Normal file
43
internal/model/device_import_task.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeviceImportTask 设备导入任务模型
|
||||
// 记录设备批量导入的任务状态和处理结果
|
||||
// 通过异步任务处理 CSV 文件导入设备并绑定卡
|
||||
type DeviceImportTask struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"`
|
||||
TotalCount int `gorm:"column:total_count;type:int;default:0;comment:总记录数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;type:int;default:0;comment:成功数" json:"success_count"`
|
||||
SkipCount int `gorm:"column:skip_count;type:int;default:0;comment:跳过数" json:"skip_count"`
|
||||
FailCount int `gorm:"column:fail_count;type:int;default:0;comment:失败数" json:"fail_count"`
|
||||
SkippedItems ImportResultItems `gorm:"column:skipped_items;type:jsonb;comment:跳过记录详情" json:"skipped_items"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
|
||||
WarningCount int `gorm:"column:warning_count;default:0;comment:警告数量(部分成功的设备)" json:"warning_count"`
|
||||
WarningItems ImportResultItems `gorm:"column:warning_items;type:jsonb;comment:警告记录详情" json:"warning_items"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;comment:错误信息" json:"error_message"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (DeviceImportTask) TableName() string {
|
||||
return "tb_device_import_task"
|
||||
}
|
||||
|
||||
// DeviceImportResultItem 设备导入结果项
|
||||
type DeviceImportResultItem struct {
|
||||
Line int `json:"line"`
|
||||
DeviceNo string `json:"device_no"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
24
internal/model/device_sim_binding.go
Normal file
24
internal/model/device_sim_binding.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeviceSimBinding 设备-IoT卡绑定关系模型
|
||||
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
|
||||
type DeviceSimBinding struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
|
||||
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
|
||||
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
|
||||
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"`
|
||||
}
|
||||
|
||||
func (DeviceSimBinding) TableName() string {
|
||||
return "tb_device_sim_binding"
|
||||
}
|
||||
120
internal/model/dto/device_dto.go
Normal file
120
internal/model/dto/device_dto.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type ListDeviceRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
DeviceNo string `json:"device_no" query:"device_no" validate:"omitempty,max=100" maxLength:"100" description:"设备号(模糊查询)"`
|
||||
DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID (NULL表示平台库存)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"`
|
||||
Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"`
|
||||
CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"`
|
||||
CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"`
|
||||
}
|
||||
|
||||
type DeviceResponse struct {
|
||||
ID uint `json:"id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
DeviceModel string `json:"device_model" description:"设备型号"`
|
||||
DeviceType string `json:"device_type" description:"设备类型"`
|
||||
MaxSimSlots int `json:"max_sim_slots" description:"最大插槽数"`
|
||||
Manufacturer string `json:"manufacturer" description:"制造商"`
|
||||
BatchNo string `json:"batch_no" description:"批次号"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
BoundCardCount int `json:"bound_card_count" description:"已绑定卡数量"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
type ListDeviceResponse struct {
|
||||
List []*DeviceResponse `json:"list" description:"设备列表"`
|
||||
Total int64 `json:"total" description:"总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
TotalPages int `json:"total_pages" description:"总页数"`
|
||||
}
|
||||
|
||||
type GetDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
}
|
||||
|
||||
type DeleteDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
}
|
||||
|
||||
type ListDeviceCardsRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
}
|
||||
|
||||
type DeviceCardBindingResponse struct {
|
||||
ID uint `json:"id" description:"绑定记录ID"`
|
||||
SlotPosition int `json:"slot_position" description:"插槽位置 (1-4)"`
|
||||
IotCardID uint `json:"iot_card_id" description:"IoT卡ID"`
|
||||
ICCID string `json:"iccid" description:"ICCID"`
|
||||
MSISDN string `json:"msisdn,omitempty" description:"接入号"`
|
||||
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
|
||||
Status int `json:"status" description:"卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"`
|
||||
}
|
||||
|
||||
type ListDeviceCardsResponse struct {
|
||||
Bindings []*DeviceCardBindingResponse `json:"bindings" description:"绑定列表"`
|
||||
}
|
||||
|
||||
type BindCardToDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
IotCardID uint `json:"iot_card_id" validate:"required,min=1" required:"true" minimum:"1" description:"IoT卡ID"`
|
||||
SlotPosition int `json:"slot_position" validate:"required,min=1,max=4" required:"true" minimum:"1" maximum:"4" description:"插槽位置 (1-4)"`
|
||||
}
|
||||
|
||||
type BindCardToDeviceResponse struct {
|
||||
BindingID uint `json:"binding_id" description:"绑定记录ID"`
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
}
|
||||
|
||||
type UnbindCardFromDeviceRequest struct {
|
||||
ID uint `path:"id" description:"设备ID" required:"true"`
|
||||
CardID uint `path:"cardId" description:"IoT卡ID" required:"true"`
|
||||
}
|
||||
|
||||
type UnbindCardFromDeviceResponse struct {
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
}
|
||||
|
||||
type AllocateDevicesRequest struct {
|
||||
TargetShopID uint `json:"target_shop_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标店铺ID"`
|
||||
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"设备ID列表"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
|
||||
}
|
||||
|
||||
type AllocationDeviceFailedItem struct {
|
||||
DeviceID uint `json:"device_id" description:"设备ID"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
type AllocateDevicesResponse struct {
|
||||
SuccessCount int `json:"success_count" description:"成功数量"`
|
||||
FailCount int `json:"fail_count" description:"失败数量"`
|
||||
FailedItems []AllocationDeviceFailedItem `json:"failed_items" description:"失败详情列表"`
|
||||
}
|
||||
|
||||
type RecallDevicesRequest struct {
|
||||
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"设备ID列表"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"`
|
||||
}
|
||||
|
||||
type RecallDevicesResponse struct {
|
||||
SuccessCount int `json:"success_count" description:"成功数量"`
|
||||
FailCount int `json:"fail_count" description:"失败数量"`
|
||||
FailedItems []AllocationDeviceFailedItem `json:"failed_items" description:"失败详情列表"`
|
||||
}
|
||||
66
internal/model/dto/device_import_dto.go
Normal file
66
internal/model/dto/device_import_dto.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type ImportDeviceRequest struct {
|
||||
BatchNo string `json:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"对象存储文件路径(通过 /storage/upload-url 获取)"`
|
||||
}
|
||||
|
||||
type ImportDeviceResponse struct {
|
||||
TaskID uint `json:"task_id" description:"导入任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
}
|
||||
|
||||
type ListDeviceImportTaskRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"`
|
||||
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
|
||||
}
|
||||
|
||||
type DeviceImportTaskResponse struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
StatusText string `json:"status_text" description:"任务状态文本"`
|
||||
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
|
||||
FileName string `json:"file_name,omitempty" description:"文件名"`
|
||||
TotalCount int `json:"total_count" description:"总数"`
|
||||
SuccessCount int `json:"success_count" description:"成功数"`
|
||||
SkipCount int `json:"skip_count" description:"跳过数"`
|
||||
FailCount int `json:"fail_count" description:"失败数"`
|
||||
WarningCount int `json:"warning_count" description:"警告数(部分成功的设备数量)"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
type ListDeviceImportTaskResponse struct {
|
||||
List []*DeviceImportTaskResponse `json:"list" description:"任务列表"`
|
||||
Total int64 `json:"total" description:"总数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
TotalPages int `json:"total_pages" description:"总页数"`
|
||||
}
|
||||
|
||||
type DeviceImportResultItemDTO struct {
|
||||
Line int `json:"line" description:"行号"`
|
||||
DeviceNo string `json:"device_no" description:"设备号"`
|
||||
Reason string `json:"reason" description:"原因"`
|
||||
}
|
||||
|
||||
type GetDeviceImportTaskRequest struct {
|
||||
ID uint `path:"id" description:"任务ID" required:"true"`
|
||||
}
|
||||
|
||||
type DeviceImportTaskDetailResponse struct {
|
||||
DeviceImportTaskResponse
|
||||
SkippedItems []*DeviceImportResultItemDTO `json:"skipped_items" description:"跳过记录详情"`
|
||||
FailedItems []*DeviceImportResultItemDTO `json:"failed_items" description:"失败记录详情"`
|
||||
WarningItems []*DeviceImportResultItemDTO `json:"warning_items" description:"警告记录详情(部分成功的设备及其卡绑定失败原因)"`
|
||||
}
|
||||
@@ -62,24 +62,6 @@ func (AgentPackageAllocation) TableName() string {
|
||||
return "tb_agent_package_allocation"
|
||||
}
|
||||
|
||||
// DeviceSimBinding 设备-IoT卡绑定关系模型
|
||||
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
|
||||
type DeviceSimBinding struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID" json:"device_id"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
|
||||
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)" json:"slot_position"`
|
||||
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑" json:"bind_status"`
|
||||
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间" json:"bind_time"`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间" json:"unbind_time"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (DeviceSimBinding) TableName() string {
|
||||
return "tb_device_sim_binding"
|
||||
}
|
||||
|
||||
// PackageUsage 套餐使用情况模型
|
||||
// 跟踪单卡套餐和设备级套餐的流量使用
|
||||
type PackageUsage struct {
|
||||
|
||||
@@ -58,6 +58,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.IotCard != nil {
|
||||
registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath)
|
||||
}
|
||||
if handlers.Device != nil {
|
||||
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
|
||||
}
|
||||
if handlers.AssetAllocationRecord != nil {
|
||||
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
|
||||
}
|
||||
|
||||
127
internal/routes/device.go
Normal file
127
internal/routes/device.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, importHandler *admin.DeviceImportHandler, doc *openapi.Generator, basePath string) {
|
||||
devices := router.Group("/devices")
|
||||
groupPath := basePath + "/devices"
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "设备列表",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.ListDeviceRequest),
|
||||
Output: new(dto.ListDeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
|
||||
Summary: "设备详情",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.GetDeviceRequest),
|
||||
Output: new(dto.DeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
|
||||
Summary: "删除设备",
|
||||
Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.DeleteDeviceRequest),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/:id/cards", handler.ListCards, RouteSpec{
|
||||
Summary: "获取设备绑定的卡列表",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.ListDeviceCardsRequest),
|
||||
Output: new(dto.ListDeviceCardsResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/:id/cards", handler.BindCard, RouteSpec{
|
||||
Summary: "绑定卡到设备",
|
||||
Description: "仅平台用户可操作。用于导入后调整卡绑定关系(补卡、换卡)。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.BindCardToDeviceRequest),
|
||||
Output: new(dto.BindCardToDeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "DELETE", "/:id/cards/:cardId", handler.UnbindCard, RouteSpec{
|
||||
Summary: "解绑设备上的卡",
|
||||
Description: "仅平台用户可操作。解绑不改变卡的 shop_id。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.UnbindCardFromDeviceRequest),
|
||||
Output: new(dto.UnbindCardFromDeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/allocate", handler.Allocate, RouteSpec{
|
||||
Summary: "批量分配设备",
|
||||
Description: "分配设备给直属下级店铺。分配时自动同步绑定的所有卡的 shop_id。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.AllocateDevicesRequest),
|
||||
Output: new(dto.AllocateDevicesResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/recall", handler.Recall, RouteSpec{
|
||||
Summary: "批量回收设备",
|
||||
Description: "从直属下级店铺回收设备。回收时自动同步绑定的所有卡的 shop_id。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.RecallDevicesRequest),
|
||||
Output: new(dto.RecallDevicesResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{
|
||||
Summary: "批量导入设备",
|
||||
Description: `仅平台用户可操作。
|
||||
|
||||
### 完整导入流程
|
||||
|
||||
1. **获取上传 URL**: 调用 ` + "`POST /api/admin/storage/upload-url`" + `
|
||||
2. **上传 CSV 文件**: 使用预签名 URL 上传文件到对象存储
|
||||
3. **调用本接口**: 使用返回的 ` + "`file_key`" + ` 提交导入任务
|
||||
|
||||
### CSV 文件格式
|
||||
|
||||
必须包含列(首行为表头):
|
||||
- ` + "`device_no`" + `: 设备号(必填,唯一)
|
||||
- ` + "`device_name`" + `: 设备名称
|
||||
- ` + "`device_model`" + `: 设备型号
|
||||
- ` + "`device_type`" + `: 设备类型
|
||||
- ` + "`max_sim_slots`" + `: 最大插槽数(默认4)
|
||||
- ` + "`manufacturer`" + `: 制造商
|
||||
- ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 绑定的卡 ICCID(卡必须已存在且未绑定)`,
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.ImportDeviceRequest),
|
||||
Output: new(dto.ImportDeviceResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/import/tasks", importHandler.List, RouteSpec{
|
||||
Summary: "导入任务列表",
|
||||
Description: "仅平台用户可操作。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.ListDeviceImportTaskRequest),
|
||||
Output: new(dto.ListDeviceImportTaskResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(devices, doc, groupPath, "GET", "/import/tasks/:id", importHandler.GetByID, RouteSpec{
|
||||
Summary: "导入任务详情",
|
||||
Description: "仅平台用户可操作。包含跳过和失败记录的详细信息。",
|
||||
Tags: []string{"设备管理"},
|
||||
Input: new(dto.GetDeviceImportTaskRequest),
|
||||
Output: new(dto.DeviceImportTaskDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
172
internal/service/device/binding.go
Normal file
172
internal/service/device/binding.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *Service) ListBindings(ctx context.Context, deviceID uint) (*dto.ListDeviceCardsResponse, error) {
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, device.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
cardIDs = append(cardIDs, binding.IotCardID)
|
||||
}
|
||||
|
||||
var cards []*model.IotCard
|
||||
if len(cardIDs) > 0 {
|
||||
cards, err = s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
cardMap := make(map[uint]*model.IotCard)
|
||||
for _, card := range cards {
|
||||
cardMap[card.ID] = card
|
||||
}
|
||||
|
||||
carrierMap := s.loadCarrierData(ctx, cards)
|
||||
|
||||
responses := make([]*dto.DeviceCardBindingResponse, 0, len(bindings))
|
||||
for _, binding := range bindings {
|
||||
card := cardMap[binding.IotCardID]
|
||||
if card == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
resp := &dto.DeviceCardBindingResponse{
|
||||
ID: binding.ID,
|
||||
SlotPosition: binding.SlotPosition,
|
||||
IotCardID: binding.IotCardID,
|
||||
ICCID: card.ICCID,
|
||||
MSISDN: card.MSISDN,
|
||||
CarrierName: carrierMap[card.CarrierID],
|
||||
Status: card.Status,
|
||||
BindTime: binding.BindTime,
|
||||
}
|
||||
responses = append(responses, resp)
|
||||
}
|
||||
|
||||
return &dto.ListDeviceCardsResponse{
|
||||
Bindings: responses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCardToDeviceRequest) (*dto.BindCardToDeviceResponse, error) {
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.SlotPosition > device.MaxSimSlots {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数")
|
||||
}
|
||||
|
||||
existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
if existingBinding != nil {
|
||||
return nil, errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
|
||||
}
|
||||
|
||||
card, err := s.iotCardStore.GetByID(ctx, req.IotCardID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeIotCardNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
if activeBinding != nil {
|
||||
return nil, errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
|
||||
}
|
||||
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: req.SlotPosition,
|
||||
BindStatus: 1,
|
||||
}
|
||||
|
||||
if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.BindCardToDeviceResponse{
|
||||
BindingID: binding.ID,
|
||||
Message: "绑定成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*dto.UnbindCardFromDeviceResponse, error) {
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "该卡未绑定到此设备")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.UnbindCardFromDeviceResponse{
|
||||
Message: "解绑成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCarrierData(ctx context.Context, cards []*model.IotCard) map[uint]string {
|
||||
carrierIDs := make([]uint, 0)
|
||||
carrierIDSet := make(map[uint]bool)
|
||||
|
||||
for _, card := range cards {
|
||||
if card.CarrierID > 0 && !carrierIDSet[card.CarrierID] {
|
||||
carrierIDs = append(carrierIDs, card.CarrierID)
|
||||
carrierIDSet[card.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
|
||||
}
|
||||
551
internal/service/device/service.go
Normal file
551
internal/service/device/service.go
Normal file
@@ -0,0 +1,551 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
deviceStore *postgres.DeviceStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
shopStore *postgres.ShopStore
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
|
||||
}
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
deviceStore: deviceStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
iotCardStore: iotCardStore,
|
||||
shopStore: shopStore,
|
||||
assetAllocationRecordStore: assetAllocationRecordStore,
|
||||
}
|
||||
}
|
||||
|
||||
// List 获取设备列表
|
||||
func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.ListDeviceResponse, 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.DeviceNo != "" {
|
||||
filters["device_no"] = req.DeviceNo
|
||||
}
|
||||
if req.DeviceName != "" {
|
||||
filters["device_name"] = req.DeviceName
|
||||
}
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
if req.ShopID != nil {
|
||||
filters["shop_id"] = req.ShopID
|
||||
}
|
||||
if req.BatchNo != "" {
|
||||
filters["batch_no"] = req.BatchNo
|
||||
}
|
||||
if req.DeviceType != "" {
|
||||
filters["device_type"] = req.DeviceType
|
||||
}
|
||||
if req.Manufacturer != "" {
|
||||
filters["manufacturer"] = req.Manufacturer
|
||||
}
|
||||
if req.CreatedAtStart != nil {
|
||||
filters["created_at_start"] = *req.CreatedAtStart
|
||||
}
|
||||
if req.CreatedAtEnd != nil {
|
||||
filters["created_at_end"] = *req.CreatedAtEnd
|
||||
}
|
||||
|
||||
devices, total, err := s.deviceStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shopMap := s.loadShopData(ctx, devices)
|
||||
bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]*dto.DeviceResponse, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
item := s.toDeviceResponse(device, shopMap, bindingCounts)
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &dto.ListDeviceResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get 获取设备详情
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) {
|
||||
device, err := s.deviceStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
||||
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toDeviceResponse(device, shopMap, bindingCounts), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
device, err := s.deviceStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.deviceStore.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// AllocateDevices 批量分配设备
|
||||
func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) {
|
||||
// 验证目标店铺是否为直属下级
|
||||
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
return &dto.AllocateDevicesResponse{
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
FailedItems: []dto.AllocationDeviceFailedItem{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var deviceIDs []uint
|
||||
var failedItems []dto.AllocationDeviceFailedItem
|
||||
|
||||
isPlatform := operatorShopID == nil
|
||||
|
||||
for _, device := range devices {
|
||||
// 平台只能分配 shop_id=NULL 的设备
|
||||
if isPlatform && device.ShopID != nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "平台只能分配库存设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 代理只能分配自己店铺的设备
|
||||
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备不属于当前店铺",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
deviceIDs = append(deviceIDs, device.ID)
|
||||
}
|
||||
|
||||
if len(deviceIDs) == 0 {
|
||||
return &dto.AllocateDevicesResponse{
|
||||
SuccessCount: 0,
|
||||
FailCount: len(failedItems),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
newStatus := constants.DeviceStatusDistributed
|
||||
targetShopID := req.TargetShopID
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txDeviceStore := postgres.NewDeviceStore(tx, nil)
|
||||
txCardStore := postgres.NewIotCardStore(tx, nil)
|
||||
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
|
||||
|
||||
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boundCardIDs) > 0 {
|
||||
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
|
||||
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
|
||||
return txRecordStore.BatchCreate(ctx, records)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.AllocateDevicesResponse{
|
||||
SuccessCount: len(deviceIDs),
|
||||
FailCount: len(failedItems),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecallDevices 批量回收设备
|
||||
func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) {
|
||||
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
return &dto.RecallDevicesResponse{
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
FailedItems: []dto.AllocationDeviceFailedItem{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var deviceIDs []uint
|
||||
var failedItems []dto.AllocationDeviceFailedItem
|
||||
|
||||
isPlatform := operatorShopID == nil
|
||||
|
||||
for _, device := range devices {
|
||||
// 验证设备所属店铺是否为直属下级
|
||||
if device.ShopID == nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备已在平台库存中",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证直属下级关系
|
||||
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "只能回收直属下级店铺的设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
deviceIDs = append(deviceIDs, device.ID)
|
||||
}
|
||||
|
||||
if len(deviceIDs) == 0 {
|
||||
return &dto.RecallDevicesResponse{
|
||||
SuccessCount: 0,
|
||||
FailCount: len(failedItems),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var newShopID *uint
|
||||
var newStatus int
|
||||
if isPlatform {
|
||||
newShopID = nil
|
||||
newStatus = constants.DeviceStatusInStock
|
||||
} else {
|
||||
newShopID = operatorShopID
|
||||
newStatus = constants.DeviceStatusDistributed
|
||||
}
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txDeviceStore := postgres.NewDeviceStore(tx, nil)
|
||||
txCardStore := postgres.NewIotCardStore(tx, nil)
|
||||
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
|
||||
|
||||
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(boundCardIDs) > 0 {
|
||||
var cardStatus int
|
||||
if isPlatform {
|
||||
cardStatus = constants.IotCardStatusInStock
|
||||
} else {
|
||||
cardStatus = constants.IotCardStatusDistributed
|
||||
}
|
||||
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
|
||||
records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark)
|
||||
return txRecordStore.BatchCreate(ctx, records)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.RecallDevicesResponse{
|
||||
SuccessCount: len(deviceIDs),
|
||||
FailCount: len(failedItems),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
|
||||
func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error {
|
||||
if operatorShopID != nil && *operatorShopID == targetShopID {
|
||||
return errors.ErrCannotAllocateToSelf
|
||||
}
|
||||
|
||||
targetShop, err := s.shopStore.GetByID(ctx, targetShopID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeShopNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if operatorShopID == nil {
|
||||
if targetShop.ParentID != nil {
|
||||
return errors.ErrNotDirectSubordinate
|
||||
}
|
||||
} else {
|
||||
if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID {
|
||||
return errors.ErrNotDirectSubordinate
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string {
|
||||
shopIDs := make([]uint, 0)
|
||||
shopIDSet := make(map[uint]bool)
|
||||
|
||||
for _, device := range devices {
|
||||
if device.ShopID != nil && *device.ShopID > 0 && !shopIDSet[*device.ShopID] {
|
||||
shopIDs = append(shopIDs, *device.ShopID)
|
||||
shopIDSet[*device.ShopID] = true
|
||||
}
|
||||
}
|
||||
|
||||
shopMap := make(map[uint]string)
|
||||
if len(shopIDs) > 0 {
|
||||
var shops []model.Shop
|
||||
s.db.WithContext(ctx).Where("id IN ?", shopIDs).Find(&shops)
|
||||
for _, shop := range shops {
|
||||
shopMap[shop.ID] = shop.ShopName
|
||||
}
|
||||
}
|
||||
|
||||
return shopMap
|
||||
}
|
||||
|
||||
func (s *Service) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) {
|
||||
result := make(map[uint]int64)
|
||||
if len(deviceIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceIDs(ctx, deviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, binding := range bindings {
|
||||
result[binding.DeviceID]++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
|
||||
ids := make([]uint, len(devices))
|
||||
for i, device := range devices {
|
||||
ids[i] = device.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
|
||||
resp := &dto.DeviceResponse{
|
||||
ID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
DeviceType: device.DeviceType,
|
||||
MaxSimSlots: device.MaxSimSlots,
|
||||
Manufacturer: device.Manufacturer,
|
||||
BatchNo: device.BatchNo,
|
||||
ShopID: device.ShopID,
|
||||
Status: device.Status,
|
||||
StatusName: s.getDeviceStatusName(device.Status),
|
||||
BoundCardCount: int(bindingCounts[device.ID]),
|
||||
ActivatedAt: device.ActivatedAt,
|
||||
CreatedAt: device.CreatedAt,
|
||||
UpdatedAt: device.UpdatedAt,
|
||||
}
|
||||
|
||||
if device.ShopID != nil && *device.ShopID > 0 {
|
||||
resp.ShopName = shopMap[*device.ShopID]
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (s *Service) getDeviceStatusName(status int) string {
|
||||
switch status {
|
||||
case constants.DeviceStatusInStock:
|
||||
return "在库"
|
||||
case constants.DeviceStatusDistributed:
|
||||
return "已分销"
|
||||
case constants.DeviceStatusActivated:
|
||||
return "已激活"
|
||||
case constants.DeviceStatusSuspended:
|
||||
return "已停用"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
|
||||
successIDSet := make(map[uint]bool)
|
||||
for _, id := range successDeviceIDs {
|
||||
successIDSet[id] = true
|
||||
}
|
||||
|
||||
var records []*model.AssetAllocationRecord
|
||||
for _, device := range devices {
|
||||
if !successIDSet[device.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
record := &model.AssetAllocationRecord{
|
||||
AllocationNo: allocationNo,
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.DeviceNo,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: toShopID,
|
||||
OperatorID: operatorID,
|
||||
Remark: remark,
|
||||
}
|
||||
|
||||
if fromShopID == nil {
|
||||
record.FromOwnerType = constants.OwnerTypePlatform
|
||||
record.FromOwnerID = nil
|
||||
} else {
|
||||
record.FromOwnerType = constants.OwnerTypeShop
|
||||
record.FromOwnerID = fromShopID
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
|
||||
successIDSet := make(map[uint]bool)
|
||||
for _, id := range successDeviceIDs {
|
||||
successIDSet[id] = true
|
||||
}
|
||||
|
||||
var records []*model.AssetAllocationRecord
|
||||
for _, device := range devices {
|
||||
if !successIDSet[device.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
record := &model.AssetAllocationRecord{
|
||||
AllocationNo: allocationNo,
|
||||
AllocationType: constants.AssetAllocationTypeRecall,
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.DeviceNo,
|
||||
OperatorID: operatorID,
|
||||
Remark: remark,
|
||||
}
|
||||
|
||||
if fromShopID == nil {
|
||||
record.FromOwnerType = constants.OwnerTypePlatform
|
||||
record.FromOwnerID = nil
|
||||
} else {
|
||||
record.FromOwnerType = constants.OwnerTypeShop
|
||||
record.FromOwnerID = fromShopID
|
||||
}
|
||||
|
||||
if toShopID == nil {
|
||||
record.ToOwnerType = constants.OwnerTypePlatform
|
||||
record.ToOwnerID = 0
|
||||
} else {
|
||||
record.ToOwnerType = constants.OwnerTypeShop
|
||||
record.ToOwnerID = *toShopID
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
209
internal/service/device_import/service.go
Normal file
209
internal/service/device_import/service.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package device_import
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
importTaskStore *postgres.DeviceImportTaskStore
|
||||
queueClient *queue.Client
|
||||
}
|
||||
|
||||
type DeviceImportPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, importTaskStore *postgres.DeviceImportTaskStore, queueClient *queue.Client) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
importTaskStore: importTaskStore,
|
||||
queueClient: queueClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
fileName := filepath.Base(req.FileKey)
|
||||
|
||||
task := &model.DeviceImportTask{
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
|
||||
if err := s.importTaskStore.Create(ctx, task); err != nil {
|
||||
return nil, fmt.Errorf("创建导入任务失败: %w", err)
|
||||
}
|
||||
|
||||
payload := DeviceImportPayload{TaskID: task.ID}
|
||||
err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, payload)
|
||||
if err != nil {
|
||||
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
return nil, fmt.Errorf("任务入队失败: %w", err)
|
||||
}
|
||||
|
||||
return &dto.ImportDeviceResponse{
|
||||
TaskID: task.ID,
|
||||
TaskNo: taskNo,
|
||||
Message: "导入任务已创建,Worker 将异步处理文件",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest) (*dto.ListDeviceImportTaskResponse, 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.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
|
||||
}
|
||||
|
||||
list := make([]*dto.DeviceImportTaskResponse, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
list = append(list, s.toTaskResponse(task))
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &dto.ListDeviceImportTaskResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDetailResponse, error) {
|
||||
task, err := s.importTaskStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "导入任务不存在")
|
||||
}
|
||||
|
||||
resp := &dto.DeviceImportTaskDetailResponse{
|
||||
DeviceImportTaskResponse: *s.toTaskResponse(task),
|
||||
SkippedItems: make([]*dto.DeviceImportResultItemDTO, 0),
|
||||
FailedItems: make([]*dto.DeviceImportResultItemDTO, 0),
|
||||
WarningItems: make([]*dto.DeviceImportResultItemDTO, 0),
|
||||
}
|
||||
|
||||
for _, item := range task.SkippedItems {
|
||||
resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
DeviceNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range task.FailedItems {
|
||||
resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
DeviceNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range task.WarningItems {
|
||||
resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
DeviceNo: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImportTaskResponse {
|
||||
var startedAt, completedAt *time.Time
|
||||
if task.StartedAt != nil {
|
||||
startedAt = task.StartedAt
|
||||
}
|
||||
if task.CompletedAt != nil {
|
||||
completedAt = task.CompletedAt
|
||||
}
|
||||
|
||||
return &dto.DeviceImportTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
BatchNo: task.BatchNo,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
WarningCount: task.WarningCount,
|
||||
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 "未知"
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func New(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) allocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) {
|
||||
func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, req *dto.AllocateCardsPreviewReq) (*dto.AllocateCardsPreviewResp, error) {
|
||||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||||
if currentUserID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
@@ -181,7 +181,7 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
|
||||
return nil, errors.New(errors.CodeEnterpriseNotFound, "企业不存在")
|
||||
}
|
||||
|
||||
preview, err := s.allocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
|
||||
preview, err := s.AllocateCardsPreview(ctx, enterpriseID, &dto.AllocateCardsPreviewReq{ICCIDs: req.ICCIDs})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
135
internal/store/postgres/device_import_task_store.go
Normal file
135
internal/store/postgres/device_import_task_store.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceImportTaskStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewDeviceImportTaskStore(db *gorm.DB, redis *redis.Client) *DeviceImportTaskStore {
|
||||
return &DeviceImportTaskStore{
|
||||
db: db,
|
||||
redis: redis,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) Create(ctx context.Context, task *model.DeviceImportTask) error {
|
||||
return s.db.WithContext(ctx).Create(task).Error
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) GetByID(ctx context.Context, id uint) (*model.DeviceImportTask, error) {
|
||||
var task model.DeviceImportTask
|
||||
if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) GetByTaskNo(ctx context.Context, taskNo string) (*model.DeviceImportTask, error) {
|
||||
var task model.DeviceImportTask
|
||||
if err := s.db.WithContext(ctx).Where("task_no = ?", taskNo).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) Update(ctx context.Context, task *model.DeviceImportTask) error {
|
||||
return s.db.WithContext(ctx).Save(task).Error
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error {
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if status == model.ImportTaskStatusProcessing {
|
||||
now := time.Now()
|
||||
updates["started_at"] = &now
|
||||
}
|
||||
if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed {
|
||||
now := time.Now()
|
||||
updates["completed_at"] = &now
|
||||
}
|
||||
if errorMessage != "" {
|
||||
updates["error_message"] = errorMessage
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, skipCount, failCount, warningCount int, skippedItems, failedItems, warningItems model.ImportResultItems) error {
|
||||
updates := map[string]any{
|
||||
"total_count": totalCount,
|
||||
"success_count": successCount,
|
||||
"skip_count": skipCount,
|
||||
"fail_count": failCount,
|
||||
"warning_count": warningCount,
|
||||
"skipped_items": skippedItems,
|
||||
"failed_items": failedItems,
|
||||
"warning_items": warningItems,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.DeviceImportTask, int64, error) {
|
||||
var tasks []*model.DeviceImportTask
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.DeviceImportTask{})
|
||||
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
|
||||
query = query.Where("batch_no LIKE ?", "%"+batchNo+"%")
|
||||
}
|
||||
if startTime, ok := filters["start_time"].(time.Time); ok && !startTime.IsZero() {
|
||||
query = query.Where("created_at >= ?", startTime)
|
||||
}
|
||||
if endTime, ok := filters["end_time"].(time.Time); ok && !endTime.IsZero() {
|
||||
query = query.Where("created_at <= ?", endTime)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &store.QueryOptions{
|
||||
Page: 1,
|
||||
PageSize: constants.DefaultPageSize,
|
||||
}
|
||||
}
|
||||
offset := (opts.Page - 1) * opts.PageSize
|
||||
query = query.Offset(offset).Limit(opts.PageSize)
|
||||
|
||||
if opts.OrderBy != "" {
|
||||
query = query.Order(opts.OrderBy)
|
||||
} else {
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
|
||||
if err := query.Find(&tasks).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tasks, total, nil
|
||||
}
|
||||
|
||||
func (s *DeviceImportTaskStore) GenerateTaskNo(ctx context.Context) string {
|
||||
now := time.Now()
|
||||
dateStr := now.Format("20060102")
|
||||
seq := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("DEV-IMP-%s-%06d", dateStr, seq)
|
||||
}
|
||||
202
internal/store/postgres/device_sim_binding_store.go
Normal file
202
internal/store/postgres/device_sim_binding_store.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceSimBindingStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewDeviceSimBindingStore(db *gorm.DB, redis *redis.Client) *DeviceSimBindingStore {
|
||||
return &DeviceSimBindingStore{
|
||||
db: db,
|
||||
redis: redis,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) Create(ctx context.Context, binding *model.DeviceSimBinding) error {
|
||||
err := s.db.WithContext(ctx).Create(binding).Error
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
if strings.Contains(err.Error(), "idx_active_device_slot") {
|
||||
return errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
|
||||
}
|
||||
if strings.Contains(err.Error(), "idx_device_sim_bindings_active_card") {
|
||||
return errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if stderrors.As(err, &pgErr) {
|
||||
return pgErr.Code == "23505"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) CreateBatch(ctx context.Context, bindings []*model.DeviceSimBinding) error {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).CreateInBatches(bindings, 100).Error
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetByID(ctx context.Context, id uint) (*model.DeviceSimBinding, error) {
|
||||
var binding model.DeviceSimBinding
|
||||
if err := s.db.WithContext(ctx).First(&binding, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &binding, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) ListByDeviceID(ctx context.Context, deviceID uint) ([]*model.DeviceSimBinding, error) {
|
||||
var bindings []*model.DeviceSimBinding
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Order("slot_position ASC").
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) ListByDeviceIDs(ctx context.Context, deviceIDs []uint) ([]*model.DeviceSimBinding, error) {
|
||||
var bindings []*model.DeviceSimBinding
|
||||
if len(deviceIDs) == 0 {
|
||||
return bindings, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("device_id IN ? AND bind_status = 1", deviceIDs).
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetByDeviceAndCard(ctx context.Context, deviceID, iotCardID uint) (*model.DeviceSimBinding, error) {
|
||||
var binding model.DeviceSimBinding
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("device_id = ? AND iot_card_id = ? AND bind_status = 1", deviceID, iotCardID).
|
||||
First(&binding).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &binding, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetByDeviceAndSlot(ctx context.Context, deviceID uint, slotPosition int) (*model.DeviceSimBinding, error) {
|
||||
var binding model.DeviceSimBinding
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("device_id = ? AND slot_position = ? AND bind_status = 1", deviceID, slotPosition).
|
||||
First(&binding).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &binding, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetActiveBindingByCardID(ctx context.Context, iotCardID uint) (*model.DeviceSimBinding, error) {
|
||||
var binding model.DeviceSimBinding
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("iot_card_id = ? AND bind_status = 1", iotCardID).
|
||||
First(&binding).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &binding, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetActiveBindingsByCardIDs(ctx context.Context, iotCardIDs []uint) ([]*model.DeviceSimBinding, error) {
|
||||
var bindings []*model.DeviceSimBinding
|
||||
if len(iotCardIDs) == 0 {
|
||||
return bindings, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("iot_card_id IN ? AND bind_status = 1", iotCardIDs).
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) Unbind(ctx context.Context, id uint) error {
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"bind_status": 2,
|
||||
"unbind_time": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) UnbindByDeviceID(ctx context.Context, deviceID uint) error {
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"bind_status": 2,
|
||||
"unbind_time": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) CountByDeviceID(ctx context.Context, deviceID uint) (int64, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetBoundCardIDsByDeviceIDs(ctx context.Context, deviceIDs []uint) ([]uint, error) {
|
||||
if len(deviceIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var cardIDs []uint
|
||||
if err := s.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
|
||||
Select("iot_card_id").
|
||||
Where("device_id IN ? AND bind_status = 1", deviceIDs).
|
||||
Pluck("iot_card_id", &cardIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cardIDs, nil
|
||||
}
|
||||
|
||||
func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
if len(iccids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var bindings []struct {
|
||||
ICCID string
|
||||
}
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("tb_device_sim_binding b").
|
||||
Select("c.iccid").
|
||||
Joins("JOIN tb_iot_card c ON c.id = b.iot_card_id").
|
||||
Where("c.iccid IN ? AND b.bind_status = 1 AND c.deleted_at IS NULL", iccids).
|
||||
Find(&bindings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, b := range bindings {
|
||||
result[b.ICCID] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
209
internal/store/postgres/device_sim_binding_store_test.go
Normal file
209
internal/store/postgres/device_sim_binding_store_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeviceSimBindingStore_Create_DuplicateCard(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
bindingStore := NewDeviceSimBindingStore(tx, rdb)
|
||||
deviceStore := NewDeviceStore(tx, rdb)
|
||||
cardStore := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device1 := &model.Device{DeviceNo: "TEST-DEV-UC-001", Status: 1, MaxSimSlots: 4}
|
||||
device2 := &model.Device{DeviceNo: "TEST-DEV-UC-002", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device1))
|
||||
require.NoError(t, deviceStore.Create(ctx, device2))
|
||||
|
||||
card := &model.IotCard{ICCID: "89860012345678910001", CardType: "data_card", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card))
|
||||
|
||||
now := time.Now()
|
||||
binding1 := &model.DeviceSimBinding{
|
||||
DeviceID: device1.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, bindingStore.Create(ctx, binding1))
|
||||
|
||||
binding2 := &model.DeviceSimBinding{
|
||||
DeviceID: device2.ID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
err := bindingStore.Create(ctx, binding2)
|
||||
require.Error(t, err)
|
||||
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok, "错误应该是 AppError 类型")
|
||||
assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "该卡已绑定到其他设备")
|
||||
}
|
||||
|
||||
func TestDeviceSimBindingStore_Create_DuplicateSlot(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
bindingStore := NewDeviceSimBindingStore(tx, rdb)
|
||||
deviceStore := NewDeviceStore(tx, rdb)
|
||||
cardStore := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device := &model.Device{DeviceNo: "TEST-DEV-UC-003", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device))
|
||||
|
||||
card1 := &model.IotCard{ICCID: "89860012345678910011", CardType: "data_card", CarrierID: 1, Status: 1}
|
||||
card2 := &model.IotCard{ICCID: "89860012345678910012", CardType: "data_card", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card1))
|
||||
require.NoError(t, cardStore.Create(ctx, card2))
|
||||
|
||||
now := time.Now()
|
||||
binding1 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card1.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, bindingStore.Create(ctx, binding1))
|
||||
|
||||
binding2 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card2.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
err := bindingStore.Create(ctx, binding2)
|
||||
require.Error(t, err)
|
||||
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
require.True(t, ok, "错误应该是 AppError 类型")
|
||||
assert.Equal(t, errors.CodeConflict, appErr.Code)
|
||||
assert.Contains(t, appErr.Message, "该插槽已有绑定的卡")
|
||||
}
|
||||
|
||||
func TestDeviceSimBindingStore_Create_DifferentSlots(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
bindingStore := NewDeviceSimBindingStore(tx, rdb)
|
||||
deviceStore := NewDeviceStore(tx, rdb)
|
||||
cardStore := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device := &model.Device{DeviceNo: "TEST-DEV-UC-004", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device))
|
||||
|
||||
card1 := &model.IotCard{ICCID: "89860012345678910021", CardType: "data_card", CarrierID: 1, Status: 1}
|
||||
card2 := &model.IotCard{ICCID: "89860012345678910022", CardType: "data_card", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card1))
|
||||
require.NoError(t, cardStore.Create(ctx, card2))
|
||||
|
||||
now := time.Now()
|
||||
binding1 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card1.ID,
|
||||
SlotPosition: 1,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
require.NoError(t, bindingStore.Create(ctx, binding1))
|
||||
assert.NotZero(t, binding1.ID)
|
||||
|
||||
binding2 := &model.DeviceSimBinding{
|
||||
DeviceID: device.ID,
|
||||
IotCardID: card2.ID,
|
||||
SlotPosition: 2,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
err := bindingStore.Create(ctx, binding2)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, binding2.ID)
|
||||
}
|
||||
|
||||
func TestDeviceSimBindingStore_ConcurrentBinding(t *testing.T) {
|
||||
db := testutils.GetTestDB(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
deviceStore := NewDeviceStore(db, rdb)
|
||||
cardStore := NewIotCardStore(db, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
device1 := &model.Device{DeviceNo: "TEST-CONCURRENT-001", Status: 1, MaxSimSlots: 4}
|
||||
device2 := &model.Device{DeviceNo: "TEST-CONCURRENT-002", Status: 1, MaxSimSlots: 4}
|
||||
require.NoError(t, deviceStore.Create(ctx, device1))
|
||||
require.NoError(t, deviceStore.Create(ctx, device2))
|
||||
|
||||
card := &model.IotCard{ICCID: "89860012345678920001", CardType: "data_card", CarrierID: 1, Status: 1}
|
||||
require.NoError(t, cardStore.Create(ctx, card))
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.Where("device_id IN ?", []uint{device1.ID, device2.ID}).Delete(&model.DeviceSimBinding{})
|
||||
db.Delete(device1)
|
||||
db.Delete(device2)
|
||||
db.Delete(card)
|
||||
})
|
||||
|
||||
t.Run("并发绑定同一张卡到不同设备", func(t *testing.T) {
|
||||
bindingStore := NewDeviceSimBindingStore(db, rdb)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
|
||||
for i, deviceID := range []uint{device1.ID, device2.ID} {
|
||||
wg.Add(1)
|
||||
go func(devID uint, slot int) {
|
||||
defer wg.Done()
|
||||
now := time.Now()
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: devID,
|
||||
IotCardID: card.ID,
|
||||
SlotPosition: slot,
|
||||
BindStatus: 1,
|
||||
BindTime: &now,
|
||||
}
|
||||
results <- bindingStore.Create(ctx, binding)
|
||||
}(deviceID, i+1)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
var successCount, errorCount int
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
successCount++
|
||||
} else {
|
||||
errorCount++
|
||||
appErr, ok := err.(*errors.AppError)
|
||||
if ok {
|
||||
assert.Equal(t, errors.CodeIotCardBoundToDevice, appErr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, successCount, "应该只有一个请求成功")
|
||||
assert.Equal(t, 1, errorCount, "应该有一个请求失败")
|
||||
})
|
||||
}
|
||||
183
internal/store/postgres/device_store.go
Normal file
183
internal/store/postgres/device_store.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DeviceStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewDeviceStore(db *gorm.DB, redis *redis.Client) *DeviceStore {
|
||||
return &DeviceStore{
|
||||
db: db,
|
||||
redis: redis,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DeviceStore) Create(ctx context.Context, device *model.Device) error {
|
||||
return s.db.WithContext(ctx).Create(device).Error
|
||||
}
|
||||
|
||||
func (s *DeviceStore) CreateBatch(ctx context.Context, devices []*model.Device) error {
|
||||
if len(devices) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).CreateInBatches(devices, 100).Error
|
||||
}
|
||||
|
||||
func (s *DeviceStore) GetByID(ctx context.Context, id uint) (*model.Device, error) {
|
||||
var device model.Device
|
||||
if err := s.db.WithContext(ctx).First(&device, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &device, nil
|
||||
}
|
||||
|
||||
func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*model.Device, error) {
|
||||
var device model.Device
|
||||
if err := s.db.WithContext(ctx).Where("device_no = ?", deviceNo).First(&device).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &device, nil
|
||||
}
|
||||
|
||||
func (s *DeviceStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Device, error) {
|
||||
var devices []*model.Device
|
||||
if len(ids) == 0 {
|
||||
return devices, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&devices).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
func (s *DeviceStore) Update(ctx context.Context, device *model.Device) error {
|
||||
return s.db.WithContext(ctx).Save(device).Error
|
||||
}
|
||||
|
||||
func (s *DeviceStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.Device{}, id).Error
|
||||
}
|
||||
|
||||
func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Device, int64, error) {
|
||||
var devices []*model.Device
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.Device{})
|
||||
|
||||
if deviceNo, ok := filters["device_no"].(string); ok && deviceNo != "" {
|
||||
query = query.Where("device_no LIKE ?", "%"+deviceNo+"%")
|
||||
}
|
||||
if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" {
|
||||
query = query.Where("device_name LIKE ?", "%"+deviceName+"%")
|
||||
}
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if shopID, ok := filters["shop_id"].(*uint); ok {
|
||||
if shopID == nil {
|
||||
query = query.Where("shop_id IS NULL")
|
||||
} else {
|
||||
query = query.Where("shop_id = ?", *shopID)
|
||||
}
|
||||
}
|
||||
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
|
||||
query = query.Where("batch_no = ?", batchNo)
|
||||
}
|
||||
if deviceType, ok := filters["device_type"].(string); ok && deviceType != "" {
|
||||
query = query.Where("device_type = ?", deviceType)
|
||||
}
|
||||
if manufacturer, ok := filters["manufacturer"].(string); ok && manufacturer != "" {
|
||||
query = query.Where("manufacturer LIKE ?", "%"+manufacturer+"%")
|
||||
}
|
||||
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() {
|
||||
query = query.Where("created_at >= ?", createdAtStart)
|
||||
}
|
||||
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() {
|
||||
query = query.Where("created_at <= ?", createdAtEnd)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if opts == nil {
|
||||
opts = &store.QueryOptions{
|
||||
Page: 1,
|
||||
PageSize: constants.DefaultPageSize,
|
||||
}
|
||||
}
|
||||
offset := (opts.Page - 1) * opts.PageSize
|
||||
query = query.Offset(offset).Limit(opts.PageSize)
|
||||
|
||||
if opts.OrderBy != "" {
|
||||
query = query.Order(opts.OrderBy)
|
||||
} else {
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
|
||||
if err := query.Find(&devices).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return devices, total, nil
|
||||
}
|
||||
|
||||
func (s *DeviceStore) UpdateShopID(ctx context.Context, id uint, shopID *uint) error {
|
||||
return s.db.WithContext(ctx).Model(&model.Device{}).Where("id = ?", id).Update("shop_id", shopID).Error
|
||||
}
|
||||
|
||||
func (s *DeviceStore) BatchUpdateShopIDAndStatus(ctx context.Context, ids []uint, shopID *uint, status int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
updates := map[string]any{
|
||||
"shop_id": shopID,
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.Device{}).Where("id IN ?", ids).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []string) (map[string]bool, error) {
|
||||
result := make(map[string]bool)
|
||||
if len(deviceNos) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var existingDevices []struct {
|
||||
DeviceNo string
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Select("device_no").
|
||||
Where("device_no IN ?", deviceNos).
|
||||
Find(&existingDevices).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, d := range existingDevices {
|
||||
result[d.DeviceNo] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *DeviceStore) GetByDeviceNos(ctx context.Context, deviceNos []string) ([]*model.Device, error) {
|
||||
var devices []*model.Device
|
||||
if len(deviceNos) == 0 {
|
||||
return devices, nil
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Where("device_no IN ?", deviceNos).Find(&devices).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
439
internal/task/device_import.go
Normal file
439
internal/task/device_import.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
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.downloadAndParseCSV(ctx, importTask)
|
||||
if err != nil {
|
||||
h.logger.Error("下载或解析 CSV 失败",
|
||||
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
|
||||
}
|
||||
|
||||
type deviceRow struct {
|
||||
Line int
|
||||
DeviceNo string
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
MaxSimSlots int
|
||||
Manufacturer string
|
||||
ICCIDs []string
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) downloadAndParseCSV(ctx context.Context, task *model.DeviceImportTask) ([]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()
|
||||
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return h.parseDeviceCSV(f)
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) parseDeviceCSV(r io.Reader) ([]deviceRow, int, error) {
|
||||
reader := csv.NewReader(r)
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.TrimLeadingSpace = true
|
||||
|
||||
header, err := reader.Read()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
colIndex := h.buildColumnIndex(header)
|
||||
if colIndex["device_no"] == -1 {
|
||||
return nil, 0, ErrMissingDeviceNoColumn
|
||||
}
|
||||
|
||||
var rows []deviceRow
|
||||
lineNum := 1
|
||||
|
||||
for {
|
||||
record, err := reader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lineNum++
|
||||
|
||||
row := deviceRow{Line: lineNum}
|
||||
|
||||
if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceNo = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceName = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_model"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceModel = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_type"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceType = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["max_sim_slots"]; idx >= 0 && idx < len(record) {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(record[idx])); err == nil {
|
||||
row.MaxSimSlots = n
|
||||
}
|
||||
}
|
||||
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
|
||||
row.Manufacturer = strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
row.ICCIDs = make([]string, 0, 4)
|
||||
for i := 1; i <= 4; i++ {
|
||||
colName := "iccid_" + strconv.Itoa(i)
|
||||
if idx := colIndex[colName]; idx >= 0 && idx < len(record) {
|
||||
iccid := strings.TrimSpace(record[idx])
|
||||
if iccid != "" {
|
||||
row.ICCIDs = append(row.ICCIDs, iccid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if row.DeviceNo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if row.MaxSimSlots == 0 {
|
||||
row.MaxSimSlots = 4
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
return rows, len(rows), nil
|
||||
}
|
||||
|
||||
func (h *DeviceImportHandler) buildColumnIndex(header []string) map[string]int {
|
||||
index := map[string]int{
|
||||
"device_no": -1,
|
||||
"device_name": -1,
|
||||
"device_model": -1,
|
||||
"device_type": -1,
|
||||
"max_sim_slots": -1,
|
||||
"manufacturer": -1,
|
||||
"iccid_1": -1,
|
||||
"iccid_2": -1,
|
||||
"iccid_3": -1,
|
||||
"iccid_4": -1,
|
||||
}
|
||||
|
||||
for i, col := range header {
|
||||
col = strings.ToLower(strings.TrimSpace(col))
|
||||
if _, exists := index[col]; exists {
|
||||
index[col] = i
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
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 []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 []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 列")
|
||||
190
internal/task/device_import_test.go
Normal file
190
internal/task/device_import_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDeviceImportHandler_ProcessBatch_AllOrNothingValidation(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
logger := zap.NewNop()
|
||||
importTaskStore := postgres.NewDeviceImportTaskStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
bindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
cardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
|
||||
handler := NewDeviceImportHandler(tx, rdb, importTaskStore, deviceStore, bindingStore, cardStore, nil, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := uint(100)
|
||||
platformCard := &model.IotCard{ICCID: "89860012345670001001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
|
||||
platformCard2 := &model.IotCard{ICCID: "89860012345670001003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
|
||||
shopCard := &model.IotCard{ICCID: "89860012345670001002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID}
|
||||
require.NoError(t, cardStore.Create(ctx, platformCard))
|
||||
require.NoError(t, cardStore.Create(ctx, platformCard2))
|
||||
require.NoError(t, cardStore.Create(ctx, shopCard))
|
||||
|
||||
t.Run("所有卡可用-成功", func(t *testing.T) {
|
||||
task := &model.DeviceImportTask{
|
||||
BatchNo: "TEST_BATCH_001",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
batch := []deviceRow{
|
||||
{Line: 2, DeviceNo: "DEV-OWNER-001", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001001"}},
|
||||
}
|
||||
result := &deviceImportResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
handler.processBatch(ctx, task, batch, result)
|
||||
|
||||
assert.Equal(t, 1, result.successCount)
|
||||
assert.Equal(t, 0, result.failCount)
|
||||
})
|
||||
|
||||
t.Run("任一卡分配给店铺-整体失败", func(t *testing.T) {
|
||||
task := &model.DeviceImportTask{
|
||||
BatchNo: "TEST_BATCH_002",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
batch := []deviceRow{
|
||||
{Line: 3, DeviceNo: "DEV-OWNER-002", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001003", "89860012345670001002"}},
|
||||
}
|
||||
result := &deviceImportResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
handler.processBatch(ctx, task, batch, result)
|
||||
|
||||
assert.Equal(t, 0, result.successCount)
|
||||
assert.Equal(t, 1, result.failCount)
|
||||
require.Len(t, result.failedItems, 1)
|
||||
assert.Contains(t, result.failedItems[0].Reason, "已分配给店铺")
|
||||
})
|
||||
|
||||
t.Run("任一卡不存在-整体失败", func(t *testing.T) {
|
||||
task := &model.DeviceImportTask{
|
||||
BatchNo: "TEST_BATCH_003",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
batch := []deviceRow{
|
||||
{Line: 4, DeviceNo: "DEV-OWNER-003", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001002", "89860012345670009999"}},
|
||||
}
|
||||
result := &deviceImportResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
handler.processBatch(ctx, task, batch, result)
|
||||
|
||||
assert.Equal(t, 0, result.successCount)
|
||||
assert.Equal(t, 1, result.failCount)
|
||||
require.Len(t, result.failedItems, 1)
|
||||
assert.Contains(t, result.failedItems[0].Reason, "卡验证失败")
|
||||
})
|
||||
|
||||
t.Run("无指定卡时创建设备成功", func(t *testing.T) {
|
||||
task := &model.DeviceImportTask{
|
||||
BatchNo: "TEST_BATCH_004",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
batch := []deviceRow{
|
||||
{Line: 5, DeviceNo: "DEV-OWNER-004", MaxSimSlots: 4, ICCIDs: []string{}},
|
||||
}
|
||||
result := &deviceImportResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
handler.processBatch(ctx, task, batch, result)
|
||||
|
||||
assert.Equal(t, 1, result.successCount)
|
||||
assert.Equal(t, 0, result.failCount)
|
||||
})
|
||||
|
||||
t.Run("多张卡全部可用-成功", func(t *testing.T) {
|
||||
newCard1 := &model.IotCard{ICCID: "89860012345670001010", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
|
||||
newCard2 := &model.IotCard{ICCID: "89860012345670001011", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
|
||||
require.NoError(t, cardStore.Create(ctx, newCard1))
|
||||
require.NoError(t, cardStore.Create(ctx, newCard2))
|
||||
|
||||
task := &model.DeviceImportTask{
|
||||
BatchNo: "TEST_BATCH_005",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
batch := []deviceRow{
|
||||
{Line: 6, DeviceNo: "DEV-OWNER-005", MaxSimSlots: 4, ICCIDs: []string{"89860012345670001010", "89860012345670001011"}},
|
||||
}
|
||||
result := &deviceImportResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
handler.processBatch(ctx, task, batch, result)
|
||||
|
||||
assert.Equal(t, 1, result.successCount)
|
||||
assert.Equal(t, 0, result.failCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceImportHandler_ProcessImport_AllOrNothing(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
logger := zap.NewNop()
|
||||
importTaskStore := postgres.NewDeviceImportTaskStore(tx, rdb)
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
bindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
cardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
|
||||
handler := NewDeviceImportHandler(tx, rdb, importTaskStore, deviceStore, bindingStore, cardStore, nil, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := uint(200)
|
||||
platformCard1 := &model.IotCard{ICCID: "89860012345680001001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
|
||||
platformCard2 := &model.IotCard{ICCID: "89860012345680001002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil}
|
||||
shopCard := &model.IotCard{ICCID: "89860012345680001003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID}
|
||||
require.NoError(t, cardStore.Create(ctx, platformCard1))
|
||||
require.NoError(t, cardStore.Create(ctx, platformCard2))
|
||||
require.NoError(t, cardStore.Create(ctx, shopCard))
|
||||
|
||||
task := &model.DeviceImportTask{
|
||||
BatchNo: "TEST_PROCESS_IMPORT",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
rows := []deviceRow{
|
||||
{Line: 2, DeviceNo: "DEV-PI-001", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001001"}},
|
||||
{Line: 3, DeviceNo: "DEV-PI-002", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001002", "89860012345680001003"}},
|
||||
{Line: 4, DeviceNo: "DEV-PI-003", MaxSimSlots: 4, ICCIDs: []string{"89860012345680001003", "89860012345680009999"}},
|
||||
}
|
||||
|
||||
result := handler.processImport(ctx, task, rows, len(rows))
|
||||
|
||||
assert.Equal(t, 1, result.successCount, "只有第一个设备应该成功(所有卡都可用)")
|
||||
assert.Equal(t, 2, result.failCount, "第二和第三个设备应该失败(有卡不可用)")
|
||||
|
||||
assert.Len(t, result.failedItems, 2)
|
||||
assert.Equal(t, 3, result.failedItems[0].Line)
|
||||
assert.Contains(t, result.failedItems[0].Reason, "已分配给店铺")
|
||||
assert.Equal(t, 4, result.failedItems[1].Line)
|
||||
assert.Contains(t, result.failedItems[1].Reason, "卡验证失败")
|
||||
}
|
||||
Reference in New Issue
Block a user