feat: 停复机规则演练与终态自动关闭轮询
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 加列与约束,并为"已销户""仅支持风险复机"置位
This commit is contained in:
邹进
2026-10-25 17:52:40 +09:00
parent fad7dbfdff
commit af8249158d
33 changed files with 1227 additions and 56 deletions

View File

@@ -0,0 +1,181 @@
package stopresumerule
import (
"context"
"sort"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// 明细分页默认值与上限。
const (
defaultPageSize = 20
maxPageSize = 500
)
// EnsureAllowed 校验演练与导出权限:仅超级管理员与平台账号。
func EnsureAllowed(userType int) error {
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// BuildFilter 把请求参数转换为演练筛选;假设开启与关闭的账户不得重叠。
func BuildFilter(carrierID *uint, stopReason, assumeOn, assumeOff string) (Filter, error) {
on, err := parseIDs(assumeOn, "assume_control_on")
if err != nil {
return Filter{}, err
}
off, err := parseIDs(assumeOff, "assume_control_off")
if err != nil {
return Filter{}, err
}
control := make(map[uint]bool, len(on)+len(off))
for _, id := range on {
control[id] = true
}
for _, id := range off {
if control[id] {
return Filter{}, errors.New(errors.CodeInvalidParam, "同一运营商账户不能同时假设开启和关闭实名管控")
}
control[id] = false
}
return Filter{CarrierID: carrierID, StopReason: strings.TrimSpace(stopReason), RealnameControl: control}, nil
}
func parseIDs(raw, field string) ([]uint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
id, err := strconv.ParseUint(part, 10, 64)
if err != nil || id == 0 {
return nil, errors.New(errors.CodeInvalidParam, field+" 只能是逗号分隔的运营商账户ID")
}
ids = append(ids, uint(id))
}
return ids, nil
}
type summaryKey struct {
carrierID uint
stopReason string
result string
reason string
}
// DryRun 执行演练:全量分批判断,返回汇总与按 result 筛选后的分页明细。只读。
func (q *Query) DryRun(ctx context.Context, req *dto.StopResumeRuleDryRunRequest) (*dto.StopResumeRuleDryRunResponse, error) {
if err := EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil {
return nil, err
}
filter, err := BuildFilter(req.CarrierID, req.StopReason, req.AssumeControlOn, req.AssumeControlOff)
if err != nil {
return nil, err
}
page, size := req.Page, req.PageSize
if page < 1 {
page = 1
}
if size < 1 {
size = defaultPageSize
}
if size > maxPageSize {
size = maxPageSize
}
offset := (page - 1) * size
resp := &dto.StopResumeRuleDryRunResponse{Page: page, PageSize: size, Items: []dto.StopResumeRuleDryRunItem{}}
counts := map[summaryKey]int64{}
names := map[uint]string{}
var matched int64
err = q.Scan(ctx, filter, func(rows []Row) error {
for index := range rows {
row := &rows[index]
resp.Totals.Total++
switch row.Result {
case stopresume.ResultWillResume:
resp.Totals.WillResume++
case stopresume.ResultWillStop:
resp.Totals.WillStop++
default:
resp.Totals.NoAction++
}
counts[summaryKey{row.CarrierID, row.StopReason, row.Result, row.Reason}]++
names[row.CarrierID] = row.CarrierName
if req.Result != "" && row.Result != req.Result {
continue
}
if matched >= int64(offset) && len(resp.Items) < size {
resp.Items = append(resp.Items, ToItem(row))
}
matched++
}
return nil
})
if err != nil {
return nil, err
}
resp.Total = matched
resp.Summary = buildSummary(counts, names)
return resp, nil
}
func buildSummary(counts map[summaryKey]int64, names map[uint]string) []dto.StopResumeRuleDryRunSummaryItem {
items := make([]dto.StopResumeRuleDryRunSummaryItem, 0, len(counts))
for key, count := range counts {
items = append(items, dto.StopResumeRuleDryRunSummaryItem{
CarrierID: key.carrierID, CarrierName: names[key.carrierID],
StopReason: key.stopReason, StopReasonName: stopReasonName(key.stopReason),
Result: key.result, ResultName: stopresume.ResultName(key.result),
Reason: key.reason, ReasonName: stopresume.ReasonName(key.reason), Count: count,
})
}
sort.Slice(items, func(i, j int) bool {
a, b := items[i], items[j]
if a.CarrierID != b.CarrierID {
return a.CarrierID < b.CarrierID
}
if a.StopReason != b.StopReason {
return a.StopReason < b.StopReason
}
if a.Result != b.Result {
return a.Result < b.Result
}
return a.Reason < b.Reason
})
return items
}
// ToItem 把演练行转换为接口明细。
func ToItem(row *Row) dto.StopResumeRuleDryRunItem {
return dto.StopResumeRuleDryRunItem{
CardID: row.CardID, ICCID: row.ICCID, CarrierID: row.CarrierID, CarrierName: row.CarrierName,
DeviceID: row.DeviceID, NetworkStatus: row.NetworkStatus,
StopReason: row.StopReason, StopReasonName: stopReasonName(row.StopReason),
CardCategory: row.CardCategory, RealnameOK: row.RealnameOK,
Result: row.Result, ResultName: stopresume.ResultName(row.Result),
Reason: row.Reason, ReasonName: stopresume.ReasonName(row.Reason),
}
}
// stopReasonName 返回当前停机原因名称;空停因显示为"无"。
func stopReasonName(reason string) string {
if strings.TrimSpace(reason) == "" {
return "无"
}
return constants.AgentOpenAPIStopReasonName(reason)
}

View File

@@ -0,0 +1,329 @@
// 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
}