Files
junhong_cmp_fiber/internal/service/polling/config_tier_service.go
邹进 79b7580554
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m30s
feat: 下线设备保护期
- 后台设备停复机不再设置 1 小时保护期,也不再因保护期拒绝相反操作;单卡手动停复机去掉保护期检查
- 删除保护期一致性轮询(polling:protect)及其强制停复机方法;调度、初始化、监控、匹配预览不再包含保护期;已入队的旧任务由空处理函数直接确认,下一次发布移除
- 轮询配置接口不再接受与返回 protect_check_interval;并发配置不再返回 protect,查询、更新、重置该类型时拒绝
- 资产详情 device_protect_status 固定返回 none 并标记废弃,兼容老版本客户端
- 删除保护期 Redis key 与时长常量;停机原因 protect_period 保留供历史数据与开放接口映射
- 迁移 000249 只把 protect_check_interval、last_protect_check_at 列注释标记为已废弃,不删列不改数据
2026-10-25 17:19:10 +09:00

417 lines
17 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 polling
import (
"context"
"sort"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/google/uuid"
"github.com/hibiken/asynq"
"gorm.io/gorm"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingcore "github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// tierStatsCacheKey 是分层统计结果的缓存 Key(5 分钟)。
const tierStatsCacheKey = "polling:tier_stats"
// TaskEnqueuer 是提交异步任务的最小端口(由 pkg/queue.Client 实现)。
type TaskEnqueuer interface {
EnqueueTask(ctx context.Context, taskType string, payload interface{}, opts ...asynq.Option) error
}
// SetTierDeps 注入分层轮询所需的配置管理器与异步队列;未注入时预览、重算与统计不可用。
func (s *ConfigService) SetTierDeps(configMgr *pollingcore.PollingConfigManager, queueClient TaskEnqueuer) {
s.configMgr = configMgr
s.queueClient = queueClient
}
// UpdatePriorities 按给定顺序把配置优先级重排为 1、2、3…(事务内完成并逐条写统一审计),仅超级管理员可用。
func (s *ConfigService) UpdatePriorities(ctx context.Context, req *dto.UpdatePollingConfigPrioritiesRequest) error {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "仅超级管理员可调整轮询配置优先级")
}
operatorID := middleware.GetUserIDFromContext(ctx)
seen := map[uint]bool{}
for _, id := range req.OrderedIDs {
if id == 0 || seen[id] {
return errors.New(errors.CodeInvalidParam, "轮询配置优先级参数不合法:配置 ID 不能为空或重复")
}
seen[id] = true
}
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
for index, id := range req.OrderedIDs {
var cfg model.PollingConfig
if err := tx.WithContext(ctx).First(&cfg, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在:"+strconv.FormatUint(uint64(id), 10))
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询轮询配置失败")
}
priority := index + 1
if cfg.Priority == priority {
continue
}
before := cfg
if err := tx.WithContext(ctx).Model(&model.PollingConfig{}).Where("id = ?", id).
Updates(map[string]any{"priority": priority, "updated_by": operatorID, "updated_at": time.Now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "调整轮询配置优先级失败")
}
cfg.Priority = priority
if err := writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{
ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "调整轮询配置优先级",
ResourceType: constants.AuditResourcePollingConfig, ResourceID: cfg.ID,
ResourceKey: pollingManualTriggerKey(cfg.ID), DisplayName: cfg.ConfigName,
OperatorID: operatorID, IdentitySnapshot: pollingConfigIdentity(&cfg),
BeforeData: pollingConfigState(&before), AfterData: pollingConfigState(&cfg),
}); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
s.notifyConfigChanged(ctx, "priorities_updated")
return nil
}
// MatchPreview 返回卡的匹配维度、命中配置与各检查类型最终间隔及来源;可带未保存修改预演,不写任何数据。
func (s *ConfigService) MatchPreview(ctx context.Context, req *dto.PollingConfigMatchPreviewRequest) (*dto.PollingConfigMatchPreviewResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
if s.configMgr == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "轮询配置匹配预览能力未配置")
}
iccid := strings.TrimSpace(req.ICCID)
var card model.IotCard
if err := s.db.WithContext(ctx).Where("iccid = ?", iccid).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "IoT 卡不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询 IoT 卡失败")
}
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询配置失败")
}
configs, drafts, err := applyPreviewOverrides(configs, req.Overrides)
if err != nil {
return nil, err
}
if tier := s.configMgr.TierResolver(); tier != nil {
tier.Invalidate(card.ID)
}
matched, sources := s.configMgr.PreviewIntervalSources(configs, &card)
facts := s.configMgr.TierFactsOf(&card)
resp := &dto.PollingConfigMatchPreviewResponse{
Card: dto.PollingCardMatchFacts{
CardID: card.ID, ICCID: card.ICCID, CardCondition: pollingcore.CardConditionOf(&card), CardCategory: card.CardCategory,
CarrierID: card.CarrierID, AssetStatus: card.AssetStatus, EnablePolling: card.EnablePolling,
},
Matched: []dto.PollingConfigBrief{}, Intervals: []dto.PollingIntervalPreview{},
}
if facts.Known {
resp.Card.PackageState = facts.PackageState
resp.Card.PackageStateName = constants.PollingPackageStateName(facts.PackageState)
}
for _, cfg := range matched {
resp.Matched = append(resp.Matched, dto.PollingConfigBrief{ID: cfg.ID, ConfigName: cfg.ConfigName, Priority: cfg.Priority, Draft: drafts[cfg]})
}
firstID := uint(0)
if len(matched) > 0 {
firstID = matched[0].ID
}
for _, taskType := range previewTaskTypes {
item := dto.PollingIntervalPreview{TaskType: taskType, TaskTypeName: previewTaskTypeName(taskType), Mode: "unmatched"}
if source, ok := sources[taskType]; ok {
item.ConfigID, item.ConfigName = source.ConfigID, source.Config.ConfigName
item.Inherited = source.ConfigID != firstID
if source.Disabled {
item.Mode = "disabled"
} else {
item.Mode, item.Interval = "interval", source.Interval
}
}
resp.Intervals = append(resp.Intervals, item)
}
return resp, nil
}
var previewTaskTypes = []string{constants.TaskTypePollingCardStatus, constants.TaskTypePollingCarddata, constants.TaskTypePollingRealname, constants.TaskTypePollingPackage}
// applyPreviewOverrides 把未保存修改合并进启用配置快照并重新排序;返回草稿配置标记。
func applyPreviewOverrides(configs []*model.PollingConfig, overrides []dto.PollingConfigOverride) ([]*model.PollingConfig, map[*model.PollingConfig]bool, error) {
drafts := map[*model.PollingConfig]bool{}
if len(overrides) == 0 {
return configs, drafts, nil
}
byID := map[uint]int{}
out := make([]*model.PollingConfig, 0, len(configs)+len(overrides))
for _, cfg := range configs {
byID[cfg.ID] = len(out)
out = append(out, cfg)
}
removed := map[int]bool{}
for i := range overrides {
item := overrides[i]
if item.ID != 0 && item.Disabled {
if idx, ok := byID[item.ID]; ok {
removed[idx] = true
}
continue
}
draft := &model.PollingConfig{
ID: item.ID, ConfigName: item.ConfigName, CardCondition: item.CardCondition, CardCategory: item.CardCategory,
CarrierID: item.CarrierID, Priority: item.Priority, RealnameCheckInterval: item.RealnameCheckInterval,
CarddataCheckInterval: item.CarddataCheckInterval, PackageCheckInterval: item.PackageCheckInterval,
CardStatusCheckInterval: item.CardStatusCheckInterval,
PackageState: item.PackageState, AssetStatus: item.AssetStatus, PreResetWindow: item.PreResetWindow,
PreResetCarddataInterval: item.PreResetCarddataInterval, Status: 1,
}
if draft.ConfigName == "" {
draft.ConfigName = "未保存的配置"
}
if err := validatePollingConfig(draft); err != nil {
return nil, nil, err
}
drafts[draft] = true
if idx, ok := byID[item.ID]; ok && item.ID != 0 {
out[idx] = draft
continue
}
out = append(out, draft)
}
filtered := make([]*model.PollingConfig, 0, len(out))
for idx, cfg := range out {
if !removed[idx] {
filtered = append(filtered, cfg)
}
}
sort.SliceStable(filtered, func(i, j int) bool {
if filtered[i].Priority != filtered[j].Priority {
return filtered[i].Priority < filtered[j].Priority
}
// 草稿(ID 为 0)排在同优先级已有配置之后。
if filtered[i].ID == 0 || filtered[j].ID == 0 {
return filtered[j].ID == 0 && filtered[i].ID != 0
}
return filtered[i].ID < filtered[j].ID
})
return filtered, drafts, nil
}
// CreateRecalculateTask 创建"立即重算到期时间"异步任务(由 Worker 执行),仅超级管理员可用。
func (s *ConfigService) CreateRecalculateTask(ctx context.Context, req *dto.PollingRecalculateDueRequest) (*dto.PollingRecalculateDueResponse, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "仅超级管理员可重算轮询到期时间")
}
if s.queueClient == nil || s.redis == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "重算到期时间能力未配置")
}
taskID := uuid.NewString()
carrier := ""
if req.CarrierID != nil {
carrier = strconv.FormatUint(uint64(*req.CarrierID), 10)
}
now := time.Now().Format(time.RFC3339)
key := constants.RedisPollingRecalcProgressKey(taskID)
pipe := s.redis.Pipeline()
pipe.HSet(ctx, key, map[string]any{"status": constants.PollingRecalcStatusPending, "carrier_id": carrier, "created_at": now})
pipe.Expire(ctx, key, constants.PollingRecalcProgressTTLSeconds*time.Second)
if _, err := pipe.Exec(ctx); err != nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "创建重算任务失败")
}
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypePollingRecalculateDue,
pollingcore.RecalculateDuePayload{TaskID: taskID, CarrierID: req.CarrierID},
asynq.Queue(constants.QueueDataCleanup), asynq.MaxRetry(0), asynq.Timeout(30*time.Minute)); err != nil {
_ = s.redis.HSet(ctx, key, "status", constants.PollingRecalcStatusFailed, "error", "任务入队失败").Err()
return nil, errors.Wrap(errors.CodeServiceUnavailable, err, "重算任务入队失败")
}
return &dto.PollingRecalculateDueResponse{TaskID: taskID, Status: constants.PollingRecalcStatusPending, CarrierID: carrier, CreatedAt: now}, nil
}
// GetRecalculateTask 查询重算到期时间任务进度。
func (s *ConfigService) GetRecalculateTask(ctx context.Context, taskID string) (*dto.PollingRecalculateDueResponse, error) {
if s.redis == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "重算到期时间能力未配置")
}
values, err := s.redis.HGetAll(ctx, constants.RedisPollingRecalcProgressKey(taskID)).Result()
if err != nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "查询重算任务失败")
}
if len(values) == 0 {
return nil, errors.New(errors.CodeNotFound, "重算任务不存在或已过期")
}
atoi := func(v string) int { n, _ := strconv.Atoi(v); return n }
return &dto.PollingRecalculateDueResponse{
TaskID: taskID, Status: values["status"], CarrierID: values["carrier_id"], Total: atoi(values["total"]),
Processed: atoi(values["processed"]), Advanced: atoi(values["advanced"]), Error: values["error"],
CreatedAt: values["created_at"], StartedAt: values["started_at"], FinishedAt: values["finished_at"],
}, nil
}
// TierStats 统计开启轮询的卡按首个命中配置与套餐状态的分布,以及应告警的未匹配卡数;结果缓存 5 分钟。
func (s *ConfigService) TierStats(ctx context.Context) (*dto.PollingTierStatsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
if s.configMgr == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "分层统计能力未配置")
}
if s.redis != nil {
if raw, err := s.redis.Get(ctx, tierStatsCacheKey).Bytes(); err == nil {
var cached dto.PollingTierStatsResponse
if sonic.Unmarshal(raw, &cached) == nil {
return &cached, nil
}
}
}
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询配置失败")
}
names := map[uint]string{0: "未匹配任何配置"}
for _, cfg := range configs {
names[cfg.ID] = cfg.ConfigName
}
resp := &dto.PollingTierStatsResponse{ByPackageState: map[string]int{}, ByConfig: []dto.PollingTierStatItem{}}
counts := map[uint]int{}
var lastID uint
for {
var cards []*model.IotCard
if err := s.db.WithContext(ctx).Where("id > ? AND enable_polling = true AND deleted_at IS NULL", lastID).
Order("id ASC").Limit(1000).Find(&cards).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询轮询卡失败")
}
if len(cards) == 0 {
break
}
lastID = cards[len(cards)-1].ID
if tier := s.configMgr.TierResolver(); tier != nil {
tier.Prefill(ctx, cards)
}
for _, card := range cards {
resp.TotalCards++
facts := s.configMgr.TierFactsOf(card)
state := "unknown"
if facts.Known {
state = facts.PackageState
}
resp.ByPackageState[state]++
matched, _ := s.configMgr.PreviewIntervalSources(configs, card)
if len(matched) == 0 {
counts[0]++
if card.AssetStatus == constants.AssetStatusInStock {
resp.UnmatchedInStock++
} else if card.AssetStatus == constants.AssetStatusSold && facts.Known && facts.PackageState == constants.PollingPackageStateNone {
resp.UnmatchedSoldNoPackage++
}
continue
}
counts[matched[0].ID]++
}
}
for id, n := range counts {
resp.ByConfig = append(resp.ByConfig, dto.PollingTierStatItem{ConfigID: id, ConfigName: names[id], Cards: n})
}
sort.Slice(resp.ByConfig, func(i, j int) bool { return resp.ByConfig[i].Cards > resp.ByConfig[j].Cards })
resp.ComputedAt = time.Now().Format(time.RFC3339)
if s.redis != nil {
if raw, err := sonic.Marshal(resp); err == nil {
_ = s.redis.Set(ctx, tierStatsCacheKey, raw, 5*time.Minute).Err()
}
}
return resp, nil
}
// previewTaskTypeName 返回检查类型中文名称。
func previewTaskTypeName(taskType string) string {
return constants.PollingPriorityTaskTypeName(taskType)
}
// PreResetStats 按运营商账户统计重置日前加密效果:上一个与即将到来的重置周期内,窗口内查到流量的卡数、
// 有生效套餐但窗口内未查到的卡数,以及最后一次查询距重置时刻的分布。
func (s *ConfigService) PreResetStats(ctx context.Context) (*dto.PollingPreResetStatsResponse, error) {
if err := requirePlatformReader(ctx); err != nil {
return nil, err
}
if s.configMgr == nil || s.redis == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "重置日前加密统计能力未配置")
}
var rows []struct {
CarrierID uint
CarrierName string
Active int
}
err := s.db.WithContext(ctx).Raw(`
SELECT c.carrier_id, COALESCE(ca.carrier_name, '') AS carrier_name,
COUNT(*) FILTER (WHERE (c.is_standalone AND EXISTS (
SELECT 1 FROM tb_package_usage u WHERE u.iot_card_id = c.id AND u.status = ? AND u.deleted_at IS NULL))
OR (NOT c.is_standalone AND EXISTS (
SELECT 1 FROM tb_device_sim_binding b JOIN tb_package_usage u ON u.device_id = b.device_id AND u.status = ? AND u.deleted_at IS NULL
WHERE b.iot_card_id = c.id AND b.bind_status = 1 AND b.deleted_at IS NULL))) AS active
FROM tb_iot_card c LEFT JOIN tb_carrier ca ON ca.id = c.carrier_id
WHERE c.enable_polling = true AND c.deleted_at IS NULL
GROUP BY c.carrier_id, ca.carrier_name ORDER BY c.carrier_id`,
constants.PackageUsageStatusActive, constants.PackageUsageStatusActive).Scan(&rows).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计有生效套餐的卡失败")
}
resp := &dto.PollingPreResetStatsResponse{Carriers: []dto.PollingPreResetCarrierStat{}}
now := time.Now()
location, _ := time.LoadLocation("Asia/Shanghai")
for _, row := range rows {
previous, upcoming := s.configMgr.ResetMoments(ctx, row.CarrierID, now)
item := dto.PollingPreResetCarrierStat{CarrierID: row.CarrierID, CarrierName: row.CarrierName}
item.Previous = s.preResetCycle(ctx, row.CarrierID, previous, row.Active, location)
item.Upcoming = s.preResetCycle(ctx, row.CarrierID, upcoming, row.Active, location)
resp.Carriers = append(resp.Carriers, item)
}
return resp, nil
}
func (s *ConfigService) preResetCycle(ctx context.Context, carrierID uint, reset time.Time, active int, location *time.Location) dto.PollingPreResetCycleStat {
date := reset.Format("20060102")
stat := dto.PollingPreResetCycleStat{ResetAt: reset.In(location).Format("2006-01-02 15:04"), ActivePackageCards: active,
LastOffsetBuckets: map[string]int{"le_5": 0, "le_15": 0, "le_30": 0, "le_60": 0, "gt_60": 0}}
if n, err := s.redis.PFCount(ctx, constants.RedisPollingPreResetCheckedKey(carrierID, date)).Result(); err == nil {
stat.CheckedInWindow = n
}
if missing := int64(active) - stat.CheckedInWindow; missing > 0 {
stat.NotCheckedInWindow = missing
}
if values, err := s.redis.HVals(ctx, constants.RedisPollingPreResetOffsetKey(carrierID, date)).Result(); err == nil {
for _, raw := range values {
minutes, convErr := strconv.Atoi(raw)
if convErr != nil {
continue
}
switch {
case minutes <= 5:
stat.LastOffsetBuckets["le_5"]++
case minutes <= 15:
stat.LastOffsetBuckets["le_15"]++
case minutes <= 30:
stat.LastOffsetBuckets["le_30"]++
case minutes <= 60:
stat.LastOffsetBuckets["le_60"]++
default:
stat.LastOffsetBuckets["gt_60"]++
}
}
}
return stat
}