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:
@@ -19,6 +19,7 @@ type Service struct {
|
||||
iotCardStore *postgres.IotCardStore
|
||||
shopStore *postgres.ShopStore
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||
}
|
||||
|
||||
func New(
|
||||
@@ -28,6 +29,7 @@ func New(
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
|
||||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -36,6 +38,7 @@ func New(
|
||||
iotCardStore: iotCardStore,
|
||||
shopStore: shopStore,
|
||||
assetAllocationRecordStore: assetAllocationRecordStore,
|
||||
seriesAllocationStore: seriesAllocationStore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +86,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
|
||||
if req.CreatedAtEnd != nil {
|
||||
filters["created_at_end"] = *req.CreatedAtEnd
|
||||
}
|
||||
if req.SeriesAllocationID != nil {
|
||||
filters["series_allocation_id"] = *req.SeriesAllocationID
|
||||
}
|
||||
|
||||
devices, total, err := s.deviceStore.List(ctx, opts, filters)
|
||||
if err != nil {
|
||||
@@ -448,21 +454,24 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
|
||||
|
||||
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
|
||||
resp := &dto.DeviceResponse{
|
||||
ID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
DeviceType: device.DeviceType,
|
||||
MaxSimSlots: device.MaxSimSlots,
|
||||
Manufacturer: device.Manufacturer,
|
||||
BatchNo: device.BatchNo,
|
||||
ShopID: device.ShopID,
|
||||
Status: device.Status,
|
||||
StatusName: s.getDeviceStatusName(device.Status),
|
||||
BoundCardCount: int(bindingCounts[device.ID]),
|
||||
ActivatedAt: device.ActivatedAt,
|
||||
CreatedAt: device.CreatedAt,
|
||||
UpdatedAt: device.UpdatedAt,
|
||||
ID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
DeviceName: device.DeviceName,
|
||||
DeviceModel: device.DeviceModel,
|
||||
DeviceType: device.DeviceType,
|
||||
MaxSimSlots: device.MaxSimSlots,
|
||||
Manufacturer: device.Manufacturer,
|
||||
BatchNo: device.BatchNo,
|
||||
ShopID: device.ShopID,
|
||||
Status: device.Status,
|
||||
StatusName: s.getDeviceStatusName(device.Status),
|
||||
BoundCardCount: int(bindingCounts[device.ID]),
|
||||
SeriesAllocationID: device.SeriesAllocationID,
|
||||
FirstCommissionPaid: device.FirstCommissionPaid,
|
||||
AccumulatedRecharge: device.AccumulatedRecharge,
|
||||
ActivatedAt: device.ActivatedAt,
|
||||
CreatedAt: device.CreatedAt,
|
||||
UpdatedAt: device.UpdatedAt,
|
||||
}
|
||||
|
||||
if device.ShopID != nil && *device.ShopID > 0 {
|
||||
@@ -568,3 +577,105 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
// BatchSetSeriesBinding 批量设置设备的套餐系列绑定
|
||||
func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetDeviceSeriesBindngResponse, error) {
|
||||
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
return &dto.BatchSetDeviceSeriesBindngResponse{
|
||||
SuccessCount: 0,
|
||||
FailCount: len(req.DeviceIDs),
|
||||
FailedItems: s.buildDeviceNotFoundFailedItems(req.DeviceIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
deviceMap := make(map[uint]*model.Device)
|
||||
for _, device := range devices {
|
||||
deviceMap[device.ID] = device
|
||||
}
|
||||
|
||||
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 successDeviceIDs []uint
|
||||
var failedItems []dto.DeviceSeriesBindngFailedItem
|
||||
|
||||
for _, deviceID := range req.DeviceIDs {
|
||||
device, exists := deviceMap[deviceID]
|
||||
if !exists {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: "",
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if req.SeriesAllocationID > 0 {
|
||||
if device.ShopID == nil || *device.ShopID != seriesAllocation.ShopID {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备不属于套餐系列分配的店铺",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if operatorShopID != nil {
|
||||
if device.ShopID == nil || *device.ShopID != *operatorShopID {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "无权操作此设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
successDeviceIDs = append(successDeviceIDs, device.ID)
|
||||
}
|
||||
|
||||
if len(successDeviceIDs) > 0 {
|
||||
var seriesAllocationIDPtr *uint
|
||||
if req.SeriesAllocationID > 0 {
|
||||
seriesAllocationIDPtr = &req.SeriesAllocationID
|
||||
}
|
||||
if err := s.deviceStore.BatchUpdateSeriesAllocation(ctx, successDeviceIDs, seriesAllocationIDPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.BatchSetDeviceSeriesBindngResponse{
|
||||
SuccessCount: len(successDeviceIDs),
|
||||
FailCount: len(failedItems),
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceSeriesBindngFailedItem {
|
||||
items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs))
|
||||
for i, id := range deviceIDs {
|
||||
items[i] = dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: id,
|
||||
DeviceNo: "",
|
||||
Reason: "设备不存在",
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
149
internal/service/device/service_test.go
Normal file
149
internal/service/device/service_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package device
|
||||
|
||||
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 uniqueTestDeviceNoPrefix() string {
|
||||
return fmt.Sprintf("D%d", time.Now().UnixNano()%1000000000)
|
||||
}
|
||||
|
||||
func TestDeviceService_BatchSetSeriesBinding(t *testing.T) {
|
||||
tx := testutils.NewTestTransaction(t)
|
||||
rdb := testutils.GetTestRedis(t)
|
||||
testutils.CleanTestRedisKeys(t, rdb)
|
||||
|
||||
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(tx, rdb)
|
||||
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||
shopStore := postgres.NewShopStore(tx, rdb)
|
||||
assetAllocationRecordStore := postgres.NewAssetAllocationRecordStore(tx, rdb)
|
||||
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||
|
||||
svc := New(tx, deviceStore, deviceSimBindingStore, 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 := uniqueTestDeviceNoPrefix()
|
||||
devices := []*model.Device{
|
||||
{DeviceNo: prefix + "001", DeviceName: "测试设备1", Status: 1, ShopID: &shop.ID},
|
||||
{DeviceNo: prefix + "002", DeviceName: "测试设备2", Status: 1, ShopID: &shop.ID},
|
||||
{DeviceNo: prefix + "003", DeviceName: "测试设备3", Status: 1, ShopID: nil},
|
||||
}
|
||||
require.NoError(t, deviceStore.CreateBatch(ctx, devices))
|
||||
|
||||
t.Run("成功设置系列绑定", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[0].ID, devices[1].ID},
|
||||
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 updatedDevices []*model.Device
|
||||
require.NoError(t, tx.Where("id IN ?", req.DeviceIDs).Find(&updatedDevices).Error)
|
||||
for _, device := range updatedDevices {
|
||||
require.NotNil(t, device.SeriesAllocationID)
|
||||
assert.Equal(t, allocation.ID, *device.SeriesAllocationID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("设备不属于套餐系列分配的店铺", func(t *testing.T) {
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[2].ID},
|
||||
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.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{99999},
|
||||
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.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[0].ID},
|
||||
SeriesAllocationID: 0,
|
||||
}
|
||||
|
||||
resp, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, resp.SuccessCount)
|
||||
|
||||
var updatedDevice model.Device
|
||||
require.NoError(t, tx.First(&updatedDevice, devices[0].ID).Error)
|
||||
assert.Nil(t, updatedDevice.SeriesAllocationID)
|
||||
})
|
||||
|
||||
t.Run("代理用户只能操作自己店铺的设备", func(t *testing.T) {
|
||||
otherShopID := uint(99999)
|
||||
req := &dto.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[1].ID},
|
||||
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.BatchSetDeviceSeriesBindngRequest{
|
||||
DeviceIDs: []uint{devices[1].ID},
|
||||
SeriesAllocationID: 99999,
|
||||
}
|
||||
|
||||
_, err := svc.BatchSetSeriesBinding(ctx, req, nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user