Files
junhong_cmp_fiber/internal/service/device/service.go
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
2026-09-15 11:54:56 +08:00

1748 lines
62 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package device
import (
"context"
"strconv"
"strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/gateway"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
"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"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
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
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore
associationStore *postgres.PhoneAssetAssociationStore
packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
auditWriter *auditinfra.Writer
gatewayIntegration deviceGatewayIntegrationLog
}
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetObservationSeriesDispatcher 注入设备控制成功后的后台观测分发器。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
// SetPhoneAssetAssociationStore 注入手机号—资产关联 store用于列表响应回填关联手机号
func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) {
s.associationStore = store
}
type deviceControlObservationSnapshot struct {
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
TargetICCID string
}
func (s *Service) captureDeviceControlObservation(ctx context.Context, deviceID, knownTargetCardID uint, targetICCID string) deviceControlObservationSnapshot {
snapshot := deviceControlObservationSnapshot{TargetICCID: targetICCID}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测绑定快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.Error(err))
return snapshot
}
bound := make(map[uint]struct{}, len(bindings))
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
snapshot.BoundCardIDs = append(snapshot.BoundCardIDs, binding.IotCardID)
bound[binding.IotCardID] = struct{}{}
if binding.IsCurrent {
snapshot.SourceCardID = binding.IotCardID
}
}
if knownTargetCardID != 0 {
if _, ok := bound[knownTargetCardID]; ok {
snapshot.TargetCardID = knownTargetCardID
}
return snapshot
}
if strings.TrimSpace(targetICCID) == "" || len(snapshot.BoundCardIDs) == 0 {
return snapshot
}
cards, err := s.iotCardStore.GetByIDs(ctx, snapshot.BoundCardIDs)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测目标卡快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.String("target_iccid", targetICCID), zap.Error(err))
return snapshot
}
for _, card := range cards {
if card != nil && cardMatchesICCID(card, targetICCID) {
snapshot.TargetCardID = card.ID
break
}
}
return snapshot
}
func cardMatchesICCID(card *model.IotCard, expected string) bool {
expected = strings.TrimSpace(expected)
return strings.EqualFold(strings.TrimSpace(card.ICCID), expected) ||
strings.EqualFold(strings.TrimSpace(card.ICCID19), expected) ||
(card.ICCID20 != nil && strings.EqualFold(strings.TrimSpace(*card.ICCID20), expected))
}
func (s *Service) dispatchDeviceControlObservation(ctx context.Context, deviceID uint, scene string, snapshot deviceControlObservationSnapshot, includeTargetTraffic bool) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
request := cardObservationApp.SeriesRequest{
Scene: scene, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
}
s.observationSeries.DispatchDeviceControl(ctx, cardObservationApp.DeviceControlSeriesRequest{
DeviceID: deviceID, TargetICCID: snapshot.TargetICCID,
SourceCardID: snapshot.SourceCardID, TargetCardID: snapshot.TargetCardID,
BoundCardIDs: snapshot.BoundCardIDs,
IncludeTargetTraffic: includeTargetTraffic, Request: request,
})
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) {
s.packageExpiryQuery = query
}
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,
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore,
enterpriseStore *postgres.EnterpriseStore,
) *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,
enterpriseDeviceAuthStore: enterpriseDeviceAuthStore,
enterpriseStore: enterpriseStore,
packageExpiryQuery: packageexpiry.NewQuery(db),
auditWriter: auditinfra.NewWriter(nil, nil),
}
}
// 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
}
if req.RealNameStatus != nil {
filters["real_name_status"] = *req.RealNameStatus
}
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
}
if req.AuthorizedEnterpriseID != nil {
filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID
}
if req.IsAuthorizedToEnterprise != nil {
filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise
}
devices, total, err := s.deviceStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
deviceIDs := s.extractDeviceIDs(devices)
expiryEstimates, err := s.packageExpiryQuery.ResolveBatch(ctx, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, err
}
// 关联手机号按当页资产集合一次 IN 批量聚合后装配,禁止逐资产查询形成 N+1。
if s.associationStore == nil {
return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化")
}
associatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeDevice, deviceIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备关联手机号失败")
}
shopMap := s.loadShopData(ctx, devices)
seriesMap := s.loadSeriesNames(ctx, 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
}
// 批量查询有效企业授权(避免 N+1 查询)
deviceEnterpriseMap, err := s.enterpriseDeviceAuthStore.GetActiveAuthEnterpriseByDeviceIDs(ctx, deviceIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备企业授权失败")
}
enterpriseIDs := make([]uint, 0)
seen := make(map[uint]struct{})
for _, eid := range deviceEnterpriseMap {
if _, ok := seen[eid]; !ok {
seen[eid] = struct{}{}
enterpriseIDs = append(enterpriseIDs, eid)
}
}
enterpriseNameMap, err := s.enterpriseStore.GetNameMapByIDs(ctx, enterpriseIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询企业名称失败")
}
list := make([]*dto.DeviceResponse, 0, len(devices))
for _, device := range devices {
item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses)
item.PackageExpiryEstimate = expiryEstimates[device.ID]
item.AssociatedPhones = associatedPhones[device.ID]
if eid, ok := deviceEnterpriseMap[device.ID]; ok {
item.AuthorizedEnterpriseID = &eid
item.AuthorizedEnterpriseName = enterpriseNameMap[eid]
}
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 {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置")
}
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "设备不存在")
s.recordDeviceLifecycleFailure(ctx, constants.AuditActionDeviceDeleted, "删除设备被拒绝", constants.AuditResultDenied, nil, id, appErr)
return appErr
}
s.recordDeviceLifecycleFailure(ctx, constants.AuditActionDeviceDeleted, "删除设备失败", constants.AuditResultFailed, nil, id, err)
return err
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
bindingReferences, txErr := loadDeviceUnbindAuditReferences(ctx, tx, device)
if txErr != nil {
return txErr
}
if txErr := postgres.NewDeviceSimBindingStore(tx, nil).UnbindByDeviceID(ctx, device.ID); txErr != nil {
return txErr
}
if txErr := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("device_id = ?", device.ID).Update("is_current", false).Error; txErr != nil {
return txErr
}
if txErr := postgres.NewDeviceStore(tx, nil).Delete(ctx, id); txErr != nil {
return txErr
}
if s.assetIdentifierStore != nil {
if txErr := postgres.NewAssetIdentifierStore(tx).DeleteByAsset(ctx, model.AssetTypeDevice, id); txErr != nil {
return txErr
}
}
return s.appendDeviceLifecycleAudit(
ctx, tx, constants.AuditActionDeviceDeleted, "删除设备", constants.AuditResultSuccess,
device, auditinfra.DeviceIdentitySnapshot(device), map[string]any{"deleted": true}, bindingReferences, nil,
)
})
if err != nil {
s.recordDeviceLifecycleFailure(ctx, constants.AuditActionDeviceDeleted, "删除设备失败", constants.AuditResultFailed, device, id, err)
return err
}
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 {
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 {
denyErr := errors.New(errors.CodeForbidden, "无可分配设备")
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可分配")
setDeviceAuditFailedItems(outcomes, failedItems)
targetShopID := req.TargetShopID
s.recordDeviceTransferAuditFailure(ctx,
constants.AuditActionDeviceAllocationBatch, constants.AuditActionDeviceAllocated,
"allocate", "批量分配设备被拒绝", constants.AuditResultDenied,
devices, outcomes, &targetShopID, constants.DeviceStatusDistributed,
len(devices), 0, len(failedItems), denyErr)
return &dto.AllocateDevicesResponse{
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
newStatus := constants.DeviceStatusDistributed
targetShopID := req.TargetShopID
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark)
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可分配")
setDeviceAuditFailedItems(outcomes, failedItems)
setDeviceAuditOutcomes(outcomes, deviceIDs, constants.AuditResultSuccess, "设备已分配")
auditResult := constants.AuditResultSuccess
if len(failedItems) > 0 {
auditResult = constants.AuditResultPartial
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
allCardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
successDevices := deviceModelsByIDs(devices, deviceIDs)
changedCardReferences, boundCardIDs, err := loadDeviceCardAuditReferences(ctx, tx, successDevices, &deviceCardAuditChange{ShopID: &targetShopID, Status: constants.IotCardStatusDistributed})
if err != nil {
return err
}
for deviceID, references := range changedCardReferences {
allCardReferences[deviceID] = references
}
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil {
return err
}
if len(boundCardIDs) > 0 {
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil {
return err
}
}
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
return err
}
return s.appendDeviceTransferAudit(ctx, tx,
constants.AuditActionDeviceAllocationBatch, constants.AuditActionDeviceAllocated,
"allocate", "批量分配设备", auditResult,
devices, outcomes, records, &targetShopID, newStatus,
len(devices), len(deviceIDs), len(failedItems), allCardReferences, nil)
})
if err != nil {
failedOutcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可分配")
setDeviceAuditFailedItems(failedOutcomes, failedItems)
setDeviceAuditOutcomes(failedOutcomes, deviceIDs, constants.AuditResultFailed, "设备分配失败")
s.recordDeviceTransferAuditFailure(ctx,
constants.AuditActionDeviceAllocationBatch, constants.AuditActionDeviceAllocated,
"allocate", "批量分配设备失败", constants.AuditResultFailed,
devices, failedOutcomes, &targetShopID, newStatus,
len(devices), 0, len(devices), err)
return nil, err
}
s.iotCardStore.InvalidateListCountCache(ctx)
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 {
denyErr := errors.New(errors.CodeForbidden, "无可回收设备")
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可回收")
setDeviceAuditFailedItems(outcomes, failedItems)
var deniedTargetShopID *uint
deniedStatus := constants.DeviceStatusInStock
if !isPlatform {
deniedTargetShopID = operatorShopID
deniedStatus = constants.DeviceStatusDistributed
}
s.recordDeviceTransferAuditFailure(ctx,
constants.AuditActionDeviceRecallBatch, constants.AuditActionDeviceRecalled,
"recall", "批量回收设备被拒绝", constants.AuditResultDenied,
devices, outcomes, deniedTargetShopID, deniedStatus,
len(devices), 0, len(failedItems), denyErr)
return &dto.RecallDevicesResponse{
SuccessCount: 0,
FailCount: len(failedItems),
FailedItems: failedItems,
}, nil
}
var newShopID *uint
newStatus := constants.DeviceStatusInStock
cardStatus := constants.IotCardStatusInStock
if !isPlatform {
newShopID = operatorShopID
newStatus = constants.DeviceStatusDistributed
cardStatus = constants.IotCardStatusDistributed
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall)
records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark)
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可回收")
setDeviceAuditFailedItems(outcomes, failedItems)
setDeviceAuditOutcomes(outcomes, deviceIDs, constants.AuditResultSuccess, "设备已回收")
auditResult := constants.AuditResultSuccess
if len(failedItems) > 0 {
auditResult = constants.AuditResultPartial
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
txCardStore := postgres.NewIotCardStore(tx, nil)
txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil)
allCardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
successDevices := deviceModelsByIDs(devices, deviceIDs)
changedCardReferences, boundCardIDs, err := loadDeviceCardAuditReferences(ctx, tx, successDevices, &deviceCardAuditChange{ShopID: newShopID, Status: cardStatus})
if err != nil {
return err
}
for deviceID, references := range changedCardReferences {
allCardReferences[deviceID] = references
}
if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil {
return err
}
if len(boundCardIDs) > 0 {
if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil {
return err
}
}
if err := txRecordStore.BatchCreate(ctx, records); err != nil {
return err
}
return s.appendDeviceTransferAudit(ctx, tx,
constants.AuditActionDeviceRecallBatch, constants.AuditActionDeviceRecalled,
"recall", "批量回收设备", auditResult,
devices, outcomes, records, newShopID, newStatus,
len(devices), len(deviceIDs), len(failedItems), allCardReferences, nil)
})
if err != nil {
failedOutcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可回收")
setDeviceAuditFailedItems(failedOutcomes, failedItems)
setDeviceAuditOutcomes(failedOutcomes, deviceIDs, constants.AuditResultFailed, "设备回收失败")
s.recordDeviceTransferAuditFailure(ctx,
constants.AuditActionDeviceRecallBatch, constants.AuditActionDeviceRecalled,
"recall", "批量回收设备失败", constants.AuditResultFailed,
devices, failedOutcomes, newShopID, newStatus,
len(devices), 0, len(devices), err)
return nil, err
}
s.iotCardStore.InvalidateListCountCache(ctx)
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) 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,
RealnamePolicy: device.RealnamePolicy,
CreatedAt: device.CreatedAt,
UpdatedAt: device.UpdatedAt,
OnlineStatus: device.OnlineStatus,
LastOnlineTime: device.LastOnlineTime,
SoftwareVersion: device.SoftwareVersion,
SwitchMode: device.SwitchMode,
LastGatewaySyncAt: device.LastGatewaySyncAt,
AssetStatus: device.AssetStatus,
AssetStatusName: constants.GetAssetStatusName(device.AssetStatus),
Generation: device.Generation,
}
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)
if err != nil {
return nil, err
}
if len(devices) == 0 {
failedItems := []dto.DeviceSeriesBindngFailedItem{}
if selectionType == dto.SelectionTypeList {
failedItems = s.buildDeviceNotFoundFailedItems(req.DeviceIDs)
}
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, "套餐系列不存在或已禁用")
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "套餐系列不存在或已禁用")
targetSeriesID := req.SeriesID
s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID,
constants.AuditResultDenied, batchTotal, 0, batchTotal,
deviceSeriesBindingAuditData(req, selectionType, operatorShopID), appErr)
return nil, appErr
}
outcomes := deviceAuditOutcomes(devices, constants.AuditResultFailed, "查询套餐系列失败")
targetSeriesID := req.SeriesID
s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID,
constants.AuditResultFailed, batchTotal, 0, batchTotal,
deviceSeriesBindingAuditData(req, selectionType, operatorShopID), err)
return nil, err
}
if packageSeries.Status != 1 {
appErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用")
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "套餐系列不存在或已禁用")
targetSeriesID := req.SeriesID
s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID,
constants.AuditResultDenied, batchTotal, 0, batchTotal,
deviceSeriesBindingAuditData(req, selectionType, operatorShopID), 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 {
outcomes := deviceAuditOutcomes(devices, constants.AuditResultFailed, "查询店铺系列授权失败")
targetSeriesID := req.SeriesID
s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID,
constants.AuditResultFailed, batchTotal, 0, batchTotal,
deviceSeriesBindingAuditData(req, selectionType, operatorShopID), 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)
}
}
var seriesIDPtr *uint
if req.SeriesID > 0 {
seriesIDPtr = &req.SeriesID
}
outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可设置系列")
for _, item := range failedItems {
if _, ok := outcomes[item.DeviceID]; ok {
outcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason}
}
}
setDeviceAuditOutcomes(outcomes, successDeviceIDs, constants.AuditResultSuccess, "设备系列绑定已更新")
resultStatus := constants.AuditResultSuccess
if len(successDeviceIDs) == 0 && len(failedItems) > 0 {
resultStatus = constants.AuditResultDenied
} else if len(successDeviceIDs) > 0 && len(failedItems) > 0 {
resultStatus = constants.AuditResultPartial
}
metadata := map[string]any{
"selection_type": selectionType, "series_id": req.SeriesID,
"success_device_ids": successDeviceIDs, "failed_items": failedItems,
}
if len(successDeviceIDs) > 0 {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
if err := txDeviceStore.BatchUpdateSeriesID(ctx, successDeviceIDs, seriesIDPtr); err != nil {
return err
}
return s.appendDeviceSeriesBindingAudit(ctx, tx, devices, outcomes, seriesIDPtr, resultStatus,
batchTotal, len(successDeviceIDs), len(failedItems), metadata, nil)
})
if err != nil {
failedOutcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可设置系列")
for _, item := range failedItems {
if _, ok := failedOutcomes[item.DeviceID]; ok {
failedOutcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason}
}
}
setDeviceAuditOutcomes(failedOutcomes, successDeviceIDs, constants.AuditResultFailed, "设备系列绑定失败")
s.recordDeviceSeriesBindingAuditFailure(ctx, devices, failedOutcomes, seriesIDPtr,
constants.AuditResultFailed, batchTotal, 0, len(devices), metadata, err)
return nil, err
}
} else if len(failedItems) > 0 {
denyErr := errors.New(errors.CodeForbidden, "无可绑定设备")
s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, seriesIDPtr,
constants.AuditResultDenied, batchTotal, 0, len(failedItems), metadata, denyErr)
}
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 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
// 复机保护期内禁止停机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result()
if exists > 0 {
appErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机")
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络被拒绝",
constants.AuditResultDenied, device, nil, nil, nil, nil, appErr)
return nil, appErr
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络失败",
constants.AuditResultFailed, device, nil, nil, nil, nil, appErr)
return nil, appErr
}
if len(bindings) == 0 {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "设备没有需要停用的绑定卡",
constants.AuditResultSuccess, device, nil, nil, nil,
map[string]any{"success_count": 0, "fail_count": 0, "skip_count": 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.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络失败",
constants.AuditResultFailed, device, nil, nil, nil,
map[string]any{"card_count": len(cardIDs)}, appErr)
return nil, appErr
}
var successCount, skipCount int
var failedItems []dto.DeviceSuspendFailItem
for _, card := range cards {
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOnline {
skipCount++
continue
}
integrationID := ""
var observer *deviceGatewayAttemptObserver
if s.gatewayClient != nil {
cardID := strconv.FormatUint(uint64(card.ID), 10)
observer = &deviceGatewayAttemptObserver{
service: s, operation: constants.IntegrationOperationGatewayStopCard,
scene: constants.CardObservationSceneBusinessStop, seriesKey: deviceCommandSeriesKey(ctx),
resource: deviceGatewayResource{
Type: constants.AuditResourceIotCard, ID: cardID, Key: auditinfra.IotCardResourceKey(card), ExternalID: card.ICCID,
RequestSummary: map[string]any{"device_id": device.ID, "iot_card_id": card.ID, "iccid": card.ICCID},
},
}
log.Info("调用网关停机(设备)",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID))
gwErr := s.gatewayClient.StopCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID})
integrationID = observer.integration
if gwErr != nil {
log.Error("设备停机-调网关停机失败",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID),
zap.Error(gwErr))
result := constants.AuditResultFailed
summary := "停用设备绑定卡网络失败"
if observer.unknown {
result = constants.AuditResultUnknown
summary = "停用设备绑定卡网络结果未知"
}
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, summary, result,
device, card, nil, nil, map[string]any{"integration_id": integrationID}, 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.updateCardAndAppendNetworkSeries(ctx, device, card, map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}, constants.CardObservationSceneBusinessStop, "offline",
constants.AuditActionDeviceStopped, "停用设备绑定卡网络", integrationID, s.gatewayClient != nil); dbErr != nil {
if observer != nil {
if logErr := observer.completeSuccess(ctx, false); logErr != nil {
log.Error("终结设备停机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr))
}
}
log.Error("设备停机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络结果未知",
constants.AuditResultUnknown, device, card, nil,
map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": constants.StopReasonManual},
map[string]any{"integration_id": integrationID}, dbErr)
failedItems = append(failedItems, dto.DeviceSuspendFailItem{
ICCID: card.ICCID,
Reason: "更新卡状态失败",
})
continue
}
if observer != nil {
if logErr := observer.completeSuccess(ctx, true); logErr != nil {
log.Error("终结设备停机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr))
}
}
s.invalidatePollingCardCache(card.ID)
successCount++
}
// 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
}
if successCount == 0 && len(failedItems) == 0 {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "设备绑定卡网络无需停用",
constants.AuditResultSuccess, device, nil, nil, nil,
map[string]any{"success_count": 0, "fail_count": 0, "skip_count": skipCount}, nil)
}
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, "设备不存在")
}
// 停机保护期内禁止复机
if s.redis != nil {
exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result()
if exists > 0 {
appErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机")
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络被拒绝",
constants.AuditResultDenied, device, nil, nil, nil, nil, appErr)
return appErr
}
}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败")
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络失败",
constants.AuditResultFailed, device, nil, nil, nil, nil, appErr)
return appErr
}
if len(bindings) == 0 {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "设备没有需要恢复的绑定卡",
constants.AuditResultSuccess, device, nil, nil, nil,
map[string]any{"success_count": 0, "fail_count": 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.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络失败",
constants.AuditResultFailed, device, nil, nil, nil,
map[string]any{"card_count": len(cardIDs)}, appErr)
return appErr
}
var successCount int
var failCount int
var lastErr error
for _, card := range cards {
if card.RealNameStatus != constants.RealNameStatusVerified || card.NetworkStatus != constants.NetworkStatusOffline {
continue
}
integrationID := ""
var observer *deviceGatewayAttemptObserver
if s.gatewayClient != nil {
cardID := strconv.FormatUint(uint64(card.ID), 10)
observer = &deviceGatewayAttemptObserver{
service: s, operation: constants.IntegrationOperationGatewayStartCard,
scene: constants.CardObservationSceneBusinessResume, seriesKey: deviceCommandSeriesKey(ctx),
resource: deviceGatewayResource{
Type: constants.AuditResourceIotCard, ID: cardID, Key: auditinfra.IotCardResourceKey(card), ExternalID: card.ICCID,
RequestSummary: map[string]any{"device_id": device.ID, "iot_card_id": card.ID, "iccid": card.ICCID},
},
}
log.Info("调用网关复机(设备)",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID))
gwErr := s.gatewayClient.StartCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID})
integrationID = observer.integration
if gwErr != nil {
log.Error("设备复机-调网关复机失败",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID),
zap.Error(gwErr))
result := constants.AuditResultFailed
summary := "恢复设备绑定卡网络失败"
if observer.unknown {
result = constants.AuditResultUnknown
summary = "恢复设备绑定卡网络结果未知"
}
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, summary, result,
device, card, nil, nil, map[string]any{"integration_id": integrationID}, gwErr)
lastErr = gwErr
failCount++
continue
}
log.Info("网关复机成功(设备)",
zap.Uint("device_id", deviceID),
zap.String("iccid", card.ICCID))
}
now := time.Now()
if dbErr := s.updateCardAndAppendNetworkSeries(ctx, device, card, map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}, constants.CardObservationSceneBusinessResume, "online",
constants.AuditActionDeviceStarted, "恢复设备绑定卡网络", integrationID, s.gatewayClient != nil); dbErr != nil {
if observer != nil {
if logErr := observer.completeSuccess(ctx, false); logErr != nil {
log.Error("终结设备复机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr))
}
}
log.Error("设备复机-更新卡状态失败",
zap.Uint("card_id", card.ID),
zap.Error(dbErr))
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络结果未知",
constants.AuditResultUnknown, device, card, nil,
map[string]any{"requested_network_status": constants.NetworkStatusOnline, "stop_reason": ""},
map[string]any{"integration_id": integrationID}, dbErr)
lastErr = dbErr
failCount++
continue
}
if observer != nil {
if logErr := observer.completeSuccess(ctx, true); logErr != nil {
log.Error("终结设备复机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr))
}
}
s.invalidatePollingCardCache(card.ID)
successCount++
}
// 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
}
// 全部失败时返回 error
if successCount == 0 && lastErr != nil {
appErr := errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败")
return appErr
}
if successCount == 0 && failCount == 0 {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "设备绑定卡网络无需恢复",
constants.AuditResultSuccess, device, nil, nil, nil,
map[string]any{"success_count": 0, "fail_count": 0}, nil)
}
return nil
}
// UpdateRealnamePolicy 更新设备的实名认证策略
func (s *Service) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realnamePolicy string) error {
var device model.Device
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", deviceID).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
changed := device.RealnamePolicy != realnamePolicy
if changed {
if err := tx.Model(&model.Device{}).Where("id = ?", deviceID).Update("realname_policy", realnamePolicy).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败")
}
}
summary := "更新设备实名策略"
if !changed {
summary = "确认设备实名策略无需变化"
}
return s.appendDeviceRealnamePolicyAudit(ctx, tx, summary, constants.AuditResultSuccess,
&device, map[string]any{"realname_policy": device.RealnamePolicy},
map[string]any{"realname_policy": realnamePolicy, "status_changed": changed}, nil)
})
if err != nil {
if device.ID > 0 {
s.recordDeviceRealnamePolicyFailure(ctx, &device, deviceID, err)
}
return err
}
return nil
}
func (s *Service) invalidatePollingCardCache(cardID uint) {
if s.redis == nil {
return
}
_ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err()
}
func (s *Service) updateCardAndAppendNetworkSeries(
ctx context.Context,
device *model.Device,
card *model.IotCard,
fields map[string]any,
scene, expected, actionCode, summary, integrationID string,
upstreamCalled bool,
) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备绑定卡停复机状态失败")
}
if err := s.appendDeviceCommandAudit(ctx, tx, actionCode, summary, constants.AuditResultSuccess,
device, card,
map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason},
fields, map[string]any{"integration_id": integrationID}, nil); err != nil {
return err
}
if !upstreamCalled || cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "设备停复机观测 Outbox Writer 未配置")
}
operationID := uuid.NewString()
requestID := operationID
if value := middleware.GetRequestIDFromContext(ctx); value != nil && *value != "" {
requestID = *value
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: outboxid.Stable("card-observation:network-command:", operationID),
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}