Files
junhong_cmp_fiber/internal/infrastructure/pollingpause/recorder.go
邹进 4139732719
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m31s
fix: 不轮询清单审查问题
- 写清单 ON CONFLICT 的部分索引谓词改为字面量:预编译语句的通用执行计划下参数化谓词匹配不到部分唯一索引,第 6 次起关闭轮询全部失败
- 轮询开关审计允许 Worker、开放接口、C 端与支付回调来源;导入与自动恢复补审计操作者
- 文件导入按 19/20 位 ICCID 匹配
- 钱包重复支付不再恢复轮询

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

134 lines
5.3 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 pollingpause 在调用方事务内写不轮询清单(tb_card_polling_pause)。
// 所有改 enable_polling 的入口在同一事务内调用 Record;调度只认 enable_polling,不读本表(add-polling-pause-list-and-batch-toggle)。
package pollingpause
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// Origin 是一次轮询开关变更的来源信息。
type Origin struct {
Source string // constants.PollingToggleSource*
Reason string
ImportTaskID uint
OperatorID uint // 0 时取 ctx 中的登录账号
}
type originKey struct{}
// WithOrigin 声明本次轮询开关变更的来源;入口未声明时由 Record 的默认来源决定。
func WithOrigin(ctx context.Context, origin Origin) context.Context {
return context.WithValue(ctx, originKey{}, origin)
}
// OriginFrom 返回 ctx 中声明的来源;未声明时返回 fallback 来源。
func OriginFrom(ctx context.Context, fallback string) Origin {
origin, ok := ctx.Value(originKey{}).(Origin)
if !ok || origin.Source == "" {
origin = Origin{Source: fallback, Reason: origin.Reason, ImportTaskID: origin.ImportTaskID, OperatorID: origin.OperatorID}
}
if origin.OperatorID == 0 {
origin.OperatorID = middleware.GetUserIDFromContext(ctx)
}
return origin
}
// Change 是一个资产的轮询开关变更。
type Change struct {
AssetType string // constants.AssetTypeIotCard / AssetTypeDevice
AssetID uint
Identifier string // 卡 ICCID;设备 IMEI(为空时虚拟号)
CarrierID uint
Before bool
After bool
}
// Record 在调用方事务内写不轮询清单:开启 → 关闭新增"已关闭"记录(同一资产已有已关闭记录时不重复),
// 关闭 → 开启把已关闭记录标为"已恢复"。开关未变化时不写。fallback 为入口默认来源。
func Record(ctx context.Context, tx *gorm.DB, change Change, fallback string) error {
if tx == nil || change.AssetID == 0 || change.Before == change.After {
return nil
}
origin := OriginFrom(ctx, fallback)
now := time.Now()
if !change.After {
row := &model.CardPollingPause{
AssetType: change.AssetType, AssetID: change.AssetID, Identifier: truncate(change.Identifier, 100), CarrierID: change.CarrierID,
Status: constants.PollingPauseStatusClosed, CloseSource: origin.Source, CloseReason: truncate(origin.Reason, 500),
ClosedBy: origin.OperatorID, ClosedAt: now, ImportTaskID: origin.ImportTaskID, CreatedAt: now, UpdatedAt: now,
}
// 部分唯一索引 (asset_type, asset_id) WHERE status = 1:已有已关闭记录时保留原记录。
// 谓词必须写成字面量:启用预编译语句时,参数化的 WHERE 在通用执行计划下无法匹配部分唯一索引。
err := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}},
TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: closedStatusPredicate}}},
DoNothing: true,
}).Create(row).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入不轮询清单失败")
}
return nil
}
err := tx.WithContext(ctx).Model(&model.CardPollingPause{}).
Where("asset_type = ? AND asset_id = ? AND status = ?", change.AssetType, change.AssetID, constants.PollingPauseStatusClosed).
Updates(map[string]any{
"status": constants.PollingPauseStatusRestored, "restore_source": origin.Source, "restore_reason": truncate(origin.Reason, 500),
"restored_by": origin.OperatorID, "restored_at": now, "updated_at": now,
}).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新不轮询清单失败")
}
return nil
}
// CardChange 构造卡的轮询开关变更。
func CardChange(card *model.IotCard, after bool) Change {
return Change{AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID, CarrierID: card.CarrierID,
Before: card.EnablePolling, After: after}
}
// DeviceChange 构造设备的轮询开关变更。
func DeviceChange(device *model.Device, after bool) Change {
identifier := device.IMEI
if identifier == "" {
identifier = device.VirtualNo
}
return Change{AssetType: constants.AssetTypeDevice, AssetID: device.ID, Identifier: identifier, Before: device.EnablePolling, After: after}
}
// IsRiskExtend 判断网关扩展状态是否为风险停机或已销户:这类卡必须关闭轮询,不能开启。
func IsRiskExtend(extend string) bool {
extend = strings.TrimSpace(extend)
return extend == constants.GatewayCardExtendRiskStop || extend == constants.GatewayCardExtendCancelled
}
// RiskEnableDeniedReason 返回风险停机或已销户卡不能开启轮询的原因。
func RiskEnableDeniedReason(extend string) string {
if strings.TrimSpace(extend) == constants.GatewayCardExtendCancelled {
return "已销户,不能开启轮询"
}
return "风险停机,不能开启轮询"
}
// closedStatusPredicate 是部分唯一索引 uq_card_polling_pause_open 的谓词字面量。
var closedStatusPredicate = "status = " + strconv.Itoa(constants.PollingPauseStatusClosed)
func truncate(s string, max int) string {
runes := []rune(s)
if len(runes) <= max {
return s
}
return string(runes[:max])
}