feat(轮询优先队列): AUG26-016 卡轮询优先队列、人工入队与读侧接口,归档并同步主 Spec 与证据矩阵
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 14m13s
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:
80
internal/model/dto/polling_priority_dto.go
Normal file
80
internal/model/dto/polling_priority_dto.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// EnqueuePriorityPollingRequest 是人工优先入队请求。
|
||||
// 一次优先需求覆盖该卡的**全部**纳入轮询任务类型,因此不接受按任务类型入队。
|
||||
type EnqueuePriorityPollingRequest struct {
|
||||
CardID uint `json:"card_id" validate:"required" description:"卡ID"`
|
||||
Reason string `json:"reason" validate:"required,min=1,max=500" description:"人工加急原因(必填,最长500字符)"`
|
||||
}
|
||||
|
||||
// EnqueuePriorityPollingResponse 是人工优先入队结果(资产级:一次入队对应多个任务类型的项)。
|
||||
type EnqueuePriorityPollingResponse struct {
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
TaskTypes []string `json:"task_types" description:"本次覆盖的轮询任务类型集合:polling:realname-实名检查 polling:carddata-流量检查 polling:package-套餐检查 polling:card_status-卡状态检查"`
|
||||
TaskTypeNames []string `json:"task_type_names" description:"轮询任务类型名称集合,与 task_types 一一对应"`
|
||||
CreatedCount int `json:"created_count" description:"本次新建的优先项数"`
|
||||
MergedCount int `json:"merged_count" description:"本次合并到既有活动项的项数"`
|
||||
Items []EnqueuePriorityPollingItemView `json:"items" description:"逐任务类型的优先项结果"`
|
||||
}
|
||||
|
||||
// EnqueuePriorityPollingItemView 是单个任务类型的入队结果。
|
||||
type EnqueuePriorityPollingItemView struct {
|
||||
ItemID uint `json:"item_id" description:"优先轮询项ID"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
TriggerCount int `json:"trigger_count" description:"累计触发次数(同一卡同一任务类型的后续触发合并计数)"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"`
|
||||
Created bool `json:"created" description:"是否新建活动项:false 表示已合并到既有活动项"`
|
||||
}
|
||||
|
||||
// PriorityPollingItemListRequest 是优先轮询项列表查询请求。
|
||||
type PriorityPollingItemListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||
CardID *uint `json:"card_id" query:"card_id" validate:"omitempty" description:"按卡ID筛选"`
|
||||
TaskType *string `json:"task_type" query:"task_type" validate:"omitempty,oneof=polling:realname polling:carddata polling:package polling:card_status" enum:"polling:realname,polling:carddata,polling:package,polling:card_status" description:"按轮询任务类型筛选:polling:realname-实名检查 polling:carddata-流量检查 polling:package-套餐检查 polling:card_status-卡状态检查"`
|
||||
Status *string `json:"status" query:"status" validate:"omitempty,oneof=pending processing completed failed" enum:"pending,processing,completed,failed" description:"按状态筛选:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"`
|
||||
TriggerType *string `json:"trigger_type" query:"trigger_type" validate:"omitempty,oneof=purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger" enum:"purchase_activated,renewal_activated,queue_activated,addon_activated,no_valid_package,manual_trigger" description:"按触发类型筛选:purchase_activated-主套餐购买后立即生效 renewal_activated-续购套餐生效 queue_activated-排队主套餐顺延生效 addon_activated-加油包生效 no_valid_package-资产无有效套餐 manual_trigger-人工入队"`
|
||||
}
|
||||
|
||||
// PriorityPollingItemResponse 是优先轮询项的读侧投影。
|
||||
type PriorityPollingItemResponse struct {
|
||||
ID uint `json:"id" description:"优先轮询项ID"`
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
ICCID string `json:"iccid,omitempty" description:"卡ICCID(卡已不存在时为空)"`
|
||||
TaskType string `json:"task_type" description:"轮询任务类型"`
|
||||
TaskTypeName string `json:"task_type_name" description:"轮询任务类型名称"`
|
||||
Status string `json:"status" description:"状态:pending-待执行 processing-执行中 completed-已完成 failed-失败出队"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
TriggerType string `json:"trigger_type" description:"首次触发类型:purchase_activated renewal_activated queue_activated addon_activated no_valid_package manual_trigger"`
|
||||
TriggerTypeName string `json:"trigger_type_name" description:"首次触发类型名称"`
|
||||
TriggerTypes string `json:"trigger_types" description:"触发来源集合(去重后的全部触发类型,英文逗号分隔)"`
|
||||
TriggerCount int `json:"trigger_count" description:"累计触发次数"`
|
||||
LastTriggeredAt time.Time `json:"last_triggered_at" description:"最近触发时间"`
|
||||
SourceOrderID *uint `json:"source_order_id,omitempty" description:"来源订单ID"`
|
||||
SourcePackageUse *uint `json:"source_package_usage_id,omitempty" description:"来源套餐使用记录ID"`
|
||||
AttemptCount int `json:"attempt_count" description:"真实发起执行的次数(上限3次)"`
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty" description:"最近认领时间"`
|
||||
ManualReason string `json:"manual_reason,omitempty" description:"人工入队原因"`
|
||||
ManualOperator uint `json:"manual_operator_id,omitempty" description:"人工入队操作者账号ID"`
|
||||
ManualOperatorNm string `json:"manual_operator_name,omitempty" description:"人工入队操作者名称"`
|
||||
ShopIDSnapshot *uint `json:"shop_id_snapshot,omitempty" description:"触发时冻结的卡所属店铺ID,平台卡为空"`
|
||||
Result string `json:"result" description:"执行结果:success-成功 failed-失败,未出结果时为空"`
|
||||
ResultName string `json:"result_name" description:"执行结果名称"`
|
||||
FailureReason string `json:"failure_reason,omitempty" description:"可安全展示的失败原因"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"最近更新时间"`
|
||||
}
|
||||
|
||||
// PriorityPollingItemListResponse 是优先轮询项列表响应。
|
||||
type PriorityPollingItemListResponse struct {
|
||||
List []*PriorityPollingItemResponse `json:"items" description:"优先轮询项列表"`
|
||||
Total int64 `json:"total" description:"总条数"`
|
||||
Page int `json:"page" description:"页码"`
|
||||
// 与 pkg/response.PaginationData 一致:响应侧字段名为 size,请求侧仍用 page_size。
|
||||
PageSize int `json:"size" description:"每页条数"`
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PollingConfig 轮询配置表
|
||||
@@ -152,3 +154,38 @@ type PollingManualTriggerLog struct {
|
||||
func (PollingManualTriggerLog) TableName() string {
|
||||
return "tb_polling_manual_trigger_log"
|
||||
}
|
||||
|
||||
// PollingPriorityItem 是「某卡某轮询任务类型被加急」的持久化事实。
|
||||
//
|
||||
// 活动项粒度为 (card_id, task_type),由部分唯一索引保证至多一条:Status 取
|
||||
// pending-待执行 / processing-执行中 时为活动项,取 completed-已完成 / failed-失败出队 时为终态,
|
||||
// 终态行不占用唯一键。TriggerType 是首次触发场景,TriggerTypes 是去重后的全部触发场景集合;
|
||||
// ClaimedAt 是认领租约基准,AttemptCount 只在真正发起执行后累加,Result 与 FailureReason
|
||||
// 只承载可安全展示的结果与原因,ShopIDSnapshot 是消费时点解析的卡所属店铺快照(平台卡为 NULL),
|
||||
// 读侧数据范围按它下推,卡改归属不追溯已入队的加急事实。
|
||||
// 本模型不声明任何关联标签与数据库外键,关联一律只存 ID 并由应用层显式查询。
|
||||
type PollingPriorityItem struct {
|
||||
gorm.Model
|
||||
CardID uint `gorm:"column:card_id;type:bigint;not null" json:"card_id"`
|
||||
TaskType string `gorm:"column:task_type;type:varchar(50);not null" json:"task_type"`
|
||||
Status string `gorm:"column:status;type:varchar(16);not null;default:'pending'" json:"status"`
|
||||
TriggerType string `gorm:"column:trigger_type;type:varchar(30);not null" json:"trigger_type"`
|
||||
TriggerTypes string `gorm:"column:trigger_types;type:varchar(255);not null;default:''" json:"trigger_types"`
|
||||
TriggerCount int `gorm:"column:trigger_count;type:int;not null;default:1" json:"trigger_count"`
|
||||
LastTriggeredAt time.Time `gorm:"column:last_triggered_at;type:timestamptz;not null;default:now()" json:"last_triggered_at"`
|
||||
SourceOrderID *uint `gorm:"column:source_order_id;type:bigint" json:"source_order_id,omitempty"`
|
||||
SourcePackageUsageID *uint `gorm:"column:source_package_usage_id;type:bigint" json:"source_package_usage_id,omitempty"`
|
||||
AttemptCount int `gorm:"column:attempt_count;type:int;not null;default:0" json:"attempt_count"`
|
||||
ClaimedAt *time.Time `gorm:"column:claimed_at;type:timestamptz" json:"claimed_at,omitempty"`
|
||||
ManualReason string `gorm:"column:manual_reason;type:varchar(500);not null;default:''" json:"manual_reason"`
|
||||
ManualOperatorID uint `gorm:"column:manual_operator_id;type:bigint;not null;default:0" json:"manual_operator_id"`
|
||||
ManualOperatorName string `gorm:"column:manual_operator_name;type:varchar(100);not null;default:''" json:"manual_operator_name"`
|
||||
ShopIDSnapshot *uint `gorm:"column:shop_id_snapshot;type:bigint" json:"shop_id_snapshot,omitempty"`
|
||||
Result string `gorm:"column:result;type:varchar(16);not null;default:''" json:"result"`
|
||||
FailureReason string `gorm:"column:failure_reason;type:varchar(500);not null;default:''" json:"failure_reason"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PollingPriorityItem) TableName() string {
|
||||
return "tb_polling_priority_item"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user