Files
junhong_cmp_fiber/internal/query/stopresumerule/dry_run.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

182 lines
5.4 KiB
Go

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)
}