Files
junhong_cmp_fiber/internal/service/polling/priority_manual_service.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

541 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, ",")
}