Files
junhong_cmp_fiber/internal/query/stopresumerule/query.go
邹进 af8249158d
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
feat: 停复机规则演练与终态自动关闭轮询
统一停复机规则转正的第一次发布:总开关仍关闭,现有停复机行为不变。

- 新增停复机规则演练:GET /stop-resume-rule/dry-run 按新规则对开机与停机卡只判断不执行,返回运营商账户 × 停机原因 × 判断结果汇总与分页明细,可假设通道实名管控配置;POST /stop-resume-rule/dry-run/export 导出明细(场景 stop_resume_rule_dry_run);仅超级管理员与平台账号
- 风险停机、已销户关闭轮询不再限于独立卡,只关该卡自身轮询,不级联设备
- 错误分类规则增加 auto_disable_polling,仅终态类别可勾选;停复机(含设备停复机)命中该规则时不重试、关闭该卡轮询、回写网关扩展状态并写审计 iot_card.terminal_disable_polling
- 迁移 000250 加列与约束,并为"已销户""仅支持风险复机"置位
2026-10-25 17:52:40 +09:00

330 lines
11 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 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
}