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

@@ -249,58 +249,145 @@ func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
}
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
// routingOutcome 是只读选路结果。
type routingOutcome struct {
// Merchant 是当前命中成员。
Merchant *model.PaymentMerchant
// OpenNewRound 为真表示每轮累计已全部达标,调用方需以条件更新开启新一轮。
OpenNewRound bool
}
// routeStats 是已按统计世代隔离的商户成功累计。
type routeStats struct {
amounts map[uint]int64
counts map[uint]int64
}
// newRouteStats 创建空的成功累计聚合。
func newRouteStats() routeStats {
return routeStats{amounts: map[uint]int64{}, counts: map[uint]int64{}}
}
// add 累加一条成功事实。
func (s routeStats) add(row *model.PaymentMerchantRoutingSuccess) {
s.amounts[row.MerchantID] += row.Amount
s.counts[row.MerchantID]++
}
// statisticCycle 返回金额/笔数池的统计周期。
func statisticCycle(pool *model.PaymentMerchantPool) (string, error) {
if pool.StatisticCycle == nil {
return "", errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
}
return *pool.StatisticCycle, nil
}
// thresholdValue 返回金额/笔数池的达标阈值。
func thresholdValue(pool *model.PaymentMerchantPool) (int64, error) {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if pool.ThresholdAmount == nil {
return 0, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
}
return *pool.ThresholdAmount, nil
}
if pool.ThresholdCount == nil {
return 0, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
}
return *pool.ThresholdCount, nil
}
// selectRouteMember 只读计算当前命中成员,与支付创建共用同一套规则:
// 金额/笔数取成员顺序中第一个未达阈值的成员;「每轮累计」全部达标时取成员顺序第一项,
// 由调用方开启新一轮;自然日与自然月全部达标时视为当期无可用成员;时间方式按当前时段计算。
// 池内只有一个启用成员时该成员固定命中,不因达到阈值被跳过、也不因全部达标被拒绝,
// 该规则优先于自然周期拒绝语义,短路在全部达标判定之前。
// 本函数 MUST NOT 写库:不创建支付单、不推进统计世代、不计入成功累计。
func selectRouteMember(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, stats routeStats, now time.Time) (routingOutcome, error) {
if len(merchants) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
if pool.Strategy == model.PaymentMerchantStrategyTime {
return chooseTimedMerchant(pool, merchants, now)
merchant, err := chooseTimedMerchant(pool, merchants, now)
if err != nil {
return routingOutcome{}, err
}
return routingOutcome{Merchant: merchant}, nil
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
return routingOutcome{}, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
}
if pool.StatisticCycle == nil {
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
cycle, err := statisticCycle(pool)
if err != nil {
return routingOutcome{}, err
}
threshold, err := thresholdValue(pool)
if err != nil {
return routingOutcome{}, err
}
if len(merchants) == 1 {
return routingOutcome{Merchant: merchants[0]}, nil
}
for _, merchant := range merchants {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if stats.amounts[merchant.ID] < threshold {
return routingOutcome{Merchant: merchant}, nil
}
continue
}
if stats.counts[merchant.ID] < threshold {
return routingOutcome{Merchant: merchant}, nil
}
}
if cycle != "round" {
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "当前周期暂无可用商户")
}
return routingOutcome{Merchant: merchants[0], OpenNewRound: true}, nil
}
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
var stats routeStats
if pool.Strategy == model.PaymentMerchantStrategyAmount || pool.Strategy == model.PaymentMerchantStrategyCount {
loaded, err := loadPoolRouteStats(ctx, tx, pool, now)
if err != nil {
return nil, err
}
stats = loaded
}
outcome, err := selectRouteMember(pool, merchants, stats, now)
if err != nil {
return nil, err
}
if !outcome.OpenNewRound {
return outcome.Merchant, nil
}
// 每轮累计全部达标:当轮结束,以条件更新递增统计世代开启新一轮;
// 世代已被并发改变时按并发冲突拒绝本次创建,绝不在旧世代上重复累计。
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
return nil, err
}
return outcome.Merchant, nil
}
// loadPoolRouteStats 读取单个商户池当前统计世代的成功累计。
// 自然日与自然月周期只统计支付时间不早于当期窗口起点的成功事实。
func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, now time.Time) (routeStats, error) {
cycle, err := statisticCycle(pool)
if err != nil {
return routeStats{}, err
}
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
if start, limited := routingWindowStart(cycle, now); limited {
query = query.Where("paid_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingSuccess
if err := query.Find(&rows).Error; err != nil {
return nil, err
return routeStats{}, err
}
amounts := make(map[uint]int64, len(merchants))
counts := make(map[uint]int64, len(merchants))
for _, row := range rows {
amounts[row.MerchantID] += row.Amount
counts[row.MerchantID]++
stats := newRouteStats()
for index := range rows {
stats.add(&rows[index])
}
for _, merchant := range merchants {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if pool.ThresholdAmount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
}
if amounts[merchant.ID] < *pool.ThresholdAmount {
return merchant, nil
}
continue
}
if pool.ThresholdCount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
}
if counts[merchant.ID] < *pool.ThresholdCount {
return merchant, nil
}
}
if *pool.StatisticCycle != "round" {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
}
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
return nil, err
}
return merchants[0], nil
return stats, nil
}
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
@@ -353,6 +440,115 @@ func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMe
return nil
}
// poolProjection 是商户池列表投影的只读计算结果。
type poolProjection struct {
// MemberIDs 是按顺序排列的成员商户ID。
MemberIDs []uint
// EnabledMembers 是成员中当前启用且支付方式与池一致的商户数量。
EnabledMembers int
// CurrentMerchant 是当前命中成员;池停用或无可用成员时为空。
CurrentMerchant *model.PaymentMerchant
}
// loadPoolProjections 按页批量计算成员计数与当前命中成员,供列表与详情投影复用。
// 只读:不创建支付单、不推进统计世代、不计入成功累计;查询次数为常数,不随池数量线性增长。
// 池停用或没有可用成员时当前命中成员为空,不影响其余字段返回。
func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.PaymentMerchantPool, now time.Time) (map[uint]poolProjection, error) {
projections := make(map[uint]poolProjection, len(pools))
if len(pools) == 0 {
return projections, nil
}
poolIDs := make([]uint, 0, len(pools))
for _, pool := range pools {
poolIDs = append(poolIDs, pool.ID)
}
var members []model.PaymentMerchantPoolMember
if err := db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
return nil, err
}
membersByPool := make(map[uint][]uint, len(pools))
merchantIDs := make([]uint, 0, len(members))
for index := range members {
member := &members[index]
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
merchantIDs = append(merchantIDs, member.MerchantID)
}
merchants := make(map[uint]*model.PaymentMerchant)
if len(merchantIDs) > 0 {
var rows []model.PaymentMerchant
if err := db.WithContext(ctx).Where("id IN ? AND status = ?", merchantIDs, model.PaymentMerchantStatusEnabled).Find(&rows).Error; err != nil {
return nil, err
}
for index := range rows {
merchant := &rows[index]
merchants[merchant.ID] = merchant
}
}
// 金额与笔数启用池当前世代的成功累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」
// 条件,既避免逐池查询,也避免把其他世代与上一周期的成功事实读进内存。
terms := make([]string, 0, len(pools))
args := make([]any, 0, len(pools)*3)
for _, pool := range pools {
if pool.Status != model.PaymentMerchantStatusEnabled || len(membersByPool[pool.ID]) == 0 {
continue
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
continue
}
cycle, err := statisticCycle(pool)
if err != nil {
continue
}
if start, limited := routingWindowStart(cycle, now); limited {
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND paid_at >= ?)")
args = append(args, pool.ID, pool.RoutingEpoch, start)
continue
}
terms = append(terms, "(pool_id = ? AND routing_epoch = ?)")
args = append(args, pool.ID, pool.RoutingEpoch)
}
statsByPool := make(map[uint]routeStats, len(pools))
if len(terms) > 0 {
var rows []model.PaymentMerchantRoutingSuccess
if err := db.WithContext(ctx).Where(strings.Join(terms, " OR "), args...).Find(&rows).Error; err != nil {
return nil, err
}
for index := range rows {
row := &rows[index]
stats, ok := statsByPool[row.PoolID]
if !ok {
stats = newRouteStats()
statsByPool[row.PoolID] = stats
}
stats.add(row)
}
}
for _, pool := range pools {
ordered := orderedEnabledMembers(pool, membersByPool[pool.ID], merchants)
projection := poolProjection{MemberIDs: membersByPool[pool.ID], EnabledMembers: len(ordered)}
if pool.Status == model.PaymentMerchantStatusEnabled {
if outcome, err := selectRouteMember(pool, ordered, statsByPool[pool.ID], now); err == nil {
projection.CurrentMerchant = outcome.Merchant
}
}
projections[pool.ID] = projection
}
return projections, nil
}
// orderedEnabledMembers 按成员顺序返回启用且支付方式与池一致的商户。
func orderedEnabledMembers(pool *model.PaymentMerchantPool, memberIDs []uint, merchants map[uint]*model.PaymentMerchant) []*model.PaymentMerchant {
ordered := make([]*model.PaymentMerchant, 0, len(memberIDs))
for _, id := range memberIDs {
merchant := merchants[id]
if merchant == nil || merchant.Status != model.PaymentMerchantStatusEnabled || merchant.PaymentMethod != pool.PaymentMethod {
continue
}
ordered = append(ordered, merchant)
}
return ordered
}
// FreezeRoute writes only non-sensitive route facts onto the payment.
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {