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,62 @@
// Package prioritypolling 定义卡轮询优先队列的应用层契约。
//
// 本包只承载业务事实的形状与执行语义,不依赖 Fiber、GORM、Redis、Asynq 或具体外部 SDK
// Outbox 写入、消费者展开与执行接缝适配分别位于基础设施与任务层。
package prioritypolling
import (
"context"
"time"
"gorm.io/gorm"
)
// PriorityRequestedEvent 是业务成功边界可靠请求「为该资源的卡建立优先轮询项」的事实。
//
// 载荷沿用既有业务观测事件的形状(资源类型 / 资源 ID / 资源 ID 列表),并在业务事务内冻结卡快照:
// 独立卡为自身ResourceIDs 为空),绑定设备的资产为绑定状态有效的全部在用卡(写入 ResourceIDs
// TriggerType 取值与 pkg/constants.PollingPriorityTrigger* 一致;本轮要逐类型建项的任务类型集合
// 由代码常量给出pkg/constants.PollingPriorityTaskTypes不由事件载荷决定避免同一决策出现两个权威。
// 来源订单与来源套餐使用记录只保存 ID由消费者与读侧显式查询。
type PriorityRequestedEvent struct {
EventID string `json:"event_id"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
ResourceIDs []uint `json:"resource_ids,omitempty"`
TriggerType string `json:"trigger_type"`
SourceOrderID uint `json:"source_order_id,omitempty"`
SourcePackageUsageID uint `json:"source_package_usage_id,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// PromptPublisher 把优先执行提示下发到既有轮询提示通道。
//
// 提示通道不是权威:丢失或缓存服务重启时库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,
// 只退化为延迟一个普通轮询周期;调用方不得以提示的下发成功作为入队事实成立的条件。
type PromptPublisher interface {
EnqueuePriority(ctx context.Context, cardID uint, taskType string) error
}
// PriorityEventWriter 在原业务事务中追加优先轮询请求事件。
type PriorityEventWriter interface {
AppendPriorityRequested(ctx context.Context, tx *gorm.DB, event PriorityRequestedEvent) error
}
type pollingPriorityTriggerKey struct{}
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
//
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
}
// IsPollingPriorityTrigger 判断当前业务评估是否来自普通套餐轮询。
func IsPollingPriorityTrigger(ctx context.Context) bool {
value, _ := ctx.Value(pollingPriorityTriggerKey{}).(bool)
return value
}

View File

