All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m13s
- 单卡停复机:网关错误从 CodeInternalError(2001) 改为 CodeGatewayError(1110),前端可看到具体失败原因 - 单卡停复机:DB 更新裸返 GORM error 改为 CodeDatabaseError(2002) 包装 - 设备复机:全部卡失败时错误码从 CodeInternalError 改为 CodeGatewayError
981 lines
28 KiB
Go
981 lines
28 KiB
Go
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"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||
"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/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
|
||
shopStore *postgres.ShopStore
|
||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore
|
||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||
packageSeriesStore *postgres.PackageSeriesStore
|
||
gatewayClient *gateway.Client
|
||
}
|
||
|
||
func New(
|
||
db *gorm.DB,
|
||
rds *redis.Client,
|
||
deviceStore *postgres.DeviceStore,
|
||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||
iotCardStore *postgres.IotCardStore,
|
||
shopStore *postgres.ShopStore,
|
||
assetAllocationRecordStore *postgres.AssetAllocationRecordStore,
|
||
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||
packageSeriesStore *postgres.PackageSeriesStore,
|
||
gatewayClient *gateway.Client,
|
||
) *Service {
|
||
return &Service{
|
||
db: db,
|
||
redis: rds,
|
||
deviceStore: deviceStore,
|
||
deviceSimBindingStore: deviceSimBindingStore,
|
||
iotCardStore: iotCardStore,
|
||
shopStore: shopStore,
|
||
assetAllocationRecordStore: assetAllocationRecordStore,
|
||
shopPackageAllocationStore: shopPackageAllocationStore,
|
||
shopSeriesAllocationStore: shopSeriesAllocationStore,
|
||
packageSeriesStore: packageSeriesStore,
|
||
gatewayClient: gatewayClient,
|
||
}
|
||
}
|
||
|
||
// List 获取设备列表
|
||
func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.ListDeviceResponse, error) {
|
||
page := req.Page
|
||
pageSize := req.PageSize
|
||
if page == 0 {
|
||
page = 1
|
||
}
|
||
if pageSize == 0 {
|
||
pageSize = constants.DefaultPageSize
|
||
}
|
||
|
||
opts := &store.QueryOptions{
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
}
|
||
|
||
filters := make(map[string]interface{})
|
||
if req.VirtualNo != "" {
|
||
filters["virtual_no"] = req.VirtualNo
|
||
}
|
||
if req.DeviceName != "" {
|
||
filters["device_name"] = req.DeviceName
|
||
}
|
||
if req.Status != nil {
|
||
filters["status"] = *req.Status
|
||
}
|
||
if req.ShopID != nil {
|
||
filters["shop_id"] = req.ShopID
|
||
}
|
||
if req.BatchNo != "" {
|
||
filters["batch_no"] = req.BatchNo
|
||
}
|
||
if req.DeviceType != "" {
|
||
filters["device_type"] = req.DeviceType
|
||
}
|
||
if req.Manufacturer != "" {
|
||
filters["manufacturer"] = req.Manufacturer
|
||
}
|
||
if req.CreatedAtStart != nil {
|
||
filters["created_at_start"] = *req.CreatedAtStart
|
||
}
|
||
if req.CreatedAtEnd != nil {
|
||
filters["created_at_end"] = *req.CreatedAtEnd
|
||
}
|
||
if req.SeriesID != nil {
|
||
filters["series_id"] = *req.SeriesID
|
||
}
|
||
|
||
devices, total, err := s.deviceStore.List(ctx, opts, filters)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
shopMap := s.loadShopData(ctx, devices)
|
||
seriesMap := s.loadSeriesNames(ctx, devices)
|
||
bindingCounts, err := s.getBindingCounts(ctx, s.extractDeviceIDs(devices))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
list := make([]*dto.DeviceResponse, 0, len(devices))
|
||
for _, device := range devices {
|
||
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts)
|
||
list = append(list, item)
|
||
}
|
||
|
||
totalPages := int(total) / pageSize
|
||
if int(total)%pageSize > 0 {
|
||
totalPages++
|
||
}
|
||
|
||
return &dto.ListDeviceResponse{
|
||
List: list,
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
TotalPages: totalPages,
|
||
}, nil
|
||
}
|
||
|
||
// Get 获取设备详情
|
||
func (s *Service) Get(ctx context.Context, id uint) (*dto.DeviceResponse, error) {
|
||
device, err := s.deviceStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
||
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
|
||
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
|
||
}
|
||
|
||
// GetByDeviceNo 通过设备号获取设备详情
|
||
func (s *Service) GetByDeviceNo(ctx context.Context, deviceNo string) (*dto.DeviceResponse, error) {
|
||
device, err := s.deviceStore.GetByDeviceNo(ctx, deviceNo)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
||
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
|
||
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
|
||
}
|
||
|
||
// GetByIdentifier 通过任意标识符获取设备详情
|
||
// 支持 device_no(虚拟号)、imei、sn 三个字段的自动匹配
|
||
func (s *Service) GetByIdentifier(ctx context.Context, identifier string) (*dto.DeviceResponse, error) {
|
||
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
shopMap := s.loadShopData(ctx, []*model.Device{device})
|
||
seriesMap := s.loadSeriesNames(ctx, []*model.Device{device})
|
||
bindingCounts, err := s.getBindingCounts(ctx, []uint{device.ID})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts), nil
|
||
}
|
||
|
||
// GetDeviceByIdentifier 通过任意标识符获取设备模型(内部使用,不转为 DTO)
|
||
// 用于 Handler 层获取设备后提取 IMEI 调用 Gateway API
|
||
func (s *Service) GetDeviceByIdentifier(ctx context.Context, identifier string) (*model.Device, error) {
|
||
device, err := s.deviceStore.GetByIdentifier(ctx, identifier)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
||
}
|
||
return nil, err
|
||
}
|
||
return device, nil
|
||
}
|
||
|
||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||
device, err := s.deviceStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "设备不存在")
|
||
}
|
||
return err
|
||
}
|
||
|
||
if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil {
|
||
return err
|
||
}
|
||
|
||
return s.deviceStore.Delete(ctx, id)
|
||
}
|
||
|
||
// AllocateDevices 批量分配设备
|
||
func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) {
|
||
// 验证目标店铺是否为直属下级
|
||
if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(devices) == 0 {
|
||
return &dto.AllocateDevicesResponse{
|
||
SuccessCount: 0,
|
||
FailCount: 0,
|
||
FailedItems: []dto.AllocationDeviceFailedItem{},
|
||
}, nil
|
||
}
|
||
|
||
var deviceIDs []uint
|
||
var failedItems []dto.AllocationDeviceFailedItem
|
||
|
||
isPlatform := operatorShopID == nil
|
||
|
||
for _, device := range devices {
|
||
// 平台只能分配 shop_id=NULL 的设备
|
||
if isPlatform && device.ShopID != nil {
|
||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: "平台只能分配库存设备",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 代理只能分配自己店铺的设备
|
||
if !isPlatform && (device.ShopID == nil || *device.ShopID != *operatorShopID) {
|
||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: "设备不属于当前店铺",
|
||
})
|
||
continue
|
||
}
|
||
|
||
deviceIDs = append(deviceIDs, device.ID)
|
||
}
|
||
|
||
if len(deviceIDs) == 0 {
|
||
return &dto.AllocateDevicesResponse{
|
||
SuccessCount: 0,
|
||
FailCount: len(failedItems),
|
||
FailedItems: failedItems,
|
||
}, nil
|
||
}
|
||
|
||
newStatus := constants.DeviceStatusDistributed
|
||
targetShopID := req.TargetShopID
|
||
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
txDeviceStore := postgres.NewDeviceStore(tx, nil)
|
||
txCardStore := postgres.NewIotCardStore(tx, nil)
|
||
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
|
||
|
||
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil {
|
||
return err
|
||
}
|
||
|
||
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(boundCardIDs) > 0 {
|
||
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
|
||
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
|
||
return txRecordStore.BatchCreate(ctx, records)
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &dto.AllocateDevicesResponse{
|
||
SuccessCount: len(deviceIDs),
|
||
FailCount: len(failedItems),
|
||
FailedItems: failedItems,
|
||
}, nil
|
||
}
|
||
|
||
// RecallDevices 批量回收设备
|
||
func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) {
|
||
devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if len(devices) == 0 {
|
||
return &dto.RecallDevicesResponse{
|
||
SuccessCount: 0,
|
||
FailCount: 0,
|
||
FailedItems: []dto.AllocationDeviceFailedItem{},
|
||
}, nil
|
||
}
|
||
|
||
var deviceIDs []uint
|
||
var failedItems []dto.AllocationDeviceFailedItem
|
||
|
||
isPlatform := operatorShopID == nil
|
||
|
||
for _, device := range devices {
|
||
// 验证设备所属店铺是否为直属下级
|
||
if device.ShopID == nil {
|
||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: "设备已在平台库存中",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 验证直属下级关系(平台用户可以回收所有店铺的设备)
|
||
if !isPlatform {
|
||
if err := s.validateDirectSubordinate(ctx, operatorShopID, *device.ShopID); err != nil {
|
||
failedItems = append(failedItems, dto.AllocationDeviceFailedItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: "只能回收直属下级店铺的设备",
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
|
||
deviceIDs = append(deviceIDs, device.ID)
|
||
}
|
||
|
||
if len(deviceIDs) == 0 {
|
||
return &dto.RecallDevicesResponse{
|
||
SuccessCount: 0,
|
||
FailCount: len(failedItems),
|
||
FailedItems: failedItems,
|
||
}, nil
|
||
}
|
||
|
||
var newShopID *uint
|
||
var newStatus int
|
||
if isPlatform {
|
||
newShopID = nil
|
||
newStatus = constants.DeviceStatusInStock
|
||
} else {
|
||
newShopID = operatorShopID
|
||
newStatus = constants.DeviceStatusDistributed
|
||
}
|
||
|
||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||
txDeviceStore := postgres.NewDeviceStore(tx, nil)
|
||
txCardStore := postgres.NewIotCardStore(tx, nil)
|
||
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
|
||
|
||
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil {
|
||
return err
|
||
}
|
||
|
||
boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(boundCardIDs) > 0 {
|
||
var cardStatus int
|
||
if isPlatform {
|
||
cardStatus = constants.IotCardStatusInStock
|
||
} else {
|
||
cardStatus = constants.IotCardStatusDistributed
|
||
}
|
||
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
|
||
records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark)
|
||
return txRecordStore.BatchCreate(ctx, records)
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &dto.RecallDevicesResponse{
|
||
SuccessCount: len(deviceIDs),
|
||
FailCount: len(failedItems),
|
||
FailedItems: failedItems,
|
||
}, nil
|
||
}
|
||
|
||
// 辅助方法
|
||
|
||
func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID *uint, targetShopID uint) error {
|
||
if operatorShopID != nil && *operatorShopID == targetShopID {
|
||
return errors.ErrCannotAllocateToSelf
|
||
}
|
||
|
||
targetShop, err := s.shopStore.GetByID(ctx, targetShopID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeShopNotFound)
|
||
}
|
||
return err
|
||
}
|
||
|
||
if operatorShopID == nil {
|
||
if targetShop.ParentID != nil {
|
||
return errors.ErrNotDirectSubordinate
|
||
}
|
||
} else {
|
||
if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID {
|
||
return errors.ErrNotDirectSubordinate
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string {
|
||
shopIDs := make([]uint, 0)
|
||
shopIDSet := make(map[uint]bool)
|
||
|
||
for _, device := range devices {
|
||
if device.ShopID != nil && *device.ShopID > 0 && !shopIDSet[*device.ShopID] {
|
||
shopIDs = append(shopIDs, *device.ShopID)
|
||
shopIDSet[*device.ShopID] = true
|
||
}
|
||
}
|
||
|
||
shopMap := make(map[uint]string)
|
||
if len(shopIDs) > 0 {
|
||
var shops []model.Shop
|
||
// 使用 Unscoped() 包含已删除的店铺,确保能显示店铺名称
|
||
s.db.WithContext(ctx).Unscoped().Where("id IN ?", shopIDs).Find(&shops)
|
||
for _, shop := range shops {
|
||
shopMap[shop.ID] = shop.ShopName
|
||
}
|
||
}
|
||
|
||
return shopMap
|
||
}
|
||
|
||
func (s *Service) loadSeriesNames(ctx context.Context, devices []*model.Device) map[uint]string {
|
||
seriesIDs := make([]uint, 0)
|
||
seriesIDSet := make(map[uint]bool)
|
||
|
||
for _, device := range devices {
|
||
if device.SeriesID != nil && *device.SeriesID > 0 && !seriesIDSet[*device.SeriesID] {
|
||
seriesIDs = append(seriesIDs, *device.SeriesID)
|
||
seriesIDSet[*device.SeriesID] = true
|
||
}
|
||
}
|
||
|
||
seriesMap := make(map[uint]string)
|
||
if len(seriesIDs) > 0 {
|
||
var seriesList []model.PackageSeries
|
||
s.db.WithContext(ctx).Where("id IN ?", seriesIDs).Find(&seriesList)
|
||
for _, series := range seriesList {
|
||
seriesMap[series.ID] = series.SeriesName
|
||
}
|
||
}
|
||
|
||
return seriesMap
|
||
}
|
||
|
||
func (s *Service) getBindingCounts(ctx context.Context, deviceIDs []uint) (map[uint]int64, error) {
|
||
result := make(map[uint]int64)
|
||
if len(deviceIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
bindings, err := s.deviceSimBindingStore.ListByDeviceIDs(ctx, deviceIDs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, binding := range bindings {
|
||
result[binding.DeviceID]++
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) extractDeviceIDs(devices []*model.Device) []uint {
|
||
ids := make([]uint, len(devices))
|
||
for i, device := range devices {
|
||
ids[i] = device.ID
|
||
}
|
||
return ids
|
||
}
|
||
|
||
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,
|
||
VirtualNo: device.VirtualNo,
|
||
IMEI: device.IMEI,
|
||
SN: device.SN,
|
||
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]),
|
||
SeriesID: device.SeriesID,
|
||
FirstCommissionPaid: device.FirstCommissionPaid,
|
||
AccumulatedRecharge: device.AccumulatedRecharge,
|
||
ActivatedAt: device.ActivatedAt,
|
||
CreatedAt: device.CreatedAt,
|
||
UpdatedAt: device.UpdatedAt,
|
||
}
|
||
|
||
if device.ShopID != nil && *device.ShopID > 0 {
|
||
resp.ShopName = shopMap[*device.ShopID]
|
||
}
|
||
|
||
if device.SeriesID != nil && *device.SeriesID > 0 {
|
||
resp.SeriesName = seriesMap[*device.SeriesID]
|
||
}
|
||
|
||
return resp
|
||
}
|
||
|
||
func (s *Service) getDeviceStatusName(status int) string {
|
||
switch status {
|
||
case constants.DeviceStatusInStock:
|
||
return "在库"
|
||
case constants.DeviceStatusDistributed:
|
||
return "已分销"
|
||
case constants.DeviceStatusActivated:
|
||
return "已激活"
|
||
case constants.DeviceStatusSuspended:
|
||
return "已停用"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|
||
|
||
func (s *Service) buildAllocationRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
|
||
successIDSet := make(map[uint]bool)
|
||
for _, id := range successDeviceIDs {
|
||
successIDSet[id] = true
|
||
}
|
||
|
||
var records []*model.AssetAllocationRecord
|
||
for _, device := range devices {
|
||
if !successIDSet[device.ID] {
|
||
continue
|
||
}
|
||
|
||
record := &model.AssetAllocationRecord{
|
||
AllocationNo: allocationNo,
|
||
AllocationType: constants.AssetAllocationTypeAllocate,
|
||
AssetType: constants.AssetTypeDevice,
|
||
AssetID: device.ID,
|
||
AssetIdentifier: device.VirtualNo,
|
||
ToOwnerType: constants.OwnerTypeShop,
|
||
ToOwnerID: toShopID,
|
||
OperatorID: operatorID,
|
||
Remark: remark,
|
||
}
|
||
|
||
if fromShopID == nil {
|
||
record.FromOwnerType = constants.OwnerTypePlatform
|
||
record.FromOwnerID = nil
|
||
} else {
|
||
record.FromOwnerType = constants.OwnerTypeShop
|
||
record.FromOwnerID = fromShopID
|
||
}
|
||
|
||
records = append(records, record)
|
||
}
|
||
|
||
return records
|
||
}
|
||
|
||
func (s *Service) buildRecallRecords(devices []*model.Device, successDeviceIDs []uint, fromShopID *uint, toShopID *uint, operatorID uint, allocationNo, remark string) []*model.AssetAllocationRecord {
|
||
successIDSet := make(map[uint]bool)
|
||
for _, id := range successDeviceIDs {
|
||
successIDSet[id] = true
|
||
}
|
||
|
||
var records []*model.AssetAllocationRecord
|
||
for _, device := range devices {
|
||
if !successIDSet[device.ID] {
|
||
continue
|
||
}
|
||
|
||
record := &model.AssetAllocationRecord{
|
||
AllocationNo: allocationNo,
|
||
AllocationType: constants.AssetAllocationTypeRecall,
|
||
AssetType: constants.AssetTypeDevice,
|
||
AssetID: device.ID,
|
||
AssetIdentifier: device.VirtualNo,
|
||
OperatorID: operatorID,
|
||
Remark: remark,
|
||
}
|
||
|
||
if fromShopID == nil {
|
||
record.FromOwnerType = constants.OwnerTypePlatform
|
||
record.FromOwnerID = nil
|
||
} else {
|
||
record.FromOwnerType = constants.OwnerTypeShop
|
||
record.FromOwnerID = fromShopID
|
||
}
|
||
|
||
if toShopID == nil {
|
||
record.ToOwnerType = constants.OwnerTypePlatform
|
||
record.ToOwnerID = 0
|
||
} else {
|
||
record.ToOwnerType = constants.OwnerTypeShop
|
||
record.ToOwnerID = *toShopID
|
||
}
|
||
|
||
records = append(records, record)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// 验证系列存在(仅当 SeriesID > 0 时)
|
||
var packageSeries *model.PackageSeries
|
||
if req.SeriesID > 0 {
|
||
packageSeries, err = s.packageSeriesStore.GetByID(ctx, req.SeriesID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用")
|
||
}
|
||
return nil, err
|
||
}
|
||
if packageSeries.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,
|
||
VirtualNo: "",
|
||
Reason: "设备不存在",
|
||
})
|
||
continue
|
||
}
|
||
|
||
// 验证操作者权限(仅代理用户)- 检查是否有该系列的套餐分配
|
||
if operatorShopID != nil && req.SeriesID > 0 {
|
||
seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, *operatorShopID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
hasSeriesAllocation := false
|
||
for _, alloc := range seriesAllocations {
|
||
if alloc.SeriesID == req.SeriesID && alloc.Status == 1 {
|
||
hasSeriesAllocation = true
|
||
break
|
||
}
|
||
}
|
||
if !hasSeriesAllocation {
|
||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||
DeviceID: deviceID,
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: "您没有权限分配该套餐系列",
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 验证设备权限(基于 device.ShopID)
|
||
if operatorShopID != nil {
|
||
if device.ShopID == nil || *device.ShopID != *operatorShopID {
|
||
failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{
|
||
DeviceID: device.ID,
|
||
VirtualNo: device.VirtualNo,
|
||
Reason: "无权操作此设备",
|
||
})
|
||
continue
|
||
}
|
||
}
|
||
|
||
successDeviceIDs = append(successDeviceIDs, device.ID)
|
||
}
|
||
|
||
if len(successDeviceIDs) > 0 {
|
||
var seriesIDPtr *uint
|
||
if req.SeriesID > 0 {
|
||
seriesIDPtr = &req.SeriesID
|
||
}
|
||
if err := s.deviceStore.BatchUpdateSeriesID(ctx, successDeviceIDs, seriesIDPtr); 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,
|
||
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.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败")
|
||
}
|
||
|
||
return nil
|
||
}
|