fix: 不轮询清单审查问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m31s
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:
@@ -98,7 +98,12 @@ func NewRegistry() *Registry {
|
||||
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
|
||||
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
|
||||
}
|
||||
// 轮询开关可由后台人工、文件导入 Worker、订单套餐生效(C 端、开放接口、支付回调)与改有效期触发,均需留下审计事实。
|
||||
iotCardPollingStatusUpdated.AllowedActor, iotCardPollingStatusUpdated.Source = "", ""
|
||||
iotCardPollingStatusUpdated.AllowedOrigins = pollingToggleOrigins()
|
||||
iotCardPollingStatusBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardPollingStatusBatchUpdated, "批量更新 IoT 卡轮询开关")
|
||||
iotCardPollingStatusBatchUpdated.AllowedActor, iotCardPollingStatusBatchUpdated.Source = "", ""
|
||||
iotCardPollingStatusBatchUpdated.AllowedOrigins = pollingToggleOrigins()
|
||||
iotCardBatchDeleted := ActionDefinition{
|
||||
Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡",
|
||||
Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh,
|
||||
@@ -135,6 +140,8 @@ func NewRegistry() *Registry {
|
||||
deviceDeactivated := deviceAction(constants.AuditActionDeviceDeactivated, "停用设备资产", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
deviceDeactivated.Risk = constants.AuditRiskHigh
|
||||
devicePollingStatusUpdated := deviceAction(constants.AuditActionDevicePollingStatusUpdated, "更新设备轮询开关", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
|
||||
devicePollingStatusUpdated.AllowedActor, devicePollingStatusUpdated.Source = "", ""
|
||||
devicePollingStatusUpdated.AllowedOrigins = pollingToggleOrigins()
|
||||
deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备")
|
||||
deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备")
|
||||
deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备")
|
||||
@@ -1707,3 +1714,15 @@ func (r *Registry) Resource(resourceType string) (ResourceDefinition, bool) {
|
||||
resource, ok := r.resources[resourceType]
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package pollingpause
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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,
|
||||
}
|
||||
// 部分唯一索引 (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: "status = ?", Vars: []any{constants.PollingPauseStatusClosed}}}},
|
||||
TargetWhere: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: closedStatusPredicate}}},
|
||||
DoNothing: true,
|
||||
}).Create(row).Error
|
||||
if err != nil {
|
||||
@@ -119,6 +121,9 @@ func RiskEnableDeniedReason(extend string) string {
|
||||
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 {
|
||||
|
||||
@@ -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) {
|
||||
alreadyPaid := false
|
||||
order, err := s.orderStore.GetByID(ctx, orderID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -1852,6 +1853,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
|
||||
switch currentOrder.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
alreadyPaid = true
|
||||
return nil
|
||||
case model.PaymentStatusCancelled:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
|
||||
@@ -1912,6 +1914,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
|
||||
switch currentOrder.PaymentStatus {
|
||||
case model.PaymentStatusPaid:
|
||||
alreadyPaid = true
|
||||
return nil
|
||||
case model.PaymentStatusCancelled:
|
||||
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
|
||||
@@ -1974,7 +1977,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.afterOrderActivated(ctx, order, false)
|
||||
if !alreadyPaid {
|
||||
// 重复支付请求不再恢复轮询,避免把运营在两次请求之间刚关闭的轮询重新打开。
|
||||
s.afterOrderActivated(ctx, order, false)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package polling
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -259,6 +260,10 @@ func (s *AssetPollingService) RestoreAssetPolling(ctx context.Context, assetType
|
||||
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})
|
||||
}
|
||||
restored := false
|
||||
switch assetType {
|
||||
case constants.AssetTypeIotCard:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"io"
|
||||
"os"
|
||||
"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,
|
||||
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))
|
||||
success, skipped, failed := 0, 0, 0
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user