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, ",") }