Files
junhong_cmp_fiber/internal/service/polling/priority_expiry_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

138 lines
5.0 KiB
Go

package polling
import (
"context"
"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/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// pollingPriorityExpiryBatchSize 是有效期到期单轮扫描的批量上限。
// 只限制每轮处理量,不改变语义:剩余的超期项由下一轮计划任务继续终结。
const pollingPriorityExpiryBatchSize = 200
// PriorityExpiryService 优先轮询项有效期到期处理用例。
//
// 由独立周期任务承载(查询与读侧 MUST NOT 隐式触发):按固定有效期长度扫描有效期起算时间
// 已超期的未完成项,逐条以条件更新转已过期终态并出队;终态写入与审计同事务。
// 触发类型、尝试次数与最近失败原因全部保留,不删除任何既有事实,也不发起任何上游调用。
type PriorityExpiryService struct {
db *gorm.DB
priorityStore *postgres.PollingPriorityItemStore
auditWriter *audit.Writer
logger *zap.Logger
}
// NewPriorityExpiryService 创建优先轮询项有效期到期处理用例。
func NewPriorityExpiryService(
db *gorm.DB,
priorityStore *postgres.PollingPriorityItemStore,
logger *zap.Logger,
) *PriorityExpiryService {
if logger == nil {
logger = zap.NewNop()
}
return &PriorityExpiryService{db: db, priorityStore: priorityStore, logger: logger}
}
// SetAudit 注入统一审计 Writer。
func (s *PriorityExpiryService) SetAudit(writer *audit.Writer) {
s.auditWriter = writer
}
// ExpireOverdue 终结所有已超过固定有效期且仍未完成的优先项,返回本轮实际终结的条数。
// 逐条独立事务:单条失败不阻断其余超期项,失败原因由调用方按任务错误处理并重试。
func (s *PriorityExpiryService) ExpireOverdue(ctx context.Context, now time.Time) (int, error) {
if s == nil || s.db == nil || s.priorityStore == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "优先轮询项有效期到期处理未配置")
}
deadline := now.Add(-constants.PollingPriorityValidity)
items, err := s.priorityStore.ListOverdueActive(ctx, deadline, pollingPriorityExpiryBatchSize)
if err != nil {
return 0, err
}
if len(items) == 0 {
return 0, nil
}
expiredCount := 0
for _, item := range items {
if item == nil {
continue
}
marked := false
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
hit, markErr := s.priorityStore.WithTx(tx).MarkExpired(ctx, item.ID)
if markErr != nil {
return markErr
}
if !hit {
// 并发下该项已被执行终结或人工关闭:保留既有结论,不覆盖也不重复审计。
return nil
}
marked = true
return s.writeExpiryAudit(ctx, tx, item, now)
})
if err != nil {
return expiredCount, err
}
if marked {
expiredCount++
}
}
if expiredCount > 0 {
s.logger.Info("优先轮询项有效期到期处理完成",
zap.Int("expired_count", expiredCount), zap.Int("scanned_count", len(items)))
}
return expiredCount, nil
}
// writeExpiryAudit 以系统任务身份写入一条超期出队审计。
// 计划任务没有后台账号,因此走任务审计形态(操作者=计划任务),而不是要求账号操作者的后台入口形态。
func (s *PriorityExpiryService) writeExpiryAudit(ctx context.Context, tx *gorm.DB, item *model.PollingPriorityItem, now time.Time) error {
if s == nil || s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "统一审计 Writer 未配置")
}
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
ActionCode: constants.AuditActionPollingPriorityExpired,
Summary: "卡轮询优先项有效期到期出队",
TaskID: item.ID,
TaskNo: pollingPriorityResourceKey(item.ID),
DisplayName: "卡轮询优先项",
Actor: audit.ActorInput{
Kind: constants.AuditActorSystemTask,
ID: constants.TaskTypePollingPriorityExpiry,
Name: "优先轮询项有效期到期处理任务",
},
Source: constants.AuditSourceWorker,
ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess,
CorrelationID: constants.TaskTypePollingPriorityExpiry,
IdentitySnapshot: map[string]any{
"id": item.ID, "card_id": item.CardID, "task_type": item.TaskType,
"status": item.Status, "trigger_type": item.TriggerType,
"attempt_count": item.AttemptCount,
},
BeforeData: map[string]any{
"status": item.Status, "result": item.Result, "failure_reason": item.FailureReason,
"attempt_count": item.AttemptCount, "next_run_at": item.NextRunAt,
},
AfterData: map[string]any{
"status": constants.PollingPriorityStatusExpired, "dequeued_at": now,
"priority_effective_from": item.PriorityEffectiveFrom,
"expired_at": now,
},
Metadata: map[string]any{
"card_id": item.CardID, "task_type": item.TaskType,
"priority_effective_from": item.PriorityEffectiveFrom,
},
})
}