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

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