All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
统一停复机规则转正的第一次发布:总开关仍关闭,现有停复机行为不变。 - 新增停复机规则演练: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 加列与约束,并为"已销户""仅支持风险复机"置位
330 lines
11 KiB
Go
330 lines
11 KiB
Go
// 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
|
||
}
|