feat: 实现企业设备授权功能并归档 OpenSpec 变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m39s

- 新增企业设备授权模块(Model、DTO、Service、Handler、Store)
- 实现设备授权的创建、查询、更新、删除等完整业务逻辑
- 添加企业卡授权与设备授权的关联关系
- 新增 2 个数据库迁移脚本
- 同步 OpenSpec delta specs 到 main specs
- 归档 add-enterprise-device-authorization 变更
- 更新 API 文档和路由配置
- 新增完整的集成测试和单元测试覆盖
This commit is contained in:
2026-01-29 13:18:49 +08:00
parent e87513541b
commit b02175271a
118 changed files with 14306 additions and 472 deletions

View File

@@ -0,0 +1,161 @@
package enterprise_card
import (
"context"
"testing"
"time"
"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/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/tests/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestAuthorizationService_BatchAuthorize_BoundCardRejected(t *testing.T) {
tx := testutils.NewTestTransaction(t)
rdb := testutils.GetTestRedis(t)
testutils.CleanTestRedisKeys(t, rdb)
logger, _ := zap.NewDevelopment()
enterpriseStore := postgres.NewEnterpriseStore(tx, rdb)
iotCardStore := postgres.NewIotCardStore(tx, rdb)
authStore := postgres.NewEnterpriseCardAuthorizationStore(tx, rdb)
service := NewAuthorizationService(enterpriseStore, iotCardStore, authStore, logger)
shop := &model.Shop{
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
ShopName: "测试店铺",
ShopCode: "TEST_SHOP_001",
Level: 1,
Status: 1,
}
require.NoError(t, tx.Create(shop).Error)
enterprise := &model.Enterprise{
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
EnterpriseName: "测试企业",
EnterpriseCode: "TEST_ENT_001",
OwnerShopID: &shop.ID,
Status: 1,
}
require.NoError(t, tx.Create(enterprise).Error)
carrier := &model.Carrier{CarrierName: "测试运营商", CarrierType: "CMCC", Status: 1}
require.NoError(t, tx.Create(carrier).Error)
unboundCard := &model.IotCard{
ICCID: "UNBOUND_CARD_001",
CardType: "normal",
CarrierID: carrier.ID,
Status: 2,
ShopID: &shop.ID,
}
require.NoError(t, tx.Create(unboundCard).Error)
boundCard := &model.IotCard{
ICCID: "BOUND_CARD_001",
CardType: "normal",
CarrierID: carrier.ID,
Status: 2,
ShopID: &shop.ID,
}
require.NoError(t, tx.Create(boundCard).Error)
device := &model.Device{
DeviceNo: "TEST_DEVICE_001",
DeviceName: "测试设备",
Status: 2,
ShopID: &shop.ID,
}
require.NoError(t, tx.Create(device).Error)
now := time.Now()
binding := &model.DeviceSimBinding{
DeviceID: device.ID,
IotCardID: boundCard.ID,
SlotPosition: 1,
BindStatus: 1,
BindTime: &now,
}
require.NoError(t, tx.Create(binding).Error)
ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{
UserID: 1,
UserType: constants.UserTypePlatform,
ShopID: shop.ID,
})
t.Run("绑定设备的卡被拒绝授权", func(t *testing.T) {
req := BatchAuthorizeRequest{
EnterpriseID: enterprise.ID,
CardIDs: []uint{boundCard.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypePlatform,
Remark: "测试授权",
}
err := service.BatchAuthorize(ctx, req)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok, "应返回 AppError 类型")
assert.Equal(t, errors.CodeCannotAuthorizeBoundCard, appErr.Code)
assert.Contains(t, appErr.Message, "已绑定设备")
})
t.Run("未绑定设备的卡可以授权", func(t *testing.T) {
req := BatchAuthorizeRequest{
EnterpriseID: enterprise.ID,
CardIDs: []uint{unboundCard.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypePlatform,
Remark: "测试授权",
}
err := service.BatchAuthorize(ctx, req)
require.NoError(t, err)
auths, err := authStore.ListByCards(ctx, []uint{unboundCard.ID}, false)
require.NoError(t, err)
assert.Len(t, auths, 1)
assert.Equal(t, enterprise.ID, auths[0].EnterpriseID)
})
t.Run("混合卡列表中有绑定卡时整体拒绝", func(t *testing.T) {
unboundCard2 := &model.IotCard{
ICCID: "UNBOUND_CARD_002",
CardType: "normal",
CarrierID: carrier.ID,
Status: 2,
ShopID: &shop.ID,
}
require.NoError(t, tx.Create(unboundCard2).Error)
req := BatchAuthorizeRequest{
EnterpriseID: enterprise.ID,
CardIDs: []uint{unboundCard2.ID, boundCard.ID},
AuthorizerID: 1,
AuthorizerType: constants.UserTypePlatform,
Remark: "测试授权",
}
err := service.BatchAuthorize(ctx, req)
require.Error(t, err)
appErr, ok := err.(*errors.AppError)
require.True(t, ok, "应返回 AppError 类型")
assert.Equal(t, errors.CodeCannotAuthorizeBoundCard, appErr.Code)
auths, err := authStore.ListByCards(ctx, []uint{unboundCard2.ID}, false)
require.NoError(t, err)
assert.Len(t, auths, 0, "混合列表中的未绑定卡也不应被授权")
})
}

View File

