feat: 实现物联网卡独立管理和批量导入功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m42s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m42s
新增物联网卡独立管理模块,支持单卡查询、批量导入和状态管理。主要变更包括: 功能特性: - 新增物联网卡 CRUD 接口(查询、分页列表、删除) - 支持 CSV/Excel 批量导入物联网卡 - 实现异步导入任务处理和进度跟踪 - 新增 ICCID 号码格式校验器(支持 Luhn 算法) - 新增 CSV 文件解析工具(支持编码检测和错误处理) 数据库变更: - 移除 iot_card 和 device 表的 owner_id/owner_type 字段 - 新增 iot_card_import_task 导入任务表 - 为导入任务添加运营商类型字段 测试覆盖: - 新增 IoT 卡 Store 层单元测试 - 新增 IoT 卡导入任务单元测试 - 新增 IoT 卡集成测试(包含导入流程测试) - 新增 CSV 工具和 ICCID 校验器测试 文档更新: - 更新 OpenAPI 文档(新增 7 个 IoT 卡接口) - 归档 OpenSpec 变更提案 - 更新 API 文档规范和生成器指南 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package bootstrap
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -17,4 +18,5 @@ type Dependencies struct {
|
||||
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
|
||||
TokenManager *auth.TokenManager // Token 管理器(后台和H5认证)
|
||||
VerificationService *verification.Service // 验证码服务
|
||||
QueueClient *queue.Client // Asynq 任务队列客户端
|
||||
}
|
||||
|
||||
@@ -26,5 +26,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
||||
CustomerAccount: admin.NewCustomerAccountHandler(svc.CustomerAccount),
|
||||
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
||||
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
||||
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
customerAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_account"
|
||||
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"
|
||||
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
||||
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
||||
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
|
||||
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
||||
@@ -32,6 +34,8 @@ type services struct {
|
||||
EnterpriseCard *enterpriseCardSvc.Service
|
||||
CustomerAccount *customerAccountSvc.Service
|
||||
MyCommission *myCommissionSvc.Service
|
||||
IotCard *iotCardSvc.Service
|
||||
IotCardImport *iotCardImportSvc.Service
|
||||
}
|
||||
|
||||
func initServices(s *stores, deps *Dependencies) *services {
|
||||
@@ -50,5 +54,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization),
|
||||
CustomerAccount: customerAccountSvc.New(deps.DB, s.Account, s.Shop, s.Enterprise),
|
||||
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.Wallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.WalletTransaction),
|
||||
IotCard: iotCardSvc.New(deps.DB, s.IotCard),
|
||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type stores struct {
|
||||
CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore
|
||||
Enterprise *postgres.EnterpriseStore
|
||||
EnterpriseCardAuthorization *postgres.EnterpriseCardAuthorizationStore
|
||||
IotCard *postgres.IotCardStore
|
||||
IotCardImportTask *postgres.IotCardImportTaskStore
|
||||
}
|
||||
|
||||
func initStores(deps *Dependencies) *stores {
|
||||
@@ -39,5 +41,7 @@ func initStores(deps *Dependencies) *stores {
|
||||
CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis),
|
||||
Enterprise: postgres.NewEnterpriseStore(deps.DB, deps.Redis),
|
||||
EnterpriseCardAuthorization: postgres.NewEnterpriseCardAuthorizationStore(deps.DB, deps.Redis),
|
||||
IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis),
|
||||
IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ type Handlers struct {
|
||||
EnterpriseCard *admin.EnterpriseCardHandler
|
||||
CustomerAccount *admin.CustomerAccountHandler
|
||||
MyCommission *admin.MyCommissionHandler
|
||||
IotCard *admin.IotCardHandler
|
||||
IotCardImport *admin.IotCardImportHandler
|
||||
}
|
||||
|
||||
// Middlewares 封装所有中间件
|
||||
|
||||
32
internal/handler/admin/iot_card.go
Normal file
32
internal/handler/admin/iot_card.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
type IotCardHandler struct {
|
||||
service *iotCardService.Service
|
||||
}
|
||||
|
||||
func NewIotCardHandler(service *iotCardService.Service) *IotCardHandler {
|
||||
return &IotCardHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
|
||||
var req dto.ListStandaloneIotCardRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.ListStandalone(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
74
internal/handler/admin/iot_card_import.go
Normal file
74
internal/handler/admin/iot_card_import.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
iotCardImportService "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
type IotCardImportHandler struct {
|
||||
service *iotCardImportService.Service
|
||||
}
|
||||
|
||||
func NewIotCardImportHandler(service *iotCardImportService.Service) *IotCardImportHandler {
|
||||
return &IotCardImportHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) Import(c *fiber.Ctx) error {
|
||||
var req dto.ImportIotCardRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请上传 CSV 文件")
|
||||
}
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无法读取上传文件")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
result, err := h.service.CreateImportTask(c.UserContext(), &req, f, file.Filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) List(c *fiber.Ctx) error {
|
||||
var req dto.ListImportTaskRequest
|
||||
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 *IotCardImportHandler) 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.GetByID(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// Device 设备模型
|
||||
// 物联网设备(如 GPS 追踪器、智能传感器)
|
||||
// 可绑定 1-4 张 IoT 卡,主要用于批量管理和设备操作
|
||||
// 通过 shop_id 区分所有权:NULL=平台库存,有值=店铺所有
|
||||
type Device struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
@@ -19,9 +19,7 @@ type Device struct {
|
||||
MaxSimSlots int `gorm:"column:max_sim_slots;type:int;default:4;comment:最大插槽数量(默认4)" json:"max_sim_slots"`
|
||||
Manufacturer string `gorm:"column:manufacturer;type:varchar(255);comment:制造商" json:"manufacturer"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 shop-店铺" json:"owner_type"`
|
||||
OwnerID uint `gorm:"column:owner_id;index;default:0;not null;comment:所有者ID" json:"owner_id"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(冗余字段,方便查询)" json:"shop_id,omitempty"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台库存,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
DeviceUsername string `gorm:"column:device_username;type:varchar(100);comment:设备登录用户名" json:"device_username"`
|
||||
|
||||
116
internal/model/dto/iot_card_dto.go
Normal file
116
internal/model/dto/iot_card_dto.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
type ListStandaloneIotCardRequest 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:已停用)"`
|
||||
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"分销商ID"`
|
||||
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID(模糊查询)"`
|
||||
MSISDN string `json:"msisdn" query:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号(模糊查询)"`
|
||||
BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
PackageID *uint `json:"package_id" query:"package_id" description:"套餐ID"`
|
||||
IsDistributed *bool `json:"is_distributed" query:"is_distributed" description:"是否已分销 (true:已分销, false:未分销)"`
|
||||
IsReplaced *bool `json:"is_replaced" query:"is_replaced" description:"是否有换卡记录 (true:有换卡记录, false:无换卡记录)"`
|
||||
ICCIDStart string `json:"iccid_start" query:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"ICCID起始号"`
|
||||
ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"`
|
||||
}
|
||||
|
||||
type StandaloneIotCardResponse struct {
|
||||
ID uint `json:"id" description:"卡ID"`
|
||||
ICCID string `json:"iccid" description:"ICCID"`
|
||||
CardType string `json:"card_type" description:"卡类型"`
|
||||
CardCategory string `json:"card_category" description:"卡业务类型 (normal:普通卡, industry:行业卡)"`
|
||||
CarrierID uint `json:"carrier_id" description:"运营商ID"`
|
||||
CarrierName string `json:"carrier_name,omitempty" description:"运营商名称"`
|
||||
IMSI string `json:"imsi,omitempty" description:"IMSI"`
|
||||
MSISDN string `json:"msisdn,omitempty" description:"卡接入号"`
|
||||
BatchNo string `json:"batch_no,omitempty" description:"批次号"`
|
||||
Supplier string `json:"supplier,omitempty" description:"供应商"`
|
||||
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
|
||||
DistributePrice int64 `json:"distribute_price" description:"分销价(分)"`
|
||||
Status int `json:"status" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"`
|
||||
ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name,omitempty" description:"店铺名称"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
|
||||
ActivationStatus int `json:"activation_status" description:"激活状态 (0:未激活, 1:已激活)"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
|
||||
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
|
||||
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
type ListStandaloneIotCardResponse struct {
|
||||
List []*StandaloneIotCardResponse `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 ImportIotCardRequest struct {
|
||||
CarrierID uint `json:"carrier_id" form:"carrier_id" validate:"required,min=1" required:"true" minimum:"1" description:"运营商ID"`
|
||||
BatchNo string `json:"batch_no" form:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"`
|
||||
}
|
||||
|
||||
type ImportIotCardResponse struct {
|
||||
TaskID uint `json:"task_id" description:"导入任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Message string `json:"message" description:"提示信息"`
|
||||
}
|
||||
|
||||
type ListImportTaskRequest 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:失败)"`
|
||||
CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"`
|
||||
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 ImportTaskResponse 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:"任务状态文本"`
|
||||
CarrierID uint `json:"carrier_id" description:"运营商ID"`
|
||||
CarrierName string `json:"carrier_name,omitempty" 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:"失败数"`
|
||||
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 ListImportTaskResponse struct {
|
||||
List []*ImportTaskResponse `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 ImportResultItemDTO struct {
|
||||
Line int `json:"line" description:"行号"`
|
||||
ICCID string `json:"iccid" description:"ICCID"`
|
||||
Reason string `json:"reason" description:"原因"`
|
||||
}
|
||||
|
||||
type ImportTaskDetailResponse struct {
|
||||
ImportTaskResponse
|
||||
SkippedItems []*ImportResultItemDTO `json:"skipped_items" description:"跳过记录详情"`
|
||||
FailedItems []*ImportResultItemDTO `json:"failed_items" description:"失败记录详情"`
|
||||
}
|
||||
|
||||
type GetImportTaskRequest struct {
|
||||
ID uint `path:"id" description:"任务ID" required:"true"`
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
|
||||
// IotCard IoT 卡模型
|
||||
// 物联网卡/流量卡的统一管理实体
|
||||
// 支持平台自营、代理分销等所有权模式
|
||||
// 通过 shop_id 区分所有权:NULL=平台所有,有值=店铺所有
|
||||
type IotCard struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(50);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识)" json:"iccid"`
|
||||
ICCID string `gorm:"column:iccid;type:varchar(20);uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL;not null;comment:ICCID(唯一标识,电信19位/其他20位)" json:"iccid"`
|
||||
CardType string `gorm:"column:card_type;type:varchar(50);not null;comment:卡类型" json:"card_type"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:'normal';not null;comment:卡业务类型 normal-普通卡 industry-行业卡" json:"card_category"`
|
||||
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
|
||||
@@ -23,9 +23,7 @@ type IotCard struct {
|
||||
CostPrice int64 `gorm:"column:cost_price;type:bigint;default:0;comment:成本价(分为单位)" json:"cost_price"`
|
||||
DistributePrice int64 `gorm:"column:distribute_price;type:bigint;default:0;comment:分销价(分为单位)" json:"distribute_price"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-在库 2-已分销 3-已激活 4-已停用" json:"status"`
|
||||
OwnerType string `gorm:"column:owner_type;type:varchar(20);default:'platform';not null;comment:所有者类型 platform-平台 shop-店铺" json:"owner_type"`
|
||||
OwnerID uint `gorm:"column:owner_id;index;default:0;not null;comment:所有者ID" json:"owner_id"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(冗余字段,方便查询)" json:"shop_id,omitempty"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台所有,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
ActivationStatus int `gorm:"column:activation_status;type:int;default:0;not null;comment:激活状态 0-未激活 1-已激活" json:"activation_status"`
|
||||
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
|
||||
|
||||
90
internal/model/iot_card_import_task.go
Normal file
90
internal/model/iot_card_import_task.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type IotCardImportTask struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(IMP-YYYYMMDD-XXXXXX)" json:"task_no"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"`
|
||||
CarrierID uint `gorm:"column:carrier_id;index;not null;comment:运营商ID" json:"carrier_id"`
|
||||
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
|
||||
BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"`
|
||||
TotalCount int `gorm:"column:total_count;type:int;default:0;not null;comment:总数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;type:int;default:0;not null;comment:成功数" json:"success_count"`
|
||||
SkipCount int `gorm:"column:skip_count;type:int;default:0;not null;comment:跳过数" json:"skip_count"`
|
||||
FailCount int `gorm:"column:fail_count;type:int;default:0;not null;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"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;comment:任务级错误信息" json:"error_message"`
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(发起导入的店铺)" json:"shop_id,omitempty"`
|
||||
ICCIDList ICCIDListJSON `gorm:"column:iccid_list;type:jsonb;comment:待导入ICCID列表" json:"-"`
|
||||
}
|
||||
|
||||
type ICCIDListJSON []string
|
||||
|
||||
func (list ICCIDListJSON) Value() (driver.Value, error) {
|
||||
if list == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(list)
|
||||
}
|
||||
|
||||
func (list *ICCIDListJSON) Scan(value any) error {
|
||||
if value == nil {
|
||||
*list = ICCIDListJSON{}
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, list)
|
||||
}
|
||||
|
||||
func (IotCardImportTask) TableName() string {
|
||||
return "tb_iot_card_import_task"
|
||||
}
|
||||
|
||||
type ImportResultItem struct {
|
||||
Line int `json:"line"`
|
||||
ICCID string `json:"iccid"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type ImportResultItems []ImportResultItem
|
||||
|
||||
func (items ImportResultItems) Value() (driver.Value, error) {
|
||||
if items == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(items)
|
||||
}
|
||||
|
||||
func (items *ImportResultItems) Scan(value any) error {
|
||||
if value == nil {
|
||||
*items = ImportResultItems{}
|
||||
return nil
|
||||
}
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(bytes, items)
|
||||
}
|
||||
|
||||
const (
|
||||
ImportTaskStatusPending = 1
|
||||
ImportTaskStatusProcessing = 2
|
||||
ImportTaskStatusCompleted = 3
|
||||
ImportTaskStatusFailed = 4
|
||||
)
|
||||
@@ -52,6 +52,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.MyCommission != nil {
|
||||
registerMyCommissionRoutes(authGroup, handlers.MyCommission, doc, basePath)
|
||||
}
|
||||
if handlers.IotCard != nil {
|
||||
registerIotCardRoutes(authGroup, handlers.IotCard, handlers.IotCardImport, doc, basePath)
|
||||
}
|
||||
}
|
||||
|
||||
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
||||
|
||||
46
internal/routes/iot_card.go
Normal file
46
internal/routes/iot_card.go
Normal file
@@ -0,0 +1,46 @@
|
||||
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 registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, importHandler *admin.IotCardImportHandler, doc *openapi.Generator, basePath string) {
|
||||
iotCards := router.Group("/iot-cards")
|
||||
groupPath := basePath + "/iot-cards"
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/standalone", handler.ListStandalone, RouteSpec{
|
||||
Summary: "单卡列表(未绑定设备)",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.ListStandaloneIotCardRequest),
|
||||
Output: new(dto.ListStandaloneIotCardResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{
|
||||
Summary: "批量导入ICCID",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.ImportIotCardRequest),
|
||||
Output: new(dto.ImportIotCardResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/import-tasks", importHandler.List, RouteSpec{
|
||||
Summary: "导入任务列表",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.ListImportTaskRequest),
|
||||
Output: new(dto.ListImportTaskResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(iotCards, doc, groupPath, "GET", "/import-tasks/:id", importHandler.GetByID, RouteSpec{
|
||||
Summary: "导入任务详情",
|
||||
Tags: []string{"IoT卡管理"},
|
||||
Input: new(dto.GetImportTaskRequest),
|
||||
Output: new(dto.ImportTaskDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
171
internal/service/iot_card/service.go
Normal file
171
internal/service/iot_card/service.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package iot_card
|
||||
|
||||
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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, iotCardStore *postgres.IotCardStore) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIotCardRequest) (*dto.ListStandaloneIotCardResponse, error) {
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
|
||||
opts := &store.QueryOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
filters := make(map[string]interface{})
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
if req.CarrierID != nil {
|
||||
filters["carrier_id"] = *req.CarrierID
|
||||
}
|
||||
if req.ShopID != nil {
|
||||
filters["shop_id"] = *req.ShopID
|
||||
}
|
||||
if req.ICCID != "" {
|
||||
filters["iccid"] = req.ICCID
|
||||
}
|
||||
if req.MSISDN != "" {
|
||||
filters["msisdn"] = req.MSISDN
|
||||
}
|
||||
if req.BatchNo != "" {
|
||||
filters["batch_no"] = req.BatchNo
|
||||
}
|
||||
if req.PackageID != nil {
|
||||
filters["package_id"] = *req.PackageID
|
||||
}
|
||||
if req.IsDistributed != nil {
|
||||
filters["is_distributed"] = *req.IsDistributed
|
||||
}
|
||||
if req.ICCIDStart != "" {
|
||||
filters["iccid_start"] = req.ICCIDStart
|
||||
}
|
||||
if req.ICCIDEnd != "" {
|
||||
filters["iccid_end"] = req.ICCIDEnd
|
||||
}
|
||||
if req.IsReplaced != nil {
|
||||
filters["is_replaced"] = *req.IsReplaced
|
||||
}
|
||||
|
||||
cards, total, err := s.iotCardStore.ListStandalone(ctx, opts, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
carrierMap, shopMap := s.loadRelatedData(ctx, cards)
|
||||
|
||||
list := make([]*dto.StandaloneIotCardResponse, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
item := s.toStandaloneResponse(card, carrierMap, shopMap)
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &dto.ListStandaloneIotCardResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadRelatedData(ctx context.Context, cards []*model.IotCard) (map[uint]string, map[uint]string) {
|
||||
carrierIDs := make([]uint, 0)
|
||||
shopIDs := make([]uint, 0)
|
||||
carrierIDSet := make(map[uint]bool)
|
||||
shopIDSet := make(map[uint]bool)
|
||||
|
||||
for _, card := range cards {
|
||||
if card.CarrierID > 0 && !carrierIDSet[card.CarrierID] {
|
||||
carrierIDs = append(carrierIDs, card.CarrierID)
|
||||
carrierIDSet[card.CarrierID] = true
|
||||
}
|
||||
if card.ShopID != nil && *card.ShopID > 0 && !shopIDSet[*card.ShopID] {
|
||||
shopIDs = append(shopIDs, *card.ShopID)
|
||||
shopIDSet[*card.ShopID] = 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
|
||||
}
|
||||
}
|
||||
|
||||
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 carrierMap, shopMap
|
||||
}
|
||||
|
||||
func (s *Service) toStandaloneResponse(card *model.IotCard, carrierMap map[uint]string, shopMap map[uint]string) *dto.StandaloneIotCardResponse {
|
||||
resp := &dto.StandaloneIotCardResponse{
|
||||
ID: card.ID,
|
||||
ICCID: card.ICCID,
|
||||
CardType: card.CardType,
|
||||
CardCategory: card.CardCategory,
|
||||
CarrierID: card.CarrierID,
|
||||
CarrierName: carrierMap[card.CarrierID],
|
||||
IMSI: card.IMSI,
|
||||
MSISDN: card.MSISDN,
|
||||
BatchNo: card.BatchNo,
|
||||
Supplier: card.Supplier,
|
||||
CostPrice: card.CostPrice,
|
||||
DistributePrice: card.DistributePrice,
|
||||
Status: card.Status,
|
||||
ShopID: card.ShopID,
|
||||
ActivatedAt: card.ActivatedAt,
|
||||
ActivationStatus: card.ActivationStatus,
|
||||
RealNameStatus: card.RealNameStatus,
|
||||
NetworkStatus: card.NetworkStatus,
|
||||
DataUsageMB: card.DataUsageMB,
|
||||
CreatedAt: card.CreatedAt,
|
||||
UpdatedAt: card.UpdatedAt,
|
||||
}
|
||||
|
||||
if card.ShopID != nil && *card.ShopID > 0 {
|
||||
resp.ShopName = shopMap[*card.ShopID]
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
275
internal/service/iot_card_import/service.go
Normal file
275
internal/service/iot_card_import/service.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package iot_card_import
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
importTaskStore *postgres.IotCardImportTaskStore
|
||||
carrierStore carrierGetter
|
||||
queueClient *queue.Client
|
||||
}
|
||||
|
||||
type carrierGetter interface {
|
||||
GetByID(ctx context.Context, id uint) (*model.Carrier, error)
|
||||
}
|
||||
|
||||
type CarrierStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCarrierStore(db *gorm.DB) *CarrierStore {
|
||||
return &CarrierStore{db: db}
|
||||
}
|
||||
|
||||
func (s *CarrierStore) GetByID(ctx context.Context, id uint) (*model.Carrier, error) {
|
||||
var carrier model.Carrier
|
||||
if err := s.db.WithContext(ctx).First(&carrier, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &carrier, nil
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, importTaskStore *postgres.IotCardImportTaskStore, queueClient *queue.Client) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
importTaskStore: importTaskStore,
|
||||
carrierStore: NewCarrierStore(db),
|
||||
queueClient: queueClient,
|
||||
}
|
||||
}
|
||||
|
||||
type IotCardImportPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRequest, csvReader io.Reader, fileName string) (*dto.ImportIotCardResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
carrier, err := s.carrierStore.GetByID(ctx, req.CarrierID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "运营商不存在")
|
||||
}
|
||||
|
||||
parseResult, err := utils.ParseICCIDFromCSV(csvReader)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "CSV 解析失败: "+err.Error())
|
||||
}
|
||||
|
||||
if parseResult.TotalCount == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "CSV 文件中没有有效的 ICCID")
|
||||
}
|
||||
|
||||
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
TaskNo: taskNo,
|
||||
Status: model.ImportTaskStatusPending,
|
||||
CarrierID: req.CarrierID,
|
||||
CarrierType: carrier.CarrierType,
|
||||
BatchNo: req.BatchNo,
|
||||
FileName: fileName,
|
||||
TotalCount: parseResult.TotalCount,
|
||||
SuccessCount: 0,
|
||||
SkipCount: 0,
|
||||
FailCount: 0,
|
||||
ICCIDList: model.ICCIDListJSON(parseResult.ICCIDs),
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
|
||||
if err := s.importTaskStore.Create(ctx, task); err != nil {
|
||||
return nil, fmt.Errorf("创建导入任务失败: %w", err)
|
||||
}
|
||||
|
||||
payload := IotCardImportPayload{TaskID: task.ID}
|
||||
err = s.queueClient.EnqueueTask(ctx, constants.TaskTypeIotCardImport, payload)
|
||||
if err != nil {
|
||||
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
|
||||
return nil, fmt.Errorf("任务入队失败: %w", err)
|
||||
}
|
||||
|
||||
return &dto.ImportIotCardResponse{
|
||||
TaskID: task.ID,
|
||||
TaskNo: taskNo,
|
||||
Message: fmt.Sprintf("导入任务已创建,共 %d 条 ICCID 待处理", parseResult.TotalCount),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, req *dto.ListImportTaskRequest) (*dto.ListImportTaskResponse, error) {
|
||||
page := req.Page
|
||||
pageSize := req.PageSize
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize == 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
|
||||
opts := &store.QueryOptions{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
|
||||
filters := make(map[string]interface{})
|
||||
if req.Status != nil {
|
||||
filters["status"] = *req.Status
|
||||
}
|
||||
if req.CarrierID != nil {
|
||||
filters["carrier_id"] = *req.CarrierID
|
||||
}
|
||||
if req.BatchNo != "" {
|
||||
filters["batch_no"] = req.BatchNo
|
||||
}
|
||||
if req.StartTime != nil {
|
||||
filters["start_time"] = *req.StartTime
|
||||
}
|
||||
if req.EndTime != nil {
|
||||
filters["end_time"] = *req.EndTime
|
||||
}
|
||||
|
||||
tasks, total, err := s.importTaskStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
carrierMap := s.loadCarriers(ctx, tasks)
|
||||
|
||||
list := make([]*dto.ImportTaskResponse, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
list = append(list, s.toTaskResponse(task, carrierMap))
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return &dto.ListImportTaskResponse{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.ImportTaskDetailResponse, error) {
|
||||
task, err := s.importTaskStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "导入任务不存在")
|
||||
}
|
||||
|
||||
carrierMap := make(map[uint]string)
|
||||
var carrier model.Carrier
|
||||
if s.db.WithContext(ctx).First(&carrier, task.CarrierID).Error == nil {
|
||||
carrierMap[carrier.ID] = carrier.CarrierName
|
||||
}
|
||||
|
||||
resp := &dto.ImportTaskDetailResponse{
|
||||
ImportTaskResponse: *s.toTaskResponse(task, carrierMap),
|
||||
SkippedItems: make([]*dto.ImportResultItemDTO, 0),
|
||||
FailedItems: make([]*dto.ImportResultItemDTO, 0),
|
||||
}
|
||||
|
||||
for _, item := range task.SkippedItems {
|
||||
resp.SkippedItems = append(resp.SkippedItems, &dto.ImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
ICCID: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range task.FailedItems {
|
||||
resp.FailedItems = append(resp.FailedItems, &dto.ImportResultItemDTO{
|
||||
Line: item.Line,
|
||||
ICCID: item.ICCID,
|
||||
Reason: item.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCarriers(ctx context.Context, tasks []*model.IotCardImportTask) map[uint]string {
|
||||
carrierIDs := make([]uint, 0)
|
||||
carrierIDSet := make(map[uint]bool)
|
||||
for _, task := range tasks {
|
||||
if task.CarrierID > 0 && !carrierIDSet[task.CarrierID] {
|
||||
carrierIDs = append(carrierIDs, task.CarrierID)
|
||||
carrierIDSet[task.CarrierID] = true
|
||||
}
|
||||
}
|
||||
|
||||
carrierMap := make(map[uint]string)
|
||||
if len(carrierIDs) > 0 {
|
||||
var carriers []model.Carrier
|
||||
s.db.WithContext(ctx).Where("id IN ?", carrierIDs).Find(&carriers)
|
||||
for _, c := range carriers {
|
||||
carrierMap[c.ID] = c.CarrierName
|
||||
}
|
||||
}
|
||||
return carrierMap
|
||||
}
|
||||
|
||||
func (s *Service) toTaskResponse(task *model.IotCardImportTask, carrierMap map[uint]string) *dto.ImportTaskResponse {
|
||||
var startedAt, completedAt *time.Time
|
||||
if task.StartedAt != nil {
|
||||
startedAt = task.StartedAt
|
||||
}
|
||||
if task.CompletedAt != nil {
|
||||
completedAt = task.CompletedAt
|
||||
}
|
||||
|
||||
return &dto.ImportTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
CarrierID: task.CarrierID,
|
||||
CarrierName: carrierMap[task.CarrierID],
|
||||
BatchNo: task.BatchNo,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func getStatusText(status int) string {
|
||||
switch status {
|
||||
case model.ImportTaskStatusPending:
|
||||
return "待处理"
|
||||
case model.ImportTaskStatusProcessing:
|
||||
return "处理中"
|
||||
case model.ImportTaskStatusCompleted:
|
||||
return "已完成"
|
||||
case model.ImportTaskStatusFailed:
|
||||
return "失败"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
133
internal/store/postgres/iot_card_import_task_store.go
Normal file
133
internal/store/postgres/iot_card_import_task_store.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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 IotCardImportTaskStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewIotCardImportTaskStore(db *gorm.DB, redis *redis.Client) *IotCardImportTaskStore {
|
||||
return &IotCardImportTaskStore{
|
||||
db: db,
|
||||
redis: redis,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) Create(ctx context.Context, task *model.IotCardImportTask) error {
|
||||
return s.db.WithContext(ctx).Create(task).Error
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) GetByID(ctx context.Context, id uint) (*model.IotCardImportTask, error) {
|
||||
var task model.IotCardImportTask
|
||||
if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) GetByTaskNo(ctx context.Context, taskNo string) (*model.IotCardImportTask, error) {
|
||||
var task model.IotCardImportTask
|
||||
if err := s.db.WithContext(ctx).Where("task_no = ?", taskNo).First(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) Update(ctx context.Context, task *model.IotCardImportTask) error {
|
||||
return s.db.WithContext(ctx).Save(task).Error
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error {
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if status == model.ImportTaskStatusProcessing {
|
||||
updates["started_at"] = time.Now()
|
||||
}
|
||||
if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed {
|
||||
updates["completed_at"] = time.Now()
|
||||
}
|
||||
if errorMessage != "" {
|
||||
updates["error_message"] = errorMessage
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) UpdateResult(ctx context.Context, id uint, successCount, skipCount, failCount int, skippedItems, failedItems model.ImportResultItems) error {
|
||||
updates := map[string]interface{}{
|
||||
"success_count": successCount,
|
||||
"skip_count": skipCount,
|
||||
"fail_count": failCount,
|
||||
"skipped_items": skippedItems,
|
||||
"failed_items": failedItems,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *IotCardImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.IotCardImportTask, int64, error) {
|
||||
var tasks []*model.IotCardImportTask
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCardImportTask{})
|
||||
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
|
||||
query = query.Where("carrier_id = ?", carrierID)
|
||||
}
|
||||
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 *IotCardImportTaskStore) GenerateTaskNo(ctx context.Context) string {
|
||||
now := time.Now()
|
||||
dateStr := now.Format("20060102")
|
||||
seq := now.UnixNano() % 1000000
|
||||
return fmt.Sprintf("IMP-%s-%06d", dateStr, seq)
|
||||
}
|
||||
218
internal/store/postgres/iot_card_store.go
Normal file
218
internal/store/postgres/iot_card_store.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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 IotCardStore struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewIotCardStore(db *gorm.DB, redis *redis.Client) *IotCardStore {
|
||||
return &IotCardStore{
|
||||
db: db,
|
||||
redis: redis,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IotCardStore) Create(ctx context.Context, card *model.IotCard) error {
|
||||
return s.db.WithContext(ctx).Create(card).Error
|
||||
}
|
||||
|
||||
func (s *IotCardStore) CreateBatch(ctx context.Context, cards []*model.IotCard) error {
|
||||
if len(cards) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).CreateInBatches(cards, 100).Error
|
||||
}
|
||||
|
||||
func (s *IotCardStore) GetByID(ctx context.Context, id uint) (*model.IotCard, error) {
|
||||
var card model.IotCard
|
||||
if err := s.db.WithContext(ctx).First(&card, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) GetByICCID(ctx context.Context, iccid string) (*model.IotCard, error) {
|
||||
var card model.IotCard
|
||||
if err := s.db.WithContext(ctx).Where("iccid = ?", iccid).First(&card).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) ExistsByICCID(ctx context.Context, iccid string) (bool, error) {
|
||||
var count int64
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).Where("iccid = ?", iccid).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) ExistsByICCIDBatch(ctx context.Context, iccids []string) (map[string]bool, error) {
|
||||
if len(iccids) == 0 {
|
||||
return make(map[string]bool), nil
|
||||
}
|
||||
|
||||
var existingICCIDs []string
|
||||
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("iccid IN ?", iccids).
|
||||
Pluck("iccid", &existingICCIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]bool)
|
||||
for _, iccid := range existingICCIDs {
|
||||
result[iccid] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) Update(ctx context.Context, card *model.IotCard) error {
|
||||
return s.db.WithContext(ctx).Save(card).Error
|
||||
}
|
||||
|
||||
func (s *IotCardStore) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.WithContext(ctx).Delete(&model.IotCard{}, id).Error
|
||||
}
|
||||
|
||||
func (s *IotCardStore) ListStandalone(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) {
|
||||
var cards []*model.IotCard
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCard{})
|
||||
|
||||
query = query.Where("id NOT IN (?)",
|
||||
s.db.Model(&model.DeviceSimBinding{}).
|
||||
Select("iot_card_id").
|
||||
Where("bind_status = ?", 1))
|
||||
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
|
||||
query = query.Where("carrier_id = ?", carrierID)
|
||||
}
|
||||
if shopID, ok := filters["shop_id"].(uint); ok && shopID > 0 {
|
||||
query = query.Where("shop_id = ?", shopID)
|
||||
}
|
||||
if iccid, ok := filters["iccid"].(string); ok && iccid != "" {
|
||||
query = query.Where("iccid LIKE ?", "%"+iccid+"%")
|
||||
}
|
||||
if msisdn, ok := filters["msisdn"].(string); ok && msisdn != "" {
|
||||
query = query.Where("msisdn LIKE ?", "%"+msisdn+"%")
|
||||
}
|
||||
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
|
||||
query = query.Where("batch_no = ?", batchNo)
|
||||
}
|
||||
if packageID, ok := filters["package_id"].(uint); ok && packageID > 0 {
|
||||
query = query.Where("id IN (?)",
|
||||
s.db.Table("tb_package_usage").
|
||||
Select("iot_card_id").
|
||||
Where("package_id = ? AND deleted_at IS NULL", packageID))
|
||||
}
|
||||
if isDistributed, ok := filters["is_distributed"].(bool); ok {
|
||||
if isDistributed {
|
||||
query = query.Where("shop_id IS NOT NULL")
|
||||
} else {
|
||||
query = query.Where("shop_id IS NULL")
|
||||
}
|
||||
}
|
||||
if iccidStart, ok := filters["iccid_start"].(string); ok && iccidStart != "" {
|
||||
query = query.Where("iccid >= ?", iccidStart)
|
||||
}
|
||||
if iccidEnd, ok := filters["iccid_end"].(string); ok && iccidEnd != "" {
|
||||
query = query.Where("iccid <= ?", iccidEnd)
|
||||
}
|
||||
if isReplaced, ok := filters["is_replaced"].(bool); ok {
|
||||
if isReplaced {
|
||||
query = query.Where("id IN (?)",
|
||||
s.db.Table("tb_card_replacement_record").
|
||||
Select("old_iot_card_id").
|
||||
Where("deleted_at IS NULL"))
|
||||
} else {
|
||||
query = query.Where("id NOT IN (?)",
|
||||
s.db.Table("tb_card_replacement_record").
|
||||
Select("old_iot_card_id").
|
||||
Where("deleted_at IS NULL"))
|
||||
}
|
||||
}
|
||||
|
||||
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(&cards).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return cards, total, nil
|
||||
}
|
||||
|
||||
func (s *IotCardStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.IotCard, int64, error) {
|
||||
var cards []*model.IotCard
|
||||
var total int64
|
||||
|
||||
query := s.db.WithContext(ctx).Model(&model.IotCard{})
|
||||
|
||||
if status, ok := filters["status"].(int); ok && status > 0 {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
if carrierID, ok := filters["carrier_id"].(uint); ok && carrierID > 0 {
|
||||
query = query.Where("carrier_id = ?", carrierID)
|
||||
}
|
||||
if shopID, ok := filters["shop_id"].(uint); ok && shopID > 0 {
|
||||
query = query.Where("shop_id = ?", shopID)
|
||||
}
|
||||
if iccid, ok := filters["iccid"].(string); ok && iccid != "" {
|
||||
query = query.Where("iccid LIKE ?", "%"+iccid+"%")
|
||||
}
|
||||
|
||||
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(&cards).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return cards, total, nil
|
||||
}
|
||||
242
internal/store/postgres/iot_card_store_test.go
Normal file
242
internal/store/postgres/iot_card_store_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIotCardStore_Create(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: "89860012345678901234",
|
||||
CardType: "data_card",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
err := s.Create(ctx, card)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, card.ID)
|
||||
}
|
||||
|
||||
func TestIotCardStore_ExistsByICCID(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
card := &model.IotCard{
|
||||
ICCID: "89860012345678901111",
|
||||
CardType: "data_card",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, card))
|
||||
|
||||
exists, err := s.ExistsByICCID(ctx, "89860012345678901111")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
exists, err = s.ExistsByICCID(ctx, "89860012345678909999")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestIotCardStore_ExistsByICCIDBatch(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678902001", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678902002", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678902003", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
result, err := s.ExistsByICCIDBatch(ctx, []string{
|
||||
"89860012345678902001",
|
||||
"89860012345678902002",
|
||||
"89860012345678909999",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result["89860012345678902001"])
|
||||
assert.True(t, result["89860012345678902002"])
|
||||
assert.False(t, result["89860012345678909999"])
|
||||
|
||||
emptyResult, err := s.ExistsByICCIDBatch(ctx, []string{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, emptyResult)
|
||||
}
|
||||
|
||||
func TestIotCardStore_ListStandalone(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
standaloneCards := []*model.IotCard{
|
||||
{ICCID: "89860012345678903001", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678903002", CardType: "data_card", CarrierID: 1, Status: 1},
|
||||
{ICCID: "89860012345678903003", CardType: "data_card", CarrierID: 2, Status: 2},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, standaloneCards))
|
||||
|
||||
boundCard := &model.IotCard{
|
||||
ICCID: "89860012345678903004",
|
||||
CardType: "data_card",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, s.Create(ctx, boundCard))
|
||||
|
||||
binding := &model.DeviceSimBinding{
|
||||
DeviceID: 1,
|
||||
IotCardID: boundCard.ID,
|
||||
BindStatus: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(binding).Error)
|
||||
|
||||
t.Run("查询所有单卡", func(t *testing.T) {
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, cards, 3)
|
||||
|
||||
for _, card := range cards {
|
||||
assert.NotEqual(t, boundCard.ID, card.ID, "已绑定的卡不应出现在单卡列表中")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按运营商ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"carrier_id": uint(1)}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
for _, card := range cards {
|
||||
assert.Equal(t, uint(1), card.CarrierID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("按状态过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"status": 2}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, cards, 1)
|
||||
assert.Equal(t, 2, cards[0].Status)
|
||||
})
|
||||
|
||||
t.Run("按ICCID模糊查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"iccid": "903001"}
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Contains(t, cards[0].ICCID, "903001")
|
||||
})
|
||||
|
||||
t.Run("分页查询", func(t *testing.T) {
|
||||
cards, total, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, cards, 2)
|
||||
|
||||
cards2, _, err := s.ListStandalone(ctx, &store.QueryOptions{Page: 2, PageSize: 2}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cards2, 1)
|
||||
})
|
||||
|
||||
t.Run("默认分页选项", func(t *testing.T) {
|
||||
cards, total, err := s.ListStandalone(ctx, nil, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), total)
|
||||
assert.Len(t, cards, 3)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardStore_ListStandalone_Filters(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
s := NewIotCardStore(tx, rdb)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := uint(100)
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: "89860012345678904001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shopID, BatchNo: "BATCH001", MSISDN: "13800000001"},
|
||||
{ICCID: "89860012345678904002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH001", MSISDN: "13800000002"},
|
||||
{ICCID: "89860012345678904003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil, BatchNo: "BATCH002", MSISDN: "13800000003"},
|
||||
}
|
||||
require.NoError(t, s.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("按店铺ID过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"shop_id": shopID}
|
||||
cards, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, shopID, *cards[0].ShopID)
|
||||
})
|
||||
|
||||
t.Run("按批次号过滤", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"batch_no": "BATCH001"}
|
||||
_, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
})
|
||||
|
||||
t.Run("按MSISDN模糊查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"msisdn": "000001"}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Contains(t, result[0].MSISDN, "000001")
|
||||
})
|
||||
|
||||
t.Run("已分销过滤-true", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"is_distributed": true}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.NotNil(t, result[0].ShopID)
|
||||
})
|
||||
|
||||
t.Run("已分销过滤-false", func(t *testing.T) {
|
||||
filters := map[string]interface{}{"is_distributed": false}
|
||||
result, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
for _, card := range result {
|
||||
assert.Nil(t, card.ShopID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ICCID范围查询", func(t *testing.T) {
|
||||
filters := map[string]interface{}{
|
||||
"iccid_start": "89860012345678904001",
|
||||
"iccid_end": "89860012345678904002",
|
||||
}
|
||||
_, total, err := s.ListStandalone(ctx, nil, filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
})
|
||||
}
|
||||
230
internal/task/iot_card_import.go
Normal file
230
internal/task/iot_card_import.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/validator"
|
||||
)
|
||||
|
||||
const batchSize = 1000
|
||||
|
||||
type IotCardImportPayload struct {
|
||||
TaskID uint `json:"task_id"`
|
||||
}
|
||||
|
||||
type IotCardImportHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
importTaskStore *postgres.IotCardImportTaskStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewIotCardImportHandler(db *gorm.DB, redis *redis.Client, importTaskStore *postgres.IotCardImportTaskStore, iotCardStore *postgres.IotCardStore, logger *zap.Logger) *IotCardImportHandler {
|
||||
return &IotCardImportHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
importTaskStore: importTaskStore,
|
||||
iotCardStore: iotCardStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *asynq.Task) error {
|
||||
ctx = pkggorm.SkipDataPermission(ctx)
|
||||
|
||||
var payload IotCardImportPayload
|
||||
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
h.logger.Error("解析 IoT 卡导入任务载荷失败",
|
||||
zap.Error(err),
|
||||
zap.String("task_id", task.ResultWriter().TaskID()),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
importTask, err := h.importTaskStore.GetByID(ctx, payload.TaskID)
|
||||
if err != nil {
|
||||
h.logger.Error("获取导入任务失败",
|
||||
zap.Uint("task_id", payload.TaskID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
|
||||
if importTask.Status != model.ImportTaskStatusPending {
|
||||
h.logger.Info("导入任务已处理,跳过",
|
||||
zap.Uint("task_id", payload.TaskID),
|
||||
zap.Int("status", importTask.Status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "")
|
||||
|
||||
h.logger.Info("开始处理 IoT 卡导入任务",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
zap.String("task_no", importTask.TaskNo),
|
||||
zap.Int("total_count", importTask.TotalCount),
|
||||
)
|
||||
|
||||
result := h.processImport(ctx, importTask)
|
||||
|
||||
h.importTaskStore.UpdateResult(ctx, importTask.ID, result.successCount, result.skipCount, result.failCount, result.skippedItems, result.failedItems)
|
||||
|
||||
if result.failCount > 0 && result.successCount == 0 {
|
||||
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败")
|
||||
} else {
|
||||
h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "")
|
||||
}
|
||||
|
||||
h.logger.Info("IoT 卡导入任务完成",
|
||||
zap.Uint("task_id", importTask.ID),
|
||||
zap.Int("success_count", result.successCount),
|
||||
zap.Int("skip_count", result.skipCount),
|
||||
zap.Int("fail_count", result.failCount),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type importResult struct {
|
||||
successCount int
|
||||
skipCount int
|
||||
failCount int
|
||||
skippedItems model.ImportResultItems
|
||||
failedItems model.ImportResultItems
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) processImport(ctx context.Context, task *model.IotCardImportTask) *importResult {
|
||||
result := &importResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
iccids := h.getICCIDsFromTask(task)
|
||||
if len(iccids) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
for i := 0; i < len(iccids); i += batchSize {
|
||||
end := min(i+batchSize, len(iccids))
|
||||
batch := iccids[i:end]
|
||||
h.processBatch(ctx, task, batch, i+1, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) getICCIDsFromTask(task *model.IotCardImportTask) []string {
|
||||
return []string(task.ICCIDList)
|
||||
}
|
||||
|
||||
func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.IotCardImportTask, batch []string, startLine int, result *importResult) {
|
||||
validICCIDs := make([]string, 0)
|
||||
lineMap := make(map[string]int)
|
||||
|
||||
for i, iccid := range batch {
|
||||
line := startLine + i
|
||||
lineMap[iccid] = line
|
||||
|
||||
validationResult := validator.ValidateICCID(iccid, task.CarrierType)
|
||||
if !validationResult.Valid {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: line,
|
||||
ICCID: iccid,
|
||||
Reason: validationResult.Message,
|
||||
})
|
||||
result.failCount++
|
||||
continue
|
||||
}
|
||||
validICCIDs = append(validICCIDs, iccid)
|
||||
}
|
||||
|
||||
if len(validICCIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
existingMap, err := h.iotCardStore.ExistsByICCIDBatch(ctx, validICCIDs)
|
||||
if err != nil {
|
||||
h.logger.Error("批量检查 ICCID 是否存在失败",
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(validICCIDs)),
|
||||
)
|
||||
for _, iccid := range validICCIDs {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: lineMap[iccid],
|
||||
ICCID: iccid,
|
||||
Reason: "数据库查询失败",
|
||||
})
|
||||
result.failCount++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
newICCIDs := make([]string, 0)
|
||||
for _, iccid := range validICCIDs {
|
||||
if existingMap[iccid] {
|
||||
result.skippedItems = append(result.skippedItems, model.ImportResultItem{
|
||||
Line: lineMap[iccid],
|
||||
ICCID: iccid,
|
||||
Reason: "ICCID 已存在",
|
||||
})
|
||||
result.skipCount++
|
||||
} else {
|
||||
newICCIDs = append(newICCIDs, iccid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newICCIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cards := make([]*model.IotCard, 0, len(newICCIDs))
|
||||
now := time.Now()
|
||||
for _, iccid := range newICCIDs {
|
||||
card := &model.IotCard{
|
||||
ICCID: iccid,
|
||||
CarrierID: task.CarrierID,
|
||||
BatchNo: task.BatchNo,
|
||||
Status: constants.IotCardStatusInStock,
|
||||
CardCategory: constants.CardCategoryNormal,
|
||||
ActivationStatus: constants.ActivationStatusInactive,
|
||||
RealNameStatus: constants.RealNameStatusNotVerified,
|
||||
NetworkStatus: constants.NetworkStatusOffline,
|
||||
}
|
||||
card.BaseModel.Creator = task.Creator
|
||||
card.BaseModel.Updater = task.Creator
|
||||
card.CreatedAt = now
|
||||
card.UpdatedAt = now
|
||||
cards = append(cards, card)
|
||||
}
|
||||
|
||||
if err := h.iotCardStore.CreateBatch(ctx, cards); err != nil {
|
||||
h.logger.Error("批量创建 IoT 卡失败",
|
||||
zap.Error(err),
|
||||
zap.Int("batch_size", len(cards)),
|
||||
)
|
||||
for _, iccid := range newICCIDs {
|
||||
result.failedItems = append(result.failedItems, model.ImportResultItem{
|
||||
Line: lineMap[iccid],
|
||||
ICCID: iccid,
|
||||
Reason: "数据库写入失败",
|
||||
})
|
||||
result.failCount++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
result.successCount += len(newICCIDs)
|
||||
}
|
||||
187
internal/task/iot_card_import_test.go
Normal file
187
internal/task/iot_card_import_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestIotCardImportHandler_ProcessImport(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
logger := zap.NewNop()
|
||||
importTaskStore := postgres.NewIotCardImportTaskStore(tx, rdb)
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
|
||||
handler := NewIotCardImportHandler(tx, rdb, importTaskStore, iotCardStore, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("成功导入新ICCID", func(t *testing.T) {
|
||||
task := &model.IotCardImportTask{
|
||||
CarrierID: 1,
|
||||
CarrierType: constants.CarrierCodeCMCC,
|
||||
BatchNo: "TEST_BATCH_001",
|
||||
ICCIDList: model.ICCIDListJSON{"89860012345678905001", "89860012345678905002", "89860012345678905003"},
|
||||
TotalCount: 3,
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
result := handler.processImport(ctx, task)
|
||||
|
||||
assert.Equal(t, 3, result.successCount)
|
||||
assert.Equal(t, 0, result.skipCount)
|
||||
assert.Equal(t, 0, result.failCount)
|
||||
|
||||
exists, _ := iotCardStore.ExistsByICCID(ctx, "89860012345678905001")
|
||||
assert.True(t, exists)
|
||||
})
|
||||
|
||||
t.Run("跳过已存在的ICCID", func(t *testing.T) {
|
||||
existingCard := &model.IotCard{
|
||||
ICCID: "89860012345678906001",
|
||||
CardType: "data_card",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, iotCardStore.Create(ctx, existingCard))
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
CarrierID: 1,
|
||||
CarrierType: constants.CarrierCodeCMCC,
|
||||
BatchNo: "TEST_BATCH_002",
|
||||
ICCIDList: model.ICCIDListJSON{"89860012345678906001", "89860012345678906002"},
|
||||
TotalCount: 2,
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
result := handler.processImport(ctx, task)
|
||||
|
||||
assert.Equal(t, 1, result.successCount)
|
||||
assert.Equal(t, 1, result.skipCount)
|
||||
assert.Equal(t, 0, result.failCount)
|
||||
assert.Len(t, result.skippedItems, 1)
|
||||
assert.Equal(t, "89860012345678906001", result.skippedItems[0].ICCID)
|
||||
assert.Equal(t, "ICCID 已存在", result.skippedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("ICCID格式校验失败", func(t *testing.T) {
|
||||
task := &model.IotCardImportTask{
|
||||
CarrierID: 1,
|
||||
CarrierType: constants.CarrierCodeCTCC,
|
||||
BatchNo: "TEST_BATCH_003",
|
||||
ICCIDList: model.ICCIDListJSON{"89860312345678907001", "898603123456789070"},
|
||||
TotalCount: 2,
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
result := handler.processImport(ctx, task)
|
||||
|
||||
assert.Equal(t, 0, result.successCount)
|
||||
assert.Equal(t, 0, result.skipCount)
|
||||
assert.Equal(t, 2, result.failCount)
|
||||
assert.Len(t, result.failedItems, 2)
|
||||
})
|
||||
|
||||
t.Run("混合场景-成功跳过和失败", func(t *testing.T) {
|
||||
existingCard := &model.IotCard{
|
||||
ICCID: "89860012345678908001",
|
||||
CardType: "data_card",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, iotCardStore.Create(ctx, existingCard))
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
CarrierID: 1,
|
||||
CarrierType: constants.CarrierCodeCMCC,
|
||||
BatchNo: "TEST_BATCH_004",
|
||||
ICCIDList: model.ICCIDListJSON{
|
||||
"89860012345678908001",
|
||||
"89860012345678908002",
|
||||
"invalid!iccid",
|
||||
},
|
||||
TotalCount: 3,
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
result := handler.processImport(ctx, task)
|
||||
|
||||
assert.Equal(t, 1, result.successCount)
|
||||
assert.Equal(t, 1, result.skipCount)
|
||||
assert.Equal(t, 1, result.failCount)
|
||||
})
|
||||
|
||||
t.Run("空ICCID列表", func(t *testing.T) {
|
||||
task := &model.IotCardImportTask{
|
||||
CarrierID: 1,
|
||||
CarrierType: constants.CarrierCodeCMCC,
|
||||
BatchNo: "TEST_BATCH_005",
|
||||
ICCIDList: model.ICCIDListJSON{},
|
||||
TotalCount: 0,
|
||||
}
|
||||
|
||||
result := handler.processImport(ctx, task)
|
||||
|
||||
assert.Equal(t, 0, result.successCount)
|
||||
assert.Equal(t, 0, result.skipCount)
|
||||
assert.Equal(t, 0, result.failCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIotCardImportHandler_ProcessBatch(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
logger := zap.NewNop()
|
||||
importTaskStore := postgres.NewIotCardImportTaskStore(tx, rdb)
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
|
||||
handler := NewIotCardImportHandler(tx, rdb, importTaskStore, iotCardStore, logger)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("验证行号正确记录", func(t *testing.T) {
|
||||
existingCard := &model.IotCard{
|
||||
ICCID: "89860012345678909002",
|
||||
CardType: "data_card",
|
||||
CarrierID: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, iotCardStore.Create(ctx, existingCard))
|
||||
|
||||
task := &model.IotCardImportTask{
|
||||
CarrierID: 1,
|
||||
CarrierType: constants.CarrierCodeCMCC,
|
||||
BatchNo: "TEST_BATCH_LINE",
|
||||
}
|
||||
task.Creator = 1
|
||||
|
||||
batch := []string{
|
||||
"89860012345678909001",
|
||||
"89860012345678909002",
|
||||
"invalid",
|
||||
}
|
||||
result := &importResult{
|
||||
skippedItems: make(model.ImportResultItems, 0),
|
||||
failedItems: make(model.ImportResultItems, 0),
|
||||
}
|
||||
|
||||
handler.processBatch(ctx, task, batch, 100, result)
|
||||
|
||||
assert.Equal(t, 1, result.successCount)
|
||||
assert.Equal(t, 1, result.skipCount)
|
||||
assert.Equal(t, 1, result.failCount)
|
||||
|
||||
assert.Equal(t, 101, result.skippedItems[0].Line)
|
||||
assert.Equal(t, 102, result.failedItems[0].Line)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user