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

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