feat(轮询优先队列): AUG26-016 卡轮询优先队列、人工入队与读侧接口,归档并同步主 Spec 与证据矩阵
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s

新增 000228 成对迁移 tb_polling_priority_item:卡、任务类型、状态、触发类型、来源订单/套餐使用记录、
触发次数与来源集合、尝试次数、失败原因、人工原因与操作者、店铺快照与各时间列;以活动项部分唯一索引
uq_polling_priority_item_active(仅 deleted_at IS NULL AND status IN ('pending','processing') 占键位)
表达「同卡同任务类型至多一条活动项」,另有状态/时间索引与全列注释;down 守卫在存在活动项或未终态行时
拒绝回滚并给出中文原因。

新增优先轮询请求可靠事件 polling.priority.requested(载荷版本 v1、事件键前缀 prio:)与消费者:只在原
业务事务内追加、幂等键稳定;消费者按卡 × 纳入任务类型(realname/carddata/card_status/package)逐条
建项并在提交后下发执行提示,重复投递只合并触发次数、来源集合与最近触发时间,不新建行也不重复调用。
触发点为四类自动场景 purchase_activated / renewal_activated(按同载体更早套餐使用记录判定)/
queue_activated / addon_activated 与「无有效套餐」no_valid_package(仅在普通套餐轮询来源且存在待生效
套餐使用记录时追加;事件通道显式拒绝 manual_trigger);入队对象恒为卡,绑定设备资产在触发事务内冻结
在用卡快照逐卡建项,不使用设备当前卡槽口径。

轮询共享基类新增认领接缝:四个 Handler(realname/carddata/card_status/package)在并发信号量之后、调用
上游之前探测活动项——待执行条件认领、执行中且 90 秒租约未到期则跳过并延后、无活动项时行为与既有完全
等价;超租约允许相邻执行接管,尝试次数只在真正发起执行后累加,未达上限(3)回到活动态按既有间隔重排,
达上限或业务校验类失败进入失败终态并保留可安全展示原因;执行前校验卡自身与绑定设备的轮询开关。未引入
通用卡级锁与 Redis 活动标记,分片队列的出队、入队与移除路径未改动。

提示通道按任务类型独立键(polling:priority:{taskType}),与既有手动触发队列分离;调度器在同一周期内先
排空优先提示、再排空手动触发队列,提示排空不受分片背压跳过影响;未新建调度设施或异步任务类型。

新增人工优先入队与只读查询三条路由 POST /api/admin/polling-priority-items、
GET /api/admin/polling-priority-items、GET /api/admin/polling-priority-items/:id:人工入队复用既有轮询
权限判定(抽取为同包共享函数),原因必填,不受每日 500 次上限与 24 小时去重约束,重复抑制由活动项合并
承担;读侧按店铺快照下推数据范围,越权与不存在不可区分,不提供优先级分级、有效期或人工重触发入口。
新增 7 个审计动作(enqueue/claim/fail/retry/complete/dequeue/manual_denied)与资源
polling_priority_item,并按(操作者类型,来源)注册,人工侧与 Worker 侧均通过来源校验。

同步 OpenAPI 文档装配三处与路由注册;归档 Change 至
openspec/changes/archive/2026-09-17-add-priority-polling-queue/ 并同步主 Spec(新增
priority-polling-queue、polling-operations 追加单次执行互斥 Requirement 与三条路由索引)与上下文健康
证据(requirement-evidence 150 行、入口矩阵 http 403 / async 56)。

本机验证:junhong_cmp_test 与隔离 Redis DB 15,未连生产、未启动 Worker/API、未调用运营商上游;迁移
up/down/up 与 down 守卫实测(含 dirty=true 记账口径与 force 恢复),A–F 批 94 PASS、接缝 63 PASS、
提示通道 12 PASS、清理零残留 20 PASS。成功路径 Complete、真并发互斥、尝试上限第 3 次判定、HTTP 层权限
矩阵、通道阈值持锁复机边界与三类生效触发点生产集成留待测试部署验证(见
docs/verification/add-priority-polling-queue-verification.md 第 4 节)。自动化测试按项目决策为 N/A,
未新增 *_test.go。
This commit is contained in:
2026-09-17 14:29:56 +08:00
parent e7b93e4634
commit aab56a6998
59 changed files with 4129 additions and 221 deletions

