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。
295 lines
13 KiB
Go
295 lines
13 KiB
Go
// Package prioritypolling 实现卡轮询优先队列的可靠事件适配器。
|
||
//
|
||
// 职责分工:本包只做「业务事实 → Outbox 事件」与「Outbox 事件 → 优先项事实 + 执行提示」两侧适配,
|
||
// 不承载状态机(权威在优先项事实表)、不决定任务类型集合(权威在 pkg/constants)。
|
||
package prioritypolling
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/bytedance/sonic"
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
|
||
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
|
||
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/infrastructure/messaging/outbox"
|
||
"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/outboxid"
|
||
)
|
||
|
||
// PriorityEventWriter 将业务成功事实写入公共 Outbox。
|
||
type PriorityEventWriter struct {
|
||
outbox *outbox.Repository
|
||
}
|
||
|
||
// NewPriorityEventWriter 创建优先轮询事件 Writer。
|
||
func NewPriorityEventWriter(repository *outbox.Repository) *PriorityEventWriter {
|
||
return &PriorityEventWriter{outbox: repository}
|
||
}
|
||
|
||
// AppendPriorityRequested 在原业务事务中追加稳定优先轮询请求事件。
|
||
//
|
||
// 三个不可省略的约束:
|
||
// 1. 必须在调用方的业务事务内追加(tx 非空),由 Outbox 在提交后投递,避免「提交后 goroutine」丢失可靠副作用。
|
||
// 2. 观测结果驱动的业务评估必须短路:与既有观测序列写入共用同一抑制标记,避免「观测→业务→优先轮询→观测」反向触发环。
|
||
// 3. event_id 必须使用独立命名空间前缀,否则会被 Outbox 全局幂等键静默吞掉。
|
||
func (w *PriorityEventWriter) AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event priorityapp.PriorityRequestedEvent) error {
|
||
if w == nil || w.outbox == nil {
|
||
return errors.New(errors.CodeInternalError, "优先轮询 Outbox Writer 未配置")
|
||
}
|
||
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
|
||
return nil
|
||
}
|
||
if tx == nil {
|
||
return errors.New(errors.CodeInternalError, "优先轮询事件缺少原业务事务")
|
||
}
|
||
if strings.TrimSpace(event.EventID) == "" || event.ResourceID == 0 || strings.TrimSpace(event.TriggerType) == "" {
|
||
return errors.New(errors.CodeInvalidParam, "优先轮询事件载荷不完整")
|
||
}
|
||
if !strings.HasPrefix(event.EventID, constants.PollingPriorityEventKeyPrefix) {
|
||
return errors.New(errors.CodeInvalidParam,
|
||
"优先轮询事件 ID 必须使用 "+constants.PollingPriorityEventKeyPrefix+" 命名空间前缀")
|
||
}
|
||
// 长度契约:event_id 列上限 64,超限会让写入失败并连带回滚触发事务(如套餐轮询进失败分支)。
|
||
// 因此在此提前按既有 Outbox 标识契约校验,把数据库层失败变成可定位的参数错误。
|
||
if err := outboxid.Validate(event.EventID, ""); err != nil {
|
||
return errors.Wrap(errors.CodeInvalidParam, err, "优先轮询事件 ID 超长")
|
||
}
|
||
if event.ResourceType != constants.AssetTypeIotCard && event.ResourceType != constants.AssetTypeDevice {
|
||
return errors.New(errors.CodeInvalidParam, "优先轮询事件资源类型不受支持")
|
||
}
|
||
// 人工入队不经事件通道:manual_trigger 由 internal/service/polling/priority_enqueue_service.go 直写事实表,
|
||
// 事件通道没有操作者,无法满足事实表 CHECK (trigger_type <> 'manual_trigger' OR manual_operator_id > 0)。
|
||
// 权威约束在事实表 CHECK,这里只是把「DB 23514 → 整个投递事务回滚」提前为可定位的参数错误,
|
||
// 避免调用方业务事务被数据库层失败牵连回滚。
|
||
if event.TriggerType == constants.PollingPriorityTriggerManual {
|
||
return errors.New(errors.CodeInvalidParam,
|
||
"优先轮询事件不接受人工入队触发类型 "+constants.PollingPriorityTriggerManual+",人工入队请调用人工入队用例")
|
||
}
|
||
|
||
// 冻结卡快照:独立卡为自身,绑定设备的资产为绑定状态有效的全部在用卡。
|
||
// 快照在业务事务内写入,触发提交后绑定关系或当前卡槽变化都不追溯。
|
||
if event.ResourceType == constants.AssetTypeDevice {
|
||
event.ResourceIDs = nil
|
||
if err := tx.WithContext(ctx).
|
||
Model(&model.DeviceSimBinding{}).
|
||
Where("device_id = ? AND bind_status = ?", event.ResourceID, constants.BindStatusBound).
|
||
Order("slot_position ASC").
|
||
Pluck("iot_card_id", &event.ResourceIDs).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "冻结优先轮询设备绑定卡快照失败")
|
||
}
|
||
}
|
||
|
||
if _, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
|
||
EventID: event.EventID,
|
||
EventType: constants.OutboxEventTypePollingPriorityRequested,
|
||
PayloadVersion: constants.PollingPriorityRequestedPayloadVersionV1,
|
||
AggregateType: event.ResourceType,
|
||
AggregateID: strconv.FormatUint(uint64(event.ResourceID), 10),
|
||
ResourceType: event.ResourceType,
|
||
ResourceID: strconv.FormatUint(uint64(event.ResourceID), 10),
|
||
BusinessKey: event.EventID,
|
||
RequestID: event.RequestID,
|
||
CorrelationID: event.CorrelationID,
|
||
Payload: event,
|
||
}); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "写入优先轮询 Outbox 事件失败")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PriorityRequestedConsumer 消费业务成功事实,按卡 × 纳入任务类型建立或合并优先项并下发执行提示。
|
||
type PriorityRequestedConsumer struct {
|
||
db *gorm.DB
|
||
store *postgres.PollingPriorityItemStore
|
||
publisher priorityapp.PromptPublisher
|
||
audit *audit.Writer
|
||
logger *zap.Logger
|
||
}
|
||
|
||
// NewPriorityRequestedConsumer 创建优先轮询请求消费者。
|
||
func NewPriorityRequestedConsumer(
|
||
db *gorm.DB,
|
||
store *postgres.PollingPriorityItemStore,
|
||
publisher priorityapp.PromptPublisher,
|
||
auditWriter *audit.Writer,
|
||
logger *zap.Logger,
|
||
) *PriorityRequestedConsumer {
|
||
if logger == nil {
|
||
logger = zap.NewNop()
|
||
}
|
||
return &PriorityRequestedConsumer{db: db, store: store, publisher: publisher, audit: auditWriter, logger: logger}
|
||
}
|
||
|
||
// Consume 将一个业务事件展开为逐卡逐任务类型的优先项;重复投递只合并,不新建行也不重复调用。
|
||
func (c *PriorityRequestedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
|
||
if c == nil || c.db == nil || c.store == nil || c.publisher == nil {
|
||
return errors.New(errors.CodeInternalError, "优先轮询消费者未配置")
|
||
}
|
||
if envelope.EventType != constants.OutboxEventTypePollingPriorityRequested ||
|
||
envelope.PayloadVersion != constants.PollingPriorityRequestedPayloadVersionV1 {
|
||
return errors.New(errors.CodeInvalidParam, "优先轮询事件类型或版本不受支持")
|
||
}
|
||
var event priorityapp.PriorityRequestedEvent
|
||
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
|
||
return errors.Wrap(errors.CodeInvalidParam, err, "优先轮询事件载荷无法解析")
|
||
}
|
||
if event.EventID != envelope.EventID || event.ResourceID == 0 || strings.TrimSpace(event.TriggerType) == "" {
|
||
return errors.New(errors.CodeInvalidParam, "优先轮询事件载荷不完整")
|
||
}
|
||
if event.ResourceType != constants.AssetTypeIotCard && event.ResourceType != constants.AssetTypeDevice {
|
||
return errors.New(errors.CodeInvalidParam, "优先轮询事件资源类型不受支持")
|
||
}
|
||
cardIDs := []uint{event.ResourceID}
|
||
if event.ResourceType == constants.AssetTypeDevice {
|
||
cardIDs = event.ResourceIDs
|
||
}
|
||
// 设备在触发事务内没有绑定卡:没有执行对象,事件成功结束且不重试。
|
||
// 记一条 Info 以便外部区分「触发已发生但无卡」与「触发未发生」。
|
||
if len(cardIDs) == 0 {
|
||
c.logger.Info("优先轮询触发未解析到绑定卡,事件按无执行对象结束",
|
||
zap.String("event_id", event.EventID),
|
||
zap.String("trigger_type", event.TriggerType),
|
||
zap.String("resource_type", event.ResourceType),
|
||
zap.Uint("resource_id", event.ResourceID))
|
||
return nil
|
||
}
|
||
|
||
taskTypes := constants.PollingPriorityTaskTypes()
|
||
createdCount, mergedCount := 0, 0
|
||
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
shopSnapshots, lookupErr := loadCardShopSnapshots(ctx, tx, cardIDs)
|
||
if lookupErr != nil {
|
||
return lookupErr
|
||
}
|
||
store := c.store.WithTx(tx)
|
||
createdCount, mergedCount = 0, 0
|
||
for _, cardID := range cardIDs {
|
||
for _, taskType := range taskTypes {
|
||
item := &model.PollingPriorityItem{
|
||
CardID: cardID,
|
||
TaskType: taskType,
|
||
TriggerType: event.TriggerType,
|
||
SourceOrderID: optionalUintPointer(event.SourceOrderID),
|
||
SourcePackageUsageID: optionalUintPointer(event.SourcePackageUsageID),
|
||
ShopIDSnapshot: shopSnapshots[cardID],
|
||
LastTriggeredAt: event.OccurredAt,
|
||
}
|
||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||
if insertErr != nil {
|
||
return insertErr
|
||
}
|
||
if created {
|
||
createdCount++
|
||
continue
|
||
}
|
||
mergedCount++
|
||
}
|
||
}
|
||
// 入队事实与审计同事务:任一逐卡逐类型写入失败都整体回滚,由 Outbox 重投,不存在部分静默成功。
|
||
return c.writeEnqueueAudit(ctx, tx, envelope.EventID, event, len(cardIDs), taskTypes, createdCount, mergedCount)
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 提交后再下发执行提示。提示通道不是权威,但下发失败仍返回错误交 Outbox 重投:
|
||
// 合并语义使重投幂等,且无人接收的提示只会让加急退化为延迟一个普通轮询周期,
|
||
// 因此不能用「吞掉错误」的方式掩盖通道故障。
|
||
for _, cardID := range cardIDs {
|
||
for _, taskType := range taskTypes {
|
||
if publishErr := c.publisher.EnqueuePriority(ctx, cardID, taskType); publishErr != nil {
|
||
return errors.Wrap(errors.CodeInternalError, publishErr, "下发优先轮询执行提示失败")
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// writeEnqueueAudit 写入一条入队事实审计(自动触发与人工入队共用同一动作)。
|
||
func (c *PriorityRequestedConsumer) writeEnqueueAudit(
|
||
ctx context.Context,
|
||
tx *gorm.DB,
|
||
eventID string,
|
||
event priorityapp.PriorityRequestedEvent,
|
||
cardCount int,
|
||
taskTypes []string,
|
||
createdCount, mergedCount int,
|
||
) error {
|
||
afterData := map[string]any{
|
||
"trigger_type": event.TriggerType,
|
||
"source_order_id": event.SourceOrderID,
|
||
"source_package_usage_id": event.SourcePackageUsageID,
|
||
"resource_type": event.ResourceType,
|
||
"resource_id": event.ResourceID,
|
||
"card_count": cardCount,
|
||
"task_types": taskTypes,
|
||
"created_count": createdCount,
|
||
"merged_count": mergedCount,
|
||
"source": constants.AuditSourceWorker,
|
||
"occurred_at": event.OccurredAt,
|
||
}
|
||
return c.audit.WriteTask(ctx, tx, audit.TaskInput{
|
||
// 与 Outbox 事件同 ID:至少一次重投时审计按 event_id 幂等去重,不产生重复审计行。
|
||
EventID: eventID,
|
||
ActionCode: constants.AuditActionPollingPriorityEnqueued,
|
||
Summary: "卡轮询优先项入队",
|
||
TaskID: 0, TaskNo: eventID, DisplayName: "卡轮询优先项入队",
|
||
Actor: audit.ActorInput{
|
||
Kind: constants.AuditActorSystemTask,
|
||
ID: constants.OutboxEventTypePollingPriorityRequested,
|
||
Name: "优先轮询请求消费任务",
|
||
},
|
||
Source: constants.AuditSourceWorker,
|
||
ScopeType: constants.AuditScopePlatform,
|
||
Result: constants.AuditResultSuccess,
|
||
CorrelationID: event.CorrelationID,
|
||
AfterData: afterData,
|
||
Metadata: map[string]any{
|
||
"trigger_type": event.TriggerType,
|
||
"source_order_id": event.SourceOrderID,
|
||
"source_package_usage_id": event.SourcePackageUsageID,
|
||
"created_count": createdCount,
|
||
"merged_count": mergedCount,
|
||
},
|
||
})
|
||
}
|
||
|
||
// loadCardShopSnapshots 读取卡的店铺快照:平台卡返回 nil,读侧据此把平台卡与空范围一并挡在代理之外。
|
||
// 快照取消费时点的卡归属,卡改归属不追溯已入队的加急事实;已软删的卡不产生快照,
|
||
// 其优先项会在执行时以「卡不存在」进入失败终态。
|
||
func loadCardShopSnapshots(ctx context.Context, tx *gorm.DB, cardIDs []uint) (map[uint]*uint, error) {
|
||
type cardShopRow struct {
|
||
ID uint `gorm:"column:id"`
|
||
ShopID *uint `gorm:"column:shop_id"`
|
||
}
|
||
var rows []cardShopRow
|
||
if err := tx.WithContext(ctx).
|
||
Model(&model.IotCard{}).
|
||
Select("id, shop_id").
|
||
Where("id IN ? AND deleted_at IS NULL", cardIDs).
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询卡店铺快照失败")
|
||
}
|
||
snapshots := make(map[uint]*uint, len(rows))
|
||
for _, row := range rows {
|
||
snapshots[row.ID] = row.ShopID
|
||
}
|
||
return snapshots, nil
|
||
}
|
||
|
||
// optionalUintPointer 把 0 视为「无来源」,避免用哨兵 0 占用可空来源列。
|
||
func optionalUintPointer(value uint) *uint {
|
||
if value == 0 {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|