diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index aaea325..bd3addb 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -105,23 +105,40 @@ func (s *StopResumeService) CheckAndStopCard(ctx context.Context, cardID uint) e return nil } -// ResumeCardIfStopped 任务 24.4: 购买套餐后自动复机 -// 当购买新套餐且卡之前因流量耗尽停机时,自动复机 -func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint) error { - // 查询卡信息 +// ResumeCardIfStopped 套餐激活或流量重置后自动复机入口 +// 支持 iot_card 和 device 两种载体类型: +// - iot_card:对单张卡执行实名检查 → 停机原因检查 → 分布式锁 → Gateway → 更新 DB +// - 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": + return s.resumeDeviceCards(ctx, carrierID) + default: + return nil + } +} + +// resumeSingleCard 对单张卡执行复机逻辑 +// 依次检查:已开机则跳过 → 未实名则跳过(WARN)→ 非流量耗尽停机则跳过 → 加锁 → 调 Gateway → 更新 DB +func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { return err } - // 幂等性检查:如果已经是开机状态,跳过 if card.NetworkStatus == constants.NetworkStatusOnline { - s.logger.Debug("卡已处于开机状态,跳过", - zap.Uint("card_id", cardID)) return nil } - // 只有因流量耗尽停机的卡才自动复机 + if card.RealNameStatus != constants.RealNameStatusVerified { + s.logger.Warn("未实名卡跳过自动复机", + zap.Uint("card_id", cardID), + zap.String("iccid", card.ICCID)) + return nil + } + if card.StopReason != constants.StopReasonTrafficExhausted { s.logger.Debug("卡非流量耗尽停机,不自动复机", zap.Uint("card_id", cardID), @@ -129,7 +146,21 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint return nil } - // 任务 24.5: 调用运营商复机接口(带重试机制) + // 分布式锁防止同一张卡并发触发多次 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), @@ -138,23 +169,50 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, cardID uint return err } - // 更新卡状态 now := time.Now() if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, - "stop_reason": "", // 清空停机原因 + "stop_reason": "", }).Error; err != nil { - return err + // Gateway 成功但 DB 更新失败:记录 ERROR 供人工排查,不阻断流程 + s.logger.Error("复机 Gateway 成功但 DB 更新失败", + zap.Uint("card_id", cardID), + zap.String("iccid", card.ICCID), + zap.Error(err)) + return nil } - s.logger.Info("卡购买套餐后已自动复机", + s.logger.Info("卡已自动复机", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) return nil } +// resumeDeviceCards 对设备绑定的所有已绑定卡逐一执行复机逻辑 +// 部分卡实名、部分未实名时:已实名卡复机,未实名卡跳过,互不影响 +func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error { + bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) + if err != nil { + s.logger.Warn("查询设备绑定卡失败", + zap.Uint("device_id", deviceID), + zap.Error(err)) + return nil + } + + for _, binding := range bindings { + if resumeErr := s.resumeSingleCard(ctx, binding.IotCardID); resumeErr != nil { + s.logger.Warn("设备绑定卡复机失败,继续处理其他卡", + zap.Uint("device_id", deviceID), + zap.Uint("card_id", binding.IotCardID), + zap.Error(resumeErr)) + } + } + + return nil +} + // hasAvailablePackage 检查是否有可用套餐 func (s *StopResumeService) hasAvailablePackage(ctx context.Context, cardID uint) (bool, error) { var count int64