package polling import ( "context" stderrors "errors" "strconv" "go.uber.org/zap" "gorm.io/gorm" 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/polling" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/auditfailure" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // AssetPollingService 资产轮询管控服务 // 管理 IoT 卡和设备的轮询启用状态 // S2 修复:card 类型委托给 IotCardService(含 DB 写入 + callback 通知),避免绕过生命周期 type AssetPollingService struct { db *gorm.DB deviceStore *postgres.DeviceStore deviceBindingStore *postgres.DeviceSimBindingStore iotCardService *iotCardSvc.Service queueMgr *polling.PollingQueueManager logger *zap.Logger auditWriter *auditinfra.Writer } // NewAssetPollingService 创建资产轮询管控服务 func NewAssetPollingService( db *gorm.DB, deviceStore *postgres.DeviceStore, deviceBindingStore *postgres.DeviceSimBindingStore, iotCardService *iotCardSvc.Service, queueMgr *polling.PollingQueueManager, logger *zap.Logger, auditWriter *auditinfra.Writer, ) *AssetPollingService { return &AssetPollingService{ db: db, deviceStore: deviceStore, deviceBindingStore: deviceBindingStore, iotCardService: iotCardService, queueMgr: queueMgr, logger: logger, auditWriter: auditWriter, } } // UpdatePollingStatus 更新资产轮询状态 // assetType: "card" 或 "device" // assetID: 资产ID // enablePolling: 是否启用轮询 func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error { switch assetType { case constants.AssetTypeIotCard: // S2 修复:委托给 IotCardService,确保 DB 写入 + PollingCallback 回调一并触发 return s.iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling) case constants.AssetTypeDevice: device, getErr := s.deviceStore.GetByID(ctx, assetID) if getErr != nil { appErr := errors.Wrap(errors.CodeDatabaseError, getErr, "查询设备失败") result := constants.AuditResultFailed if stderrors.Is(getErr, gorm.ErrRecordNotFound) { appErr = errors.New(errors.CodeNotFound, "设备不存在") result = constants.AuditResultDenied } s.recordDevicePollingFailure(ctx, &model.Device{Model: gorm.Model{ID: assetID}}, enablePolling, result, appErr) return appErr } if err := s.updateDevicePollingStatus(ctx, device, enablePolling); err != nil { s.recordDevicePollingFailure(ctx, device, enablePolling, constants.AuditResultFailed, err) return err } bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID) if err != nil { s.logger.Warn("查询设备绑定卡失败,绑定卡轮询状态同步可能不完整", zap.Uint("device_id", assetID), zap.Error(err)) return nil } if len(bindings) == 0 { return nil } cardIDs := make([]uint, len(bindings)) for i, b := range bindings { cardIDs[i] = b.IotCardID } // 2. M3 修复:级联同步绑定卡的 enable_polling,防止生命周期事件绕过设备级设置 if syncErr := s.iotCardService.BatchUpdatePollingStatus(ctx, cardIDs, enablePolling); syncErr != nil { s.logger.Warn("批量同步绑定卡轮询状态失败", zap.Uint("device_id", assetID), zap.Error(syncErr)) } if !enablePolling { for _, b := range bindings { if rmErr := s.queueMgr.RemoveFromAllQueues(ctx, b.IotCardID); rmErr != nil { s.logger.Warn("从队列移除卡失败", zap.Uint("card_id", b.IotCardID), zap.Error(rmErr)) } } } return nil default: return errors.New(errors.CodeInvalidParam, "资产类型无效,支持 card 或 device") } } func (s *AssetPollingService) updateDevicePollingStatus(ctx context.Context, device *model.Device, enablePolling bool) error { if s.db == nil || s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置") } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if device.EnablePolling != enablePolling { result := tx.Model(&model.Device{}).Where("id = ? AND enable_polling = ?", device.ID, device.EnablePolling).Update("enable_polling", enablePolling) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新设备轮询状态失败") } if result.RowsAffected == 0 { return errors.New(errors.CodeConflict, "轮询状态已变更,请刷新后重试") } } return s.appendDevicePollingAudit(ctx, tx, device, enablePolling, constants.AuditResultSuccess, nil) }) } func (s *AssetPollingService) appendDevicePollingAudit(ctx context.Context, tx *gorm.DB, device *model.Device, enablePolling bool, result string, businessErr error) error { if device == nil || device.ID == 0 { return errors.New(errors.CodeInvalidStatus, "设备审计资源不完整") } id := strconv.FormatUint(uint64(device.ID), 10) errorCode, errorSummary := auditErrorInfo(businessErr) return s.auditWriter.Append(ctx, tx, auditinfra.AppendInput{ ActionCode: constants.AuditActionDevicePollingStatusUpdated, Summary: "更新设备轮询开关", Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary, ScopeType: constants.AuditScopePlatform, Resources: []auditinfra.ResourceInput{{ Type: constants.AuditResourceDevice, ID: &id, Key: auditinfra.DeviceResourceKey(device), DisplayName: device.VirtualNo, Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget, IdentitySnapshot: auditinfra.DeviceIdentitySnapshot(device), BeforeData: map[string]any{"enable_polling": device.EnablePolling}, AfterData: map[string]any{"enable_polling": enablePolling}, SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备轮询开关已更新", }}, }) } func (s *AssetPollingService) recordDevicePollingFailure(ctx context.Context, device *model.Device, enablePolling bool, result string, businessErr error) { if s == nil || s.db == nil || s.auditWriter == nil || device == nil || device.ID == 0 { return } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.appendDevicePollingAudit(ctx, tx, device, enablePolling, result, businessErr) }) if err == nil { return } linkage := auditcontext.From(ctx) errorCode, _ := auditErrorInfo(businessErr) auditfailure.RecordSecondaryWriteFailure(constants.AuditActionDevicePollingStatusUpdated, strconv.FormatUint(uint64(device.ID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, err) } func auditErrorInfo(err error) (string, string) { if err == nil { return "", "" } var appErr *errors.AppError if stderrors.As(err, &appErr) { return strconv.Itoa(appErr.Code), appErr.Error() } return "", err.Error() }