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

@@ -193,29 +193,21 @@ func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMercha
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
}
var pools []model.PaymentMerchantPool
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
var rows []model.PaymentMerchantPool
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
poolIDs := make([]uint, 0, len(pools))
for index := range pools {
poolIDs = append(poolIDs, pools[index].ID)
pools := make([]*model.PaymentMerchantPool, 0, len(rows))
for index := range rows {
pools = append(pools, &rows[index])
}
membersByPool := make(map[uint][]uint, len(pools))
if len(poolIDs) > 0 {
var members []model.PaymentMerchantPoolMember
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
for index := range members {
member := &members[index]
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
}
projections, err := loadPoolProjections(ctx, s.db, pools, time.Now())
if err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
for index := range pools {
pool := &pools[index]
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
for _, pool := range pools {
result = append(result, poolResponse(pool, projections[pool.ID]))
}
return result, total, nil
}
@@ -232,7 +224,7 @@ func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentM
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
return poolResponse(ctx, s.db, &pool)
return s.poolView(ctx, &pool, time.Now())
}
// ListMerchants 分页查询特权商户配置。
@@ -429,8 +421,19 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
pool.RoutingEpoch++
}
// 时间轮询池修改周期数值、单位、起始时间或成员顺序并保存成功时,起点由服务端按保存成功时间重算,
// 忽略请求携带的旧起点,避免前端时钟或漏传导致时段漂移。
restartTimedSlot := !creating && strings.TrimSpace(req.Strategy) == model.PaymentMerchantStrategyTime && timedSlotChanged(&pool, &req, previousMemberIDs)
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
if creating {
// 创建按请求起点;请求缺省起点时取保存时间。
restartTimedSlot = pool.Strategy == model.PaymentMerchantStrategyTime && pool.TimePeriodStartedAt == nil
}
savedAt := time.Now()
if restartTimedSlot {
pool.TimePeriodStartedAt = &savedAt
}
if req.Enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
@@ -479,7 +482,7 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
}
return poolResponse(ctx, s.db, &pool)
return s.poolView(ctx, &pool, time.Now())
}
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
@@ -537,7 +540,7 @@ func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
}
return poolResponse(ctx, s.db, &pool)
return s.poolView(ctx, &pool, time.Now())
}
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
@@ -582,6 +585,17 @@ func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerch
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
}
// timedSlotChanged 判断保存时间轮询池时是否需要按保存成功时间重算时段起点:
// 周期数值、单位、起始时间或成员顺序任一变化即视为需要重算。
func timedSlotChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
if !sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
return true
}
return !sameMemberOrder(previousMemberIDs, request.MemberIDs)
}
func sameMemberOrder(left, right []uint) bool {
if len(left) != len(right) {
return false
@@ -633,16 +647,25 @@ func sameTime(a, b *time.Time) bool {
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
}
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
var rows []model.PaymentMerchantPoolMember
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
return nil, err
// poolResponse 组装商户池响应,成员、成员计数与命中成员由只读投影提供。
func poolResponse(p *model.PaymentMerchantPool, projection poolProjection) *dto.PaymentMerchantPoolResponse {
response := &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: projection.MemberIDs, EnabledMemberCount: projection.EnabledMembers, MemberCount: len(projection.MemberIDs), UpdatedAt: p.UpdatedAt, Remark: p.Remark}
if projection.CurrentMerchant != nil {
merchantID := projection.CurrentMerchant.ID
response.CurrentMerchantID = &merchantID
response.CurrentMerchantName = projection.CurrentMerchant.Name
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.MerchantID)
return response
}
// poolView 读取商户池的只读列表投影并组装响应。
func (s *ManagementService) poolView(ctx context.Context, pool *model.PaymentMerchantPool, now time.Time) (*dto.PaymentMerchantPoolResponse, error) {
projections, err := loadPoolProjections(ctx, s.db, []*model.PaymentMerchantPool{pool}, now)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
return poolResponse(pool, projections[pool.ID]), nil
}
// GetAuthorization 查询特权全局授权配置。