fix: 不轮询清单审查问题
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>
This commit is contained in:
邹进
2026-10-26 03:18:54 +09:00
parent 7ddb9312e3
commit 4139732719
5 changed files with 49 additions and 3 deletions

View File

@@ -98,7 +98,12 @@ func NewRegistry() *Registry {
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}, {Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
} }
// 轮询开关可由后台人工、文件导入 Worker、订单套餐生效(C 端、开放接口、支付回调)与改有效期触发,均需留下审计事实。
iotCardPollingStatusUpdated.AllowedActor, iotCardPollingStatusUpdated.Source = "", ""
iotCardPollingStatusUpdated.AllowedOrigins = pollingToggleOrigins()
iotCardPollingStatusBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardPollingStatusBatchUpdated, "批量更新 IoT 卡轮询开关") iotCardPollingStatusBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardPollingStatusBatchUpdated, "批量更新 IoT 卡轮询开关")
iotCardPollingStatusBatchUpdated.AllowedActor, iotCardPollingStatusBatchUpdated.Source = "", ""
iotCardPollingStatusBatchUpdated.AllowedOrigins = pollingToggleOrigins()
iotCardBatchDeleted := ActionDefinition{ iotCardBatchDeleted := ActionDefinition{
Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡", Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡",
Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh,
@@ -135,6 +140,8 @@ func NewRegistry() *Registry {
deviceDeactivated := deviceAction(constants.AuditActionDeviceDeactivated, "停用设备资产", constants.AuditActorAccount, constants.AuditSourceAdminAPI) deviceDeactivated := deviceAction(constants.AuditActionDeviceDeactivated, "停用设备资产", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceDeactivated.Risk = constants.AuditRiskHigh deviceDeactivated.Risk = constants.AuditRiskHigh
devicePollingStatusUpdated := deviceAction(constants.AuditActionDevicePollingStatusUpdated, "更新设备轮询开关", constants.AuditActorAccount, constants.AuditSourceAdminAPI) devicePollingStatusUpdated := deviceAction(constants.AuditActionDevicePollingStatusUpdated, "更新设备轮询开关", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
devicePollingStatusUpdated.AllowedActor, devicePollingStatusUpdated.Source = "", ""
devicePollingStatusUpdated.AllowedOrigins = pollingToggleOrigins()
deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备") deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备")
deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备") deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备")
deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备") deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备")
@@ -1707,3 +1714,15 @@ func (r *Registry) Resource(resourceType string) (ResourceDefinition, bool) {
resource, ok := r.resources[resourceType] resource, ok := r.resources[resourceType]
return resource, ok return resource, ok
} }
// pollingToggleOrigins 是轮询开关变更允许的审计来源(后台、Worker、开放接口、C 端、支付回调)。
func pollingToggleOrigins() []ActionOrigin {
return []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
}
}

View File

