Files
junhong_cmp_fiber/internal/task/polling_priority_claim.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

381 lines
16 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 task
import (
"context"
"strconv"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// pollingPriorityTaskNames 是纳入优先轮询的既有任务类型的中文名,用于日志与审计展示。
var pollingPriorityTaskNames = map[string]string{
constants.TaskTypePollingRealname: "实名状态轮询任务",
constants.TaskTypePollingCarddata: "卡流量轮询任务",
constants.TaskTypePollingPackage: "套餐状态轮询任务",
constants.TaskTypePollingCardStatus: "卡网络状态轮询任务",
}
// pollingPriorityRound 承载本轮轮询执行是否同时是一次优先执行。
//
// 无活动优先项时接缝返回 nil其所有方法都是无操作因此调用方在「无优先项」路径上
// 完全保持既有行为(入队、出队、移除与延后一行不改)。领取成功时必须恰好调用一次结果收口方法
// Complete / FailRetryable / FailFinal以保证不出现「领了优先项却没执行也没落终态或退回活动态」的路径。
//
// 上下文约定(照 PollingBase 既有约定):本类型的写库与审计一律忽略调用方 ctx
// 内部改用 pollingFallbackContext 的独立短超时上下文。Asynq 任务 ctx 超时或取消后,
// 失败事实、终态与审计仍必须尽量落下,否则优先项会停在执行中只能等租约接管。
type pollingPriorityRound struct {
base *PollingBase
item *model.PollingPriorityItem
taskType string
taskName string
// attempts 与库内 attempt_count 保持同步,用于判定是否达到尝试上限。
attempts int
}
// beginPollingPriority 轮询共享基类的优先项认领接缝。
//
// 位置约定必须在取并发信号量之后、调用上游之前carddata 还需在流量同步锁获取成功之后)。
// 返回语义:
// - round 非 nil本轮领取成功本轮执行同时就是优先执行
// - skip 为 true存在活动优先项但本轮必须跳过并延后已被其它执行持有或超租约接管未命中
// 调用方必须释放并发、按既有间隔 requeueCard 延后、零上游调用、不写失败审计;
// - err 非 nil活动项探测失败调用方按既有失败分支处理
// - round 为 nil 且 skip 为 false无活动优先项调用方后续行为与既有实现完全一致。
func (b *PollingBase) beginPollingPriority(ctx context.Context, cardID uint, taskType string) (*pollingPriorityRound, bool, error) {
if b == nil || b.priorityStore == nil {
return nil, false, nil
}
item, err := b.priorityStore.FindActive(ctx, cardID, taskType)
if err != nil {
return nil, false, err
}
if item == nil {
return nil, false, nil
}
now := time.Now()
switch item.Status {
case constants.PollingPriorityStatusPending:
// 待执行项必须由到达的执行认领并执行,否则提示通道产生的执行会被自己跳过、优先项永远无人执行。
claimed, claimErr := b.priorityStore.ClaimPending(ctx, cardID, taskType, now)
if claimErr != nil {
return nil, false, claimErr
}
if claimed == nil {
return nil, true, nil
}
return b.newPriorityRound(ctx, claimed, taskType, now), false, nil
case constants.PollingPriorityStatusProcessing:
// 执行中且认领租约未到期:不发第二次上游调用。
if item.ClaimedAt != nil && now.Before(item.ClaimedAt.Add(time.Duration(constants.PollingPriorityClaimLease)*time.Second)) {
return nil, true, nil
}
// 超过租约或认领时间缺失视为崩溃遗留:允许相邻执行接管,该卡该任务类型不被永久跳过。
takenOver, takeoverErr := b.priorityStore.TakeOverExpiredClaim(ctx, cardID, taskType, now, constants.PollingPriorityClaimLease)
if takeoverErr != nil {
return nil, false, takeoverErr
}
if takenOver == nil {
return nil, true, nil
}
return b.newPriorityRound(ctx, takenOver, taskType, now), false, nil
default:
// 终态行不属于活动集合FindActive 不会返回;防御性按跳过处理。
return nil, true, nil
}
}
// newPriorityRound 创建领取成功后的执行轮次,并记录领取事实。
func (b *PollingBase) newPriorityRound(ctx context.Context, item *model.PollingPriorityItem, taskType string, now time.Time) *pollingPriorityRound {
round := &pollingPriorityRound{
base: b,
item: item,
taskType: taskType,
taskName: pollingPriorityTaskName(taskType),
attempts: item.AttemptCount,
}
before := round.stateData()
item.Status = constants.PollingPriorityStatusProcessing
item.ClaimedAt = &now
round.writeAudit(constants.AuditActionPollingPriorityClaimed, "卡轮询优先项已领取", constants.AuditResultSuccess, "", "", before)
return round
}
// priorityInPollingScope 执行前校验该卡是否仍在轮询范围内:卡自身与绑定设备的轮询开关均为启用。
//
// 优先通道不得重启已停止轮询的卡;既有人工触发入口的范围语义不受本方法影响。
// 无活动优先项round 为 nil时恒为真普通轮询保持既有行为不在范围内时按业务校验类失败
// 终结优先项并返回 false调用方据此跳过上游调用。
func (b *PollingBase) priorityInPollingScope(_ context.Context, round *pollingPriorityRound, card *model.IotCard) bool {
if round == nil {
return true
}
if card == nil {
round.FailFinal(nil, "卡不存在")
return false
}
scopeCtx, cancel := pollingFallbackContext()
inScope := b.scopeChecker == nil || b.scopeChecker.ShouldEnqueue(scopeCtx, card)
cancel()
if inScope {
return true
}
round.FailFinal(nil, "卡已不在轮询范围内")
return false
}
// MarkExecution 标记即将真正发起上游调用,并在调用之前累加尝试次数与执行开始时间。
// 崩溃遗留的执行中项被接管后据此按尝试上限收敛。
func (r *pollingPriorityRound) MarkExecution(_ context.Context) {
if r == nil {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
now := time.Now()
hit, err := r.base.priorityStore.MarkExecutionStarted(ctx, r.item.ID, now)
if err != nil {
r.base.logger.Warn("累加优先轮询项尝试次数失败",
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
}
if !hit {
r.base.logger.Warn("优先轮询项已不处于执行中,尝试次数未累加",
zap.Uint("priority_item_id", r.item.ID))
}
r.attempts = r.item.AttemptCount + 1
r.item.AttemptCount = r.attempts
r.item.StartedAt = &now
r.item.FinishedAt = nil
r.item.NextRunAt = nil
}
// RecordIntegrationLog 把本次执行对应的集成交互日志标识写回优先项。
// 只保存日志标识(不复制凭证或响应原文);写入失败只损失溯源细节,不阻断执行。
func (r *pollingPriorityRound) RecordIntegrationLog(integrationLogID string) {
if r == nil || r.item == nil || integrationLogID == "" {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
if err := r.base.priorityStore.RecordIntegrationLog(ctx, r.item.ID, integrationLogID); err != nil {
r.base.logger.Warn("记录优先轮询项集成交互日志标识失败",
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
return
}
r.item.IntegrationLogID = integrationLogID
}
// nextRunAt 计算该项退回活动态后的下一次计划执行时间,复用与重入队完全相同的间隔解析。
func (r *pollingPriorityRound) nextRunAt() time.Time {
if r == nil || r.base == nil {
return time.Now().Add(pollingRequeueFallbackInterval)
}
ctx, cancel := pollingFallbackContext()
defer cancel()
nextCheckAt, ok := r.base.pollingNextCheckAt(ctx, r.item.CardID, r.taskType)
if !ok {
// 该任务类型轮询间隔为 NULL普通轮询不会接管仍写下兜底时间让重试路径可见。
return time.Now().Add(pollingRequeueFallbackInterval)
}
return nextCheckAt
}
// Complete 记录优先项执行成功并出队,该卡继续按普通轮询运行。
func (r *pollingPriorityRound) Complete(_ context.Context) {
if r == nil {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
before := r.stateData()
finishedAt := time.Now()
hit, err := r.base.priorityStore.MarkCompleted(ctx, r.item.ID)
if err != nil {
r.base.logger.Warn("更新优先轮询项完成状态失败",
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
}
if !hit {
r.base.logger.Warn("优先轮询项已完成或不处于执行中,跳过完成收口",
zap.Uint("priority_item_id", r.item.ID))
return
}
r.item.Status = constants.PollingPriorityStatusCompleted
r.item.Result = constants.PollingPriorityResultSuccess
r.item.FailureReason = ""
r.item.FinishedAt = &finishedAt
r.item.DequeuedAt = &finishedAt
r.item.NextRunAt = nil
r.writeAudit(constants.AuditActionPollingPriorityCompleted, "卡轮询优先项执行完成并出队", constants.AuditResultSuccess, "", "", before)
}
// FailRetryable 记录一次可恢复失败(上游超时、失败或响应无效)。
// 未达尝试上限时退回活动态并写下一次计划执行时间;达到上限时记录失败终态并出队。
func (r *pollingPriorityRound) FailRetryable(_ context.Context, reason string) {
if r == nil {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
before := r.stateData()
finishedAt := time.Now()
if r.attempts < constants.PollingPriorityMaxAttempts {
nextRunAt := r.nextRunAt()
hit, err := r.base.priorityStore.ReturnToPending(ctx, r.item.ID, reason, nextRunAt)
if err != nil {
r.base.logger.Warn("退回优先轮询项活动状态失败",
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
}
if !hit {
r.base.logger.Warn("优先轮询项不处于执行中,跳过退回活动态收口",
zap.Uint("priority_item_id", r.item.ID))
return
}
r.item.Status = constants.PollingPriorityStatusPending
r.item.Result = constants.PollingPriorityResultFailed
r.item.FailureReason = reason
r.item.ClaimedAt = nil
r.item.FinishedAt = &finishedAt
r.item.NextRunAt = &nextRunAt
r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditResultFailed, "", reason, before)
r.writeAudit(constants.AuditActionPollingPriorityRetried, "卡轮询优先项未达尝试上限,退回活动态重新排期", constants.AuditResultFailed, "", reason, before)
return
}
hit, err := r.base.priorityStore.MarkFailed(ctx, r.item.ID, reason)
if err != nil {
r.base.logger.Warn("更新优先轮询项失败状态失败",
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
}
if !hit {
r.base.logger.Warn("优先轮询项不处于执行中,跳过失败出队收口",
zap.Uint("priority_item_id", r.item.ID))
return
}
r.item.Status = constants.PollingPriorityStatusFailed
r.item.Result = constants.PollingPriorityResultFailed
r.item.FailureReason = reason
r.item.FinishedAt = &finishedAt
r.item.DequeuedAt = &finishedAt
r.item.NextRunAt = nil
r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditResultFailed, "", reason, before)
r.writeAudit(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项达到尝试上限,失败出队", constants.AuditResultFailed, "", reason, before)
}
// FailFinal 记录业务校验类失败(卡不存在、不在轮询范围、能力未配置等):不重试,直接失败出队。
func (r *pollingPriorityRound) FailFinal(_ context.Context, reason string) {
if r == nil {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
before := r.stateData()
hit, err := r.base.priorityStore.MarkFailed(ctx, r.item.ID, reason)
if err != nil {
r.base.logger.Warn("更新优先轮询项失败状态失败",
zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
}
if !hit {
r.base.logger.Warn("优先轮询项不处于执行中,跳过失败出队收口",
zap.Uint("priority_item_id", r.item.ID))
return
}
finishedAt := time.Now()
r.item.Status = constants.PollingPriorityStatusFailed
r.item.Result = constants.PollingPriorityResultFailed
r.item.FailureReason = reason
r.item.FinishedAt = &finishedAt
r.item.DequeuedAt = &finishedAt
r.item.NextRunAt = nil
r.writeAudit(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行业务校验失败", constants.AuditResultFailed, "", reason, before)
r.writeAudit(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项失败出队且不重试", constants.AuditResultFailed, "", reason, before)
}
// stateData 返回当前优先项状态的前后值快照,覆盖触发来源、操作来源、时间与执行结果。
func (r *pollingPriorityRound) stateData() map[string]any {
return map[string]any{
"status": r.item.Status,
"result": r.item.Result,
"trigger_type": r.item.TriggerType,
"trigger_types": r.item.TriggerTypes,
"trigger_count": r.item.TriggerCount,
"last_triggered_at": r.item.LastTriggeredAt,
"attempt_count": r.item.AttemptCount,
"attempt_limit": r.item.AttemptLimit,
"claimed_at": r.item.ClaimedAt,
"started_at": r.item.StartedAt,
"finished_at": r.item.FinishedAt,
"next_run_at": r.item.NextRunAt,
"dequeued_at": r.item.DequeuedAt,
"integration_log_id": r.item.IntegrationLogID,
"failure_reason": r.item.FailureReason,
"source": constants.AuditSourceWorker,
"occurred_at": time.Now(),
}
}
// writeAudit 以独立短事务写入一条优先项生命周期审计。
// 审计写入失败不阻断轮询执行,按既有约定登记为失败审计二次写入故障。
func (r *pollingPriorityRound) writeAudit(
actionCode, summary, result, errorCode, errorSummary string,
before map[string]any,
) {
base := r.base
if base == nil || base.priorityAudit == nil || base.db == nil || r.item == nil || r.item.ID == 0 {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
input := audit.TaskInput{
ActionCode: actionCode, Summary: summary,
TaskID: r.item.ID, TaskNo: pollingPriorityResourceKey(r.item), DisplayName: "卡轮询优先项",
Actor: audit.ActorInput{
Kind: constants.AuditActorSystemTask, ID: r.taskType, Name: r.taskName,
},
Source: constants.AuditSourceWorker,
ScopeType: constants.AuditScopePlatform,
Result: result,
ErrorCode: errorCode,
ErrorSummary: errorSummary,
CorrelationID: r.taskType,
IdentitySnapshot: map[string]any{
"id": r.item.ID, "card_id": r.item.CardID, "task_type": r.item.TaskType,
"status": r.item.Status, "trigger_type": r.item.TriggerType,
"trigger_count": r.item.TriggerCount, "manual_operator_id": r.item.ManualOperatorID,
},
BeforeData: before,
AfterData: r.stateData(),
}
err := base.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return base.priorityAudit.WriteTask(ctx, tx, input)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(actionCode, input.TaskNo, "", r.taskType, errorCode, err)
base.logger.Error("优先轮询项审计写入失败",
zap.String("action", actionCode), zap.Uint("priority_item_id", r.item.ID), zap.Error(err))
}
}
// pollingPriorityTaskName 返回优先轮询任务类型的中文名,未知类型回退为完整任务类型。
func pollingPriorityTaskName(taskType string) string {
if name, ok := pollingPriorityTaskNames[taskType]; ok {
return name
}
return taskType
}
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
func pollingPriorityResourceKey(item *model.PollingPriorityItem) string {
if item == nil {
return ""
}
return strconv.FormatUint(uint64(item.ID), 10)
}