feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
@@ -47,12 +47,35 @@ func (s *PhoneAssetAssociationStore) ExistsValid(ctx context.Context, phone, ass
|
||||
}
|
||||
|
||||
// CountValidByPhone 统计手机号当前有效关系数,用于十项上限判定;已失效关系不占用额度。
|
||||
// 实现委托给批量计数,保证「上限判定」与「关联查询投影」共用同一计数口径,不会出现两套漂移的计数。
|
||||
func (s *PhoneAssetAssociationStore) CountValidByPhone(ctx context.Context, phone string) (int64, error) {
|
||||
var count int64
|
||||
if err := s.validScope(ctx).Where("phone = ?", phone).Count(&count).Error; err != nil {
|
||||
counts, err := s.CountValidByPhones(ctx, []string{phone})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
return counts[phone], nil
|
||||
}
|
||||
|
||||
// CountValidByPhones 一次批量统计一批手机号各自的当前有效关系数,返回「手机号 → 数量」。
|
||||
// 口径与十项上限判定完全一致:只统计 status = 有效 且未软删的关系,卡与设备各计一项。
|
||||
// 调用方必须传入整页涉及的手机号集合,禁止逐手机号或逐资产放大查询次数。
|
||||
func (s *PhoneAssetAssociationStore) CountValidByPhones(ctx context.Context, phones []string) (map[string]int64, error) {
|
||||
counts := make(map[string]int64, len(phones))
|
||||
if len(phones) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
var rows []struct {
|
||||
Phone string
|
||||
Total int64
|
||||
}
|
||||
if err := s.validScope(ctx).Select("phone, COUNT(*) AS total").
|
||||
Where("phone IN ?", phones).Group("phone").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
counts[row.Phone] = row.Total
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// PhoneAssetAssociationListFilter 后台关联列表筛选条件。
|
||||
@@ -202,12 +225,14 @@ func (s *PhoneAssetAssociationStore) LockValidByAsset(ctx context.Context, tx *g
|
||||
|
||||
// InvalidateByIDs 以 expected-status 条件把有效关系置为失效,返回实际失效行数。
|
||||
// 状态流转必须带 status = 有效 条件并核对影响行数(ENG-CONC-001),否则并发解除会覆盖已成事实的失效信息。
|
||||
// invalidatorName 是解绑当时的后台账号名称快照,只写进已失效行;有效关系保持空串,MUST NOT 以创建人或更新时间填充。
|
||||
func (s *PhoneAssetAssociationStore) InvalidateByIDs(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
ids []uint,
|
||||
method, reason string,
|
||||
invalidator uint,
|
||||
invalidatorName string,
|
||||
now time.Time,
|
||||
) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
@@ -216,12 +241,13 @@ func (s *PhoneAssetAssociationStore) InvalidateByIDs(
|
||||
result := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}).
|
||||
Where("id IN ? AND status = ?", ids, constants.PhoneAssetAssociationStatusValid).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PhoneAssetAssociationStatusInvalid,
|
||||
"invalidated_at": now,
|
||||
"invalidation_method": method,
|
||||
"invalidation_reason": reason,
|
||||
"invalidator": invalidator,
|
||||
"updated_at": now,
|
||||
"status": constants.PhoneAssetAssociationStatusInvalid,
|
||||
"invalidated_at": now,
|
||||
"invalidation_method": method,
|
||||
"invalidation_reason": reason,
|
||||
"invalidator": invalidator,
|
||||
"invalidator_name_snapshot": invalidatorName,
|
||||
"updated_at": now,
|
||||
})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
@@ -24,11 +24,18 @@ const pollingPriorityActiveConstraint = "uq_polling_priority_item_active"
|
||||
// ShopIDSnapshot 是数据范围下推:nil 表示不受限(超级管理员与平台账号),
|
||||
// 非 nil 时按店铺快照过滤,空切片表示空范围(无可见行)。平台卡的店铺快照为空,
|
||||
// 因此空范围与平台卡都不会被代理账号看到,越权与不存在由调用方收敛为同一响应。
|
||||
//
|
||||
// CreatedFrom/CreatedTo 按入队时间(created_at)取闭区间,nil 表示该端不限。
|
||||
// AbnormalOnly 是「异常与重试」视图条件:失败终态、已过期、尝试次数达上限或存在失败原因,
|
||||
// 与其余筛选是「与」关系,视图本身仍复用同一事实表。
|
||||
type PollingPriorityItemListFilter struct {
|
||||
CardID *uint
|
||||
TaskType *string
|
||||
Status *string
|
||||
TriggerType *string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
AbnormalOnly bool
|
||||
ShopIDSnapshot []uint
|
||||
Page int
|
||||
PageSize int
|
||||
@@ -49,6 +56,73 @@ func (s *PollingPriorityItemStore) WithTx(tx *gorm.DB) *PollingPriorityItemStore
|
||||
return &PollingPriorityItemStore{db: tx}
|
||||
}
|
||||
|
||||
// PollingPriorityAssetSnapshot 是入队时冻结的资产身份与代理归属快照。
|
||||
type PollingPriorityAssetSnapshot struct {
|
||||
// AssetType 取值与 constants.AssetType* 一致:卡绑定有效设备时资产为设备,独立卡为卡自身。
|
||||
AssetType string
|
||||
// AssetID 与 AssetType 共同构成资产身份。
|
||||
AssetID uint
|
||||
// DeviceNo 是绑定设备的虚拟号,独立卡为空字符串。
|
||||
DeviceNo string
|
||||
// AgentShopID 是卡所属店铺的直接上级代理店铺,一级代理与平台卡为 nil。
|
||||
AgentShopID *uint
|
||||
}
|
||||
|
||||
// AssetSnapshots 按卡批量解析入队快照,口径与迁移 000234 的回填严格一致:
|
||||
// 卡存在有效设备绑定时资产为该设备(设备号取绑定设备虚拟号),否则资产为卡自身;
|
||||
// 代理归属取卡所属店铺的直接上级代理店铺;同一卡存在多条绑定时取 is_current 优先、其次最新一条。
|
||||
//
|
||||
// 未查到卡行的卡按「资产为卡自身」兜底:入队必写资产身份,同时不因卡已软删而阻断加急事实
|
||||
// (这类项会在执行时以「卡不存在」进入失败终态,与既有语义一致)。
|
||||
func (s *PollingPriorityItemStore) AssetSnapshots(ctx context.Context, cardIDs []uint) (map[uint]PollingPriorityAssetSnapshot, error) {
|
||||
snapshots := make(map[uint]PollingPriorityAssetSnapshot, len(cardIDs))
|
||||
if len(cardIDs) == 0 {
|
||||
return snapshots, nil
|
||||
}
|
||||
type assetSnapshotRow struct {
|
||||
CardID uint `gorm:"column:card_id"`
|
||||
ParentShopID *uint `gorm:"column:parent_shop_id"`
|
||||
DeviceID *uint `gorm:"column:device_id"`
|
||||
DeviceNo string `gorm:"column:device_no"`
|
||||
}
|
||||
var rows []assetSnapshotRow
|
||||
if err := s.db.WithContext(ctx).Table("tb_iot_card AS c").
|
||||
Select("c.id AS card_id, sp.parent_id AS parent_shop_id, b.device_id AS device_id, COALESCE(d.virtual_no, '') AS device_no").
|
||||
Joins("LEFT JOIN tb_shop AS sp ON sp.id = c.shop_id AND sp.deleted_at IS NULL").
|
||||
Joins(`LEFT JOIN (SELECT DISTINCT ON (iot_card_id) iot_card_id, device_id FROM tb_device_sim_binding
|
||||
WHERE bind_status = ? AND deleted_at IS NULL
|
||||
ORDER BY iot_card_id, is_current DESC, id DESC) AS b ON b.iot_card_id = c.id`,
|
||||
constants.BindStatusBound).
|
||||
Joins("LEFT JOIN tb_device AS d ON d.id = b.device_id AND d.deleted_at IS NULL").
|
||||
Where("c.id IN ?", cardIDs).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "解析优先轮询项资产快照失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
snapshots[row.CardID] = PollingPriorityAssetSnapshot{
|
||||
AssetType: constants.AssetTypeIotCard,
|
||||
AssetID: row.CardID,
|
||||
AgentShopID: row.ParentShopID,
|
||||
}
|
||||
if row.DeviceID != nil && *row.DeviceID > 0 {
|
||||
snapshots[row.CardID] = PollingPriorityAssetSnapshot{
|
||||
AssetType: constants.AssetTypeDevice,
|
||||
AssetID: *row.DeviceID,
|
||||
DeviceNo: row.DeviceNo,
|
||||
AgentShopID: row.ParentShopID,
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, cardID := range cardIDs {
|
||||
if _, exists := snapshots[cardID]; !exists {
|
||||
snapshots[cardID] = PollingPriorityAssetSnapshot{
|
||||
AssetType: constants.AssetTypeIotCard, AssetID: cardID,
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// InsertOrMerge 以 (card_id, task_type) 为活动项唯一键合并入队。
|
||||
//
|
||||
// 先显式插入;命中活动项部分唯一索引(23505)时把本次触发合并进已有活动行:
|
||||
@@ -171,7 +245,9 @@ func (s *PollingPriorityItemStore) ClaimPending(ctx context.Context, cardID uint
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusProcessing,
|
||||
"claimed_at": now,
|
||||
"updated_at": now,
|
||||
// 计划执行时间已兑现:执行中的项不应再显示一个已经过去的下一次执行时间。
|
||||
"next_run_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "认领优先轮询项失败")
|
||||
@@ -197,8 +273,9 @@ func (s *PollingPriorityItemStore) TakeOverExpiredClaim(ctx context.Context, car
|
||||
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,
|
||||
"claimed_at": now,
|
||||
"next_run_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "接管超租约优先轮询项失败")
|
||||
@@ -209,14 +286,17 @@ func (s *PollingPriorityItemStore) TakeOverExpiredClaim(ctx context.Context, car
|
||||
return s.FindActive(ctx, cardID, taskType)
|
||||
}
|
||||
|
||||
// IncrementAttempt 累加真实发起执行的次数。调用点必须在发起上游调用之前,
|
||||
// 使崩溃遗留的执行中项在接管后仍能按次数上限收敛。
|
||||
func (s *PollingPriorityItemStore) IncrementAttempt(ctx context.Context, id uint) (bool, error) {
|
||||
// MarkExecutionStarted 标记真正发起一次执行:累加尝试次数并写执行开始时间。
|
||||
// 调用点必须在发起上游调用之前,使崩溃遗留的执行中项在接管后仍能按次数上限收敛;
|
||||
// 同时清空上一次尝试的结束时间,避免读侧把两次尝试拼成一段耗时。
|
||||
func (s *PollingPriorityItemStore) MarkExecutionStarted(ctx context.Context, id uint, now time.Time) (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(),
|
||||
"started_at": now,
|
||||
"finished_at": nil,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "累加优先轮询项尝试次数失败")
|
||||
@@ -224,15 +304,34 @@ func (s *PollingPriorityItemStore) IncrementAttempt(ctx context.Context, id uint
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// RecordIntegrationLog 记录本次执行对应的集成交互日志标识。
|
||||
// 只保存日志标识,不复制凭证或上游响应原文;标识为空时忽略,不写入空覆盖。
|
||||
func (s *PollingPriorityItemStore) RecordIntegrationLog(ctx context.Context, id uint, integrationLogID string) error {
|
||||
if strings.TrimSpace(integrationLogID) == "" {
|
||||
return nil
|
||||
}
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{"integration_log_id": integrationLogID, "updated_at": time.Now()})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "记录优先轮询项集成交互日志标识失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkCompleted 把执行中的优先项置为完成终态并出队。
|
||||
func (s *PollingPriorityItemStore) MarkCompleted(ctx context.Context, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
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(),
|
||||
"finished_at": now,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项完成状态失败")
|
||||
@@ -242,13 +341,17 @@ func (s *PollingPriorityItemStore) MarkCompleted(ctx context.Context, id uint) (
|
||||
|
||||
// MarkFailed 把执行中的优先项置为失败终态并出队,failureReason 只写可安全对外展示的原因。
|
||||
func (s *PollingPriorityItemStore) MarkFailed(ctx context.Context, id uint, failureReason string) (bool, error) {
|
||||
now := time.Now()
|
||||
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(),
|
||||
"finished_at": now,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新优先轮询项失败状态失败")
|
||||
@@ -256,9 +359,10 @@ func (s *PollingPriorityItemStore) MarkFailed(ctx context.Context, id uint, fail
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ReturnToPending 把未达尝试上限的可恢复失败退回活动态,并按既有轮询间隔由调用方重新排期。
|
||||
// 保留本次失败结果与原因,供读侧展示在途重试;释放认领租约。
|
||||
func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint, failureReason string) (bool, error) {
|
||||
// ReturnToPending 把未达尝试上限的可恢复失败退回活动态,并写下一次计划执行时间。
|
||||
// 保留本次失败结果与原因,供读侧展示在途重试;释放认领租约并记录本次执行的结束时间。
|
||||
func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint, failureReason string, nextRunAt time.Time) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status = ?", id, constants.PollingPriorityStatusProcessing).
|
||||
Updates(map[string]any{
|
||||
@@ -266,7 +370,9 @@ func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint,
|
||||
"result": constants.PollingPriorityResultFailed,
|
||||
"failure_reason": failureReason,
|
||||
"claimed_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
"finished_at": now,
|
||||
"next_run_at": nextRunAt,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "退回优先轮询项活动状态失败")
|
||||
@@ -274,6 +380,136 @@ func (s *PollingPriorityItemStore) ReturnToPending(ctx context.Context, id uint,
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// Close 人工关闭未完成(待执行或执行中)的优先项:转已关闭终态并出队。
|
||||
// 以条件更新限定「仅未完成项」并被调用方检查行数,避免与并发执行互相覆盖;
|
||||
// 既有执行结果与失败原因一律保留,关闭原因只进审计,不写入失败原因。
|
||||
func (s *PollingPriorityItemStore) Close(ctx context.Context, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status IN ?", id, constants.PollingPriorityActiveStatuses()).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusClosed,
|
||||
"claimed_at": nil,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭优先轮询项失败")
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// ListOverdueActive 扫描已超过有效期且仍未完成的优先项,供有效期到期周期任务逐条终结。
|
||||
//
|
||||
// 判定依据是有效期起算时间加固定有效期,与下一次计划执行时间无关:
|
||||
// next_run_at 为空只表示「未排期」,读侧与扫描都 MUST NOT 据此判定已过期。
|
||||
// 有效期起算时间缺失的历史行按入队时间(created_at)兜底,避免这类行永不被终结。
|
||||
func (s *PollingPriorityItemStore) ListOverdueActive(ctx context.Context, deadline time.Time, limit int) ([]*model.PollingPriorityItem, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var items []*model.PollingPriorityItem
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("status IN ? AND COALESCE(priority_effective_from, created_at) <= ?",
|
||||
constants.PollingPriorityActiveStatuses(), deadline).
|
||||
Order("COALESCE(priority_effective_from, created_at) ASC, id ASC").
|
||||
Limit(limit).
|
||||
Find(&items).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "扫描超期优先轮询项失败")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// MarkExpired 把未完成的优先项转已过期终态并出队。
|
||||
// 触发类型、尝试次数与最近失败原因全部保留:超期是队列出口,不是对既有事实的否定。
|
||||
func (s *PollingPriorityItemStore) MarkExpired(ctx context.Context, id uint) (bool, error) {
|
||||
now := time.Now()
|
||||
result := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("id = ? AND status IN ?", id, constants.PollingPriorityActiveStatuses()).
|
||||
Updates(map[string]any{
|
||||
"status": constants.PollingPriorityStatusExpired,
|
||||
"claimed_at": nil,
|
||||
"next_run_at": nil,
|
||||
"dequeued_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记优先轮询项已过期失败")
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// PriorityPollingCardProjection 是单张卡在资产详情投影里的优先轮询事实摘要。
|
||||
type PriorityPollingCardProjection struct {
|
||||
// HasActive 表示该卡存在待执行或执行中的优先轮询项。
|
||||
HasActive bool
|
||||
// Latest 是该卡最近一条优先轮询事实(含终态),无事实时为 nil。
|
||||
Latest *model.PollingPriorityItem
|
||||
}
|
||||
|
||||
// ProjectPriorityPolling 返回指定卡的优先轮询只读投影摘要:是否存在活动项与最近一条事实。
|
||||
//
|
||||
// 只读:不创建、不合并、不推进、不出队,也不发起任何上游调用。
|
||||
// shopIDs 数据范围语义与 GetByIDScoped 一致:nil 表示不受限,空切片等于空结果。
|
||||
// 活动项受部分唯一索引约束(每卡每任务类型至多一条),因此第一条查询行数有界;
|
||||
// 最近一条事实按卡逐条取一行(卡的集合来自单个资产的绑定关系,规模有界)。
|
||||
func (s *PollingPriorityItemStore) ProjectPriorityPolling(ctx context.Context, cardIDs []uint, shopIDs []uint) (map[uint]PriorityPollingCardProjection, error) {
|
||||
result := make(map[uint]PriorityPollingCardProjection, len(cardIDs))
|
||||
if len(cardIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
activeQuery := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).
|
||||
Where("card_id IN ? AND status IN ?", cardIDs, constants.PollingPriorityActiveStatuses())
|
||||
activeQuery = applyPollingPriorityShopScope(activeQuery, shopIDs)
|
||||
var activeItems []*model.PollingPriorityItem
|
||||
if err := activeQuery.Find(&activeItems).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询活动项投影失败")
|
||||
}
|
||||
for _, item := range activeItems {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
summary := result[item.CardID]
|
||||
summary.HasActive = true
|
||||
summary.Latest = pickNewerPriorityItem(summary.Latest, item)
|
||||
result[item.CardID] = summary
|
||||
}
|
||||
|
||||
for _, cardID := range cardIDs {
|
||||
summary, exists := result[cardID]
|
||||
if exists && summary.Latest != nil {
|
||||
continue
|
||||
}
|
||||
latestQuery := s.db.WithContext(ctx).Model(&model.PollingPriorityItem{}).Where("card_id = ?", cardID)
|
||||
latestQuery = applyPollingPriorityShopScope(latestQuery, shopIDs)
|
||||
var latest model.PollingPriorityItem
|
||||
if err := latestQuery.Order("id DESC").Limit(1).Find(&latest).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询最近事实投影失败")
|
||||
}
|
||||
if latest.ID == 0 {
|
||||
if !exists {
|
||||
result[cardID] = PriorityPollingCardProjection{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
summary.Latest = &latest
|
||||
result[cardID] = summary
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pickNewerPriorityItem 取两条事实里更新的一条(按创建顺序,与列表排序口径一致)。
|
||||
func pickNewerPriorityItem(current, candidate *model.PollingPriorityItem) *model.PollingPriorityItem {
|
||||
if candidate == nil {
|
||||
return current
|
||||
}
|
||||
if current == nil || candidate.ID > current.ID {
|
||||
return candidate
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// GetByIDScoped 按主键读取优先项,并应用店铺快照数据范围。
|
||||
// shopIDs 为 nil 表示不受限(超级管理员与平台账号);非 nil 时空切片表示空范围,必然无结果。
|
||||
// 越权与不存在都返回 gorm.ErrRecordNotFound,由调用方收敛为同一响应,不产生可枚举差异。
|
||||
@@ -317,6 +553,19 @@ func (s *PollingPriorityItemStore) List(ctx context.Context, filter PollingPrior
|
||||
if filter.TriggerType != nil && *filter.TriggerType != "" {
|
||||
query = query.Where("trigger_types LIKE ?", "%,"+*filter.TriggerType+",%")
|
||||
}
|
||||
if filter.CreatedFrom != nil {
|
||||
query = query.Where("created_at >= ?", *filter.CreatedFrom)
|
||||
}
|
||||
if filter.CreatedTo != nil {
|
||||
query = query.Where("created_at <= ?", *filter.CreatedTo)
|
||||
}
|
||||
if filter.AbnormalOnly {
|
||||
// 「异常与重试」视图:四个条件取或,全部来自同一事实行的状态、尝试次数与失败原因,
|
||||
// 不引入独立异常事实表,也不改变其余筛选的「与」语义。
|
||||
query = query.Where(
|
||||
"(status IN ? OR attempt_count >= attempt_limit OR failure_reason <> '')",
|
||||
[]string{constants.PollingPriorityStatusFailed, constants.PollingPriorityStatusExpired})
|
||||
}
|
||||
if filter.ShopIDSnapshot != nil {
|
||||
query = applyPollingPriorityShopScope(query, filter.ShopIDSnapshot)
|
||||
}
|
||||
@@ -336,18 +585,34 @@ func (s *PollingPriorityItemStore) List(ctx context.Context, filter PollingPrior
|
||||
}
|
||||
|
||||
// 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, "优先轮询项缺少卡、任务类型或触发类型")
|
||||
}
|
||||
if strings.TrimSpace(item.AssetType) == "" || item.AssetID == nil || *item.AssetID == 0 {
|
||||
// 资产身份是入队时解析的必填事实:缺失说明调用方跳过了快照解析,读侧将无法给出资产标识。
|
||||
return errors.New(errors.CodeInvalidParam, "优先轮询项缺少资产身份快照")
|
||||
}
|
||||
now := time.Now()
|
||||
item.Status = constants.PollingPriorityStatusPending
|
||||
item.TriggerTypes = "," + item.TriggerType + ","
|
||||
item.TriggerCount = 1
|
||||
if item.LastTriggeredAt.IsZero() {
|
||||
item.LastTriggeredAt = time.Now()
|
||||
item.LastTriggeredAt = now
|
||||
}
|
||||
item.AttemptCount = 0
|
||||
item.AttemptLimit = constants.PollingPriorityMaxAttempts
|
||||
if item.PriorityEffectiveFrom == nil {
|
||||
effectiveFrom := now
|
||||
item.PriorityEffectiveFrom = &effectiveFrom
|
||||
}
|
||||
item.ClaimedAt = nil
|
||||
item.StartedAt = nil
|
||||
item.FinishedAt = nil
|
||||
item.NextRunAt = nil
|
||||
item.DequeuedAt = nil
|
||||
item.IntegrationLogID = ""
|
||||
item.Result = ""
|
||||
item.FailureReason = ""
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user