Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
541 lines
23 KiB
Go
541 lines
23 KiB
Go
package polling
|
||
|
||
import (
|
||
"context"
|
||
stderrors "errors"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
|
||
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
)
|
||
|
||
// PriorityManualService 人工优先轮询入口用例:入队、人工关闭与人工重触发。
|
||
//
|
||
// 与既有人工触发的关系:共用同包权限判定(permission.go),但**不继承**人工触发的每日次数上限与
|
||
// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,
|
||
// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。
|
||
//
|
||
// 三个入口的权限口径:入队与重触发按卡校验(超级管理员、平台账号与数据范围内的代理账号),
|
||
// 人工关闭只允许超级管理员与平台账号。三者对越权与不存在一律返回同一拒绝文案,不产生可枚举差异。
|
||
type PriorityManualService struct {
|
||
db *gorm.DB
|
||
iotCardStore *postgres.IotCardStore
|
||
priorityStore *postgres.PollingPriorityItemStore
|
||
publisher priorityapp.PromptPublisher
|
||
auditWriter *audit.Writer
|
||
logger *zap.Logger
|
||
}
|
||
|
||
// NewPriorityManualService 创建人工优先轮询入口用例。
|
||
func NewPriorityManualService(
|
||
db *gorm.DB,
|
||
iotCardStore *postgres.IotCardStore,
|
||
priorityStore *postgres.PollingPriorityItemStore,
|
||
publisher priorityapp.PromptPublisher,
|
||
logger *zap.Logger,
|
||
) *PriorityManualService {
|
||
return &PriorityManualService{
|
||
db: db,
|
||
iotCardStore: iotCardStore,
|
||
priorityStore: priorityStore,
|
||
publisher: publisher,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
// SetAudit 注入统一审计 Writer。
|
||
func (s *PriorityManualService) SetAudit(writer *audit.Writer) {
|
||
s.auditWriter = writer
|
||
}
|
||
|
||
// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖给定的任务类型集合。
|
||
type EnqueueResult struct {
|
||
CardID uint
|
||
TaskTypes []string
|
||
CreatedCount int
|
||
MergedCount int
|
||
Items []EnqueueItemResult
|
||
}
|
||
|
||
// EnqueueItemResult 是单个任务类型的入队结果。
|
||
type EnqueueItemResult struct {
|
||
ItemID uint
|
||
TaskType string
|
||
Status string
|
||
AttemptCount int
|
||
TriggerCount int
|
||
LastTriggeredAt time.Time
|
||
Created bool
|
||
}
|
||
|
||
// CloseResult 描述一次人工关闭的结果。
|
||
type CloseResult struct {
|
||
ItemID uint
|
||
CardID uint
|
||
TaskType string
|
||
Status string
|
||
DequeuedAt time.Time
|
||
Reason string
|
||
}
|
||
|
||
// RetriggerResult 描述一次人工重触发的结果:以原终态项为来源,为该卡该任务类型建立或合并活动项。
|
||
type RetriggerResult struct {
|
||
SourceItemID uint
|
||
Reason string
|
||
Enqueue *EnqueueResult
|
||
}
|
||
|
||
// priorityManualEnqueue 描述一次人工优先入队请求(入队与重触发共用同一实现)。
|
||
type priorityManualEnqueue struct {
|
||
CardID uint
|
||
Reason string
|
||
OperatorID uint
|
||
OperatorName string
|
||
TaskTypes []string
|
||
// DeniedActionCode 是本次入口被拒绝时登记的审计动作码。
|
||
DeniedActionCode string
|
||
// RetriggeredFromItemID 非 0 表示本次入队来自对某终态项的人工重触发:
|
||
// 除逐任务类型的入队审计外追加一条重触发审计,两者与新活动项同一事务。
|
||
RetriggeredFromItemID uint
|
||
}
|
||
|
||
// 人工入口的固定中文提示:原因必填与长度上限三项入口共用同一口径。
|
||
const (
|
||
priorityEnqueueReasonEmptyMessage = "人工优先入队原因不能为空"
|
||
priorityCloseReasonEmptyMessage = "人工关闭原因不能为空"
|
||
priorityRetriggerReasonEmptyMessage = "人工重触发原因不能为空"
|
||
priorityReasonTooLongMessage = "人工原因长度超出上限"
|
||
priorityCloseInvalidStateMessage = "仅待执行或执行中的优先轮询项可关闭"
|
||
priorityRetriggerInvalidStateMessage = "仅失败出队或已过期的优先轮询项可重触发"
|
||
)
|
||
|
||
// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。
|
||
//
|
||
// 一次优先需求 = 该卡的全部纳入任务类型(constants.PollingPriorityTaskTypes),
|
||
// 与执行集合一致;入队对象是卡,不做设备维度入队。
|
||
func (s *PriorityManualService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) {
|
||
trimmedReason, message := normalizePriorityReason(reason, priorityEnqueueReasonEmptyMessage)
|
||
if message != "" {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityManualDenied, errors.CodeInvalidParam, message,
|
||
pollingPriorityAttemptKey(cardID, operatorID),
|
||
map[string]any{"card_id": cardID, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
return s.enqueue(ctx, priorityManualEnqueue{
|
||
CardID: cardID,
|
||
Reason: trimmedReason,
|
||
OperatorID: operatorID,
|
||
OperatorName: middleware.GetUsernameFromContext(ctx),
|
||
TaskTypes: constants.PollingPriorityTaskTypes(),
|
||
DeniedActionCode: constants.AuditActionPollingPriorityManualDenied,
|
||
})
|
||
}
|
||
|
||
// Close 人工关闭未完成的优先项:原因必填,未完成项转已关闭终态并出队,既有执行结果与失败原因保留。
|
||
//
|
||
// 只允许超级管理员与平台账号:代理账号即使对该卡有数据权限也不能关闭优先项。
|
||
// 关闭不发起任何上游调用,也不删除既有事实;关闭原因只进审计,不写入失败原因。
|
||
func (s *PriorityManualService) Close(ctx context.Context, itemID uint, reason string, operatorID uint) (*CloseResult, error) {
|
||
trimmedReason, message := normalizePriorityReason(reason, priorityCloseReasonEmptyMessage)
|
||
if message != "" {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeInvalidParam, message,
|
||
pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
if err := requirePriorityPollingAdmin(ctx); err != nil {
|
||
// 越权与不存在共用同一文案:不暴露他人资源是否存在。
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeForbidden, pollingDeniedMessage,
|
||
pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
item, err := s.priorityStore.GetByIDScoped(ctx, itemID, nil)
|
||
if err != nil {
|
||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeForbidden, pollingDeniedMessage,
|
||
pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项失败")
|
||
}
|
||
if !constants.PollingPriorityActive(item.Status) {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeInvalidStatus,
|
||
priorityCloseInvalidStateMessage, pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "status": item.Status, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
|
||
card := s.loadCard(ctx, item.CardID)
|
||
closedAt := time.Now()
|
||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||
hit, closeErr := s.priorityStore.WithTx(tx).Close(ctx, itemID)
|
||
if closeErr != nil {
|
||
return closeErr
|
||
}
|
||
if !hit {
|
||
// 条件更新落空:该项已被并发执行终结,本次关闭不生效也不覆盖既有结论。
|
||
return errors.New(errors.CodeInvalidStatus, "优先轮询项已被并发执行终结,本次关闭未生效")
|
||
}
|
||
// 关闭事实与审计同事务:审计写不进去就整体回滚,不出现「已关闭但无审计」。
|
||
return writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||
ActionCode: constants.AuditActionPollingPriorityClosed,
|
||
Summary: "人工关闭卡轮询优先项",
|
||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||
ResourceID: itemID,
|
||
ResourceKey: pollingPriorityResourceKey(itemID),
|
||
DisplayName: "卡轮询优先项",
|
||
OperatorID: operatorID,
|
||
IdentitySnapshot: map[string]any{
|
||
"id": itemID, "card_id": item.CardID, "task_type": item.TaskType,
|
||
"status": item.Status, "trigger_type": item.TriggerType,
|
||
"attempt_count": item.AttemptCount, "manual_operator_id": operatorID,
|
||
},
|
||
BeforeData: map[string]any{"status": item.Status, "result": item.Result, "failure_reason": item.FailureReason},
|
||
AfterData: map[string]any{
|
||
"status": constants.PollingPriorityStatusClosed, "dequeued_at": closedAt,
|
||
"reason": trimmedReason, "manual_operator_id": operatorID,
|
||
"source": constants.AuditSourceAdminAPI, "occurred_at": closedAt,
|
||
},
|
||
Metadata: map[string]any{
|
||
"reason": trimmedReason, "card_id": item.CardID, "task_type": item.TaskType,
|
||
"previous_status": item.Status,
|
||
},
|
||
Cards: cardSlice(card),
|
||
})
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.logger.Info("人工关闭优先轮询项成功",
|
||
zap.Uint("priority_item_id", itemID), zap.Uint("operator_id", operatorID),
|
||
zap.String("previous_status", item.Status))
|
||
return &CloseResult{
|
||
ItemID: itemID, CardID: item.CardID, TaskType: item.TaskType,
|
||
Status: constants.PollingPriorityStatusClosed, DequeuedAt: closedAt, Reason: trimmedReason,
|
||
}, nil
|
||
}
|
||
|
||
// Retrigger 对失败出队或已过期的优先项执行人工重触发。
|
||
//
|
||
// 复用人工入队同一实现为该卡**该任务类型**建立新的活动项(尝试次数从零开始),
|
||
// 原因必填并与操作者一同写入审计;不绕过活动项唯一键代表的并发上限,
|
||
// 停复机持锁拒绝与任务类型范围仍由既有执行路径负责,本入口不发起任何上游调用。
|
||
func (s *PriorityManualService) Retrigger(ctx context.Context, itemID uint, reason string, operatorID uint) (*RetriggerResult, error) {
|
||
trimmedReason, message := normalizePriorityReason(reason, priorityRetriggerReasonEmptyMessage)
|
||
if message != "" {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeInvalidParam, message,
|
||
pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
// 先按主键定位来源项:不存在与越权在后续按卡校验中收敛为同一拒绝文案。
|
||
item, err := s.priorityStore.GetByIDScoped(ctx, itemID, nil)
|
||
if err != nil {
|
||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeForbidden, pollingDeniedMessage,
|
||
pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项失败")
|
||
}
|
||
// 数据范围按卡在业务边界校验,且必须先于状态判断:否则可依据状态差异枚举范围外资源是否存在。
|
||
if err := canManagePollingCard(ctx, s.iotCardStore, item.CardID); err != nil {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeForbidden, pollingDeniedMessage,
|
||
pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "card_id": item.CardID, "manual_operator_id": operatorID},
|
||
operatorID, cardSlice(s.loadCard(ctx, item.CardID)))
|
||
}
|
||
if !constants.PollingPriorityRetriggerable(item.Status) {
|
||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeInvalidStatus,
|
||
priorityRetriggerInvalidStateMessage, pollingPriorityResourceKey(itemID),
|
||
map[string]any{"priority_item_id": itemID, "status": item.Status, "manual_operator_id": operatorID},
|
||
operatorID, cardSlice(s.loadCard(ctx, item.CardID)))
|
||
}
|
||
|
||
result, err := s.enqueue(ctx, priorityManualEnqueue{
|
||
CardID: item.CardID,
|
||
Reason: trimmedReason,
|
||
OperatorID: operatorID,
|
||
OperatorName: middleware.GetUsernameFromContext(ctx),
|
||
TaskTypes: []string{item.TaskType},
|
||
DeniedActionCode: constants.AuditActionPollingPriorityRetriggered,
|
||
RetriggeredFromItemID: itemID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.logger.Info("人工重触发优先轮询项成功",
|
||
zap.Uint("source_item_id", itemID), zap.Uint("card_id", item.CardID),
|
||
zap.String("task_type", item.TaskType), zap.Uint("operator_id", operatorID))
|
||
return &RetriggerResult{SourceItemID: itemID, Reason: trimmedReason, Enqueue: result}, nil
|
||
}
|
||
|
||
// enqueue 执行人工优先入队:权限校验、逐任务类型建立或合并活动项、同事务写审计,提交后下发执行提示。
|
||
func (s *PriorityManualService) enqueue(ctx context.Context, input priorityManualEnqueue) (*EnqueueResult, error) {
|
||
if input.CardID == 0 {
|
||
return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||
}
|
||
// 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。
|
||
if err := canManagePollingCard(ctx, s.iotCardStore, input.CardID); err != nil {
|
||
return nil, s.denyManual(ctx, input.DeniedActionCode, errors.CodeForbidden, pollingDeniedMessage,
|
||
pollingPriorityAttemptKey(input.CardID, input.OperatorID),
|
||
map[string]any{"card_id": input.CardID, "manual_operator_id": input.OperatorID}, input.OperatorID, nil)
|
||
}
|
||
card, err := s.iotCardStore.GetByID(ctx, input.CardID)
|
||
if err != nil || card == nil {
|
||
return nil, s.denyManual(ctx, input.DeniedActionCode, errors.CodeForbidden, pollingDeniedMessage,
|
||
pollingPriorityAttemptKey(input.CardID, input.OperatorID),
|
||
map[string]any{"card_id": input.CardID, "manual_operator_id": input.OperatorID}, input.OperatorID, nil)
|
||
}
|
||
|
||
taskTypes := input.TaskTypes
|
||
result := &EnqueueResult{CardID: input.CardID, TaskTypes: taskTypes}
|
||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||
store := s.priorityStore.WithTx(tx)
|
||
// 资产身份与代理归属在入队时冻结,口径与迁移回填一致;查询不到卡行时按「资产为卡自身」兜底。
|
||
snapshots, snapshotErr := store.AssetSnapshots(ctx, []uint{input.CardID})
|
||
if snapshotErr != nil {
|
||
return snapshotErr
|
||
}
|
||
snapshot := snapshots[input.CardID]
|
||
for _, taskType := range taskTypes {
|
||
item := &model.PollingPriorityItem{
|
||
CardID: input.CardID,
|
||
TaskType: taskType,
|
||
TriggerType: constants.PollingPriorityTriggerManual,
|
||
ManualReason: input.Reason,
|
||
ManualOperatorID: input.OperatorID,
|
||
ManualOperatorName: input.OperatorName,
|
||
ShopIDSnapshot: card.ShopID,
|
||
AssetType: snapshot.AssetType,
|
||
AssetID: optionalUintValue(snapshot.AssetID),
|
||
DeviceNoSnapshot: snapshot.DeviceNo,
|
||
AgentShopIDSnapshot: snapshot.AgentShopID,
|
||
}
|
||
// 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。
|
||
// 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行,
|
||
// 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。
|
||
before, beforeErr := store.FindActive(ctx, input.CardID, taskType)
|
||
if beforeErr != nil {
|
||
return beforeErr
|
||
}
|
||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||
if insertErr != nil {
|
||
return insertErr
|
||
}
|
||
active, activeErr := store.FindActive(ctx, input.CardID, taskType)
|
||
if activeErr != nil {
|
||
return activeErr
|
||
}
|
||
if active == nil {
|
||
return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项")
|
||
}
|
||
if created {
|
||
result.CreatedCount++
|
||
} else {
|
||
result.MergedCount++
|
||
}
|
||
result.Items = append(result.Items, EnqueueItemResult{
|
||
ItemID: active.ID,
|
||
TaskType: active.TaskType,
|
||
Status: active.Status,
|
||
AttemptCount: active.AttemptCount,
|
||
TriggerCount: active.TriggerCount,
|
||
LastTriggeredAt: active.LastTriggeredAt,
|
||
Created: created,
|
||
})
|
||
// 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。
|
||
if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||
ActionCode: constants.AuditActionPollingPriorityEnqueued,
|
||
Summary: "人工优先入队",
|
||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||
ResourceID: active.ID,
|
||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||
DisplayName: "卡轮询优先项",
|
||
OperatorID: input.OperatorID,
|
||
IdentitySnapshot: map[string]any{
|
||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||
"status": active.Status, "trigger_type": active.TriggerType,
|
||
"trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID,
|
||
},
|
||
BeforeData: pollingPriorityBeforeData(before),
|
||
AfterData: map[string]any{
|
||
"status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes),
|
||
"trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt,
|
||
"manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID,
|
||
"source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(),
|
||
},
|
||
Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": input.CardID},
|
||
Cards: []*model.IotCard{card},
|
||
}); auditErr != nil {
|
||
return auditErr
|
||
}
|
||
if input.RetriggeredFromItemID != 0 {
|
||
if auditErr := s.writeRetriggerAudit(ctx, tx, input, active, taskType, card); auditErr != nil {
|
||
return auditErr
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。
|
||
if s.publisher == nil {
|
||
s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", input.CardID))
|
||
} else {
|
||
for _, taskType := range taskTypes {
|
||
if publishErr := s.publisher.EnqueuePriority(ctx, input.CardID, taskType); publishErr != nil {
|
||
// 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。
|
||
s.logger.Error("下发优先轮询执行提示失败",
|
||
zap.Uint("card_id", input.CardID), zap.String("task_type", taskType), zap.Error(publishErr))
|
||
}
|
||
}
|
||
}
|
||
s.logger.Info("人工优先入队成功",
|
||
zap.Uint("card_id", input.CardID), zap.Int("created_count", result.CreatedCount),
|
||
zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", input.OperatorID))
|
||
return result, nil
|
||
}
|
||
|
||
// writeRetriggerAudit 写入一条人工重触发审计,与新建活动项同一事务。
|
||
func (s *PriorityManualService) writeRetriggerAudit(
|
||
ctx context.Context,
|
||
tx *gorm.DB,
|
||
input priorityManualEnqueue,
|
||
active *model.PollingPriorityItem,
|
||
taskType string,
|
||
card *model.IotCard,
|
||
) error {
|
||
occurredAt := time.Now()
|
||
return writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||
ActionCode: constants.AuditActionPollingPriorityRetriggered,
|
||
Summary: "人工重触发卡轮询优先项",
|
||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||
ResourceID: active.ID,
|
||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||
DisplayName: "卡轮询优先项",
|
||
OperatorID: input.OperatorID,
|
||
IdentitySnapshot: map[string]any{
|
||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||
"status": active.Status, "trigger_type": active.TriggerType,
|
||
"attempt_count": active.AttemptCount, "manual_operator_id": active.ManualOperatorID,
|
||
},
|
||
AfterData: map[string]any{
|
||
"source_item_id": input.RetriggeredFromItemID, "reason": input.Reason,
|
||
"status": active.Status, "attempt_count": active.AttemptCount, "attempt_limit": active.AttemptLimit,
|
||
"trigger_type": active.TriggerType, "created": true,
|
||
"source": constants.AuditSourceAdminAPI, "occurred_at": occurredAt,
|
||
},
|
||
Metadata: map[string]any{
|
||
"source_item_id": input.RetriggeredFromItemID, "reason": input.Reason,
|
||
"card_id": active.CardID, "task_type": taskType,
|
||
},
|
||
Cards: cardSlice(card),
|
||
})
|
||
}
|
||
|
||
// denyManual 记录一次人工入口被拒绝的审计,并返回原错误。
|
||
// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。
|
||
func (s *PriorityManualService) denyManual(
|
||
ctx context.Context,
|
||
actionCode string,
|
||
code int,
|
||
summary string,
|
||
resourceKey string,
|
||
identity map[string]any,
|
||
operatorID uint,
|
||
cards []*model.IotCard,
|
||
) error {
|
||
appErr := errors.New(code, summary)
|
||
recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{
|
||
ActionCode: actionCode,
|
||
Summary: summary,
|
||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||
ResourceKey: resourceKey,
|
||
DisplayName: "卡轮询优先项",
|
||
OperatorID: operatorID,
|
||
Result: constants.AuditResultDenied,
|
||
IdentitySnapshot: identity,
|
||
Cards: cards,
|
||
}, appErr)
|
||
return appErr
|
||
}
|
||
|
||
// loadCard 读取卡用于审计关联资源;读取失败只损失卡的关联资源,不阻断主流程。
|
||
func (s *PriorityManualService) loadCard(ctx context.Context, cardID uint) *model.IotCard {
|
||
if cardID == 0 {
|
||
return nil
|
||
}
|
||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return card
|
||
}
|
||
|
||
// cardSlice 把可空卡指针转成审计所需的关联资源切片。
|
||
func cardSlice(card *model.IotCard) []*model.IotCard {
|
||
if card == nil {
|
||
return nil
|
||
}
|
||
return []*model.IotCard{card}
|
||
}
|
||
|
||
// normalizePriorityReason 校验人工原因:去空白后非空且不超过事实表原因列的长度上限。
|
||
// 返回的第二个值非空即为拒绝提示,调用方据此记录拒绝审计并拒绝请求。
|
||
func normalizePriorityReason(reason, emptyMessage string) (string, string) {
|
||
trimmed := strings.TrimSpace(reason)
|
||
if trimmed == "" {
|
||
return "", emptyMessage
|
||
}
|
||
if len([]rune(trimmed)) > constants.PollingPriorityManualReasonMaxLength {
|
||
return "", priorityReasonTooLongMessage
|
||
}
|
||
return trimmed, ""
|
||
}
|
||
|
||
// optionalUintValue 把非零 ID 转成可空指针,零值以 NULL 参与写入。
|
||
func optionalUintValue(value uint) *uint {
|
||
if value == 0 {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
|
||
func pollingPriorityResourceKey(itemID uint) string {
|
||
return strconv.FormatUint(uint64(itemID), 10)
|
||
}
|
||
|
||
// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。
|
||
func pollingPriorityAttemptKey(cardID, operatorID uint) string {
|
||
return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10)
|
||
}
|
||
|
||
// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。
|
||
func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any {
|
||
if before == nil {
|
||
return map[string]any{}
|
||
}
|
||
return map[string]any{
|
||
"status": before.Status,
|
||
"trigger_type": before.TriggerType,
|
||
"trigger_types": triggerTypesReadable(before.TriggerTypes),
|
||
"trigger_count": before.TriggerCount,
|
||
}
|
||
}
|
||
|
||
// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。
|
||
func triggerTypesReadable(raw string) string {
|
||
return strings.Trim(raw, ",")
|
||
}
|