// Package stopresumerule 提供停复机规则演练的只读查询:按批读取卡与资产当前事实,按新规则判断目标动作。 // // 演练不调用网关、不写任何卡、指令或调度状态;始终按新规则判断,不受停复机总开关影响; // 可传入假设的通道实名管控配置预演,实际配置不变。 package stopresumerule import ( "context" "time" "gorm.io/gorm" carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold" "github.com/break/junhong_cmp_fiber/internal/domain/stopresume" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // batchSize 是演练每批读取的卡数。 const batchSize = 1000 // StopReasonEmpty 是筛选"停机原因为空"时使用的取值。 const StopReasonEmpty = "none" // Filter 是演练筛选条件。 type Filter struct { CarrierID *uint StopReason string // 为空不限;StopReasonEmpty 表示停机原因为空 // RealnameControl 假设的通道实名管控:键为运营商账户 ID,值为是否开启;未列出的账户按实际配置。 RealnameControl map[uint]bool } // Row 是一张卡的演练结果。 type Row struct { CardID uint ICCID string CarrierID uint CarrierName string DeviceID uint // 无有效绑定为 0 NetworkStatus int StopReason string CardCategory string RealnameOK bool stopresume.Decision } // Query 停复机规则演练查询。 type Query struct { db *gorm.DB now func() time.Time } // NewQuery 创建演练查询。 func NewQuery(db *gorm.DB) *Query { return &Query{db: db, now: time.Now} } // Scan 按卡 ID 升序分批演练全部符合筛选的开机与停机卡,每批回调一次。 func (q *Query) Scan(ctx context.Context, filter Filter, fn func([]Row) error) error { carriers, err := q.loadCarriers(ctx) if err != nil { return err } var lastID uint for { if err := ctx.Err(); err != nil { return err } var cards []model.IotCard if err := q.cardQuery(ctx, filter).Where("id > ?", lastID).Order("id ASC").Limit(batchSize).Find(&cards).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询演练卡失败") } if len(cards) == 0 { return nil } lastID = cards[len(cards)-1].ID rows, err := q.evaluate(ctx, cards, carriers, filter) if err != nil { return err } if err := fn(rows); err != nil { return err } } } // Count 统计符合筛选的卡数(导出行数)。 func (q *Query) Count(ctx context.Context, filter Filter) (int64, error) { var total int64 if err := q.cardQuery(ctx, filter).Count(&total).Error; err != nil { return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计演练卡数失败") } return total, nil } // Page 按卡 ID 升序取 offset/limit 范围内的卡并演练,供导出分片使用。 func (q *Query) Page(ctx context.Context, filter Filter, offset, limit int) ([]Row, error) { carriers, err := q.loadCarriers(ctx) if err != nil { return nil, err } var cards []model.IotCard if err := q.cardQuery(ctx, filter).Order("id ASC").Offset(offset).Limit(limit).Find(&cards).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询演练卡失败") } return q.evaluate(ctx, cards, carriers, filter) } func (q *Query) cardQuery(ctx context.Context, filter Filter) *gorm.DB { query := q.db.WithContext(ctx).Model(&model.IotCard{}). Select("id", "iccid", "carrier_id", "network_status", "stop_reason", "enable_polling", "real_name_status", "card_category", "is_standalone"). Where("network_status IN ?", []int{constants.NetworkStatusOffline, constants.NetworkStatusOnline}) if filter.CarrierID != nil { query = query.Where("carrier_id = ?", *filter.CarrierID) } switch filter.StopReason { case "": case StopReasonEmpty: query = query.Where("COALESCE(stop_reason, '') = ''") default: query = query.Where("stop_reason = ?", filter.StopReason) } return query } type carrierFacts struct { name string realnameControl bool resetDay int } func (q *Query) loadCarriers(ctx context.Context) (map[uint]carrierFacts, error) { var carriers []model.Carrier if err := q.db.WithContext(ctx).Select("id", "carrier_name", "realname_control_enabled", "data_reset_day"). Find(&carriers).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询运营商账户失败") } result := make(map[uint]carrierFacts, len(carriers)) for _, carrier := range carriers { result[carrier.ID] = carrierFacts{ name: carrier.CarrierName, realnameControl: carrier.RealnameControlEnabled == 1, resetDay: carrier.DataResetDay, } } return result, nil } // evaluate 批量加载一批卡的绑定、设备轮询开关、套餐与通道阈值锁,再逐卡判断。 func (q *Query) evaluate(ctx context.Context, cards []model.IotCard, carriers map[uint]carrierFacts, filter Filter) ([]Row, error) { if len(cards) == 0 { return nil, nil } cardIDs := make([]uint, 0, len(cards)) for _, card := range cards { cardIDs = append(cardIDs, card.ID) } deviceOf, err := q.activeBindings(ctx, cards) if err != nil { return nil, err } deviceIDs := uniqueValues(deviceOf) devicePolling, err := q.devicePolling(ctx, deviceIDs) if err != nil { return nil, err } cardPackages, err := q.packages(ctx, "iot_card_id", cardIDs) if err != nil { return nil, err } devicePackages, err := q.packages(ctx, "device_id", deviceIDs) if err != nil { return nil, err } locked, err := q.lockedCards(ctx, cardIDs, carriers) if err != nil { return nil, err } rows := make([]Row, 0, len(cards)) for _, card := range cards { deviceID := deviceOf[card.ID] facts := packageFactsOf(cardPackages[card.ID]) suspended := !card.EnablePolling if deviceID != 0 { facts = packageFactsOf(devicePackages[deviceID]) if enabled, ok := devicePolling[deviceID]; ok && !enabled { suspended = true } } carrier, known := carriers[card.CarrierID] control := carrier.realnameControl if assumed, ok := filter.RealnameControl[card.CarrierID]; ok { control = assumed } verified := card.RealNameStatus == constants.RealNameStatusVerified decision := stopresume.Decide(stopresume.Facts{ NetworkStatus: card.NetworkStatus, PollingSuspended: suspended, HasValidPackage: facts.hasValid, TrafficExhausted: facts.exhausted, RealnameVerified: verified, CarrierKnown: known, RealnameControl: control, ThresholdLocked: locked[card.ID], CurrentStopReason: card.StopReason, }) rows = append(rows, Row{ CardID: card.ID, ICCID: card.ICCID, CarrierID: card.CarrierID, CarrierName: carrier.name, DeviceID: deviceID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason, CardCategory: card.CardCategory, RealnameOK: !control || verified, Decision: decision, }) } return rows, nil } // activeBindings 返回非独立卡的有效绑定设备;与停复机评估一致,独立卡不查绑定。 func (q *Query) activeBindings(ctx context.Context, cards []model.IotCard) (map[uint]uint, error) { bound := make([]uint, 0, len(cards)) for _, card := range cards { if !card.IsStandalone { bound = append(bound, card.ID) } } result := make(map[uint]uint, len(bound)) if len(bound) == 0 { return result, nil } var bindings []model.DeviceSimBinding if err := q.db.WithContext(ctx).Select("iot_card_id", "device_id"). Where("iot_card_id IN ? AND bind_status = ?", bound, constants.BindStatusBound). Find(&bindings).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") } for _, binding := range bindings { result[binding.IotCardID] = binding.DeviceID } return result, nil } func (q *Query) devicePolling(ctx context.Context, deviceIDs []uint) (map[uint]bool, error) { result := make(map[uint]bool, len(deviceIDs)) if len(deviceIDs) == 0 { return result, nil } var devices []model.Device if err := q.db.WithContext(ctx).Select("id", "enable_polling").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备轮询状态失败") } for _, device := range devices { result[device.ID] = device.EnablePolling } return result, nil } // packages 读取载体生效中与已用完的套餐,按载体分组。 func (q *Query) packages(ctx context.Context, column string, ids []uint) (map[uint][]model.PackageUsage, error) { result := make(map[uint][]model.PackageUsage, len(ids)) if len(ids) == 0 { return result, nil } var usages []model.PackageUsage if err := q.db.WithContext(ctx).Where(column+" IN ?", ids). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). Find(&usages).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") } for _, usage := range usages { key := usage.IotCardID if column == "device_id" { key = usage.DeviceID } result[key] = append(result[key], usage) } return result, nil } type packageFacts struct { hasValid bool exhausted bool } // packageFactsOf 与评估主流程同口径:有生效中套餐即有套餐;生效或已用完的套餐全部达到上限才算流量用完。 func packageFactsOf(usages []model.PackageUsage) packageFacts { facts := packageFacts{} if len(usages) == 0 { return facts } facts.exhausted = true for index := range usages { if usages[index].Status == constants.PackageUsageStatusActive { facts.hasValid = true } if !usages[index].IsTrafficDepleted() { facts.exhausted = false } } return facts } // lockedCards 返回持本周期通道阈值锁的卡;与停复机评估同口径,账户不存在或重置日非法时按仍生效处理。 func (q *Query) lockedCards(ctx context.Context, cardIDs []uint, carriers map[uint]carrierFacts) (map[uint]bool, error) { result := make(map[uint]bool) var locks []model.CarrierTrafficThresholdLock if err := q.db.WithContext(ctx).Select("card_id", "carrier_id", "period_start"). Where("card_id IN ? AND status = ?", cardIDs, carrierthresholddomain.LockStatusLocked). Find(&locks).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道阈值锁失败") } now := q.now() for _, lock := range locks { carrier, ok := carriers[lock.CarrierID] if !ok { result[lock.CardID] = true continue } periodStart, err := carrierthresholddomain.PeriodStart(now, carrier.resetDay) if err != nil || lock.PeriodStart.Equal(periodStart) { result[lock.CardID] = true } } return result, nil } func uniqueValues(m map[uint]uint) []uint { seen := make(map[uint]bool, len(m)) result := make([]uint, 0, len(m)) for _, value := range m { if value != 0 && !seen[value] { seen[value] = true result = append(result, value) } } return result }