feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
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:
2026-09-18 15:34:29 +08:00
parent 5e78809b93
commit 5ed6b39deb
142 changed files with 7878 additions and 964 deletions

View File

@@ -0,0 +1,122 @@
package asset
import (
"context"
"time"
"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"
)
// ResolvePriorityPolling 读取资产或卡的优先轮询状态只读投影,落在既有资产解析端点内。
//
// 口径:卡取自身,设备取其在用绑定卡;存在待执行或执行中的优先项时「是否处于优先轮询中」为真,
// 最近触发场景取该资产最近一条事实的触发类型,最近轮询结果取该条事实的结局,
// 最近轮询时间取最近一次执行时间(未执行过时退回最近触发时间),
// 失败原因取最近一条非空的可安全展示原因。无任何优先轮询事实时以否或空值返回,
// MUST NOT 用普通轮询结果填充。
//
// 只读:只做查询,不创建、不合并、不推进、不出队任何优先项,也不触发上游调用。
// 数据范围:资产本身的可读性由资产解析用例保证;此处再按优先项事实的店铺快照下推一次,
// 读侧鉴权不通过(企业账号或范围缺失)时返回空投影,不因投影阻断资产详情,也不泄露范围外事实。
func (q *ExchangeTraceQuery) ResolvePriorityPolling(ctx context.Context, assetType string, assetID uint) (*dto.AssetPriorityPollingProjection, error) {
projection := &dto.AssetPriorityPollingProjection{}
if q == nil || q.db == nil || assetID == 0 {
return projection, nil
}
cardIDs, err := q.assetCardIDs(ctx, assetType, assetID)
if err != nil {
return nil, err
}
if len(cardIDs) == 0 {
return projection, nil
}
scope, scopeErr := pollingsvc.PriorityPollingReadScope(ctx)
if scopeErr != nil {
return projection, nil
}
summaries, err := postgres.NewPollingPriorityItemStore(q.db).ProjectPriorityPolling(ctx, cardIDs, scope)
if err != nil {
return nil, err
}
return projectPriorityPolling(summaries), nil
}
// assetCardIDs 返回该资产对应的轮询对象卡集合:卡为自身,设备为绑定状态有效的在用卡。
func (q *ExchangeTraceQuery) assetCardIDs(ctx context.Context, assetType string, assetID uint) ([]uint, error) {
switch assetType {
case constants.AssetResolveTypeCard:
return []uint{assetID}, nil
case constants.AssetTypeDevice:
var cardIDs []uint
if err := q.db.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound).
Order("slot_position ASC").
Pluck("iot_card_id", &cardIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定的卡失败")
}
return cardIDs, nil
default:
// 其他资产类型没有轮询对象,投影按无事实返回。
return nil, nil
}
}
// projectPriorityPolling 把逐卡摘要聚合为资产级投影。
// 多卡资产以最近创建的一条事实决定最近触发场景、结果与轮询时间;
// 失败原因单独取最近一条非空原因,避免最近一条事实无原因时丢失可展示的失败说明。
func projectPriorityPolling(summaries map[uint]postgres.PriorityPollingCardProjection) *dto.AssetPriorityPollingProjection {
projection := &dto.AssetPriorityPollingProjection{}
var latest *model.PollingPriorityItem
var latestFailed *model.PollingPriorityItem
for _, summary := range summaries {
if summary.HasActive {
projection.InPriorityPolling = true
}
item := summary.Latest
if item == nil {
continue
}
if latest == nil || item.ID > latest.ID {
latest = item
}
if item.FailureReason != "" && (latestFailed == nil || item.ID > latestFailed.ID) {
latestFailed = item
}
}
if latest == nil {
return projection
}
projection.LastTriggerType = latest.TriggerType
projection.LastTriggerName = constants.PollingPriorityTriggerName(latest.TriggerType)
projection.LastResult = constants.PollingPriorityStatusOutcome(latest.Status)
projection.LastResultName = constants.PollingPriorityOutcomeName(projection.LastResult)
projection.LastPollingAt = priorityPollingLastPolledAt(latest)
if latest.FailureReason != "" {
projection.FailureReason = latest.FailureReason
} else if latestFailed != nil {
projection.FailureReason = latestFailed.FailureReason
}
return projection
}
// priorityPollingLastPolledAt 返回最近轮询时间:优先取执行时间,未执行过时取最近触发时间。
func priorityPollingLastPolledAt(item *model.PollingPriorityItem) *time.Time {
if item == nil {
return nil
}
switch {
case item.FinishedAt != nil:
return item.FinishedAt
case item.StartedAt != nil:
return item.StartedAt
default:
triggeredAt := item.LastTriggeredAt
return &triggeredAt
}
}