feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -2,6 +2,11 @@ package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -10,11 +15,13 @@ import (
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
deviceStore *postgres.DeviceStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
@@ -28,6 +35,7 @@ type Service struct {
|
||||
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
rds *redis.Client,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
@@ -40,6 +48,7 @@ func New(
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
redis: rds,
|
||||
deviceStore: deviceStore,
|
||||
deviceSimBindingStore: deviceSimBindingStore,
|
||||
iotCardStore: iotCardStore,
|
||||
@@ -69,8 +78,8 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li
|
||||
}
|
||||
|
||||
filters := make(map[string]interface{})
|
||||
if req.DeviceNo != "" {
|
||||
filters["device_no"] = req.DeviceNo
|
||||
if req.VirtualNo != "" {
|
||||
filters["virtual_no"] = req.VirtualNo
|
||||
}
|
||||
if req.DeviceName != "" {
|
||||
filters["device_name"] = req.DeviceName
|
||||
@@ -251,9 +260,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
|
||||
// 平台只能分配 shop_id=NULL 的设备
|
||||
if isPlatform && device.ShopID != nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "平台只能分配库存设备",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "平台只能分配库存设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -261,9 +270,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
|
||||
// 代理只能分配自己店铺的设备
|
||||
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备不属于当前店铺",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "设备不属于当前店铺",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -342,9 +351,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
|
||||
// 验证设备所属店铺是否为直属下级
|
||||
if device.ShopID == nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "设备已在平台库存中",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "设备已在平台库存中",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -353,9 +362,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
|
||||
if !isPlatform {
|
||||
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
|
||||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "只能回收直属下级店铺的设备",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "只能回收直属下级店铺的设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -528,7 +537,7 @@ func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
|
||||
func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string, seriesMap map[uint]string, bindingCounts map[uint]int64) *dto.DeviceResponse {
|
||||
resp := &dto.DeviceResponse{
|
||||
ID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
VirtualNo: device.VirtualNo,
|
||||
IMEI: device.IMEI,
|
||||
SN: device.SN,
|
||||
DeviceName: device.DeviceName,
|
||||
@@ -592,7 +601,7 @@ func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceI
|
||||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.DeviceNo,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
ToOwnerType: constants.OwnerTypeShop,
|
||||
ToOwnerID: toShopID,
|
||||
OperatorID: operatorID,
|
||||
@@ -630,7 +639,7 @@ func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs [
|
||||
AllocationType: constants.AssetAllocationTypeRecall,
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: device.ID,
|
||||
AssetIdentifier: device.DeviceNo,
|
||||
AssetIdentifier: device.VirtualNo,
|
||||
OperatorID: operatorID,
|
||||
Remark: remark,
|
||||
}
|
||||
@@ -699,9 +708,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
device, exists := deviceMap[deviceID]
|
||||
if !exists {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: "",
|
||||
Reason: "设备不存在",
|
||||
DeviceID: deviceID,
|
||||
VirtualNo: "",
|
||||
Reason: "设备不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -721,9 +730,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
}
|
||||
if !hasSeriesAllocation {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: deviceID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
DeviceID: deviceID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "您没有权限分配该套餐系列",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -733,9 +742,9 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe
|
||||
if operatorShopID != nil {
|
||||
if device.ShopID == nil || *device.ShopID != *operatorShopID {
|
||||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: device.ID,
|
||||
DeviceNo: device.DeviceNo,
|
||||
Reason: "无权操作此设备",
|
||||
DeviceID: device.ID,
|
||||
VirtualNo: device.VirtualNo,
|
||||
Reason: "无权操作此设备",
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -765,10 +774,207 @@ func (s *Service) buildDeviceNotFoundFailedItems(deviceIDs []uint) []dto.DeviceS
|
||||
items := make([]dto.DeviceSeriesBindngFailedItem, len(deviceIDs))
|
||||
for i, id := range deviceIDs {
|
||||
items[i] = dto.DeviceSeriesBindngFailedItem{
|
||||
DeviceID: id,
|
||||
DeviceNo: "",
|
||||
Reason: "设备不存在",
|
||||
DeviceID: id,
|
||||
VirtualNo: "",
|
||||
Reason: "设备不存在",
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// StopDevice 设备停机
|
||||
// POST /api/admin/assets/device/:device_id/stop
|
||||
// 查找设备绑定的所有已实名且已开机的卡,逐一调网关停机
|
||||
func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSuspendResponse, error) {
|
||||
log := logger.GetAppLogger()
|
||||
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
_ = device
|
||||
|
||||
// 复机保护期内禁止停机
|
||||
if s.redis != nil {
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result()
|
||||
if exists > 0 {
|
||||
return nil, errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
|
||||
}
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
|
||||
if len(bindings) == 0 {
|
||||
return &dto.DeviceSuspendResponse{}, nil
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
}
|
||||
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||||
}
|
||||
|
||||
var successCount, skipCount int
|
||||
var failedItems []dto.DeviceSuspendFailItem
|
||||
|
||||
for _, card := range cards {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOnline {
|
||||
skipCount++
|
||||
continue
|
||||
}
|
||||
|
||||
if s.gatewayClient != nil {
|
||||
if gwErr := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
|
||||
log.Error("设备停机-调网关停机失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(gwErr))
|
||||
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
|
||||
ICCID: card.ICCID,
|
||||
Reason: "网关停机失败",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", card.ID).
|
||||
Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOffline,
|
||||
"stopped_at": now,
|
||||
"stop_reason": constants.StopReasonManual,
|
||||
}).Error; dbErr != nil {
|
||||
log.Error("设备停机-更新卡状态失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(dbErr))
|
||||
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
|
||||
ICCID: card.ICCID,
|
||||
Reason: "更新卡状态失败",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 成功停机至少一张卡后设置保护期
|
||||
if successCount > 0 && s.redis != nil {
|
||||
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration)
|
||||
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
|
||||
}
|
||||
|
||||
return &dto.DeviceSuspendResponse{
|
||||
SuccessCount: successCount,
|
||||
FailCount: len(failedItems),
|
||||
SkipCount: skipCount,
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartDevice 设备复机
|
||||
// POST /api/admin/assets/device/:device_id/start
|
||||
// 查找设备绑定的所有已实名且已停机的卡,逐一调网关复机
|
||||
func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
|
||||
log := logger.GetAppLogger()
|
||||
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
|
||||
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeNotFound, "设备不存在")
|
||||
}
|
||||
_ = device
|
||||
|
||||
// 停机保护期内禁止复机
|
||||
if s.redis != nil {
|
||||
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result()
|
||||
if exists > 0 {
|
||||
return errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
|
||||
}
|
||||
}
|
||||
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
|
||||
}
|
||||
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cardIDs := make([]uint, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
}
|
||||
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败")
|
||||
}
|
||||
|
||||
var successCount int
|
||||
var lastErr error
|
||||
|
||||
for _, card := range cards {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOffline {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.gatewayClient != nil {
|
||||
if gwErr := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil {
|
||||
log.Error("设备复机-调网关复机失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.String("iccid", card.ICCID),
|
||||
zap.Error(gwErr))
|
||||
lastErr = gwErr
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if dbErr := s.db.WithContext(ctx).Model(&model.IotCard{}).
|
||||
Where("id = ?", card.ID).
|
||||
Updates(map[string]any{
|
||||
"network_status": constants.NetworkStatusOnline,
|
||||
"resumed_at": now,
|
||||
"stop_reason": "",
|
||||
}).Error; dbErr != nil {
|
||||
log.Error("设备复机-更新卡状态失败",
|
||||
zap.Uint("card_id", card.ID),
|
||||
zap.Error(dbErr))
|
||||
lastErr = dbErr
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 成功复机至少一张卡后设置保护期
|
||||
if successCount > 0 && s.redis != nil {
|
||||
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration)
|
||||
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
|
||||
}
|
||||
|
||||
// 全部失败时返回 error
|
||||
if successCount == 0 && lastErr != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user