feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
2026-09-18 15:34:29 +08:00
parent 5e78809b93
commit 5ed6b39deb
142 changed files with 7878 additions and 964 deletions

View File

@@ -0,0 +1,540 @@
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, ",")
}