package iot_card import ( "context" "time" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" stderrors "errors" "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/model" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "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" ) // StopResumeServiceInterface 停复机服务接口 // 供 4 个 Task Handler 通过接口注入,避免循环依赖 type StopResumeServiceInterface interface { // EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机 EvaluateAndAct(ctx context.Context, card *model.IotCard) error } // 编译时验证 StopResumeService 实现了 StopResumeServiceInterface var _ StopResumeServiceInterface = (*StopResumeService)(nil) // StopResumeService 停复机服务 // 处理 IoT 卡的自动停机、复机和手动停复机逻辑 type StopResumeService struct { redis *redis.Client iotCardStore *postgres.IotCardStore packageUsageStore *postgres.PackageUsageStore deviceSimBindingStore *postgres.DeviceSimBindingStore gatewayClient *gateway.Client logger *zap.Logger assetAuditService AssetAuditService maxRetries int retryInterval time.Duration } // NewStopResumeService 创建停复机服务 func NewStopResumeService( redis *redis.Client, iotCardStore *postgres.IotCardStore, packageUsageStore *postgres.PackageUsageStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, gatewayClient *gateway.Client, logger *zap.Logger, assetAuditService AssetAuditService, ) *StopResumeService { return &StopResumeService{ redis: redis, iotCardStore: iotCardStore, packageUsageStore: packageUsageStore, deviceSimBindingStore: deviceSimBindingStore, gatewayClient: gatewayClient, logger: logger, assetAuditService: assetAuditService, maxRetries: 3, retryInterval: 2 * time.Second, } } // EvaluateAndAct 停复机统一入口 // 根据卡的当前网络状态,自动判断并执行停机或复机操作 // 设备/单卡维度通过 card.IsStandalone 推导(false 则为设备维度,停/复机覆盖设备下所有卡) func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotCard) error { switch card.NetworkStatus { case constants.NetworkStatusOnline: // 卡在线,检查是否需要停机 reasons, err := s.checkStopReasons(ctx, card) if err != nil { return err } if len(reasons) == 0 { return nil } // 取最高优先级原因(第一个) primaryReason := reasons[0] if !card.IsStandalone && isDeviceScopeReason(primaryReason) { deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card) if lookupErr != nil { s.logger.Error("查询设备绑定关系失败,降级为单卡停机", zap.Uint("card_id", card.ID), zap.Error(lookupErr)) // 降级:查不到绑定时按单卡处理 } else if bound { s.stopDeviceCards(ctx, deviceID, primaryReason) return nil } } return s.stopCardWithRetry(ctx, card, primaryReason) case constants.NetworkStatusOffline: // 卡停机,检查是否可以复机 // 只处理轮询系统引起的停机原因(手动停机等不自动复机) if !isPollingStopReason(card.StopReason) { return nil } shouldResume, err := s.shouldResume(ctx, card) if err != nil { return err } if !shouldResume { return nil } if !card.IsStandalone { deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card) if lookupErr != nil { s.logger.Error("查询设备绑定关系失败,降级为单卡复机", zap.Uint("card_id", card.ID), zap.Error(lookupErr)) } else if bound { s.resumeDeviceCards(ctx, deviceID) return nil } } return s.resumeSingleCard(ctx, card.ID) default: return nil } } // isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机) func isPollingStopReason(reason string) bool { switch reason { case constants.StopReasonTrafficExhausted, constants.StopReasonNoPackage, constants.StopReasonNotRealname: return true } return false } // isDeviceScopeReason 判断停机原因是否应扩散到设备维度 // 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡 func isDeviceScopeReason(reason string) bool { return reason == constants.StopReasonNoPackage || reason == constants.StopReasonTrafficExhausted } // getCardDeviceID 获取卡所属设备ID(非独立卡时有效) // 返回:deviceID, isDeviceBound, error func (s *StopResumeService) getCardDeviceID(ctx context.Context, card *model.IotCard) (uint, bool, error) { if card.IsStandalone { return 0, false, nil } binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) if err != nil { if stderrors.Is(err, gorm.ErrRecordNotFound) { return 0, false, nil } return 0, false, err } if binding == nil { return 0, false, nil } return binding.DeviceID, true, nil } // hasValidPackage 检查卡是否有有效套餐(status IN 0,1) // 修复Bug1:绑定设备的卡查 device_id,独立卡查 iot_card_id func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) { if !card.IsStandalone { deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card) if lookupErr != nil { return false, lookupErr } if bound { return s.packageUsageStore.HasValidByCarrier(ctx, "device", deviceID) } } return s.packageUsageStore.HasValidByCarrier(ctx, "iot_card", card.ID) } // isTrafficExhausted 检查卡的流量是否已耗尽 // 条件:活跃套餐 status=2(已用完),或 data_usage_mb >= data_limit_mb(data_limit_mb > 0) // 业务约定:data_limit_mb=0 表示无限流量套餐,永远不判定为耗尽 func (s *StopResumeService) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error) { carrierType := "iot_card" carrierID := card.ID if !card.IsStandalone { deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card) if lookupErr != nil { return false, lookupErr } if bound { carrierType = "device" carrierID = deviceID } } pkg, err := s.packageUsageStore.GetActiveOrDepletedByCarrier(ctx, carrierType, carrierID) if err != nil { if stderrors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, err } if pkg.Status == constants.PackageUsageStatusDepleted { return true, nil } return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB, nil } // isRealnameOK 检查卡是否满足实名要求 // 行业卡(card_category='industry')无需实名;其他卡需 real_name_status=1 func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool { return card.CardCategory == constants.CardCategoryIndustry || card.RealNameStatus == constants.RealNameStatusVerified } // checkStopReasons 检查卡的停机原因列表,按优先级排序 // 优先级:no_package > traffic_exhausted > not_realname func (s *StopResumeService) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error) { var reasons []string // 条件A:无有效套餐(优先级最高) hasPackage, err := s.hasValidPackage(ctx, card) if err != nil { return nil, err } if !hasPackage { reasons = append(reasons, constants.StopReasonNoPackage) } // 条件B:虚流量耗尽 exhausted, err := s.isTrafficExhausted(ctx, card) if err != nil { return nil, err } if exhausted { reasons = append(reasons, constants.StopReasonTrafficExhausted) } // 条件C:非行业卡且未实名 if !s.isRealnameOK(card) { reasons = append(reasons, constants.StopReasonNotRealname) } return reasons, nil } // shouldResume 检查停机卡是否满足复机条件 // 须同时满足:有有效套餐 + 流量未耗尽 + 实名OK func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCard) (bool, error) { hasPackage, err := s.hasValidPackage(ctx, card) if err != nil { return false, err } if !hasPackage { return false, nil } exhausted, err := s.isTrafficExhausted(ctx, card) if err != nil { return false, err } if exhausted { return false, nil } return s.isRealnameOK(card), nil } // stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁) // 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用 func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) { // 设备维度幂等锁:防止多协程同时对同一设备停机 lockKey := constants.RedisPollingDeviceOpLockKey(deviceID) locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() if !locked { s.logger.Debug("设备停机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID)) return } defer s.redis.Del(ctx, lockKey) // 查询设备下所有在线卡 cards, err := s.iotCardStore.ListByDeviceIDAndNetworkStatus(ctx, deviceID, constants.NetworkStatusOnline) if err != nil { s.logger.Error("查询设备在线卡失败", zap.Uint("device_id", deviceID), zap.Error(err)) return } for _, card := range cards { if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil { s.logger.Warn("设备卡停机失败,继续处理其他卡", zap.Uint("device_id", deviceID), zap.Uint("card_id", card.ID), zap.Error(stopErr)) } } } // resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁) // 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过 func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) { // 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发 lockKey := constants.RedisPollingDeviceOpLockKey(deviceID) locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() if !locked { s.logger.Debug("设备复机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID)) return } defer s.redis.Del(ctx, lockKey) // 查询设备下因轮询原因停机的卡 cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, deviceID) if err != nil { s.logger.Error("查询设备停机卡失败", zap.Uint("device_id", deviceID), zap.Error(err)) return } for _, card := range cards { if !s.isRealnameOK(card) { if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil { s.logger.Warn("更新未实名卡停机原因失败", zap.Uint("card_id", card.ID), zap.Error(updateErr)) } continue } if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil { s.logger.Warn("设备卡复机失败,继续处理其他卡", zap.Uint("device_id", deviceID), zap.Uint("card_id", card.ID), zap.Error(resumeErr)) } } } // CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性) // 新代码应优先使用 EvaluateAndAct func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { return err } return s.EvaluateAndAct(ctx, card) } // ResumeCardIfStopped 套餐激活或流量重置后自动复机入口 // 支持 iot_card 和 device 两种载体类型 func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error { switch carrierType { case "iot_card": return s.resumeSingleCard(ctx, carrierID) case "device": s.resumeDeviceCards(ctx, carrierID) return nil default: return nil } } // resumeSingleCard 对单张卡执行复机逻辑 // 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: assetAuditSvc.SystemOperator("系统任务"), OperationType: constants.AssetAuditOpCardAutoStart, OperationDesc: "自动复机执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: cardID, }) return err } if card.NetworkStatus == constants.NetworkStatusOnline { return nil } // 只处理轮询系统引起的停机 if !isPollingStopReason(card.StopReason) { s.logger.Debug("卡非轮询停机原因,不自动复机", zap.Uint("card_id", cardID), zap.String("stop_reason", card.StopReason)) return nil } // 检查复机条件 resume, err := s.shouldResume(ctx, card) if err != nil { return err } if !resume { return nil } // 分布式锁防止同一张卡并发触发多次 Gateway 调用,TTL 30s lockKey := constants.RedisCardResumeLockKey(cardID) locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() if lockErr != nil { s.logger.Warn("获取复机分布式锁失败,跳过本次复机", zap.Uint("card_id", cardID), zap.Error(lockErr)) return nil } if !locked { s.logger.Debug("复机操作进行中,跳过", zap.Uint("card_id", cardID)) return nil } defer s.redis.Del(ctx, lockKey) if err := s.resumeCardWithRetry(ctx, card); err != nil { s.logger.Error("调用运营商复机接口失败", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(err)) errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: assetAuditSvc.SystemOperator("系统任务"), OperationType: constants.AssetAuditOpCardAutoStart, OperationDesc: "自动复机执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": card.NetworkStatus, "stop_reason": card.StopReason, }, }) return err } now := time.Now() if err := s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", }); err != nil { s.logger.Error("复机 Gateway 成功但 DB 更新失败", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(err)) errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: assetAuditSvc.SystemOperator("系统任务"), OperationType: constants.AssetAuditOpCardAutoStart, OperationDesc: "自动复机执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": constants.NetworkStatusOffline, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOnline, "stop_reason": "", }, }) return nil } s.invalidatePollingCache(ctx, card.ID) s.logger.Info("卡已自动复机", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: assetAuditSvc.SystemOperator("系统任务"), OperationType: constants.AssetAuditOpCardAutoStart, OperationDesc: "自动复机", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": constants.NetworkStatusOffline, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOnline, "stop_reason": "", }, }) return nil } // stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因 func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error { operator := assetAuditSvc.SystemOperator("系统任务") operationType := constants.AssetAuditOpCardAutoStop operationDesc := "自动停卡" if stopReason == constants.StopReasonManual { operator = assetAuditSvc.OperatorFromContext(ctx) operationType = constants.AssetAuditOpCardManualStop operationDesc = "手动停卡" } if s.gatewayClient == nil { failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(failErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: operator, OperationType: operationType, OperationDesc: operationDesc + "执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return failErr } s.logger.Info("调用网关停机", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), zap.String("stop_reason", stopReason)) var lastErr error for i := 0; i < s.maxRetries; i++ { if i > 0 { s.logger.Debug("重试调用停机接口", zap.Int("attempt", i+1), zap.String("iccid", card.ICCID)) time.Sleep(s.retryInterval) } err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{ CardNo: card.ICCID, }) if err == nil { s.logger.Info("网关停机成功", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) now := time.Now() if updateErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ "network_status": constants.NetworkStatusOffline, "stopped_at": now, "stop_reason": stopReason, }); updateErr != nil { s.logger.Error("停机 Gateway 成功但 DB 更新失败", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), zap.Error(updateErr)) errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(updateErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: operator, OperationType: operationType, OperationDesc: operationDesc + "执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": card.NetworkStatus, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOffline, "stop_reason": stopReason, }, }) } s.invalidatePollingCache(ctx, card.ID) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: operator, OperationType: operationType, OperationDesc: operationDesc, ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": card.NetworkStatus, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOffline, "stop_reason": stopReason, }, }) return nil } lastErr = err s.logger.Warn("调用停机接口失败,准备重试", zap.Int("attempt", i+1), zap.String("iccid", card.ICCID), zap.Error(err)) } errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(lastErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ Operator: operator, OperationType: operationType, OperationDesc: operationDesc + "执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": card.NetworkStatus, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOffline, "stop_reason": stopReason, }, }) return lastErr } // resumeCardWithRetry 调用运营商复机接口(带重试机制) func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error { if s.gatewayClient == nil { return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") } s.logger.Info("调用网关复机", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) var lastErr error for i := 0; i < s.maxRetries; i++ { if i > 0 { s.logger.Debug("重试调用复机接口", zap.Int("attempt", i+1), zap.String("iccid", card.ICCID)) time.Sleep(s.retryInterval) } err := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{ CardNo: card.ICCID, }) if err == nil { s.logger.Info("网关复机成功", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) return nil } lastErr = err s.logger.Warn("调用复机接口失败,准备重试", zap.Int("attempt", i+1), zap.String("iccid", card.ICCID), zap.Error(err)) } return lastErr } // ManualStopCard 手动停机单张卡(通过ICCID) func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { denyErr := errors.New(errors.CodeNotFound, "卡不存在") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStop, OperationDesc: "手动停卡被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetIdentifier: iccid, }) return denyErr } if card.RealNameStatus != constants.RealNameStatusVerified { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStop, OperationDesc: "手动停卡被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return denyErr } // 检查绑定设备是否在复机保护期 if s.deviceSimBindingStore != nil && s.redis != nil { binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) if bindErr == nil && binding != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result() if exists > 0 { denyErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStop, OperationDesc: "手动停卡被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), AfterData: map[string]any{ "device_id": binding.DeviceID, }, }) return denyErr } } else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) { wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStop, OperationDesc: "手动停卡执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return wrapErr } } if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil { return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试") } return nil } // ManualStartCard 手动复机单张卡(通过ICCID) func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { denyErr := errors.New(errors.CodeNotFound, "卡不存在") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetIdentifier: iccid, }) return denyErr } if card.RealNameStatus != constants.RealNameStatusVerified { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return denyErr } // 检查绑定设备是否在停机保护期 if s.deviceSimBindingStore != nil && s.redis != nil { binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) if bindErr == nil && binding != nil { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result() if exists > 0 { denyErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机被拒绝", ResultStatus: constants.AssetAuditResultDenied, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), AfterData: map[string]any{ "device_id": binding.DeviceID, }, }) return denyErr } } else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) { wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return wrapErr } } if err := s.resumeCardWithRetry(ctx, card); err != nil { wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: cardSnapshot(card), }) return wrapErr } now := time.Now() if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", }); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机执行失败", ResultStatus: constants.AssetAuditResultFailed, ErrorCode: errorCode, ErrorMsg: errorMsg, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": card.NetworkStatus, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOnline, "stop_reason": "", }, }) return wrapErr } s.invalidatePollingCache(ctx, card.ID) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ OperationType: constants.AssetAuditOpCardManualStart, OperationDesc: "手动复机", ResultStatus: constants.AssetAuditResultSuccess, AssetID: card.ID, AssetIdentifier: card.ICCID, BeforeData: map[string]any{ "network_status": card.NetworkStatus, "stop_reason": card.StopReason, }, AfterData: map[string]any{ "network_status": constants.NetworkStatusOnline, "stop_reason": "", }, }) return nil } // invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建 func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) { if s.redis == nil { return } key := constants.RedisPollingCardInfoKey(cardID) if err := s.redis.Del(ctx, key).Err(); err != nil { s.logger.Warn("删除轮询卡缓存失败", zap.Uint("card_id", cardID)) } }