feat: 实现物联网卡独立管理和批量导入功能
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:
2026-01-24 11:03:43 +08:00
parent 6821e5abcf
commit a924e63e68
49 changed files with 7983 additions and 284 deletions

View File

@@ -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 任务队列客户端
}

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -24,6 +24,8 @@ type Handlers struct {
EnterpriseCard *admin.EnterpriseCardHandler
CustomerAccount *admin.CustomerAccountHandler
MyCommission *admin.MyCommissionHandler
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
}
// Middlewares 封装所有中间件

View 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)
}

View 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)
}

View File

@@ -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"`

View 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"`
}

View File

@@ -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"`

View 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
)

View File

@@ -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) {

View 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,
})
}

View 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
}

View 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 "未知"
}
}

View 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)
}

View 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
}

View 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)
})
}

View 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)
}

View 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)
})
}