All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m31s
- 写清单 ON CONFLICT 的部分索引谓词改为字面量:预编译语句的通用执行计划下参数化谓词匹配不到部分唯一索引,第 6 次起关闭轮询全部失败 - 轮询开关审计允许 Worker、开放接口、C 端与支付回调来源;导入与自动恢复补审计操作者 - 文件导入按 19/20 位 ICCID 匹配 - 钱包重复支付不再恢复轮询 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.3 KiB
Go
134 lines
5.3 KiB
Go
// 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])
|
||
}
|