Files
junhong_cmp_fiber/internal/service/polling/polling_pause_service.go
邹进 ef5274864f
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m24s
fix: 改有效期自动恢复轮询识别 card 资产类型
资产解析返回的卡类型为 card,恢复轮询按 iot_card 匹配,导致改有效期后关闭轮询的卡不恢复。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:33:01 +09:00

304 lines
13 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"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pausequery "github.com/break/junhong_cmp_fiber/internal/query/pollingpause"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// 勾选批量开关轮询、告警页关闭轮询与不轮询清单查询(add-polling-pause-list-and-batch-toggle)。
// 每项独立事务(经 UpdatePollingStatus 同事务写 enable_polling、清单与审计,提交后按生命周期移入或移出轮询),部分失败不影响其他项。
// BatchUpdatePollingStatus 勾选批量开关轮询:单次最多 500、校验数据权限、逐项处理并返回结果;批量开启跳过风险停机与已销户的卡。
func (s *AssetPollingService) BatchUpdatePollingStatus(ctx context.Context, assetType string, req *dto.BatchUpdatePollingStatusRequest) (*dto.BatchUpdatePollingStatusResponse, error) {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "企业账号无权修改轮询开关")
}
if req == nil || req.EnablePolling == nil || len(req.AssetIDs) == 0 || len(req.AssetIDs) > constants.PollingBatchMaxSize {
return nil, errors.New(errors.CodeInvalidParam, "资产数量必须为 1~500")
}
seen := make(map[uint]bool, len(req.AssetIDs))
for _, id := range req.AssetIDs {
if id == 0 || seen[id] {
return nil, errors.New(errors.CodeInvalidParam, "资产ID不能为空或重复")
}
seen[id] = true
}
ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceBatch, Reason: req.Reason})
resp := &dto.BatchUpdatePollingStatusResponse{Items: make([]dto.PollingToggleItem, 0, len(req.AssetIDs)), Impact: impactFor(*req.EnablePolling)}
for _, id := range req.AssetIDs {
item := s.ToggleAsset(ctx, assetType, id, *req.EnablePolling, true)
countToggle(resp, item)
resp.Items = append(resp.Items, item)
}
return resp, nil
}
// ToggleAsset 改一个资产的轮询开关并返回逐项结果;scoped 为 true 时按当前账号数据权限查找资产。
// 来源与原因由 ctx 中的 pollingpause.Origin 决定。
func (s *AssetPollingService) ToggleAsset(ctx context.Context, assetType string, assetID uint, enable, scoped bool) dto.PollingToggleItem {
item := dto.PollingToggleItem{AssetID: assetID}
current, identifier, extend, err := s.loadToggleTarget(ctx, assetType, assetID, scoped)
item.Identifier = identifier
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return toggleResult(item, constants.PollingToggleItemForbidden, "无权限操作该资产或资产不存在")
}
return toggleResult(item, constants.PollingToggleItemFailed, "查询资产失败")
}
if current == enable {
return toggleResult(item, constants.PollingToggleItemSkipped, "已是目标状态")
}
if enable && assetType == constants.AssetTypeIotCard && pollingpause.IsRiskExtend(extend) {
return toggleResult(item, constants.PollingToggleItemSkipped, pollingpause.RiskEnableDeniedReason(extend))
}
if err := s.UpdatePollingStatus(ctx, assetType, assetID, enable); err != nil {
message := "修改轮询开关失败"
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
message = appErr.Error()
}
s.logger.Warn("修改资产轮询开关失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Error(err))
return toggleResult(item, constants.PollingToggleItemFailed, message)
}
return toggleResult(item, constants.PollingToggleItemSuccess, "")
}
func (s *AssetPollingService) loadToggleTarget(ctx context.Context, assetType string, assetID uint, scoped bool) (bool, string, string, error) {
query := s.db.WithContext(ctx)
switch assetType {
case constants.AssetTypeIotCard:
query = query.Model(&model.IotCard{})
if scoped {
query = middleware.ApplyStrictShopFilter(ctx, query)
}
var card model.IotCard
if err := query.Select("id", "iccid", "enable_polling", "gateway_extend").Where("id = ?", assetID).First(&card).Error; err != nil {
return false, "", "", err
}
return card.EnablePolling, card.ICCID, card.GatewayExtend, nil
case constants.AssetTypeDevice:
query = query.Model(&model.Device{})
if scoped {
query = middleware.ApplyStrictShopFilter(ctx, query)
}
var device model.Device
if err := query.Select("id", "imei", "virtual_no", "enable_polling").Where("id = ?", assetID).First(&device).Error; err != nil {
return false, "", "", err
}
identifier := device.IMEI
if identifier == "" {
identifier = device.VirtualNo
}
return device.EnablePolling, identifier, "", nil
default:
return false, "", "", errors.New(errors.CodeInvalidParam, "资产类型无效")
}
}
// ClosePollingByAlerts 告警页勾选批量关闭轮询:关闭告警对应卡的轮询(来源"停机被拒告警处理"),成功或已关闭的告警改为"已关闭轮询"。
func (s *AssetPollingService) ClosePollingByAlerts(ctx context.Context, req *dto.ClosePollingByAlertsRequest) (*dto.BatchUpdatePollingStatusResponse, error) {
if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil {
return nil, err
}
if req == nil || len(req.AlertIDs) == 0 || len(req.AlertIDs) > constants.PollingBatchMaxSize {
return nil, errors.New(errors.CodeInvalidParam, "告警数量必须为 1~500")
}
var alerts []model.PollingFailureAlert
if err := s.db.WithContext(ctx).Where("id IN ? AND alert_level = ? AND card_id > 0", req.AlertIDs, constants.PollingAlertLevelCard).
Find(&alerts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询失败告警失败")
}
operatorID := middleware.GetUserIDFromContext(ctx)
ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceAlert, Reason: req.Reason})
resp := &dto.BatchUpdatePollingStatusResponse{Items: []dto.PollingToggleItem{}, Impact: impactFor(false)}
done := map[uint]dto.PollingToggleItem{}
found := map[uint]bool{}
for _, alert := range alerts {
found[alert.ID] = true
item, ok := done[alert.CardID]
if !ok {
item = s.ToggleAsset(ctx, constants.AssetTypeIotCard, alert.CardID, false, false)
done[alert.CardID] = item
countToggle(resp, item)
resp.Items = append(resp.Items, item)
}
if item.Status != constants.PollingToggleItemSuccess && item.Status != constants.PollingToggleItemSkipped {
continue
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.PollingFailureAlert{}).Where("id = ?", alert.ID).Updates(map[string]any{
"status": constants.PollingAlertStatusPollingDisabled, "handled_by": operatorID, "handled_at": now,
"handle_remark": req.Reason, "updated_at": now,
}).Error; err != nil {
s.logger.Warn("更新告警为已关闭轮询失败", zap.Uint("alert_id", alert.ID), zap.Error(err))
}
}
for _, id := range req.AlertIDs {
if !found[id] {
item := toggleResult(dto.PollingToggleItem{AssetID: 0}, constants.PollingToggleItemForbidden, "告警不存在或不是卡级告警")
countToggle(resp, item)
resp.Items = append(resp.Items, item)
}
}
return resp, nil
}
// ListPauses 查询不轮询清单(仅超级管理员与平台账号)。
func (s *AssetPollingService) ListPauses(ctx context.Context, req *dto.CardPollingPauseListRequest) (*dto.CardPollingPausePageResult, error) {
if err := pausequery.EnsureAllowed(middleware.GetUserTypeFromContext(ctx)); err != nil {
return nil, err
}
filter, err := PauseFilter(req.AssetType, req.CarrierID, req.Source, req.Status, req.Identifier, req.OperatorID, req.StartTime, req.EndTime)
if err != nil {
return nil, err
}
page, size := req.Page, req.PageSize
if page <= 0 {
page = constants.DefaultPage
}
if size <= 0 {
size = constants.DefaultPageSize
}
if size > constants.MaxPageSize {
size = constants.MaxPageSize
}
query := pausequery.NewQuery(s.db)
total, err := query.Count(ctx, filter)
if err != nil {
return nil, err
}
rows, err := query.Page(ctx, filter, (page-1)*size, size)
if err != nil {
return nil, err
}
items := make([]dto.CardPollingPauseItem, 0, len(rows))
for index := range rows {
items = append(items, ToPauseItem(&rows[index]))
}
return &dto.CardPollingPausePageResult{Items: items, Total: total, Page: page, PageSize: size}, nil
}
// Impact 返回开关轮询的影响说明。
func (s *AssetPollingService) Impact() *dto.PollingToggleImpactResponse {
return &dto.PollingToggleImpactResponse{Disable: impactFor(false), Enable: impactFor(true)}
}
// PauseFilter 由请求参数构造不轮询清单筛选条件(列表与导出共用)。
func PauseFilter(assetType string, carrierID *uint, source string, status *int, identifier string, operatorID *uint, start, end string) (pausequery.Filter, error) {
filter := pausequery.Filter{AssetType: assetType, CarrierID: carrierID, Source: source, Status: status, Identifier: identifier, OperatorID: operatorID}
for _, item := range []struct {
raw string
target **time.Time
}{{start, &filter.StartTime}, {end, &filter.EndTime}} {
if item.raw == "" {
continue
}
parsed, err := time.Parse(time.RFC3339, item.raw)
if err != nil {
return filter, errors.New(errors.CodeInvalidParam, "时间格式必须为带时区的 RFC3339")
}
*item.target = &parsed
}
return filter, nil
}
// ToPauseItem 把清单查询行转换为接口返回项。
func ToPauseItem(row *pausequery.Row) dto.CardPollingPauseItem {
return dto.CardPollingPauseItem{
ID: row.ID, AssetType: row.AssetType, AssetID: row.AssetID, Identifier: row.Identifier, CarrierID: row.CarrierID,
CarrierName: row.CarrierName, EnablePolling: row.EnablePolling, GatewayExtend: row.GatewayExtend,
Status: row.Status, StatusName: constants.PollingPauseStatusName(row.Status),
CloseSource: row.CloseSource, CloseSourceName: constants.PollingToggleSourceName(row.CloseSource), CloseReason: row.CloseReason,
ClosedBy: row.ClosedBy, ClosedByName: row.ClosedByName, ClosedAt: row.ClosedAt, ImportTaskID: row.ImportTaskID,
RestoreSource: row.RestoreSource, RestoreSourceName: constants.PollingToggleSourceName(row.RestoreSource), RestoreReason: row.RestoreReason,
RestoredBy: row.RestoredBy, RestoredByName: row.RestoredByName, RestoredAt: row.RestoredAt,
}
}
func impactFor(enable bool) []string {
source := constants.PollingToggleImpactDisable
if enable {
source = constants.PollingToggleImpactEnable
}
return append([]string(nil), source...)
}
func toggleResult(item dto.PollingToggleItem, status int, reason string) dto.PollingToggleItem {
item.Status, item.StatusName, item.Reason = status, constants.PollingToggleItemStatusName(status), reason
return item
}
func countToggle(resp *dto.BatchUpdatePollingStatusResponse, item dto.PollingToggleItem) {
switch item.Status {
case constants.PollingToggleItemSuccess:
resp.SuccessCount++
case constants.PollingToggleItemSkipped:
resp.SkipCount++
default:
resp.FailCount++
}
}
// RestoreAssetPolling 套餐生效类入口自动恢复轮询(订单套餐生效、改有效期后有生效套餐):
// 卡关闭轮询且非风险停机、已销户时开启;设备关闭轮询时开启设备(级联开启设备下非风险停机、非已销户的卡),
// 设备已开启时开启设备下单独关闭的卡。返回是否恢复了任何资产。不做数据权限过滤(系统入口)。
func (s *AssetPollingService) RestoreAssetPolling(ctx context.Context, assetType string, assetID uint, source string) bool {
if s == nil || assetID == 0 {
return false
}
ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: source, Reason: constants.PollingToggleSourceName(source) + ",自动恢复轮询"})
if auditcontext.From(ctx).ActorKind == "" {
ctx = auditcontext.With(ctx, auditcontext.Context{ActorKind: constants.AuditActorSystemTask, ActorID: "polling_auto_restore",
ActorName: "套餐生效自动恢复轮询", Source: constants.AuditSourceWorker})
}
if assetType == constants.AssetResolveTypeCard {
assetType = constants.AssetTypeIotCard
}
restored := false
switch assetType {
case constants.AssetTypeIotCard:
restored = s.restoreCard(ctx, assetID)
case constants.AssetTypeDevice:
item := s.ToggleAsset(ctx, constants.AssetTypeDevice, assetID, true, false)
restored = item.Status == constants.PollingToggleItemSuccess
if item.Status == constants.PollingToggleItemSkipped {
bindings, err := s.deviceBindingStore.ListByDeviceID(ctx, assetID)
if err != nil {
s.logger.Warn("自动恢复轮询查询设备绑定卡失败", zap.Uint("device_id", assetID), zap.Error(err))
return false
}
for _, binding := range bindings {
if s.restoreCard(ctx, binding.IotCardID) {
restored = true
}
}
}
}
if restored {
s.logger.Info("套餐生效后自动恢复轮询", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.String("source", source))
}
return restored
}
// restoreCard 恢复单张卡的轮询;已开启、风险停机或已销户时不处理。
func (s *AssetPollingService) restoreCard(ctx context.Context, cardID uint) bool {
item := s.ToggleAsset(ctx, constants.AssetTypeIotCard, cardID, true, false)
if item.Status == constants.PollingToggleItemFailed {
s.logger.Warn("自动恢复卡轮询失败", zap.Uint("card_id", cardID), zap.String("reason", item.Reason))
}
return item.Status == constants.PollingToggleItemSuccess
}