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 }