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 assetIdentifierStore *postgres.AssetIdentifierStore assetAuditService AssetAuditService } 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, assetIdentifierStore *postgres.AssetIdentifierStore, assetAuditService AssetAuditService, ) *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, assetIdentifierStore: assetIdentifierStore, assetAuditService: assetAuditService, } } // 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.IMEI != "" { filters["imei"] = req.IMEI } if req.Keyword != "" { filters["keyword"] = req.Keyword } if req.DeviceName != "" { filters["device_name"] = req.DeviceName } if req.Status != nil { filters["status"] = *req.Status } if req.ActivationStatus != nil { filters["activation_status"] = *req.ActivationStatus } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs } else if req.ShopID != nil { if *req.ShopID == 0 { filters["shop_ids"] = []uint{} } else { 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 } if req.HasActivePackage != nil { filters["has_active_package"] = *req.HasActivePackage } 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) deviceIDs := s.extractDeviceIDs(devices) bindingCounts, err := s.getBindingCounts(ctx, deviceIDs) if err != nil { return nil, err } activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, deviceIDs) if err != nil { return nil, err } list := make([]*dto.DeviceResponse, 0, len(devices)) for _, device := range devices { item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses) list = append(list, item) } return &dto.ListDeviceResponse{ List: list, Total: total, Page: page, PageSize: pageSize, }, nil } func normalizeShopIDs(ids []uint) ([]uint, bool) { if len(ids) == 0 { return nil, false } seen := make(map[uint]struct{}, len(ids)) result := make([]uint, 0, len(ids)) for _, id := range ids { if id == 0 { continue } if _, exists := seen[id]; exists { continue } seen[id] = struct{}{} result = append(result, id) } return result, true } // 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 } activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) if err != nil { return nil, err } return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses), 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 } activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) if err != nil { return nil, err } return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses), 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 } activationStatuses, err := s.deviceStore.GetActivationStatusMap(ctx, []uint{device.ID}) if err != nil { return nil, err } return s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses), 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 { auditDevice := &model.Device{} auditDevice.ID = id device, err := s.deviceStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { appErr := errors.New(errors.CodeNotFound, "设备不存在") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceDelete, "删除设备失败", constants.AssetAuditResultFailed, auditDevice, nil, nil, 0, 0, 0, appErr, ) return appErr } s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceDelete, "删除设备失败", constants.AssetAuditResultFailed, auditDevice, nil, nil, 0, 0, 0, err, ) return err } beforeData := deviceSnapshot(device) if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceDelete, "删除设备失败", constants.AssetAuditResultFailed, device, beforeData, nil, 0, 0, 0, err, ) return err } if err := s.deviceStore.Delete(ctx, id); err != nil { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceDelete, "删除设备失败", constants.AssetAuditResultFailed, device, beforeData, nil, 0, 0, 0, err, ) return err } if s.assetIdentifierStore != nil { _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeDevice, id) } s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceDelete, "删除设备", constants.AssetAuditResultSuccess, device, beforeData, map[string]any{ "deleted": true, }, 0, 0, 0, nil, ) return nil } // GetByVirtualNo 通过虚拟号获取设备 func (s *Service) GetByVirtualNo(ctx context.Context, virtualNo string) (*model.Device, error) { return s.deviceStore.GetByIdentifier(ctx, virtualNo) } // GetCardByICCID 通过 ICCID 获取 IoT 卡 func (s *Service) GetCardByICCID(ctx context.Context, iccid string) (*model.IotCard, error) { cards, err := s.iotCardStore.GetByICCIDs(ctx, []string{iccid}) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败") } if len(cards) == 0 { return nil, errors.New(errors.CodeNotFound, "卡不存在") } return cards[0], nil } // 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceAllocate, "设备分配被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{ "target_shop_id": req.TargetShopID, "device_ids": req.DeviceIDs, }, len(req.DeviceIDs), 0, len(req.DeviceIDs), err, ) return nil, err } devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) if err != nil { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceAllocate, "设备分配失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{ "target_shop_id": req.TargetShopID, "device_ids": req.DeviceIDs, }, len(req.DeviceIDs), 0, len(req.DeviceIDs), err, ) return nil, err } if len(devices) == 0 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceAllocate, "设备分配被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{ "target_shop_id": req.TargetShopID, "device_ids": req.DeviceIDs, "reason": "未找到可分配设备", }, len(req.DeviceIDs), 0, len(req.DeviceIDs), errors.New(errors.CodeNotFound, "未找到可分配设备"), ) 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceAllocate, "设备分配被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{ "target_shop_id": req.TargetShopID, "failed_items": failedItems, }, len(devices), 0, len(failedItems), errors.New(errors.CodeForbidden, "无可分配设备"), ) return &dto.AllocateDevicesResponse{ SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, nil } newStatus := constants.DeviceStatusDistributed targetShopID := req.TargetShopID targetShopName := s.getAuditShopName(ctx, &targetShopID) shopMap := s.loadShopData(ctx, devices) beforeData := make([]map[string]any, 0, len(devices)) for _, device := range devices { snapshot := deviceSnapshot(device) snapshot["shop_name"] = deviceShopMapValue(shopMap, device.ShopID) beforeData = append(beforeData, snapshot) } 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceAllocate, "设备分配失败", constants.AssetAuditResultFailed, nil, map[string]any{"devices": beforeData}, map[string]any{ "target_shop_id": req.TargetShopID, "target_shop_name": targetShopName, "failed_items": failedItems, }, len(devices), len(deviceIDs), len(failedItems), err, ) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceAllocate, "设备分配", constants.AssetAuditResultSuccess, nil, map[string]any{"devices": beforeData}, map[string]any{ "target_shop_id": req.TargetShopID, "target_shop_name": targetShopName, "device_ids": deviceIDs, "failed_items": failedItems, }, len(devices), len(deviceIDs), len(failedItems), nil, ) 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceRecall, "设备回收失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{ "device_ids": req.DeviceIDs, }, len(req.DeviceIDs), 0, len(req.DeviceIDs), err, ) return nil, err } if len(devices) == 0 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceRecall, "设备回收被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{ "device_ids": req.DeviceIDs, "reason": "未找到可回收设备", }, len(req.DeviceIDs), 0, len(req.DeviceIDs), errors.New(errors.CodeNotFound, "未找到可回收设备"), ) 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceRecall, "设备回收被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{ "device_ids": req.DeviceIDs, "failed_items": failedItems, }, len(devices), 0, len(failedItems), errors.New(errors.CodeForbidden, "无可回收设备"), ) 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 } targetShopName := s.getAuditRecallTargetShopName(ctx, newShopID) shopMap := s.loadShopData(ctx, devices) beforeData := make([]map[string]any, 0, len(devices)) for _, device := range devices { snapshot := deviceSnapshot(device) snapshot["shop_name"] = deviceShopMapValue(shopMap, device.ShopID) beforeData = append(beforeData, snapshot) } 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceRecall, "设备回收失败", constants.AssetAuditResultFailed, nil, map[string]any{"devices": beforeData}, map[string]any{ "device_ids": deviceIDs, "failed_items": failedItems, "target_shop_name": targetShopName, }, len(devices), len(deviceIDs), len(failedItems), err, ) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceRecall, "设备回收", constants.AssetAuditResultSuccess, nil, map[string]any{"devices": beforeData}, map[string]any{ "device_ids": deviceIDs, "failed_items": failedItems, "target_shop": newShopID, "target_shop_name": targetShopName, }, len(devices), len(deviceIDs), len(failedItems), nil, ) 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 { return nil } // 代理仅允许分配给直属下级店铺 if targetShop.ParentID == nil || *targetShop.ParentID != *operatorShopID { return errors.ErrNotDirectSubordinate } return nil } func (s *Service) getAuditShopName(ctx context.Context, shopID *uint) string { if shopID == nil || *shopID == 0 { return "" } var shop model.Shop if err := s.db.WithContext(ctx).Unscoped().First(&shop, *shopID).Error; err != nil { return "" } return shop.ShopName } func (s *Service) getAuditRecallTargetShopName(ctx context.Context, shopID *uint) string { if shopID == nil { return "平台库存" } return s.getAuditShopName(ctx, shopID) } func deviceShopMapValue(shopMap map[uint]string, shopID *uint) string { if shopID == nil { return "" } return shopMap[*shopID] } 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, activationStatuses map[uint]int) *dto.DeviceResponse { activationStatus := activationStatuses[device.ID] 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: constants.GetDeviceStatusName(device.Status), ActivationStatus: activationStatus, ActivationStatusName: constants.GetActivationStatusName(activationStatus), BoundCardCount: int(bindingCounts[device.ID]), SeriesID: device.SeriesID, ActivatedAt: device.ActivatedAt, CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt, OnlineStatus: device.OnlineStatus, LastOnlineTime: device.LastOnlineTime, SoftwareVersion: device.SoftwareVersion, SwitchMode: device.SwitchMode, LastGatewaySyncAt: device.LastGatewaySyncAt, } 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) 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) { selectionType, err := normalizeDeviceSeriesBindingSelection(req) if err != nil { return nil, err } devices, err := s.getDevicesForSeriesBinding(ctx, req, selectionType) batchTotal := deviceSeriesBindingBatchTotal(req, selectionType, devices) auditData := deviceSeriesBindingAuditData(req, selectionType, operatorShopID) if err != nil { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定失败", constants.AssetAuditResultFailed, nil, nil, auditData, batchTotal, 0, batchTotal, err, ) return nil, err } if len(devices) == 0 { failedItems := []dto.DeviceSeriesBindngFailedItem{} if selectionType == dto.SelectionTypeList { failedItems = s.buildDeviceNotFoundFailedItems(req.DeviceIDs) s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定被拒绝", constants.AssetAuditResultDenied, nil, nil, auditData, batchTotal, 0, len(failedItems), errors.New(errors.CodeNotFound, "设备不存在"), ) } return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: 0, FailCount: len(failedItems), FailedItems: failedItems, }, 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 { appErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定被拒绝", constants.AssetAuditResultDenied, nil, nil, auditData, batchTotal, 0, batchTotal, appErr, ) return nil, appErr } s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定失败", constants.AssetAuditResultFailed, nil, nil, auditData, batchTotal, 0, batchTotal, err, ) return nil, err } if packageSeries.Status != 1 { appErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定被拒绝", constants.AssetAuditResultDenied, nil, nil, auditData, batchTotal, 0, batchTotal, appErr, ) return nil, appErr } } var successDeviceIDs []uint var failedItems []dto.DeviceSeriesBindngFailedItem successDeviceIDSet := make(map[uint]struct{}) hasSeriesAllocation := true if operatorShopID != nil && req.SeriesID > 0 { hasSeriesAllocation, err = s.hasAvailableSeriesAllocation(ctx, *operatorShopID, req.SeriesID) if err != nil { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定失败", constants.AssetAuditResultFailed, nil, nil, auditData, batchTotal, 0, batchTotal, err, ) return nil, err } } addDevice := func(device *model.Device, requestedDeviceID uint) { if device == nil { failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ DeviceID: requestedDeviceID, VirtualNo: "", Reason: "设备不存在", }) return } if !hasSeriesAllocation { failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ DeviceID: device.ID, VirtualNo: device.VirtualNo, Reason: "您没有权限分配该套餐系列", }) return } // 代理只能操作自己店铺名下的设备,保持旧接口权限语义不变。 if operatorShopID != nil && (device.ShopID == nil || *device.ShopID != *operatorShopID) { failedItems = append(failedItems, dto.DeviceSeriesBindngFailedItem{ DeviceID: device.ID, VirtualNo: device.VirtualNo, Reason: "无权操作此设备", }) return } if _, exists := successDeviceIDSet[device.ID]; exists { return } successDeviceIDSet[device.ID] = struct{}{} successDeviceIDs = append(successDeviceIDs, device.ID) } if selectionType == dto.SelectionTypeList { for _, deviceID := range req.DeviceIDs { addDevice(deviceMap[deviceID], deviceID) } } else { for _, device := range devices { addDevice(device, 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 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{ "series_id": req.SeriesID, "selection_type": selectionType, "success_device_ids": successDeviceIDs, "failed_items": failedItems, }, batchTotal, len(successDeviceIDs), len(failedItems), err, ) return nil, err } } beforeData := make([]map[string]any, 0, len(devices)) for _, device := range devices { beforeData = append(beforeData, deviceSnapshot(device)) } resultStatus := constants.AssetAuditResultSuccess var resultErr error if len(successDeviceIDs) == 0 && len(failedItems) > 0 { resultStatus = constants.AssetAuditResultDenied resultErr = errors.New(errors.CodeForbidden, "无可绑定设备") } s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceSeriesBinding, "设备系列绑定", resultStatus, nil, map[string]any{ "devices": beforeData, }, map[string]any{ "selection_type": selectionType, "series_id": req.SeriesID, "success_device_ids": successDeviceIDs, "failed_items": failedItems, }, batchTotal, len(successDeviceIDs), len(failedItems), resultErr, ) return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: len(successDeviceIDs), FailCount: len(failedItems), FailedItems: failedItems, }, nil } func normalizeDeviceSeriesBindingSelection(req *dto.BatchSetDeviceSeriesBindngRequest) (string, error) { switch req.SelectionType { case dto.SelectionTypeList: if len(req.DeviceIDs) == 0 { return "", errors.New(errors.CodeInvalidParam, "selection_type=list时device_ids不能为空") } return dto.SelectionTypeList, nil case dto.SelectionTypeRange: if req.VirtualNoStart == "" || req.VirtualNoEnd == "" { return "", errors.New(errors.CodeInvalidParam, "selection_type=range时virtual_no_start和virtual_no_end不能为空") } if req.VirtualNoStart > req.VirtualNoEnd { return "", errors.New(errors.CodeInvalidParam, "设备虚拟号起始值不能大于结束值") } return dto.SelectionTypeRange, nil case dto.SelectionTypeFilter: return dto.SelectionTypeFilter, nil case "": if len(req.DeviceIDs) > 0 { return dto.SelectionTypeList, nil } if req.VirtualNoStart != "" || req.VirtualNoEnd != "" { if req.VirtualNoStart == "" || req.VirtualNoEnd == "" { return "", errors.New(errors.CodeInvalidParam, "virtual_no_start和virtual_no_end必须同时传入") } if req.VirtualNoStart > req.VirtualNoEnd { return "", errors.New(errors.CodeInvalidParam, "设备虚拟号起始值不能大于结束值") } return dto.SelectionTypeRange, nil } if hasDeviceSeriesBindingFilters(req) { return dto.SelectionTypeFilter, nil } return "", errors.New(errors.CodeInvalidParam, "请选择要设置套餐系列的设备") default: return "", errors.New(errors.CodeInvalidParam, "无效的选设备方式") } } func hasDeviceSeriesBindingFilters(req *dto.BatchSetDeviceSeriesBindngRequest) bool { return req.VirtualNo != "" || req.DeviceName != "" || req.Status != nil || req.ActivationStatus != nil || req.ShopID != nil || len(req.ShopIDs) > 0 || req.FilterSeriesID != nil || req.BatchNo != "" || req.DeviceType != "" || req.Manufacturer != "" || req.CreatedAtStart != nil || req.CreatedAtEnd != nil } func (s *Service) getDevicesForSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, selectionType string) ([]*model.Device, error) { switch selectionType { case dto.SelectionTypeList: return s.deviceStore.GetByIDs(ctx, req.DeviceIDs) case dto.SelectionTypeRange: return s.deviceStore.GetByFilters(ctx, map[string]any{ "virtual_no_start": req.VirtualNoStart, "virtual_no_end": req.VirtualNoEnd, }) case dto.SelectionTypeFilter: return s.deviceStore.GetByFilters(ctx, buildDeviceSeriesBindingFilters(req)) default: return nil, errors.New(errors.CodeInvalidParam, "无效的选设备方式") } } func buildDeviceSeriesBindingFilters(req *dto.BatchSetDeviceSeriesBindngRequest) map[string]any { filters := make(map[string]any) if req.VirtualNo != "" { filters["virtual_no"] = req.VirtualNo } if req.VirtualNoStart != "" { filters["virtual_no_start"] = req.VirtualNoStart } if req.VirtualNoEnd != "" { filters["virtual_no_end"] = req.VirtualNoEnd } if req.DeviceName != "" { filters["device_name"] = req.DeviceName } if req.Status != nil { filters["status"] = *req.Status } if req.ActivationStatus != nil { filters["activation_status"] = *req.ActivationStatus } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs } else if req.ShopID != nil { if *req.ShopID == 0 { filters["shop_ids"] = []uint{} } else { filters["shop_id"] = req.ShopID } } if req.FilterSeriesID != nil { filters["series_id"] = *req.FilterSeriesID } 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 } return filters } func (s *Service) hasAvailableSeriesAllocation(ctx context.Context, shopID uint, seriesID uint) (bool, error) { seriesAllocations, err := s.shopSeriesAllocationStore.GetByShopID(ctx, shopID) if err != nil { return false, err } for _, alloc := range seriesAllocations { if alloc.SeriesID == seriesID && alloc.Status == 1 { return true, nil } } return false, nil } func deviceSeriesBindingBatchTotal(req *dto.BatchSetDeviceSeriesBindngRequest, selectionType string, devices []*model.Device) int { if selectionType == dto.SelectionTypeList { return len(req.DeviceIDs) } return len(devices) } func deviceSeriesBindingAuditData(req *dto.BatchSetDeviceSeriesBindngRequest, selectionType string, operatorShopID *uint) map[string]any { data := map[string]any{ "selection_type": selectionType, "series_id": req.SeriesID, "operator_shop_id": operatorShopID, } switch selectionType { case dto.SelectionTypeList: data["device_ids"] = req.DeviceIDs case dto.SelectionTypeRange: data["virtual_no_start"] = req.VirtualNoStart data["virtual_no_end"] = req.VirtualNoEnd case dto.SelectionTypeFilter: data["filters"] = buildDeviceSeriesBindingFilters(req) } return data } 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 { appErr := errors.New(errors.CodeUnauthorized, "未授权访问") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{"device_id": deviceID}, 0, 0, 0, appErr, ) return nil, appErr } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { appErr := errors.New(errors.CodeNotFound, "设备不存在") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{"device_id": deviceID}, 0, 0, 0, appErr, ) return nil, appErr } // 复机保护期内禁止停机 if s.redis != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result() if exists > 0 { appErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机被拒绝", constants.AssetAuditResultDenied, device, map[string]any{"device": deviceSnapshot(device)}, nil, 0, 0, 0, appErr, ) return nil, appErr } } bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机失败", constants.AssetAuditResultFailed, device, map[string]any{"device": deviceSnapshot(device)}, nil, 0, 0, 0, appErr, ) return nil, appErr } if len(bindings) == 0 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机", constants.AssetAuditResultSuccess, device, map[string]any{"device": deviceSnapshot(device)}, map[string]any{ "success_count": 0, "fail_count": 0, "skip_count": 0, }, 0, 0, 0, nil, ) 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 { appErr := errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机失败", constants.AssetAuditResultFailed, device, map[string]any{"device": deviceSnapshot(device)}, nil, len(cardIDs), 0, len(cardIDs), appErr, ) return nil, appErr } beforeCards := make([]map[string]any, 0, len(cards)) for _, card := range cards { beforeCards = append(beforeCards, map[string]any{ "id": card.ID, "iccid": card.ICCID, "network_status": card.NetworkStatus, "real_name_status": card.RealNameStatus, "stop_reason": card.StopReason, }) } 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 { log.Info("调用网关停机(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) 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 } log.Info("网关停机成功(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) } now := time.Now() if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ "network_status": constants.NetworkStatusOffline, "stopped_at": now, "stop_reason": constants.StopReasonManual, }); dbErr != nil { log.Error("设备停机-更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(dbErr)) failedItems = append(failedItems, dto.DeviceSuspendFailItem{ ICCID: card.ICCID, Reason: "更新卡状态失败", }) continue } s.invalidatePollingCardCache(card.ID) 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")) } resultStatus := constants.AssetAuditResultSuccess var resultErr error if successCount == 0 && len(failedItems) > 0 { resultStatus = constants.AssetAuditResultFailed resultErr = errors.New(errors.CodeGatewayError, "设备停机失败,未成功处理任何卡") } s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStop, "设备停机", resultStatus, device, map[string]any{ "device": deviceSnapshot(device), "cards": beforeCards, }, map[string]any{ "success_count": successCount, "fail_count": len(failedItems), "skip_count": skipCount, "failed_items": failedItems, }, len(cards), successCount, len(failedItems), resultErr, ) 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 { appErr := errors.New(errors.CodeUnauthorized, "未授权访问") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机被拒绝", constants.AssetAuditResultDenied, nil, nil, map[string]any{"device_id": deviceID}, 0, 0, 0, appErr, ) return appErr } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { appErr := errors.New(errors.CodeNotFound, "设备不存在") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{"device_id": deviceID}, 0, 0, 0, appErr, ) return appErr } // 停机保护期内禁止复机 if s.redis != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result() if exists > 0 { appErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机被拒绝", constants.AssetAuditResultDenied, device, map[string]any{"device": deviceSnapshot(device)}, nil, 0, 0, 0, appErr, ) return appErr } } bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机失败", constants.AssetAuditResultFailed, device, map[string]any{"device": deviceSnapshot(device)}, nil, 0, 0, 0, appErr, ) return appErr } if len(bindings) == 0 { s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机", constants.AssetAuditResultSuccess, device, map[string]any{"device": deviceSnapshot(device)}, map[string]any{ "success_count": 0, "fail_count": 0, }, 0, 0, 0, nil, ) 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 { appErr := errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机失败", constants.AssetAuditResultFailed, device, map[string]any{"device": deviceSnapshot(device)}, nil, len(cardIDs), 0, len(cardIDs), appErr, ) return appErr } beforeCards := make([]map[string]any, 0, len(cards)) for _, card := range cards { beforeCards = append(beforeCards, map[string]any{ "id": card.ID, "iccid": card.ICCID, "network_status": card.NetworkStatus, "real_name_status": card.RealNameStatus, "stop_reason": card.StopReason, }) } var successCount int var failCount int var lastErr error for _, card := range cards { if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOffline { continue } if s.gatewayClient != nil { log.Info("调用网关复机(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) 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 failCount++ continue } log.Info("网关复机成功(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) } now := time.Now() if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", }); dbErr != nil { log.Error("设备复机-更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(dbErr)) lastErr = dbErr failCount++ continue } s.invalidatePollingCardCache(card.ID) 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 { appErr := errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败") s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机失败", constants.AssetAuditResultFailed, device, map[string]any{ "device": deviceSnapshot(device), "cards": beforeCards, }, map[string]any{ "success_count": successCount, "fail_count": failCount, }, len(cards), successCount, failCount, appErr, ) return appErr } s.logDeviceOperation( ctx, constants.AssetAuditOpDeviceStart, "设备复机", constants.AssetAuditResultSuccess, device, map[string]any{ "device": deviceSnapshot(device), "cards": beforeCards, }, map[string]any{ "success_count": successCount, "fail_count": failCount, }, len(cards), successCount, failCount, nil, ) return nil } // UpdateRealnamePolicy 更新设备的实名认证策略 func (s *Service) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realnamePolicy string) error { // 检查设备是否存在 device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { if err == gorm.ErrRecordNotFound { appErr := errors.New(errors.CodeNotFound, "设备不存在") s.logDeviceOperation( ctx, constants.AssetAuditOpAssetRealnamePolicy, "更新设备实名策略失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{ "device_id": deviceID, "realname_policy": realnamePolicy, }, 0, 0, 0, appErr, ) return appErr } appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") s.logDeviceOperation( ctx, constants.AssetAuditOpAssetRealnamePolicy, "更新设备实名策略失败", constants.AssetAuditResultFailed, nil, nil, map[string]any{ "device_id": deviceID, "realname_policy": realnamePolicy, }, 0, 0, 0, appErr, ) return appErr } beforeData := map[string]any{ "realname_policy": device.RealnamePolicy, "device": deviceSnapshot(device), } // 幂等检查 if device.RealnamePolicy == realnamePolicy { s.logDeviceOperation( ctx, constants.AssetAuditOpAssetRealnamePolicy, "更新设备实名策略被拒绝", constants.AssetAuditResultDenied, device, beforeData, map[string]any{"realname_policy": realnamePolicy}, 0, 0, 0, errors.New(errors.CodeConflict, "实名认证策略未变化"), ) return nil } // 更新数据库 if err := s.deviceStore.UpdateRealnamePolicy(ctx, deviceID, realnamePolicy); err != nil { appErr := errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") s.logDeviceOperation( ctx, constants.AssetAuditOpAssetRealnamePolicy, "更新设备实名策略失败", constants.AssetAuditResultFailed, device, beforeData, map[string]any{"realname_policy": realnamePolicy}, 0, 0, 0, appErr, ) return appErr } s.logDeviceOperation( ctx, constants.AssetAuditOpAssetRealnamePolicy, "更新设备实名策略", constants.AssetAuditResultSuccess, device, beforeData, map[string]any{ "realname_policy": realnamePolicy, }, 0, 0, 0, nil, ) return nil } func (s *Service) invalidatePollingCardCache(cardID uint) { if s.redis == nil { return } _ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err() }