@@ -36,6 +36,7 @@ import (
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
@@ -69,6 +70,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
businessUserGroupStore := postgres.NewBusinessUserGroupStore(deps.DB)
carrierStore := postgres.NewCarrierStore(deps.DB)
pollingPriorityItemStore := postgres.NewPollingPriorityItemStore(deps.DB)
rechargeOrderStore := postgres.NewRechargeOrderStore(deps.DB, deps.Redis)
paymentStore := postgres.NewPaymentStore(deps.DB, deps.Redis)
commissionRecordStore := postgres.NewCommissionRecordStore(deps.DB, deps.Redis)
@@ -318,6 +320,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
PollingPriority: admin.NewPriorityPollingHandler(
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
),
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(

View File

@@ -24,6 +24,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom"
"github.com/break/junhong_cmp_fiber/internal/polling"
@@ -131,6 +132,7 @@ type services struct {
PollingAlert *pollingSvc.AlertService
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
PriorityPolling *pollingSvc.PriorityEnqueueService
Asset *assetSvc.Service
AssetLifecycle *assetSvc.LifecycleService
AssetWallet *assetWalletSvc.Service
@@ -185,6 +187,7 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
@@ -237,6 +240,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
packageActivation.SetPriorityEventWriter(priorityEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -248,6 +252,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
@@ -294,6 +299,7 @@ func initServices(s *stores, deps *Dependencies) *services {
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
orderService.SetPriorityEventWriter(priorityEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
@@ -448,6 +454,12 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
// 人工优先入队:与人工触发共用同包权限判定,但使用独立的优先提示通道与合并语义。
priorityPollingService := pollingSvc.NewPriorityEnqueueService(
deps.DB, s.IotCard, s.PollingPriorityItem, pollingQueueMgr, deps.Logger,
)
priorityPollingService.SetAudit(auditWriter)
return &services{
AccessAudit: auditWriter,
Approval: approvalCreationService,
@@ -511,6 +523,7 @@ func initServices(s *stores, deps *Dependencies) *services {
PollingAlert: pollingAlertService,
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingManualTriggerService,
PriorityPolling: priorityPollingService,
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, auditWriter),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),

View File

@@ -52,6 +52,7 @@ type stores struct {
DataCleanupConfig *postgres.DataCleanupConfigStore
DataCleanupLog *postgres.DataCleanupLogStore
PollingManualTriggerLog *postgres.PollingManualTriggerLogStore
PollingPriorityItem *postgres.PollingPriorityItemStore
// 代理钱包系统
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
@@ -132,6 +133,7 @@ func initStores(deps *Dependencies) *stores {
DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB),
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
PollingManualTriggerLog: postgres.NewPollingManualTriggerLogStore(deps.DB),
PollingPriorityItem: postgres.NewPollingPriorityItemStore(deps.DB),
// 代理钱包系统
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),

View File

@@ -68,6 +68,7 @@ type Handlers struct {
PollingAlert *admin.PollingAlertHandler
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
PollingPriority *admin.PriorityPollingHandler
Asset *admin.AssetHandler
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler

View File

@@ -9,6 +9,7 @@ import (
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
@@ -97,6 +98,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
priorityEvents := prioritypollingInfra.NewPriorityEventWriter(cardObservationOutbox)
// 运营商通道流量阈值用例:达量判定嵌入流量观测事务,持锁判定注入停复机入口。
carrierThresholdService := carrierThresholdApp.NewService(deps.DB, cardObservationOutbox).SetLogger(deps.Logger)
cardObservationService := cardObservationApp.NewService(
@@ -156,6 +158,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
orderService.SetPriorityEventWriter(priorityEvents)
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -167,12 +170,14 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger,
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
activationService.SetPriorityEventWriter(priorityEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
orderService.SetResumeCallback(stopResumeService)
@@ -190,6 +195,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
ObservationSeriesEvents: observationSeriesEvents,
PriorityEvents: priorityEvents,
CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService,
UsageService: usageService,

View File

@@ -0,0 +1,112 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
prioritypollingquery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PriorityPollingHandler 处理卡轮询优先队列的人工入队与只读查询请求。
type PriorityPollingHandler struct {
enqueue *pollingSvc.PriorityEnqueueService
query *prioritypollingquery.Query
validator *validator.Validate
}
// NewPriorityPollingHandler 创建卡轮询优先队列处理器。
func NewPriorityPollingHandler(
enqueue *pollingSvc.PriorityEnqueueService,
query *prioritypollingquery.Query,
validator *validator.Validate,
) *PriorityPollingHandler {
return &PriorityPollingHandler{enqueue: enqueue, query: query, validator: validator}
}
// Enqueue 人工优先入队。
// POST /api/admin/polling-priority-items
func (h *PriorityPollingHandler) Enqueue(c *fiber.Ctx) error {
if h.enqueue == nil {
return errors.New(errors.CodeInternalError, "人工优先入队能力未配置")
}
var req dto.EnqueuePriorityPollingRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("人工优先入队参数不合法", &req, err))
}
result, err := h.enqueue.Enqueue(c.UserContext(), req.CardID, req.Reason, middleware.GetUserIDFromContext(c.UserContext()))
if err != nil {
return err
}
taskTypeNames := make([]string, 0, len(result.TaskTypes))
items := make([]dto.EnqueuePriorityPollingItemView, 0, len(result.Items))
for _, taskType := range result.TaskTypes {
taskTypeNames = append(taskTypeNames, constants.PollingPriorityTaskTypeName(taskType))
}
for _, item := range result.Items {
items = append(items, dto.EnqueuePriorityPollingItemView{
ItemID: item.ItemID,
TaskType: item.TaskType,
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
Status: item.Status,
StatusName: constants.PollingPriorityStatusName(item.Status),
TriggerCount: item.TriggerCount,
LastTriggeredAt: item.LastTriggeredAt,
Created: item.Created,
})
}
return response.Success(c, dto.EnqueuePriorityPollingResponse{
CardID: result.CardID,
TaskTypes: result.TaskTypes,
TaskTypeNames: taskTypeNames,
CreatedCount: result.CreatedCount,
MergedCount: result.MergedCount,
Items: items,
})
}
// ListPriorityItems 分页查询优先轮询项。
// GET /api/admin/polling-priority-items
func (h *PriorityPollingHandler) ListPriorityItems(c *fiber.Ctx) error {
if h.query == nil {
return errors.New(errors.CodeInternalError, "优先轮询项查询能力未配置")
}
var req dto.PriorityPollingItemListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("优先轮询项查询参数不合法", &req, err))
}
result, err := h.query.List(c.UserContext(), req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetPriorityItem 查询优先轮询项详情。
// GET /api/admin/polling-priority-items/:id
func (h *PriorityPollingHandler) GetPriorityItem(c *fiber.Ctx) error {
if h.query == nil {
return errors.New(errors.CodeInternalError, "优先轮询项查询能力未配置")
}
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.query.Detail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -268,6 +268,14 @@ func NewRegistry() *Registry {
pollingManualTriggerBatch := pollingAction(constants.AuditActionPollingManualTriggerBatch, "批量手动触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualTriggerByCondition := pollingAction(constants.AuditActionPollingManualTriggerByCondition, "条件筛选触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualCancelled := pollingAction(constants.AuditActionPollingManualCancelled, "取消手动触发任务", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingPriorityEnqueued := pollingPriorityAction(constants.AuditActionPollingPriorityEnqueued, "卡轮询优先项入队", constants.AuditRiskNormal, true)
pollingPriorityClaimed := pollingPriorityAction(constants.AuditActionPollingPriorityClaimed, "卡轮询优先项领取", constants.AuditRiskNormal, true)
pollingPriorityFailed := pollingPriorityAction(constants.AuditActionPollingPriorityFailed, "卡轮询优先项执行失败", constants.AuditRiskNormal, true)
pollingPriorityRetried := pollingPriorityAction(constants.AuditActionPollingPriorityRetried, "卡轮询优先项退回重试", constants.AuditRiskNormal, true)
pollingPriorityCompleted := pollingPriorityAction(constants.AuditActionPollingPriorityCompleted, "卡轮询优先项完成", constants.AuditRiskNormal, true)
pollingPriorityDequeued := pollingPriorityAction(constants.AuditActionPollingPriorityDequeued, "卡轮询优先项失败出队", constants.AuditRiskNormal, true)
// 人工优先入队的拒绝只可能来自后台账号入口,不登记 Worker 侧来源。
pollingPriorityManualDenied := pollingPriorityAction(constants.AuditActionPollingPriorityManualDenied, "拒绝人工优先入队", constants.AuditRiskNormal, false)
wecomCredentialsRead := ActionDefinition{
Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据",
Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
@@ -635,6 +643,13 @@ func NewRegistry() *Registry {
constants.AuditActionPollingManualTriggerBatch: pollingManualTriggerBatch,
constants.AuditActionPollingManualTriggerByCondition: pollingManualTriggerByCondition,
constants.AuditActionPollingManualCancelled: pollingManualCancelled,
constants.AuditActionPollingPriorityEnqueued: pollingPriorityEnqueued,
constants.AuditActionPollingPriorityClaimed: pollingPriorityClaimed,
constants.AuditActionPollingPriorityFailed: pollingPriorityFailed,
constants.AuditActionPollingPriorityRetried: pollingPriorityRetried,
constants.AuditActionPollingPriorityCompleted: pollingPriorityCompleted,
constants.AuditActionPollingPriorityDequeued: pollingPriorityDequeued,
constants.AuditActionPollingPriorityManualDenied: pollingPriorityManualDenied,
constants.AuditActionWeComCredentialsRead: wecomCredentialsRead,
constants.AuditActionRoleCreated: roleCreated,
constants.AuditActionRoleUpdated: roleUpdated,
@@ -887,6 +902,10 @@ func NewRegistry() *Registry {
Type: constants.AuditResourcePollingManualTrigger, Name: "手动轮询任务",
IdentityFields: []string{"id", "task_type", "trigger_type", "total_count", "status", "triggered_by"},
},
constants.AuditResourcePollingPriorityItem: {
Type: constants.AuditResourcePollingPriorityItem, Name: "卡轮询优先项",
IdentityFields: []string{"id", "card_id", "task_type", "status", "trigger_type", "trigger_count", "manual_operator_id"},
},
constants.AuditResourceDevice: {
Type: constants.AuditResourceDevice, Name: "设备",
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "device_name", "device_model", "device_type", "manufacturer", "shop_id", "series_id", "generation"},
@@ -1462,6 +1481,26 @@ func pollingAction(code, name, primaryResource, risk string) ActionDefinition {
}
}
// pollingPriorityAction 定义卡轮询优先队列动作。
// 人工侧由后台账号经管理接口写入;当 withWorkerOrigin 为真时额外登记系统侧来源
// (轮询 Worker 任务),两侧必须分别声明,否则写入会被来源校验丢弃。
func pollingPriorityAction(code, name, risk string, withWorkerOrigin bool) ActionDefinition {
definition := ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: constants.AuditResourcePollingPriorityItem,
AllowedActor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI,
RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
if withWorkerOrigin {
definition.AllowedOrigins = []ActionOrigin{
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
}
}
return definition
}
func outboxRecoveryAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh,

View File

@@ -0,0 +1,294 @@
// 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
}

View 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:"每页条数"`
}

View File

@@ -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"
}

View File

@@ -48,7 +48,7 @@ func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, card *model
if card == nil {
return
}
if !s.shouldEnqueue(ctx, card) {
if !s.ShouldEnqueue(ctx, card) {
s.logger.Debug("卡禁用轮询,跳过入队", zap.Uint("card_id", card.ID))
return
}
@@ -75,7 +75,7 @@ func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardI
s.logger.Error("卡状态变化:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
return
}
if !s.shouldEnqueue(ctx, card) {
if !s.ShouldEnqueue(ctx, card) {
return
}
s.enqueueCard(ctx, card)
@@ -98,7 +98,7 @@ func (s *PollingLifecycleService) OnBatchCardsStatusChanged(ctx context.Context,
return
}
for _, card := range cards {
if !s.shouldEnqueue(ctx, card) {
if !s.ShouldEnqueue(ctx, card) {
continue
}
s.enqueueCard(ctx, card)
@@ -120,7 +120,7 @@ func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint
s.logger.Error("卡启用:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
return
}
if !s.shouldEnqueue(ctx, card) {
if !s.ShouldEnqueue(ctx, card) {
return
}
s.enqueueCard(ctx, card)
@@ -134,10 +134,10 @@ func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uin
s.queueMgr.InvalidateCardCache(ctx, cardID)
}
// shouldEnqueue M3 修复:检查卡或其绑定设备是否允许轮询
// ShouldEnqueue M3 修复:检查卡或其绑定设备是否允许轮询(同时被轮询优先项的执行前范围校验复用)
// 两层检查:先查卡级 enable_polling再查设备级 enable_pollingIsStandalone=false 时)
// 直接查 device.enable_polling不依赖级联同步正确性防止同步失败时生命周期事件绕过设备级禁用
func (s *PollingLifecycleService) shouldEnqueue(ctx context.Context, card *model.IotCard) bool {
func (s *PollingLifecycleService) ShouldEnqueue(ctx context.Context, card *model.IotCard) bool {
if !card.EnablePolling {
return false
}

View File

@@ -153,6 +153,14 @@ func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, ta
return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err()
}
// EnqueuePriority 优先轮询提示下发List RPUSH与手动触发队列分离
// 使用 RPUSH 与调度器的 LPopCount 组成先进先出;提示通道不是权威,丢失或缓存服务重启时
// 库内活动优先项仍在,后续普通轮询执行按条件认领仍会执行,只退化为延迟一个普通轮询周期。
func (m *PollingQueueManager) EnqueuePriority(ctx context.Context, cardID uint, taskType string) error {
key := constants.RedisPollingPriorityQueueKey(taskType)
return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err()
}
// OnCardDeleted 卡删除事件处理(移除所有队列 + 清理卡信息缓存)
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error {
// 从所有分片队列移除

View File

@@ -156,7 +156,12 @@ func (s *Scheduler) scheduleLoop(ctx context.Context) {
// processShardSchedule 处理手动队列和分片定时队列(每 1 秒触发)
// 使用 90% 的 tick 间隔作为超时,确保单分片 Redis 挂起时不阻塞下一个 tick
func (s *Scheduler) processShardSchedule(ctx context.Context) {
// 手动队列不受 Init 影响ConfigManager 已就绪即可)
// 优先提示与手动队列不受 Init 影响ConfigManager 已就绪即可)
// 同一调度周期内先排空优先提示、再排空手动触发队列:优先的强度边界仅为调度入口领先,
// 提示排空不参与分片背压判断,因此分片积压不会抑制加急入队。
for _, taskType := range allTaskTypes {
s.processPriorityQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
}
for _, taskType := range allTaskTypes {
s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
}
@@ -243,7 +248,16 @@ func (s *Scheduler) processActivationTasks(ctx context.Context) {
// processManualQueue 处理手动触发队列
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string, maxBatch int) {
key := constants.RedisPollingManualQueueKey(taskType)
s.drainPollingQueue(ctx, constants.RedisPollingManualQueueKey(taskType), taskType, maxBatch)
}
// processPriorityQueue 处理优先轮询提示通道:与手动触发队列分离,且不参与分片背压判断。
func (s *Scheduler) processPriorityQueue(ctx context.Context, taskType string, maxBatch int) {
s.drainPollingQueue(ctx, constants.RedisPollingPriorityQueueKey(taskType), taskType, maxBatch)
}
// drainPollingQueue 从指定提示通道批量取出卡,并复用既有批量提交逻辑提交到同一 Asynq 任务类型与队列。
func (s *Scheduler) drainPollingQueue(ctx context.Context, key, taskType string, maxBatch int) {
cardIDs, err := s.redis.LPopCount(ctx, key, maxBatch).Result()
if err != nil || len(cardIDs) == 0 {
return

View File

@@ -0,0 +1,192 @@
// Package prioritypolling 提供卡轮询优先项的只读查询投影。
//
// 读取不经聚合根、不修改任何状态;数据范围按事实表冗余的卡店铺快照下推,
// 平台卡(快照为空)与范围外的行对代理账号同等不可见,越权与不存在不得形成可枚举差异。
package prioritypolling
import (
"context"
stderrors "errors"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingsvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"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"
)
// Query 优先轮询项只读查询。
type Query struct {
db *gorm.DB
store *postgres.PollingPriorityItemStore
}
// NewQuery 创建优先轮询项查询。
func NewQuery(db *gorm.DB, store *postgres.PollingPriorityItemStore) *Query {
return &Query{db: db, store: store}
}
// List 分页查询优先轮询项,按创建时间倒序,并按当前账号数据范围下推。
func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest) (*dto.PriorityPollingItemListResponse, error) {
if q == nil || q.store == nil {
return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置")
}
scope, err := priorityPollingShopScope(ctx)
if err != nil {
return nil, err
}
filter := postgres.PollingPriorityItemListFilter{
CardID: req.CardID,
TaskType: req.TaskType,
Status: req.Status,
TriggerType: req.TriggerType,
ShopIDSnapshot: scope,
Page: req.Page,
PageSize: req.PageSize,
}
items, total, err := q.store.List(ctx, filter)
if err != nil {
return nil, err
}
list, err := q.projectList(ctx, items)
if err != nil {
return nil, err
}
page, pageSize := normalizePage(req.Page, req.PageSize)
return &dto.PriorityPollingItemListResponse{
List: list, Total: total, Page: page, PageSize: pageSize,
}, nil
}
// Detail 查询优先轮询项详情,并按当前账号数据范围下推。
// 越权与不存在返回同一稳定错误,不提供可枚举差异。
func (q *Query) Detail(ctx context.Context, id uint) (*dto.PriorityPollingItemResponse, error) {
if q == nil || q.store == nil {
return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置")
}
scope, err := priorityPollingShopScope(ctx)
if err != nil {
return nil, err
}
item, err := q.store.GetByIDScoped(ctx, id, scope)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
// 越权与不存在共用同一稳定错误,避免通过状态码枚举他人资源。
return nil, errors.New(errors.CodeForbidden, priorityPollingDeniedMessage)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项详情失败")
}
iccid := q.loadICCID(ctx, []*model.PollingPriorityItem{item})
return toPriorityPollingItemResponse(item, iccid[item.CardID]), nil
}
// projectList 批量载入卡的 ICCID 后投影列表,避免逐行查询。
func (q *Query) projectList(ctx context.Context, items []*model.PollingPriorityItem) ([]*dto.PriorityPollingItemResponse, error) {
if len(items) == 0 {
return []*dto.PriorityPollingItemResponse{}, nil
}
iccids := q.loadICCID(ctx, items)
list := make([]*dto.PriorityPollingItemResponse, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
list = append(list, toPriorityPollingItemResponse(item, iccids[item.CardID]))
}
return list, nil
}
// loadICCID 按本批卡ID一次投影 ICCID已删除的卡不返回由响应保留空字符串。
func (q *Query) loadICCID(ctx context.Context, items []*model.PollingPriorityItem) map[uint]string {
cardIDs := make([]uint, 0, len(items))
seen := make(map[uint]bool, len(items))
for _, item := range items {
if item == nil || seen[item.CardID] {
continue
}
seen[item.CardID] = true
cardIDs = append(cardIDs, item.CardID)
}
result := make(map[uint]string, len(cardIDs))
if len(cardIDs) == 0 {
return result
}
type cardICCIDRow struct {
ID uint `gorm:"column:id"`
ICCID string `gorm:"column:iccid"`
}
var rows []cardICCIDRow
if err := q.db.WithContext(ctx).
Model(&model.IotCard{}).
Select("id, iccid").
Where("id IN ? AND deleted_at IS NULL", cardIDs).
Scan(&rows).Error; err != nil {
// 卡号只用于展示,查询失败降级为空号,不影响读取优先轮询事实。
return result
}
for _, row := range rows {
result[row.ID] = row.ICCID
}
return result
}
// priorityPollingShopScope 返回当前账号可访问的店铺快照范围,并完成读侧鉴权。
//
// 复用与人工入队同一套用户类型分类internal/service/polling.PriorityPollingReadScope
// 只有超级管理员与平台账号不受限nil企业账号与未预计算范围的账号一律拒绝。
// 返回 nil 表示不受限,非 nil 时按列表下推,空切片表示空结果。
func priorityPollingShopScope(ctx context.Context) ([]uint, error) {
return pollingsvc.PriorityPollingReadScope(ctx)
}
// priorityPollingDeniedMessage 与人工入队共用同一拒绝文案。
const priorityPollingDeniedMessage = "无权限操作该资源或资源不存在"
// normalizePage 归一化分页参数并执行默认值与上限。
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = constants.DefaultPage
}
if pageSize < 1 || pageSize > constants.MaxPageSize {
pageSize = constants.DefaultPageSize
}
return page, pageSize
}
// toPriorityPollingItemResponse 把事实行投影为读侧 DTO枚举按 constants 提供中文名称。
func toPriorityPollingItemResponse(item *model.PollingPriorityItem, iccid string) *dto.PriorityPollingItemResponse {
if item == nil {
return nil
}
return &dto.PriorityPollingItemResponse{
ID: item.ID,
CardID: item.CardID,
ICCID: iccid,
TaskType: item.TaskType,
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
Status: item.Status,
StatusName: constants.PollingPriorityStatusName(item.Status),
TriggerType: item.TriggerType,
TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType),
TriggerTypes: strings.Trim(item.TriggerTypes, ","),
TriggerCount: item.TriggerCount,
LastTriggeredAt: item.LastTriggeredAt,
SourceOrderID: item.SourceOrderID,
SourcePackageUse: item.SourcePackageUsageID,
AttemptCount: item.AttemptCount,
ClaimedAt: item.ClaimedAt,
ManualReason: item.ManualReason,
ManualOperator: item.ManualOperatorID,
ManualOperatorNm: item.ManualOperatorName,
ShopIDSnapshot: item.ShopIDSnapshot,
Result: item.Result,
ResultName: constants.PollingPriorityResultName(item.Result),
FailureReason: item.FailureReason,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}

View File

@@ -139,6 +139,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.PollingManualTrigger != nil {
registerPollingManualTriggerRoutes(authGroup, handlers.PollingManualTrigger, doc, basePath)
}
if handlers.PollingPriority != nil {
registerPollingPriorityRoutes(authGroup, handlers.PollingPriority, doc, basePath)
}
if handlers.Asset != nil {
registerAssetRoutes(authGroup, handlers.Asset, handlers.AssetWallet, doc, basePath)
registerPackageExpiryRoutes(authGroup, handlers.Asset, doc, basePath)

View File

@@ -0,0 +1,46 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerPollingPriorityRoutes 注册卡轮询优先队列的人工入队与只读查询路由。
// 仅挂在既有后台认证中间件组下,不新增认证边界。
func registerPollingPriorityRoutes(router fiber.Router, handler *admin.PriorityPollingHandler, doc *openapi.Generator, basePath string) {
group := router.Group("/polling-priority-items")
groupPath := basePath + "/polling-priority-items"
Register(group, doc, groupPath, "POST", "", handler.Enqueue, RouteSpec{
Summary: "人工优先入队",
Description: "为该卡的全部纳入轮询任务类型(实名/流量/套餐/卡状态)建立或合并优先轮询项。" +
"同一卡同一任务类型至多一条活动项,重复入队合并触发次数与来源集合;原因必填。" +
"不受既有人工触发的每日次数上限与 24 小时去重约束;不修改调度优先级,也不绕过既有并发上限。",
Tags: []string{"轮询管理-优先队列"},
Input: new(dto.EnqueuePriorityPollingRequest),
Output: new(dto.EnqueuePriorityPollingResponse),
Auth: true,
})
// 字面量路径必须先于 /:id 注册,保证路径按字面量优先匹配。
Register(group, doc, groupPath, "GET", "", handler.ListPriorityItems, RouteSpec{
Summary: "查询优先轮询项列表",
Description: "分页查询卡轮询优先项,按创建时间倒序,支持卡、任务类型、状态与触发类型筛选。" +
"数据范围按卡所属店铺快照下推:代理只能看到自身及下级店铺资产,平台卡不可见。",
Tags: []string{"轮询管理-优先队列"},
Input: new(dto.PriorityPollingItemListRequest),
Output: new(dto.PriorityPollingItemListResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/:id", handler.GetPriorityItem, RouteSpec{
Summary: "查询优先轮询项详情",
Description: "查询单条卡轮询优先项。越权与不存在返回同一响应,不产生可枚举差异。" +
"不提供优先级分级、有效期与人工重触发入口。",
Tags: []string{"轮询管理-优先队列"},
Input: new(dto.IDReq),
Output: new(dto.PriorityPollingItemResponse),
Auth: true,
})
}

View File

@@ -7,6 +7,7 @@ import (
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
@@ -17,6 +18,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"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"
@@ -49,6 +51,7 @@ type StopResumeService struct {
logger *zap.Logger
pollingCallback PollingCallback
observationSeriesEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
auditWriter *audit.Writer
integration *integrationlog.Repository
@@ -70,6 +73,11 @@ func (s *StopResumeService) SetUnifiedAudit(writer *audit.Writer, integration *i
s.integration = integration
}
// SetPriorityEventWriter 注入「资产无有效套餐」优先轮询请求 Outbox Writer。
func (s *StopResumeService) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) {
s.priorityEvents = writer
}
// NewStopResumeService 创建停复机服务
func NewStopResumeService(
redis *redis.Client,
@@ -111,6 +119,12 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
if len(reasons) == 0 {
return nil
}
// 无有效套餐且存在待生效套餐使用记录:仅由普通套餐轮询来源追加优先轮询请求。
if containsStopReason(reasons, constants.StopReasonNoPackage) {
if err := s.appendNoValidPackagePriority(ctx, card); err != nil {
return err
}
}
// 取最高优先级原因(第一个)
primaryReason := reasons[0]
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
@@ -174,6 +188,45 @@ func isPollingStopReason(reason string) bool {
return false
}
// containsStopReason 判断停机原因集合是否包含指定原因。
func containsStopReason(reasons []string, target string) bool {
for _, reason := range reasons {
if reason == target {
return true
}
}
return false
}
// appendNoValidPackagePriority 在套餐轮询来源下,为无有效套餐且存在待生效套餐使用记录的载体追加优先轮询请求。
//
// 三个前置条件缺一不可:
// 1. 已注入优先轮询 Writer
// 2. 本次评估来自普通套餐轮询(观测消费者、手动实名、保护期轮询等调用链不打标,一律不写);
// 3. 该载体存在待生效的套餐使用记录,否则每个普通套餐周期都重新入队会形成自激循环。
//
// 事件资源为该载体对应的资产(独立卡为自身,绑定设备为设备),卡快照由 Writer 在同一事务内冻结。
func (s *StopResumeService) appendNoValidPackagePriority(ctx context.Context, card *model.IotCard) error {
if s.priorityEvents == nil || card == nil || s.db == nil || !priorityapp.IsPollingPriorityTrigger(ctx) {
return nil
}
carrierType, carrierID, err := s.resolvePackageCarrier(ctx, card)
if err != nil {
return err
}
now := time.Now()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
hasPending, pendingErr := packagepkg.HasPendingPackageUsage(ctx, tx, carrierType, carrierID)
if pendingErr != nil {
return pendingErr
}
if !hasPending {
return nil
}
return packagepkg.AppendNoValidPackagePriorityRequested(ctx, tx, s.priorityEvents, carrierType, carrierID, now)
})
}
// isDeviceScopeReason 判断停机原因是否应扩散到设备维度
// 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡
func isDeviceScopeReason(reason string) bool {
@@ -200,19 +253,29 @@ func (s *StopResumeService) getCardDeviceID(ctx context.Context, card *model.Iot
return binding.DeviceID, true, nil
}
// hasValidPackage 检查卡是否有有效套餐status IN 0,1
// 修复Bug1绑定设备的卡查 device_id独立卡查 iot_card_id
func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) {
// resolvePackageCarrier 解析卡对应的套餐载体:非独立卡优先取绑定设备,其余取卡自身。
// 与 hasValidPackage/isTrafficExhausted 的既有口径一致,供优先轮询触发与停复机判定共用。
func (s *StopResumeService) resolvePackageCarrier(ctx context.Context, card *model.IotCard) (string, uint, error) {
if !card.IsStandalone {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr != nil {
return false, lookupErr
return "", 0, lookupErr
}
if bound {
return s.packageUsageStore.HasValidByCarrier(ctx, "device", deviceID)
return constants.AssetTypeDevice, deviceID, nil
}
}
return s.packageUsageStore.HasValidByCarrier(ctx, "iot_card", card.ID)
return constants.AssetTypeIotCard, card.ID, nil
}
// hasValidPackage 检查卡是否有有效套餐status IN 0,1
// 修复Bug1绑定设备的卡查 device_id独立卡查 iot_card_id
func (s *StopResumeService) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error) {
carrierType, carrierID, err := s.resolvePackageCarrier(ctx, card)
if err != nil {
return false, err
}
return s.packageUsageStore.HasValidByCarrier(ctx, carrierType, carrierID)
}
// isTrafficExhausted 检查卡的流量是否已耗尽

View File

@@ -13,6 +13,7 @@ import (
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
@@ -76,6 +77,7 @@ type Service struct {
agentWalletDebit *walletapp.DebitService
agentWalletReservation *walletapp.ReservationService
observationSeriesEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
billCreation *employeecollectionapp.BillCreationService
@@ -91,6 +93,11 @@ func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.Seri
s.observationSeriesEvents = writer
}
// SetPriorityEventWriter 注入购包生效后的优先轮询请求 Outbox Writer。
func (s *Service) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) {
s.priorityEvents = writer
}
// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。
func (s *Service) SetAgentWalletDebitService(service *walletapp.DebitService) {
s.agentWalletDebit = service
@@ -2640,6 +2647,17 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m
return errors.Wrap(errors.CodeDatabaseError, err, "更新主套餐状态失败")
}
// 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于本 Change 的触发场景。
if status == constants.PackageUsageStatusActive {
triggerType, classifyErr := packagepkg.ResolveActivationTriggerType(ctx, tx, carrierType, carrierID, usage.ID)
if classifyErr != nil {
return classifyErr
}
if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID, triggerType, activatedAt); err != nil {
return err
}
}
return nil
}
@@ -2722,6 +2740,12 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *
return errors.Wrap(errors.CodeDatabaseError, err, "创建加油包使用记录失败")
}
// 加油包立即生效:以生效状态提交后追加优先轮询请求。
if err := packagepkg.AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID,
constants.PollingPriorityTriggerAddonActivated, activatedAt); err != nil {
return err
}
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"time"
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/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -32,6 +33,7 @@ type ActivationService struct {
logger *zap.Logger
resumeCallback ResumeCallback // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
auditWriter *audit.Writer
}
@@ -45,6 +47,11 @@ func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservati
s.observationSeriesEvents = writer
}
// SetPriorityEventWriter 注入套餐生效后的优先轮询请求 Outbox Writer。
func (s *ActivationService) SetPriorityEventWriter(writer priorityapp.PriorityEventWriter) {
s.priorityEvents = writer
}
func NewActivationService(
db *gorm.DB,
redis *redis.Client,
@@ -683,6 +690,13 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
// 排队主套餐顺延生效:仅 queue 来源追加优先轮询请求specific 与 realname 来源不属于触发场景。
if activationSource == "queue" {
if err := AppendActivatedPriorityRequested(ctx, tx, s.priorityEvents, usage, carrierType, carrierID,
constants.PollingPriorityTriggerQueueActivated, now); err != nil {
return err
}
}
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, logMessage, []packageUsageAuditChange{{
Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage),
}}, nil, map[string]any{"activation_source": activationSource}); err != nil {

View File

@@ -0,0 +1,163 @@
package packagepkg
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// 本文件集中承载卡轮询优先队列在套餐域的两个判定与两条可靠事件写入:
// 生效触发(新购/续购/加油包/排队顺延)与「资产无有效套餐」触发。
// 事件 ID 命名空间与载荷形状只在本文件构造,调用点不得各自拼装,避免同一决策出现多份实现。
// ResolveActivationTriggerType 判定主套餐本次生效属于新购还是续购。
// 依据是该载体在本次生效前是否存在更早的套餐使用记录,而不是当前是否存在在用主套餐:
// 过期后续购时已无在用主套餐,用「当前在用」判定会误判为新购。
func ResolveActivationTriggerType(ctx context.Context, tx *gorm.DB, carrierType string, carrierID, usageID uint) (string, error) {
hasEarlier, err := HasEarlierPackageUsage(ctx, tx, carrierType, carrierID, usageID)
if err != nil {
return "", err
}
if hasEarlier {
return constants.PollingPriorityTriggerRenewalActivated, nil
}
return constants.PollingPriorityTriggerPurchaseActivated, nil
}
// HasEarlierPackageUsage 判断该载体在本次生效前是否已存在更早的套餐使用记录。
// 查询与本次记录同载体,并排除本次记录自身。
func HasEarlierPackageUsage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID, excludeUsageID uint) (bool, error) {
if tx == nil || carrierID == 0 {
return false, errors.New(errors.CodeInvalidParam, "查询更早套餐使用记录缺少载体")
}
query, err := filterPackageUsageByCarrier(tx.WithContext(ctx).Model(&model.PackageUsage{}), carrierType, carrierID)
if err != nil {
return false, err
}
if excludeUsageID != 0 {
query = query.Where("id <> ?", excludeUsageID)
}
var count int64
if err := query.Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询更早套餐使用记录失败")
}
return count > 0, nil
}
// HasPendingPackageUsage 判断该载体是否存在待生效的套餐使用记录。
//
// 这是「资产无有效套餐」场景触发优先轮询的前置条件:没有待同步的生效业务时,
// 每个普通套餐轮询周期都重新入队只会形成自激循环,因此必须以此条件收敛。
func HasPendingPackageUsage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
if tx == nil || carrierID == 0 {
return false, errors.New(errors.CodeInvalidParam, "查询待生效套餐使用记录缺少载体")
}
query, err := filterPackageUsageByCarrier(tx.WithContext(ctx).Model(&model.PackageUsage{}), carrierType, carrierID)
if err != nil {
return false, err
}
var count int64
if err := query.Where("status = ?", constants.PackageUsageStatusPending).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询待生效套餐使用记录失败")
}
return count > 0, nil
}
// AppendActivatedPriorityRequested 在套餐生效事务内追加「套餐生效」优先轮询请求。
//
// triggerType 只接受 purchase_activated/renewal_activated/queue_activated/addon_activated
// 调用点必须传入生效状态分支所在的业务事务,禁止在事务提交后另行投递。
func AppendActivatedPriorityRequested(
ctx context.Context,
tx *gorm.DB,
writer priorityapp.PriorityEventWriter,
usage *model.PackageUsage,
carrierType string,
carrierID uint,
triggerType string,
occurredAt time.Time,
) error {
if writer == nil {
return errors.New(errors.CodeInternalError, "优先轮询 Outbox Writer 未配置")
}
if usage == nil || usage.ID == 0 || carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "优先轮询事件缺少套餐使用记录或载体")
}
// 事件 ID 只承担「全局唯一 + 重投稳定」:触发类型与来源已写入事件载荷与事实行,不再重复编码,
// 以把长度压进预算(见 constants.PollingPriorityEventKeyPrefix 的长度说明)。
// 一条套餐使用记录的生效是单向一次性事实,因此 usage ID 本身即唯一键。
eventID := constants.PollingPriorityEventKeyPrefix + "usage:" + strconv.FormatUint(uint64(usage.ID), 10)
return writer.AppendPriorityRequested(ctx, tx, priorityapp.PriorityRequestedEvent{
EventID: eventID,
ResourceType: carrierType,
ResourceID: carrierID,
TriggerType: triggerType,
SourceOrderID: usage.OrderID,
SourcePackageUsageID: usage.ID,
OccurredAt: occurredAt,
RequestID: eventID,
CorrelationID: eventID,
})
}
// AppendNoValidPackagePriorityRequested 在触发事务内追加「资产无有效套餐」优先轮询请求。
//
// 事件 ID 以秒级时间戳结尾:本场景在待同步业务持续存在时每个普通套餐周期重复触发,
// 稳定键会让第二次触发起被 Outbox 去重而永久失效(如优先项已终态出队后将无法再次加急),
// 而同一秒内的并发重复触发共享一个事件,等价于同一轮询周期的合并。
func AppendNoValidPackagePriorityRequested(
ctx context.Context,
tx *gorm.DB,
writer priorityapp.PriorityEventWriter,
carrierType string,
carrierID uint,
occurredAt time.Time,
) error {
if writer == nil {
return errors.New(errors.CodeInternalError, "优先轮询 Outbox Writer 未配置")
}
if carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "优先轮询事件缺少载体")
}
// 载体类型压缩为单字母代码c-iot_card d-device完整载体类型与资源 ID 已随事件载体与载荷传递,
// 此处只为把长度压进预算(见 constants.PollingPriorityEventKeyPrefix 的长度说明)。
eventID := constants.PollingPriorityEventKeyPrefix + "nvp:" + pollingPriorityCarrierCode(carrierType) + ":" +
strconv.FormatUint(uint64(carrierID), 10) + ":" + strconv.FormatInt(occurredAt.Unix(), 10)
return writer.AppendPriorityRequested(ctx, tx, priorityapp.PriorityRequestedEvent{
EventID: eventID,
ResourceType: carrierType,
ResourceID: carrierID,
TriggerType: constants.PollingPriorityTriggerNoValidPackage,
OccurredAt: occurredAt,
RequestID: eventID,
CorrelationID: eventID,
})
}
// pollingPriorityCarrierCode 把载体类型压缩为事件 ID 中使用的单字母代码。
func pollingPriorityCarrierCode(carrierType string) string {
if carrierType == constants.AssetTypeDevice {
return "d"
}
return "c"
}
// filterPackageUsageByCarrier 按载体类型过滤套餐使用记录。
// 载体口径与卡的有效套餐判定一致device 查设备载体iot_card 查卡载体。
func filterPackageUsageByCarrier(query *gorm.DB, carrierType string, carrierID uint) (*gorm.DB, error) {
switch carrierType {
case constants.AssetTypeDevice:
return query.Where("device_id = ?", carrierID), nil
case constants.AssetTypeIotCard:
return query.Where("iot_card_id = ?", carrierID), nil
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的套餐载体类型:只接受 iot_card 或 device")
}
}

View File

@@ -55,8 +55,8 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t
return errors.New(errors.CodeInvalidParam, "无效的任务类型")
}
// 权限验证:检查用户是否有权管理该卡
if err := s.canManageCard(ctx, cardID); err != nil {
// 权限验证:检查用户是否有权管理该卡(与人工优先入队共用同包共享判定)
if err := canManagePollingCard(ctx, s.iotCardStore, cardID); err != nil {
return err
}
cards, err := s.iotCardStore.GetByIDs(ctx, []uint{cardID})
@@ -183,8 +183,8 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint,
return nil, errors.New(errors.CodeInvalidParam, "单次最多触发1000张卡")
}
// 权限验证:检查用户是否有权管理所有卡
if err := s.canManageCards(ctx, cardIDs); err != nil {
// 权限验证:检查用户是否有权管理所有卡(与人工优先入队共用同包共享判定)
if err := canManagePollingCards(ctx, s.iotCardStore, cardIDs); err != nil {
return nil, err
}
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
@@ -527,85 +527,9 @@ func isValidTaskType(taskType string) bool {
}
}
// checkUserTypePermission 检查用户类型是否有手动触发权限
// 返回 skip=true 表示超级管理员/平台用户直接放行
// 返回 err!=nil 表示企业账号无权限
// 返回 skip=false, err=nil 表示代理账号需继续细粒度检查
func (s *ManualTriggerService) checkUserTypePermission(ctx context.Context) (skip bool, err error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return true, nil
}
if userType == constants.UserTypeEnterprise {
return false, errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询")
}
return false, nil
}
// canManageCard 检查用户是否有权管理单张卡
func (s *ManualTriggerService) canManageCard(ctx context.Context, cardID uint) error {
skip, err := s.checkUserTypePermission(ctx)
if err != nil || skip {
return err
}
// 代理账号只能管理自己店铺及下级店铺的卡
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "无权限操作该资源或资源不存在")
}
// 平台卡ShopID为nil代理不能管理
if card.ShopID == nil {
return errors.New(errors.CodeForbidden, "无权限操作平台卡")
}
// 检查代理是否有权管理该店铺
return middleware.CanManageShop(ctx, *card.ShopID)
}
// canManageCards 检查用户是否有权管理多张卡
func (s *ManualTriggerService) canManageCards(ctx context.Context, cardIDs []uint) error {
skip, err := s.checkUserTypePermission(ctx)
if err != nil || skip {
return err
}
// 从 Context 获取预计算的下级店铺 ID 列表
subordinateIDs := middleware.GetSubordinateShopIDs(ctx)
if subordinateIDs == nil {
// 平台用户/超管不受限制,但这里不应该进入(前面已经检查过用户类型)
return errors.New(errors.CodeForbidden, "无权限操作")
}
// 构建可管理的店铺ID集合
allowedShopIDs := make(map[uint]bool)
for _, id := range subordinateIDs {
allowedShopIDs[id] = true
}
// 批量查询卡信息
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "查询卡信息失败")
}
// 验证所有卡都在可管理范围内
for _, card := range cards {
if card.ShopID == nil {
return errors.New(errors.CodeForbidden, "无权限操作平台卡")
}
if !allowedShopIDs[*card.ShopID] {
return errors.New(errors.CodeForbidden, "包含无权限操作的卡")
}
}
return nil
}
// applyShopPermissionFilter 应用店铺权限过滤(代理只能筛选自己管理的卡)
func (s *ManualTriggerService) applyShopPermissionFilter(ctx context.Context, filter *ConditionFilter) error {
skip, err := s.checkUserTypePermission(ctx)
skip, err := pollingUserTypePermission(ctx)
if err != nil || skip {
return err
}

View File

@@ -0,0 +1,115 @@
package polling
import (
"context"
"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/middleware"
)
// 本文件承载人工轮询入口共享的权限判定:既有人工触发与人工优先入队必须使用同一套语义,
// 因此判定只在此处实现一次,调用方不得各自内联,避免权限口径漂移。
// pollingUserTypePermission 检查用户类型是否有手动轮询权限。
// 返回 skip=true 表示超级管理员/平台用户直接放行;
// 返回 err!=nil 表示企业账号无权限;
// 返回 skip=false, err=nil 表示代理账号需继续细粒度检查。
func pollingUserTypePermission(ctx context.Context) (skip bool, err error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return true, nil
}
if userType == constants.UserTypeEnterprise {
return false, errors.New(errors.CodeForbidden, "企业账号无权限手动触发轮询")
}
return false, nil
}
// pollingDeniedMessage 是优先轮询入口对外统一的拒绝文案:越权与不存在共用,不产生可枚举差异。
const pollingDeniedMessage = "无权限操作该资源或资源不存在"
// PriorityPollingReadScope 返回卡轮询优先项读侧可访问的店铺快照范围。
//
// 判定顺序与人工入队共用同一用户类型分类pollingUserTypePermission
// 1. 超级管理员与平台账号 → 不受限(返回 nil
// 2. 企业账号 → 拒绝;
// 3. 其余(代理)→ 取认证中间件预计算的下级店铺集合集合缺失nil是「未预计算」而不是
// 「不受限」,一律按拒绝处理,绝不让非代理身份或缺失范围回退成全量读取。
func PriorityPollingReadScope(ctx context.Context) ([]uint, error) {
skip, err := pollingUserTypePermission(ctx)
if err != nil {
// 企业账号同样不允许读取优先轮询事实;对外只暴露统一的不可枚举拒绝文案。
return nil, errors.New(errors.CodeForbidden, pollingDeniedMessage)
}
if skip {
return nil, nil
}
shopIDs := middleware.GetSubordinateShopIDs(ctx)
if shopIDs == nil {
return nil, errors.New(errors.CodeForbidden, pollingDeniedMessage)
}
return shopIDs, nil
}
// canManagePollingCard 检查用户是否有权管理单张卡。
func canManagePollingCard(ctx context.Context, iotCardStore *postgres.IotCardStore, cardID uint) error {
skip, err := pollingUserTypePermission(ctx)
if err != nil || skip {
return err
}
// 代理账号只能管理自己店铺及下级店铺的卡
card, err := iotCardStore.GetByID(ctx, cardID)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "无权限操作该资源或资源不存在")
}
// 平台卡ShopID为nil代理不能管理
if card.ShopID == nil {
return errors.New(errors.CodeForbidden, "无权限操作平台卡")
}
// 检查代理是否有权管理该店铺
return middleware.CanManageShop(ctx, *card.ShopID)
}
// canManagePollingCards 检查用户是否有权管理多张卡。
func canManagePollingCards(ctx context.Context, iotCardStore *postgres.IotCardStore, cardIDs []uint) error {
skip, err := pollingUserTypePermission(ctx)
if err != nil || skip {
return err
}
// 从 Context 获取预计算的下级店铺 ID 列表
subordinateIDs := middleware.GetSubordinateShopIDs(ctx)
if subordinateIDs == nil {
// 平台用户/超管不受限制,但这里不应该进入(前面已经检查过用户类型)
return errors.New(errors.CodeForbidden, "无权限操作")
}
// 构建可管理的店铺ID集合
allowedShopIDs := make(map[uint]bool)
for _, id := range subordinateIDs {
allowedShopIDs[id] = true
}
// 批量查询卡信息
cards, err := iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return errors.Wrap(errors.CodeForbidden, err, "查询卡信息失败")
}
// 验证所有卡都在可管理范围内
for _, card := range cards {
if card.ShopID == nil {
return errors.New(errors.CodeForbidden, "无权限操作平台卡")
}
if !allowedShopIDs[*card.ShopID] {
return errors.New(errors.CodeForbidden, "包含无权限操作的卡")
}
}
return nil
}

View File

@@ -0,0 +1,247 @@
package polling
import (
"context"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
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/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/middleware"
)
// PriorityEnqueueService 人工优先入队用例。
//
// 与既有人工触发的关系共用同包权限判定permission.go但**不继承**人工触发的每日次数上限与
// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,
// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。
type PriorityEnqueueService struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
priorityStore *postgres.PollingPriorityItemStore
publisher priorityapp.PromptPublisher
auditWriter *audit.Writer
logger *zap.Logger
}
// NewPriorityEnqueueService 创建人工优先入队用例。
func NewPriorityEnqueueService(
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
priorityStore *postgres.PollingPriorityItemStore,
publisher priorityapp.PromptPublisher,
logger *zap.Logger,
) *PriorityEnqueueService {
return &PriorityEnqueueService{
db: db,
iotCardStore: iotCardStore,
priorityStore: priorityStore,
publisher: publisher,
logger: logger,
}
}
// SetAudit 注入统一审计 Writer。
func (s *PriorityEnqueueService) SetAudit(writer *audit.Writer) {
s.auditWriter = writer
}
// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖该卡的全部纳入任务类型。
type EnqueueResult struct {
CardID uint
TaskTypes []string
CreatedCount int
MergedCount int
Items []EnqueueItemResult
}
// EnqueueItemResult 是单个任务类型的入队结果。
type EnqueueItemResult struct {
ItemID uint
TaskType string
Status string
TriggerCount int
LastTriggeredAt time.Time
Created bool
}
// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。
//
// 一次优先需求 = 该卡的全部纳入任务类型constants.PollingPriorityTaskTypes
// 与执行集合一致;入队对象是卡,不做设备维度入队。
func (s *PriorityEnqueueService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) {
if cardID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID")
}
trimmedReason := strings.TrimSpace(reason)
if trimmedReason == "" {
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因不能为空")
}
if len([]rune(trimmedReason)) > constants.PollingPriorityManualReasonMaxLength {
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因长度超出上限")
}
// 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。
if err := canManagePollingCard(ctx, s.iotCardStore, cardID); err != nil {
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil || card == nil {
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
operatorName := middleware.GetUsernameFromContext(ctx)
taskTypes := constants.PollingPriorityTaskTypes()
result := &EnqueueResult{CardID: cardID, TaskTypes: taskTypes}
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
store := s.priorityStore.WithTx(tx)
for _, taskType := range taskTypes {
item := &model.PollingPriorityItem{
CardID: cardID,
TaskType: taskType,
TriggerType: constants.PollingPriorityTriggerManual,
ManualReason: trimmedReason,
ManualOperatorID: operatorID,
ManualOperatorName: operatorName,
ShopIDSnapshot: card.ShopID,
}
// 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。
// 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行,
// 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。
before, beforeErr := store.FindActive(ctx, cardID, taskType)
if beforeErr != nil {
return beforeErr
}
created, insertErr := store.InsertOrMerge(ctx, item)
if insertErr != nil {
return insertErr
}
active, activeErr := store.FindActive(ctx, cardID, taskType)
if activeErr != nil {
return activeErr
}
if active == nil {
return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项")
}
if created {
result.CreatedCount++
} else {
result.MergedCount++
}
result.Items = append(result.Items, EnqueueItemResult{
ItemID: active.ID,
TaskType: active.TaskType,
Status: active.Status,
TriggerCount: active.TriggerCount,
LastTriggeredAt: active.LastTriggeredAt,
Created: created,
})
// 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。
if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
ActionCode: constants.AuditActionPollingPriorityEnqueued,
Summary: "人工优先入队",
ResourceType: constants.AuditResourcePollingPriorityItem,
ResourceID: active.ID,
ResourceKey: pollingPriorityResourceKey(active.ID),
DisplayName: "卡轮询优先项",
OperatorID: operatorID,
IdentitySnapshot: map[string]any{
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
"status": active.Status, "trigger_type": active.TriggerType,
"trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID,
},
BeforeData: pollingPriorityBeforeData(before),
AfterData: map[string]any{
"status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes),
"trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt,
"manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID,
"source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(),
},
Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": cardID},
Cards: []*model.IotCard{card},
}); auditErr != nil {
return auditErr
}
}
return nil
})
if err != nil {
return nil, err
}
// 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。
if s.publisher == nil {
s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", cardID))
} else {
for _, taskType := range taskTypes {
if publishErr := s.publisher.EnqueuePriority(ctx, cardID, taskType); publishErr != nil {
// 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。
s.logger.Error("下发优先轮询执行提示失败",
zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(publishErr))
}
}
}
s.logger.Info("人工优先入队成功",
zap.Uint("card_id", cardID), zap.Int("created_count", result.CreatedCount),
zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", operatorID))
return result, nil
}
// deny 记录一次人工优先入队被拒绝的审计,并返回原错误。
// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。
func (s *PriorityEnqueueService) deny(ctx context.Context, cardID, operatorID uint, card *model.IotCard, code int, summary string) error {
appErr := errors.New(code, summary)
identity := map[string]any{"card_id": cardID, "manual_operator_id": operatorID}
var cards []*model.IotCard
if card != nil {
cards = []*model.IotCard{card}
}
recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{
ActionCode: constants.AuditActionPollingPriorityManualDenied,
Summary: summary,
ResourceType: constants.AuditResourcePollingPriorityItem,
ResourceKey: pollingPriorityAttemptKey(cardID, operatorID),
DisplayName: "人工优先入队",
OperatorID: operatorID,
Result: constants.AuditResultDenied,
IdentitySnapshot: identity,
Cards: cards,
}, appErr)
return appErr
}
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
func pollingPriorityResourceKey(itemID uint) string {
return strconv.FormatUint(uint64(itemID), 10)
}
// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。
func pollingPriorityAttemptKey(cardID, operatorID uint) string {
return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10)
}
// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。
func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any {
if before == nil {
return map[string]any{}
}
return map[string]any{
"status": before.Status,
"trigger_type": before.TriggerType,
"trigger_types": triggerTypesReadable(before.TriggerTypes),
"trigger_count": before.TriggerCount,
}
}
// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。
func triggerTypesReadable(raw string) string {
return strings.Trim(raw, ",")
}

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
}

View File

@@ -14,6 +14,7 @@ import (
"gorm.io/gorm/clause"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
@@ -51,6 +52,7 @@ type AutoPurchaseHandler struct {
asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务
logger *zap.Logger
observationSeriesEvents cardObservationApp.SeriesEventWriter
priorityEvents priorityapp.PriorityEventWriter
auditWriter *audit.Writer
}
@@ -67,6 +69,7 @@ func NewAutoPurchaseHandler(
asynqClient *asynq.Client,
logger *zap.Logger,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
priorityEvents priorityapp.PriorityEventWriter,
auditWriter *audit.Writer,
) *AutoPurchaseHandler {
if orderStore == nil {
@@ -103,6 +106,7 @@ func NewAutoPurchaseHandler(
asynqClient: asynqClient,
logger: logger,
observationSeriesEvents: observationSeriesEvents,
priorityEvents: priorityEvents,
auditWriter: auditWriter,
}
}
@@ -712,10 +716,22 @@ func (h *AutoPurchaseHandler) activateMainPackage(
return err
}
return tx.Model(usage).Updates(map[string]any{
if err = tx.Model(usage).Updates(map[string]any{
"status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
}).Error
}).Error; err != nil {
return err
}
// 仅在生效状态分支追加优先轮询请求:待生效(含等待实名激活)不属于触发场景。
if status == constants.PackageUsageStatusActive {
triggerType, classifyErr := packagepkg.ResolveActivationTriggerType(ctx, tx, carrierType, carrierID, usage.ID)
if classifyErr != nil {
return classifyErr
}
return packagepkg.AppendActivatedPriorityRequested(ctx, tx, h.priorityEvents, usage, carrierType, carrierID, triggerType, activatedAt)
}
return nil
}
// isCarrierRealnamed 查询自动购包载体是否已满足实名激活条件。
@@ -822,7 +838,13 @@ func (h *AutoPurchaseHandler) activateAddonPackage(
usage.DeviceID = carrierID
}
return tx.Create(usage).Error
if err := tx.Create(usage).Error; err != nil {
return err
}
// 加油包立即生效:以生效状态提交后追加优先轮询请求。
return packagepkg.AppendActivatedPriorityRequested(ctx, tx, h.priorityEvents, usage, carrierType, carrierID,
constants.PollingPriorityTriggerAddonActivated, now)
}
func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, tx *gorm.DB, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) {

View File

@@ -6,7 +6,9 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -54,7 +56,7 @@ func pollingFallbackContext() (context.Context, context.CancelFunc) {
}
// PollingBase 轮询共享基类
// 封装并发控制、卡缓存、重入队、配置间隔查询等公共方法,所有 Handler 共享
// 封装并发控制、卡缓存、重入队、配置间隔查询与优先轮询项认领等公共方法,所有 Handler 共享
type PollingBase struct {
redis *redis.Client
queueMgr *polling.PollingQueueManager
@@ -64,6 +66,10 @@ type PollingBase struct {
verboseLog bool
totalMaxConcurrency int
trafficLock *cardtrafficlock.Lock
db *gorm.DB
priorityStore *postgres.PollingPriorityItemStore
priorityAudit *audit.Writer
scopeChecker *polling.PollingLifecycleService
}
// NewPollingBase 创建轮询共享基类
@@ -72,6 +78,10 @@ func NewPollingBase(
queueMgr *polling.PollingQueueManager,
configMgr *polling.PollingConfigManager,
iotCardStore *postgres.IotCardStore,
scopeChecker *polling.PollingLifecycleService,
priorityStore *postgres.PollingPriorityItemStore,
priorityAudit *audit.Writer,
db *gorm.DB,
logger *zap.Logger,
verboseLog bool,
totalMaxConcurrency int,
@@ -85,6 +95,10 @@ func NewPollingBase(
verboseLog: verboseLog,
totalMaxConcurrency: totalMaxConcurrency,
trafficLock: cardtrafficlock.New(redisClient),
db: db,
priorityStore: priorityStore,
priorityAudit: priorityAudit,
scopeChecker: scopeChecker,
}
}

View File

@@ -49,29 +49,53 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
}
defer h.base.releaseCardTrafficSyncLock(ctx, cardID, lockToken)
// 优先轮询互斥接缝:放在流量同步锁获取成功之后,避免认领后拿不到锁导致优先项空占。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCarddata)
if priorityErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr)
}
if skip {
h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后",
zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingCarddata))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
h.base.invalidateCardCache(ctx, cardID)
card, err := h.base.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if isNotFound(err) {
priority.FailFinal(ctx, "卡不存在")
return nil
}
// 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。
priority.MarkExecution(ctx)
priority.FailRetryable(ctx, "获取卡信息失败")
return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err)
}
if h.gateway == nil {
// 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。
if !h.base.priorityInPollingScope(ctx, priority, card) {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
if h.gateway == nil {
priority.FailFinal(ctx, "流量查询能力未配置")
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败")
priority.FailRetryable(ctx, "查询流量失败")
return h.failAndRequeue(ctx, cardID, startedAt, "查询流量失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID")
priority.FailRetryable(ctx, "流量查询响应缺少 ICCID")
return h.failAndRequeue(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCarddata, "卡流量轮询任务", attempt.IntegrationID)
if h.observation == nil || h.carrier == nil {
priority.FailFinal(ctx, "卡流量观测能力未配置")
return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil)
}
now := time.Now()
@@ -84,6 +108,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用流量观测失败")
priority.FailRetryable(ctx, "应用流量观测失败")
return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err)
}
changed := decision.IncrementMB > 0 || decision.CrossMonth
@@ -100,6 +125,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
metric = "polling.observation.persisted"
}
h.base.logger.Info("流量轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
priority.Complete(ctx)
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}

View File

@@ -49,28 +49,53 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCardStatus)
// 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCardStatus)
if priorityErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr)
}
if skip {
h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后",
zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingCardStatus))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
card, err := h.base.getCardWithCache(ctx, cardID)
if err != nil {
if isNotFound(err) {
priority.FailFinal(ctx, "卡不存在")
return nil
}
// 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。
priority.MarkExecution(ctx)
priority.FailRetryable(ctx, "获取卡信息失败")
return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err)
}
if h.gateway == nil {
// 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。
if !h.base.priorityInPollingScope(ctx, priority, card) {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
if h.gateway == nil {
priority.FailFinal(ctx, "卡状态查询能力未配置")
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败")
priority.FailRetryable(ctx, "查询卡状态失败")
return h.failAndRequeue(ctx, cardID, startedAt, "查询卡状态失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "卡状态查询响应缺少 ICCID")
priority.FailRetryable(ctx, "卡状态查询响应缺少 ICCID")
return h.failAndRequeue(ctx, cardID, startedAt, "卡状态查询响应缺少 ICCID", nil)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCardStatus, "卡网络状态轮询任务", attempt.IntegrationID)
if h.observation == nil {
priority.FailFinal(ctx, "卡网络观测能力未配置")
return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil)
}
now := time.Now()
@@ -83,6 +108,7 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用网络状态观测失败")
priority.FailRetryable(ctx, "应用网络状态观测失败")
return h.failAndRequeue(ctx, cardID, startedAt, "应用网络状态观测失败", err)
}
changed := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling
@@ -100,6 +126,7 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task)
metric = "polling.observation.persisted"
}
h.base.logger.Info("卡状态轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
priority.Complete(ctx)
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt))
if decision.StopPolling {
h.base.logger.Info("独立卡命中风险状态,已关闭轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend))

View File

@@ -7,6 +7,7 @@ import (
"github.com/hibiken/asynq"
"go.uber.org/zap"
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -52,39 +53,76 @@ func (h *PollingPackageHandler) Handle(ctx context.Context, t *asynq.Task) error
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingPackage)
if _, cacheErr := h.base.getCardWithCache(ctx, cardID); cacheErr != nil {
if isNotFound(cacheErr) {
return nil
}
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(cacheErr))
// 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingPackage)
if priorityErr != nil {
h.base.logger.Error("查询活动优先轮询项失败", zap.Uint("card_id", cardID), zap.Error(priorityErr))
h.base.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
if h.stopResumeSvc != nil {
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
if loadErr == nil {
h.base.logger.Info("套餐检查:执行停复机评估",
zap.Uint("card_id", cardID),
zap.Int("network_status", freshCard.NetworkStatus),
zap.String("stop_reason", freshCard.StopReason))
if h.base.verboseLog {
h.base.logger.Info("套餐检查详情",
zap.Uint("card_id", cardID),
zap.String("iccid", freshCard.ICCID),
zap.Int("network_status", freshCard.NetworkStatus),
zap.String("stop_reason", freshCard.StopReason))
}
if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
h.base.logger.Warn("套餐检查后停复机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))
}
} else {
h.base.logger.Warn("套餐检查:加载卡信息失败",
zap.Uint("card_id", cardID), zap.Error(loadErr))
}
if skip {
h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后",
zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingPackage))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
card, cacheErr := h.base.getCardWithCache(ctx, cardID)
if cacheErr != nil {
if isNotFound(cacheErr) {
priority.FailFinal(ctx, "卡不存在")
return nil
}
h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(cacheErr))
// 本地读卡失败属可恢复失败:与其余三个轮询 handler 统一按尝试上限收敛,避免加急事实被静默丢弃。
priority.MarkExecution(ctx)
priority.FailRetryable(ctx, "获取卡信息失败")
h.base.updateStats(ctx, constants.TaskTypePollingPackage, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
// 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。
if !h.base.priorityInPollingScope(ctx, priority, card) {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
if h.stopResumeSvc == nil {
priority.FailFinal(ctx, "套餐评估能力未配置")
h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
// 停复机评估就是本任务的上游执行动作:先累加尝试次数再发起。
priority.MarkExecution(ctx)
freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
if loadErr != nil {
h.base.logger.Warn("套餐检查:加载卡信息失败",
zap.Uint("card_id", cardID), zap.Error(loadErr))
priority.FailRetryable(ctx, "获取卡信息失败")
h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
h.base.logger.Info("套餐检查:执行停复机评估",
zap.Uint("card_id", cardID),
zap.Int("network_status", freshCard.NetworkStatus),
zap.String("stop_reason", freshCard.StopReason))
if h.base.verboseLog {
h.base.logger.Info("套餐检查详情",
zap.Uint("card_id", cardID),
zap.String("iccid", freshCard.ICCID),
zap.Int("network_status", freshCard.NetworkStatus),
zap.String("stop_reason", freshCard.StopReason))
}
// 标记本次评估来自普通套餐轮询:无有效套餐场景据此才允许追加优先轮询请求,
// 其余调用链(观测消费者、手动实名、保护期轮询等)不打标即不写。
evaluationCtx := priorityapp.WithPollingPriorityTrigger(ctx)
if evalErr := h.stopResumeSvc.EvaluateAndAct(evaluationCtx, freshCard); evalErr != nil {
h.base.logger.Warn("套餐检查后停复机评估失败",
zap.Uint("card_id", cardID), zap.Error(evalErr))
priority.FailRetryable(ctx, "停复机评估失败")
h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}
priority.Complete(ctx)
h.base.updateStats(ctx, constants.TaskTypePollingPackage, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingPackage)
}

View File

@@ -0,0 +1,324 @@
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()
hit, err := r.base.priorityStore.IncrementAttempt(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))
}
r.attempts = r.item.AttemptCount + 1
r.item.AttemptCount = r.attempts
}
// Complete 记录优先项执行成功并出队,该卡继续按普通轮询运行。
func (r *pollingPriorityRound) Complete(_ context.Context) {
if r == nil {
return
}
ctx, cancel := pollingFallbackContext()
defer cancel()
before := r.stateData()
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.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()
if r.attempts < constants.PollingPriorityMaxAttempts {
hit, err := r.base.priorityStore.ReturnToPending(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.PollingPriorityStatusPending
r.item.Result = constants.PollingPriorityResultFailed
r.item.FailureReason = reason
r.item.ClaimedAt = nil
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.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
}
r.item.Status = constants.PollingPriorityStatusFailed
r.item.Result = constants.PollingPriorityResultFailed
r.item.FailureReason = reason
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,
"claimed_at": r.item.ClaimedAt,
"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)
}

View File

@@ -41,28 +41,52 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingRealname)
// 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingRealname)
if priorityErr != nil {
return h.failAndRequeue(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr)
}
if skip {
h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后",
zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingRealname))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}
card, err := h.base.getCardWithCache(ctx, cardID)
if err != nil {
if isNotFound(err) {
priority.FailFinal(ctx, "卡不存在")
return nil
}
// 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。
priority.MarkExecution(ctx)
priority.FailRetryable(ctx, "获取卡信息失败")
return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err)
}
if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil {
// 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。
if !h.base.priorityInPollingScope(ctx, priority, card) {
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}
if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil {
priority.FailFinal(ctx, "卡类型不适用实名轮询或实名查询能力未配置")
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling)
result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询实名状态失败")
priority.FailRetryable(ctx, "查询实名状态失败")
return h.failAndRequeue(ctx, cardID, startedAt, "查询实名状态失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "实名查询响应缺少 ICCID")
priority.FailRetryable(ctx, "实名查询响应缺少 ICCID")
return h.failAndRequeue(ctx, cardID, startedAt, "实名查询响应缺少 ICCID", nil)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingRealname, "实名状态轮询任务", attempt.IntegrationID)
if h.observation == nil {
priority.FailFinal(ctx, "卡实名观测能力未配置")
return h.failAndRequeue(ctx, cardID, startedAt, "卡实名观测能力未配置", nil)
}
now := time.Now()
@@ -75,6 +99,7 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
})
if err != nil {
_ = recordGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用实名观测失败")
priority.FailRetryable(ctx, "应用实名观测失败")
return h.failAndRequeue(ctx, cardID, startedAt, "应用实名观测失败", err)
}
changed := decision.StatusChanged
@@ -91,6 +116,7 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) e
metric = "polling.observation.persisted"
}
h.base.logger.Info("实名轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
priority.Complete(ctx)
h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startedAt))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}