@@ -65,34 +65,16 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r
s.db.WithContext(ctx).Where("iot_card_id IN ? AND bind_status = 1", cardIDs).Find(&bindings)
}
cardToDevice := make(map[uint]uint)
deviceCards := make(map[uint][]uint)
cardToDevice := make(map[uint]bool)
for _, binding := range bindings {
cardToDevice[binding.IotCardID] = binding.DeviceID
deviceCards[binding.DeviceID] = append(deviceCards[binding.DeviceID], binding.IotCardID)
}
deviceIDs := make([]uint, 0, len(deviceCards))
for deviceID := range deviceCards {
deviceIDs = append(deviceIDs, deviceID)
}
var devices []model.Device
deviceMap := make(map[uint]*model.Device)
if len(deviceIDs) > 0 {
s.db.WithContext(ctx).Where("id IN ?", deviceIDs).Find(&devices)
for i := range devices {
deviceMap[devices[i].ID] = &devices[i]
}
cardToDevice[binding.IotCardID] = true
}
resp := &dto.AllocateCardsPreviewResp{
StandaloneCards: make([]dto.StandaloneCard, 0),
DeviceBundles: make([]dto.DeviceBundle, 0),
FailedItems: make([]dto.FailedItem, 0),
}
processedDevices := make(map[uint]bool)
for _, iccid := range req.ICCIDs {
card, exists := cardMap[iccid]
if !exists {
@@ -103,67 +85,28 @@ func (s *Service) AllocateCardsPreview(ctx context.Context, enterpriseID uint, r
continue
}
deviceID, hasDevice := cardToDevice[card.ID]
if !hasDevice {
resp.StandaloneCards = append(resp.StandaloneCards, dto.StandaloneCard{
ICCID: card.ICCID,
IotCardID: card.ID,
MSISDN: card.MSISDN,
CarrierID: card.CarrierID,
StatusName: getCardStatusName(card.Status),
if cardToDevice[card.ID] {
resp.FailedItems = append(resp.FailedItems, dto.FailedItem{
ICCID: iccid,
Reason: "该卡已绑定设备,请使用设备授权功能",
})
} else {
if processedDevices[deviceID] {
continue
}
processedDevices[deviceID] = true
device := deviceMap[deviceID]
if device == nil {
continue
}
bundleCardIDs := deviceCards[deviceID]
bundle := dto.DeviceBundle{
DeviceID: deviceID,
DeviceNo: device.DeviceNo,
BundleCards: make([]dto.DeviceBundleCard, 0),
}
for _, bundleCardID := range bundleCardIDs {
bundleCard := cardIDMap[bundleCardID]
if bundleCard == nil {
continue
}
if bundleCard.ID == card.ID {
bundle.TriggerCard = dto.DeviceBundleCard{
ICCID: bundleCard.ICCID,
IotCardID: bundleCard.ID,
MSISDN: bundleCard.MSISDN,
}
} else {
bundle.BundleCards = append(bundle.BundleCards, dto.DeviceBundleCard{
ICCID: bundleCard.ICCID,
IotCardID: bundleCard.ID,
MSISDN: bundleCard.MSISDN,
})
}
}
resp.DeviceBundles = append(resp.DeviceBundles, bundle)
continue
}
}
deviceCardCount := 0
for _, bundle := range resp.DeviceBundles {
deviceCardCount += 1 + len(bundle.BundleCards)
resp.StandaloneCards = append(resp.StandaloneCards, dto.StandaloneCard{
ICCID: card.ICCID,
IotCardID: card.ID,
MSISDN: card.MSISDN,
CarrierID: card.CarrierID,
StatusName: getCardStatusName(card.Status),
})
}
resp.Summary = dto.AllocatePreviewSummary{
StandaloneCardCount: len(resp.StandaloneCards),
DeviceCount: len(resp.DeviceBundles),
DeviceCardCount: deviceCardCount,
TotalCardCount: len(resp.StandaloneCards) + deviceCardCount,
DeviceCount: 0,
DeviceCardCount: 0,
TotalCardCount: len(resp.StandaloneCards),
FailedCount: len(resp.FailedItems),
}
@@ -186,36 +129,15 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
return nil, err
}
if len(preview.DeviceBundles) > 0 && !req.ConfirmDeviceBundles {
return nil, errors.New(errors.CodeInvalidParam, "存在设备包,请确认整体授权设备下所有卡")
}
resp := &dto.AllocateCardsResp{
FailedItems: preview.FailedItems,
FailCount: len(preview.FailedItems),
AllocatedDevices: make([]dto.AllocatedDevice, 0),
FailedItems: preview.FailedItems,
FailCount: len(preview.FailedItems),
}
cardIDsToAllocate := make([]uint, 0)
for _, card := range preview.StandaloneCards {
cardIDsToAllocate = append(cardIDsToAllocate, card.IotCardID)
}
for _, bundle := range preview.DeviceBundles {
cardIDsToAllocate = append(cardIDsToAllocate, bundle.TriggerCard.IotCardID)
for _, card := range bundle.BundleCards {
cardIDsToAllocate = append(cardIDsToAllocate, card.IotCardID)
}
iccids := []string{bundle.TriggerCard.ICCID}
for _, card := range bundle.BundleCards {
iccids = append(iccids, card.ICCID)
}
resp.AllocatedDevices = append(resp.AllocatedDevices, dto.AllocatedDevice{
DeviceID: bundle.DeviceID,
DeviceNo: bundle.DeviceNo,
CardCount: 1 + len(bundle.BundleCards),
ICCIDs: iccids,
})
}
existingAuths, err := s.enterpriseCardAuthStore.GetActiveAuthsByCardIDs(ctx, enterpriseID, cardIDsToAllocate)
if err != nil {
@@ -235,6 +157,7 @@ func (s *Service) AllocateCards(ctx context.Context, enterpriseID uint, req *dto
AuthorizedBy: currentUserID,
AuthorizedAt: now,
AuthorizerType: userType,
Remark: req.Remark,
})
}