View File

@@ -0,0 +1,383 @@
package postgres
import (
"context"
stderrors "errors"
"strings"
"time"
"github.com/jackc/pgx/v5/pgconn"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// pollingPriorityActiveConstraint 是活动项部分唯一索引名,用于把 23505 精确识别为
// 「该卡该任务类型已有活动优先项」。该索引是部分唯一索引,谓词未声明时 GORM OnConflict
// 无法命中KNOWN-ISSUE-001因此入队一律走「显式插入 + 23505 识别」,不用 OnConflict。
const pollingPriorityActiveConstraint = "uq_polling_priority_item_active"
// PollingPriorityItemListFilter 是优先轮询项列表的筛选与分页条件。
//
// ShopIDSnapshot 是数据范围下推nil 表示不受限(超级管理员与平台账号),
// 非 nil 时按店铺快照过滤,空切片表示空范围(无可见行)。平台卡的店铺快照为空,
// 因此空范围与平台卡都不会被代理账号看到,越权与不存在由调用方收敛为同一响应。
type PollingPriorityItemListFilter struct {
CardID *uint
TaskType *string
Status *string
TriggerType *string
ShopIDSnapshot []uint
Page int
PageSize int
}
// PollingPriorityItemStore 卡轮询优先队列事实存储。
type PollingPriorityItemStore struct {
db *gorm.DB
}
// NewPollingPriorityItemStore 创建优先轮询项存储实例。
func NewPollingPriorityItemStore(db *gorm.DB) *PollingPriorityItemStore {
return &PollingPriorityItemStore{db: db}
}
// WithTx 返回绑定指定事务的优先轮询项存储,供「业务事实与入队事实同事务」的调用方复用。
func (s *PollingPriorityItemStore) WithTx(tx *gorm.DB) *PollingPriorityItemStore {
return &PollingPriorityItemStore{db: tx}
}
// InsertOrMerge 以 (card_id, task_type) 为活动项唯一键合并入队。
//
// 先显式插入命中活动项部分唯一索引23505时把本次触发合并进已有活动行
// 追加触发次数、刷新最近触发时间、并入触发类型集合,并补齐可空来源与人工字段,
// 不新建行、不重复调用。返回 created 表示本次是否新建了活动项。
//
// 采用有界两轮循环,覆盖「插入冲突 → 合并落空」的竞态窗口:
// 第一轮插入冲突说明存在活动项;若此刻该行恰好被并发执行终态化,合并会命中 0 行,
// 此时必须重试插入;第二轮若再次冲突,说明活动项在此期间又被建立,必须再合并一次,
// 否则这一次触发事实会被丢掉(触发次数与来源集合少记一次)。
func (s *PollingPriorityItemStore) InsertOrMerge(ctx context.Context, item *model.PollingPriorityItem) (bool, error) {
if err := normalizePollingPriorityItem(item); err != nil {
return false, err
}
for range 2 {
createErr := s.insertWithinSavepoint(ctx, item)
if createErr == nil {
return true, nil
}
if !isPollingPriorityActiveConflict(createErr) {
return false, errors.Wrap(errors.CodeDatabaseError, createErr, "创建优先轮询项失败")
}
merged, mergeErr := s.mergeActive(ctx, item)
if mergeErr != nil {
return false, mergeErr
}
if merged {
return false, nil
}
// 冲突行已离开活动集合:清掉可能被回填的主键后重试插入。
item.ID = 0
}
// 两轮都是「插入冲突 + 合并落空」:活动项在此期间被并发建立又终态化,促成的加急需求
// 已由那次终态化满足,按已合并结束,不报错也不新建行。
return false, nil
}
// insertWithinSavepoint 在保存点内执行优先项插入。
//
// PostgreSQL 的唯一冲突会中止整个事务(后续语句必然 25P02合并更新与同事务审计都会失败
// 因此插入必须隔离在保存点内GORM 对已开启事务的嵌套 Transaction 使用 SAVEPOINT
// 冲突只回滚本次插入,调用方事务仍然可用。调用方直接持有根连接池时,该嵌套事务
// 本身就是一次完整事务,同样保证冲突不外溢。
func (s *PollingPriorityItemStore) insertWithinSavepoint(ctx context.Context, item *model.PollingPriorityItem) error {
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
return inner.Create(item).Error
})
}
// mergeActive 把一次触发合并进已存在的活动行,返回是否命中活动行。
func (s *PollingPriorityItemStore) mergeActive(ctx context.Context, item *model.PollingPriorityItem) (bool, error) {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("card_id = ? AND task_type = ? AND deleted_at IS NULL AND status IN ?",
item.CardID, item.TaskType,
[]string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}).
Updates(map[string]any{
"trigger_count": gorm.Expr("trigger_count + 1"),
"last_triggered_at": now,
// 触发类型集合以「两端补分隔符」的规范形式存储,便于用 LIKE 精确判重后追加。
"trigger_types": gorm.Expr(
"CASE WHEN trigger_types LIKE ? THEN trigger_types ELSE trigger_types || ? END",
"%,"+item.TriggerType+",%", item.TriggerType+","),
"source_order_id": gorm.Expr("COALESCE(source_order_id, ?)", optionalUintArg(item.SourceOrderID)),
"source_package_usage_id": gorm.Expr("COALESCE(source_package_usage_id, ?)", optionalUintArg(item.SourcePackageUsageID)),
"manual_reason": gorm.Expr("CASE WHEN ? <> '' THEN ? ELSE manual_reason END", item.ManualReason, item.ManualReason),
"manual_operator_id": gorm.Expr("CASE WHEN ? > 0 THEN ? ELSE manual_operator_id END", item.ManualOperatorID, item.ManualOperatorID),
"manual_operator_name": gorm.Expr("CASE WHEN ? <> '' THEN ? ELSE manual_operator_name END", item.ManualOperatorName, item.ManualOperatorName),
"shop_id_snapshot": gorm.Expr("COALESCE(shop_id_snapshot, ?)", optionalUintArg(item.ShopIDSnapshot)),
"updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "合并优先轮询项失败")
}
return result.RowsAffected > 0, nil
}
// FindActive 查询该卡该任务类型的活动优先项:不存在返回 nil。
func (s *PollingPriorityItemStore) FindActive(ctx context.Context, cardID uint, taskType string) (*model.PollingPriorityItem, error) {
if cardID == 0 || strings.TrimSpace(taskType) == "" {
return nil, nil
}
var item model.PollingPriorityItem
err := s.db.WithContext(ctx).
Where("card_id = ? AND task_type = ? AND status IN ?", cardID, taskType,
[]string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}).
First(&item).Error
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动优先轮询项失败")
}
return &item, nil
}
// ListActiveByCards 批量查询指定卡在该任务类型下的活动优先项,供轮询执行前一次性探测。
func (s *PollingPriorityItemStore) ListActiveByCards(ctx context.Context, cardIDs []uint, taskType string) ([]*model.PollingPriorityItem, error) {
if len(cardIDs) == 0 || strings.TrimSpace(taskType) == "" {
return nil, nil
}
var items []*model.PollingPriorityItem
if err := s.db.WithContext(ctx).
Where("card_id IN ? AND task_type = ? AND status IN ?", cardIDs, taskType,
[]string{constants.PollingPriorityStatusPending, constants.PollingPriorityStatusProcessing}).
Find(&items).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询活动优先轮询项失败")
}
return items, nil
}
// ClaimPending 以条件更新认领待执行优先项:仅当该项由待执行更新为执行中且命中一行时视为领取成功。
// 返回 nil 表示本轮未领取(无待执行项,或已被其它执行抢先领取),调用方按既有间隔延后即可。
func (s *PollingPriorityItemStore) ClaimPending(ctx context.Context, cardID uint, taskType string, now time.Time) (*model.PollingPriorityItem, error) {
if cardID == 0 || strings.TrimSpace(taskType) == "" {
return nil, nil
}
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("card_id = ? AND task_type = ? AND status = ?", cardID, taskType, constants.PollingPriorityStatusPending).
Updates(map[string]any{
"status": constants.PollingPriorityStatusProcessing,
"claimed_at": now,
"updated_at": now,
})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "认领优先轮询项失败")
}
if result.RowsAffected == 0 {
return nil, nil
}
return s.FindActive(ctx, cardID, taskType)
}
// TakeOverExpiredClaim 接管超过认领租约的执行中项:崩溃的执行没有产生有效结果,重新执行是必要恢复。
// 返回 nil 表示没有可接管的项(无执行中项,或租约仍未到期)。
func (s *PollingPriorityItemStore) TakeOverExpiredClaim(ctx context.Context, cardID uint, taskType string, now time.Time, leaseSeconds int) (*model.PollingPriorityItem, error) {
if cardID == 0 || strings.TrimSpace(taskType) == "" {
return nil, nil
}
if leaseSeconds <= 0 {
leaseSeconds = constants.PollingPriorityClaimLease
}
deadline := now.Add(-time.Duration(leaseSeconds) * time.Second)
// 认领时间缺失的执行中行同样视为崩溃遗留:否则该项会因租约判定永远无法接管而被永久跳过。
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("card_id = ? AND task_type = ? AND status = ? AND (claimed_at IS NULL OR claimed_at < ?)",
cardID, taskType, constants.PollingPriorityStatusProcessing, deadline).
Updates(map[string]any{
"claimed_at": now,
"updated_at": now,
})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "接管超租约优先轮询项失败")
}
if result.RowsAffected == 0 {
return nil, nil
}
return s.FindActive(ctx, cardID, taskType)
}
// IncrementAttempt 累加真实发起执行的次数。调用点必须在发起上游调用之前,
// 使崩溃遗留的执行中项在接管后仍能按次数上限收敛。
func (s *PollingPriorityItemStore) IncrementAttempt(ctx context.Context, id uint) (bool, error) {
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
Updates(map[string]any{
"attempt_count": gorm.Expr("attempt_count + 1"),
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "累加优先轮询项尝试次数失败")
}
return result.RowsAffected > 0, nil
}
// MarkCompleted 把执行中的优先项置为完成终态并出队。
func (s *PollingPriorityItemStore) MarkCompleted(ctx context.Context, id uint) (bool, error) {
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
Updates(map[string]any{
"status": constants.PollingPriorityStatusCompleted,
"result": constants.PollingPriorityResultSuccess,
"failure_reason": "",
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项完成状态失败")
}
return result.RowsAffected > 0, nil
}
// MarkFailed 把执行中的优先项置为失败终态并出队failureReason 只写可安全对外展示的原因。
func (s *PollingPriorityItemStore) MarkFailed(ctx context.Context, id uint, failureReason string) (bool, error) {
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
Updates(map[string]any{
"status": constants.PollingPriorityStatusFailed,
"result": constants.PollingPriorityResultFailed,
"failure_reason": failureReason,
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项失败状态失败")
}
return result.RowsAffected > 0, nil
}
// ReturnToPending 把未达尝试上限的可恢复失败退回活动态,并按既有轮询间隔由调用方重新排期。
// 保留本次失败结果与原因,供读侧展示在途重试;释放认领租约。
func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint, failureReason string) (bool, error) {
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
Updates(map[string]any{
"status": constants.PollingPriorityStatusPending,
"result": constants.PollingPriorityResultFailed,
"failure_reason": failureReason,
"claimed_at": nil,
"updated_at": time.Now(),
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "退回优先轮询项活动状态失败")
}
return result.RowsAffected > 0, nil
}
// GetByIDScoped 按主键读取优先项,并应用店铺快照数据范围。
// shopIDs 为 nil 表示不受限(超级管理员与平台账号);非 nil 时空切片表示空范围,必然无结果。
// 越权与不存在都返回 gorm.ErrRecordNotFound由调用方收敛为同一响应不产生可枚举差异。
func (s *PollingPriorityItemStore) GetByIDScoped(ctx context.Context, id uint, shopIDs []uint) (*model.PollingPriorityItem, error) {
if id == 0 {
return nil, gorm.ErrRecordNotFound
}
query := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).Where("id = ?", id)
query = applyPollingPriorityShopScope(query, shopIDs)
var item model.PollingPriorityItem
if err := query.First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
// applyPollingPriorityShopScope 应用店铺快照数据范围nil 不限、空切片等于空结果。
func applyPollingPriorityShopScope(query *gorm.DB, shopIDs []uint) *gorm.DB {
if shopIDs == nil {
return query
}
if len(shopIDs) == 0 {
// 空范围是「无可见行」而不同于「不受限」,两者语义相反,必须显式区分。
return query.Where("1 = 0")
}
return query.Where("shop_id_snapshot IN ?", shopIDs)
}
// List 分页查询优先轮询项,按创建时间倒序;店铺快照为空的数据范围语义见 Filter 注释。
func (s *PollingPriorityItemStore) List(ctx context.Context, filter PollingPriorityItemListFilter) ([]*model.PollingPriorityItem, int64, error) {
query := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{})
if filter.CardID != nil {
query = query.Where("card_id = ?", *filter.CardID)
}
if filter.TaskType != nil && *filter.TaskType != "" {
query = query.Where("task_type = ?", *filter.TaskType)
}
if filter.Status != nil && *filter.Status != "" {
query = query.Where("status = ?", *filter.Status)
}
if filter.TriggerType != nil && *filter.TriggerType != "" {
query = query.Where("trigger_types LIKE ?", "%,"+*filter.TriggerType+",%")
}
if filter.ShopIDSnapshot != nil {
query = applyPollingPriorityShopScope(query, filter.ShopIDSnapshot)
}
page, pageSize := normalizePollingPriorityPage(filter.Page, filter.PageSize)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计优先轮询项失败")
}
var items []*model.PollingPriorityItem
if err := query.Order("created_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&items).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项列表失败")
}
return items, total, nil
}
// normalizePollingPriorityItem 收敛优先项自身的结构不变量,调用方只需给出业务字段。
func normalizePollingPriorityItem(item *model.PollingPriorityItem) error {
if item == nil || item.CardID == 0 || strings.TrimSpace(item.TaskType) == "" || strings.TrimSpace(item.TriggerType) == "" {
return errors.New(errors.CodeInvalidParam, "优先轮询项缺少卡、任务类型或触发类型")
}
item.Status = constants.PollingPriorityStatusPending
item.TriggerTypes = "," + item.TriggerType + ","
item.TriggerCount = 1
if item.LastTriggeredAt.IsZero() {
item.LastTriggeredAt = time.Now()
}
item.AttemptCount = 0
item.ClaimedAt = nil
item.Result = ""
item.FailureReason = ""
return nil
}
// normalizePollingPriorityPage 归一化分页参数并执行默认值与上限。
func normalizePollingPriorityPage(page, pageSize int) (int, int) {
if page < 1 {
page = constants.DefaultPage
}
if pageSize < 1 || pageSize > constants.MaxPageSize {
pageSize = constants.DefaultPageSize
}
return page, pageSize
}
// optionalUintArg 把可空 ID 转成驱动可直接绑定的参数nil 以 NULL 参与 COALESCE从而保留已有值
// 避免依赖驱动对指针参数的隐式解引用。
func optionalUintArg(value *uint) any {
if value == nil {
return nil
}
return *value
}
// isPollingPriorityActiveConflict 把 23505 精确识别为活动项唯一键冲突。
func isPollingPriorityActiveConflict(err error) bool {
var pgErr *pgconn.PgError
if !stderrors.As(err, &pgErr) {
return false
}
return pgErr.Code == "23505" && pgErr.ConstraintName == pollingPriorityActiveConstraint
}