@@ -4,6 +4,7 @@ package pollingpause
import ( import (
"context" "context"
"strconv"
"strings" "strings"
"time" "time"
@@ -68,9 +69,10 @@ func Record(ctx context.Context, tx *gorm.DB, change Change, fallback string) er
ClosedBy: origin.OperatorID, ClosedAt: now, ImportTaskID: origin.ImportTaskID, CreatedAt: now, UpdatedAt: now, ClosedBy: origin.OperatorID, ClosedAt: now, ImportTaskID: origin.ImportTaskID, CreatedAt: now, UpdatedAt: now,
} }
// 部分唯一索引 (asset_type, asset_id) WHERE status = 1:已有已关闭记录时保留原记录。 // 部分唯一索引 (asset_type, asset_id) WHERE status = 1:已有已关闭记录时保留原记录。
// 谓词必须写成字面量:启用预编译语句时,参数化的 WHERE 在通用执行计划下无法匹配部分唯一索引。
err := tx.WithContext(ctx).Clauses(clause.OnConflict{ err := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}}, Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}},
TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "status = ?", Vars: []any{constants.PollingPauseStatusClosed}}}}, TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: closedStatusPredicate}}},
DoNothing: true, DoNothing: true,
}).Create(row).Error }).Create(row).Error
if err != nil { if err != nil {
@@ -119,6 +121,9 @@ func RiskEnableDeniedReason(extend string) string {
return "风险停机,不能开启轮询" return "风险停机,不能开启轮询"
} }
// closedStatusPredicate 是部分唯一索引 uq_card_polling_pause_open 的谓词字面量。
var closedStatusPredicate = "status = " + strconv.Itoa(constants.PollingPauseStatusClosed)
func truncate(s string, max int) string { func truncate(s string, max int) string {
runes := []rune(s) runes := []rune(s)
if len(runes) <= max { if len(runes) <= max {

View File

@@ -1772,6 +1772,7 @@ func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, pay
} }
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) (err error) { func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) (err error) {
alreadyPaid := false
order, err := s.orderStore.GetByID(ctx, orderID) order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
@@ -1852,6 +1853,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
switch currentOrder.PaymentStatus { switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid: case model.PaymentStatusPaid:
alreadyPaid = true
return nil return nil
case model.PaymentStatusCancelled: case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
@@ -1912,6 +1914,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
switch currentOrder.PaymentStatus { switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid: case model.PaymentStatusPaid:
alreadyPaid = true
return nil return nil
case model.PaymentStatusCancelled: case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付") return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
@@ -1974,7 +1977,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
if err != nil { if err != nil {
return err return err
} }
s.afterOrderActivated(ctx, order, false) if !alreadyPaid {
// 重复支付请求不再恢复轮询,避免把运营在两次请求之间刚关闭的轮询重新打开。
s.afterOrderActivated(ctx, order, false)
}
return nil return nil
} }

View File

@@ -3,6 +3,7 @@ package polling
import ( import (
"context" "context"
stderrors "errors" stderrors "errors"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -259,6 +260,10 @@ func (s *AssetPollingService) RestoreAssetPolling(ctx context.Context, assetType
return false return false
} }
ctx = pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: source, Reason: constants.PollingToggleSourceName(source) + ",自动恢复轮询"}) 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})
}
restored := false restored := false
switch assetType { switch assetType {
case constants.AssetTypeIotCard: case constants.AssetTypeIotCard:

View File

@@ -6,6 +6,7 @@ import (
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
stderrors "errors" stderrors "errors"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"io" "io"
"os" "os"
"strings" "strings"
@@ -77,6 +78,8 @@ func (h *PollingStatusImportHandler) Handle(ctx context.Context, t *asynq.Task)
} }
rowCtx := pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceImport, Reason: task.Reason, rowCtx := pollingpause.WithOrigin(ctx, pollingpause.Origin{Source: constants.PollingToggleSourceImport, Reason: task.Reason,
ImportTaskID: task.ID, OperatorID: task.Creator}) ImportTaskID: task.ID, OperatorID: task.Creator})
rowCtx = auditcontext.With(rowCtx, auditcontext.Context{ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePollingStatusImport,
ActorName: "轮询开关导入任务", Source: constants.AuditSourceWorker, CorrelationID: task.TaskNo})
results := make(model.PollingStatusImportResults, 0, len(identifiers)) results := make(model.PollingStatusImportResults, 0, len(identifiers))
success, skipped, failed := 0, 0, 0 success, skipped, failed := 0, 0, 0
for index, identifier := range identifiers { for index, identifier := range identifiers {
@@ -131,8 +134,16 @@ func (h *PollingStatusImportHandler) resolve(ctx context.Context, assetType, ide
err := h.db.WithContext(ctx).Select("id").Where("imei = ? OR virtual_no = ?", identifier, identifier).Order("id").First(&device).Error err := h.db.WithContext(ctx).Select("id").Where("imei = ? OR virtual_no = ?", identifier, identifier).Order("id").First(&device).Error
return device.ID, err return device.ID, err
} }
// 与 IotCardStore.GetByICCID 同口径:19 位按 iccid_19、20 位按 iccid_20 匹配(联通卡两种位数都可识别)。
column := "iccid"
switch len(identifier) {
case 19:
column = "iccid_19"
case 20:
column = "iccid_20"
}
var card model.IotCard var card model.IotCard
err := h.db.WithContext(ctx).Select("id").Where("iccid = ?", identifier).First(&card).Error err := h.db.WithContext(ctx).Select("id").Where(column+" = ?", identifier).First(&card).Error
return card.ID, err return card.ID, err
} }