feat: 实现卡和设备的套餐系列绑定功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m37s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m37s
- 添加 Device 和 IotCard 模型的 SeriesID 字段 - 实现 DeviceService 和 IotCardService 的套餐系列绑定逻辑 - 添加 DeviceStore 和 IotCardStore 的数据库操作方法 - 更新 API 接口和路由支持套餐系列绑定 - 创建数据库迁移脚本(000027_add_series_binding_fields) - 添加完整的单元测试和集成测试 - 更新 OpenAPI 文档 - 归档 OpenSpec 变更文档 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -17,6 +17,7 @@ type Service struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
shopStore *postgres.ShopStore
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -24,12 +25,14 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
shopStore: shopStore,
|
||||
assetAllocationRecordStore: assetAllocationRecordStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +85,9 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot
|
||||
if req.IsReplaced != nil {
|
||||
filters["is_replaced"] = *req.IsReplaced
|
||||
}
|
||||
if req.SeriesAllocationID != nil {
|
||||
filters["series_allocation_id"] = *req.SeriesAllocationID
|
||||
}
|
||||
|
||||
cards, total, err := s.iotCardStore.ListStandalone(ctx, opts, filters)
|
||||
if err != nil {
|
||||
@@ -153,28 +159,31 @@ func (s *Service) loadShopNames(ctx context.Context, cards []*model.IotCard) map
|
||||
|
||||
func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]string) *dto.StandaloneIotCardResponse {
|
||||
resp := &dto.StandaloneIotCardResponse{
|
||||
ID: card.ID,
|
||||
ICCID: card.ICCID,
|
||||
CardType: card.CardType,
|
||||
CardCategory: card.CardCategory,
|
||||
CarrierID: card.CarrierID,
|
||||
CarrierType: card.CarrierType,
|
||||
CarrierName: card.CarrierName,
|
||||
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,
|
||||
ID: card.ID,
|
||||
ICCID: card.ICCID,
|
||||
CardType: card.CardType,
|
||||
CardCategory: card.CardCategory,
|
||||
CarrierID: card.CarrierID,
|
||||
CarrierType: card.CarrierType,
|
||||
CarrierName: card.CarrierName,
|
||||
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,
|
||||
SeriesAllocationID: card.SeriesAllocationID,
|
||||
FirstCommissionPaid: card.FirstCommissionPaid,
|
||||
AccumulatedRecharge: card.AccumulatedRecharge,
|
||||
CreatedAt: card.CreatedAt,
|
||||
UpdatedAt: card.UpdatedAt,
|
||||
}
|
||||
|
||||
if card.ShopID != nil && *card.ShopID > 0 {
|
||||
@@ -533,3 +542,101 @@ func (s *Service) buildRecallRecords(cards []*model.IotCard, successCardIDs []ui
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// BatchSetSeriesBinding 批量设置卡的套餐系列绑定
|
||||
func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCardSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetCardSeriesBindngResponse, error) {
|
||||
cards, err := s.iotCardStore.GetByICCIDs(ctx, req.ICCIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(cards) == 0 {
|
||||
return &dto.BatchSetCardSeriesBindngResponse{
|
||||
SuccessCount: 0,
|
||||
FailCount: len(req.ICCIDs),
|
||||
FailedItems: s.buildCardNotFoundFailedItems(req.ICCIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
cardMap := make(map[string]*model.IotCard)
|
||||
for _, card := range cards {
|
||||
cardMap[card.ICCID] = card
|
||||
}
|
||||
|
||||
var seriesAllocation *model.ShopSeriesAllocation
|
||||
if req.SeriesAllocationID > 0 {
|
||||
seriesAllocation, err = s.seriesAllocationStore.GetByID(ctx, req.SeriesAllocationID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐系列分配不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if seriesAllocation.Status != 1 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配已禁用")
|
||||
}
|
||||
}
|
||||
|
||||
var successCardIDs []uint
|
||||
var failedItems []dto.CardSeriesBindngFailedItem
|
||||
|
||||
for _, iccid := range req.ICCIDs {
|
||||
card, exists := cardMap[iccid]
|
||||
if !exists {
|
||||
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
|
||||
ICCID: iccid,
|
||||
Reason: "卡不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if req.SeriesAllocationID > 0 {
|
||||
if card.ShopID == nil || *card.ShopID != seriesAllocation.ShopID {
|
||||
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
|
||||
ICCID: iccid,
|
||||
Reason: "卡不属于套餐系列分配的店铺",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if operatorShopID != nil {
|
||||
if card.ShopID == nil || *card.ShopID != *operatorShopID {
|
||||
failedItems = append(failedItems, dto.CardSeriesBindngFailedItem{
|
||||
ICCID: iccid,
|
||||
Reason: "无权操作此卡",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
successCardIDs = append(successCardIDs, card.ID)
|
||||
}
|
||||
|
||||
if len(successCardIDs) > 0 {
|
||||
var seriesAllocationIDPtr *uint
|
||||
if req.SeriesAllocationID > 0 {
|
||||
seriesAllocationIDPtr = &req.SeriesAllocationID
|
||||
}
|
||||
if err := s.iotCardStore.BatchUpdateSeriesAllocation(ctx, successCardIDs, seriesAllocationIDPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.BatchSetCardSeriesBindngResponse{
|
||||
SuccessCount: len(successCardIDs),
|
||||
FailCount: len(failedItems),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeriesBindngFailedItem {
|
||||
items := make([]dto.CardSeriesBindngFailedItem, len(iccids))
|
||||
for i, iccid := range iccids {
|
||||
items[i] = dto.CardSeriesBindngFailedItem{
|
||||
ICCID: iccid,
|
||||
Reason: "卡不存在",
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
147
internal/service/iot_card/service_test.go
Normal file
147
internal/service/iot_card/service_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package iot_card
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"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/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func uniqueTestICCIDPrefix() string {
|
||||
return fmt.Sprintf("T%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestIotCardService_BatchSetSeriesBinding(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
assetAllocationRecordStore := postgres.NewAssetAllocationRecordStore(tx, rdb)
|
||||
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||
|
||||
svc := New(tx, iotCardStore, shopStore, assetAllocationRecordStore, seriesAllocationStore)
|
||||
ctx := context.Background()
|
||||
|
||||
shop := &model.Shop{
|
||||
ShopName: "测试店铺",
|
||||
ShopCode: fmt.Sprintf("SHOP%d", time.Now().UnixNano()%1000000),
|
||||
Level: 1,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(shop).Error)
|
||||
|
||||
series := &model.PackageSeries{
|
||||
SeriesCode: fmt.Sprintf("SERIES%d", time.Now().UnixNano()%1000000),
|
||||
SeriesName: "测试系列",
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(series).Error)
|
||||
|
||||
allocation := &model.ShopSeriesAllocation{
|
||||
ShopID: shop.ID,
|
||||
SeriesID: series.ID,
|
||||
Status: 1,
|
||||
}
|
||||
require.NoError(t, tx.Create(allocation).Error)
|
||||
|
||||
prefix := uniqueTestICCIDPrefix()
|
||||
cards := []*model.IotCard{
|
||||
{ICCID: prefix + "001", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shop.ID},
|
||||
{ICCID: prefix + "002", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shop.ID},
|
||||
{ICCID: prefix + "003", CardType: "data_card", CarrierID: 1, Status: 1, ShopID: nil},
|
||||
}
|
||||
require.NoError(t, iotCardStore.CreateBatch(ctx, cards))
|
||||
|
||||
t.Run("成功设置系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "001", prefix + "002"},
|
||||
SeriesAllocationID: allocation.ID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, resp.SuccessCount)
|
||||
assert.Equal(t, 0, resp.FailCount)
|
||||
|
||||
var updatedCards []*model.IotCard
|
||||
require.NoError(t, tx.Where("iccid IN ?", req.ICCIDs).Find(&updatedCards).Error)
|
||||
for _, card := range updatedCards {
|
||||
require.NotNil(t, card.SeriesAllocationID)
|
||||
assert.Equal(t, allocation.ID, *card.SeriesAllocationID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("卡不属于套餐系列分配的店铺", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "003"},
|
||||
SeriesAllocationID: allocation.ID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "卡不属于套餐系列分配的店铺", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("卡不存在", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{"NOTEXIST001"},
|
||||
SeriesAllocationID: allocation.ID,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "卡不存在", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("清除系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "001"},
|
||||
SeriesAllocationID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.SuccessCount)
|
||||
|
||||
var updatedCard model.IotCard
|
||||
require.NoError(t, tx.Where("iccid = ?", prefix+"001").First(&updatedCard).Error)
|
||||
assert.Nil(t, updatedCard.SeriesAllocationID)
|
||||
})
|
||||
|
||||
t.Run("代理用户只能操作自己店铺的卡", func(t *testing.T) {
|
||||
otherShopID := uint(99999)
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "002"},
|
||||
SeriesAllocationID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, &otherShopID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, resp.SuccessCount)
|
||||
assert.Equal(t, 1, resp.FailCount)
|
||||
assert.Equal(t, "无权操作此卡", resp.FailedItems[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("套餐系列分配不存在", func(t *testing.T) {
|
||||
req := &dto.BatchSetCardSeriesBindngRequest{
|
||||
ICCIDs: []string{prefix + "002"},
|
||||
SeriesAllocationID: 99999,
|
||||
}
|
||||
|
||||
_, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user