完成支付失败商户轮换禁用
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 5h7m21s

This commit is contained in:
2026-09-22 15:58:03 +08:00
parent 622e99b543
commit 9959168e22
16 changed files with 579 additions and 70 deletions

View File

@@ -115,15 +115,8 @@ rg -o 'constants\.OutboxEventType[A-Za-z0-9_]+' cmd/worker/main.go internal | so
需要业务事实与可靠异步副作用原子提交的用例应同事务写 Outbox;是否已经做到必须逐调用链核对。Relay 和消费者按至少一次投递设计,消费者必须容忍重复。
## 外部调用与回调
- 支付:创建本地支付事实后调用渠道;回调需要验签、金额/业务号/状态校验,重复回调不得重复入账。
- 企业微信:本地审批实例 → Outbox 提交 → 渠道审批;加密回调、轮询和人工同步进入统一状态同步语义。
- Gateway/运营商:查询与控制结果来自外部;超时或未知结果不得理想化为成功。
- 对象存储:上传/预签名 URL 是外部副作用,数据库只保存对象引用。
- SMS:发送结果是外部通知结果,不等同业务事务成功。
外部网络调用不应持有长数据库事务;确需提交后可靠执行时使用 Outbox/Task。实际例外按 As-Is Spec 记录。
- 支付:创建本地支付事实、冻结商户池路由并写入一次不可变路由尝试事实后,事务外调用渠道;回调需要验签、金额/业务号/状态校验,重复回调不得重复入账或重复统计。预下单明确失败条件禁用冻结商户并写入脱敏失败留痕;超时、连接中断、响应无法确认和上下文取消保持结果未知,进入查单/恢复边界,不自动禁用或创建第二个渠道支付。
- 商户池金额/笔数轮询按支付创建提交时的路由尝试事实统计,后续成功、失败、取消、过期或未知不新增、不回冲;历史支付成功事实只作兼容读取。
## 事务、审计与日志边界

View File

@@ -112,6 +112,11 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
ExpireAt: *result.Payment.ExpireAt, PayerClientIP: command.PayerClientIP, Config: config,
})
if err != nil {
if merchantpayment.ShouldDisableMerchant(err) {
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, result.Payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if !isUnknownPaymentResult(err) {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付链接生成失败且关闭本地订单失败")
@@ -286,10 +291,13 @@ func (s *OnlineCreationService) createLocalFacts(
if err := tx.Create(payment).Error; err != nil {
return err
}
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
if err := s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
})
}); err != nil {
return err
}
return merchantpayment.RecordRoutingAttempt(ctx, tx, payment)
})
if err != nil {
var appErr *apperrors.AppError

View File

@@ -650,7 +650,7 @@ func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
// 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}
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, LastFailureMerchantID: projection.LastFailureMerchantID, LastFailurePaymentID: projection.LastFailurePaymentID, LastFailurePaymentNo: projection.LastFailurePaymentNo, LastFailureReason: projection.LastFailureReason, LastFailureAt: projection.LastFailureAt}
if projection.CurrentMerchant != nil {
merchantID := projection.CurrentMerchant.ID
response.CurrentMerchantID = &merchantID

View File

@@ -2,7 +2,9 @@ package merchantpayment
import (
"context"
stderrors "errors"
"fmt"
"net"
"strings"
"time"
@@ -257,19 +259,19 @@ type routingOutcome struct {
OpenNewRound bool
}
// routeStats 是已按统计世代隔离的商户成功累计。
// routeStats 是已按统计世代隔离的商户路由尝试累计。
type routeStats struct {
amounts map[uint]int64
counts map[uint]int64
}
// newRouteStats 创建空的成功累计聚合。
// newRouteStats 创建空的路由尝试累计聚合。
func newRouteStats() routeStats {
return routeStats{amounts: map[uint]int64{}, counts: map[uint]int64{}}
}
// add 累加一条成功事实。
func (s routeStats) add(row *model.PaymentMerchantRoutingSuccess) {
// add 累加一条路由尝试事实。
func (s routeStats) add(row *model.PaymentMerchantRoutingAttempt) {
s.amounts[row.MerchantID] += row.Amount
s.counts[row.MerchantID]++
}
@@ -368,8 +370,8 @@ func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchan
return outcome.Merchant, nil
}
// loadPoolRouteStats 读取单个商户池当前统计世代的成功累计。
// 自然日与自然月周期只统计支付时间不早于当期窗口起点的成功事实。
// loadPoolRouteStats 读取单个商户池当前统计世代的路由尝试累计。
// 自然日与自然月周期只统计支付创建时间不早于当期窗口起点的尝试事实。
func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, now time.Time) (routeStats, error) {
cycle, err := statisticCycle(pool)
if err != nil {
@@ -377,9 +379,9 @@ func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMer
}
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
if start, limited := routingWindowStart(cycle, now); limited {
query = query.Where("paid_at >= ?", start)
query = query.Where("created_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingSuccess
var rows []model.PaymentMerchantRoutingAttempt
if err := query.Find(&rows).Error; err != nil {
return routeStats{}, err
}
@@ -440,14 +442,16 @@ func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMe
return nil
}
// poolProjection 是商户池列表投影的只读计算结果。
// poolProjection 是商户池列表的只读计算结果。
type poolProjection struct {
// MemberIDs 是按顺序排列的成员商户ID。
MemberIDs []uint
// EnabledMembers 是成员中当前启用且支付方式与池一致的商户数量。
EnabledMembers int
// CurrentMerchant 是当前命中成员;池停用或无可用成员时为空。
CurrentMerchant *model.PaymentMerchant
LastFailureMerchantID *uint
LastFailurePaymentID *uint
LastFailurePaymentNo string
LastFailureReason string
LastFailureAt *time.Time
}
// loadPoolProjections 按页批量计算成员计数与当前命中成员,供列表与详情投影复用。
@@ -484,8 +488,7 @@ func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.Paymen
merchants[merchant.ID] = merchant
}
}
// 金额与笔数启用池当前世代的成功累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」
// 条件,既避免逐池查询,也避免把其他世代与上一周期的成功事实读进内存。
// 金额与笔数启用池当前世代的路由尝试累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」条件,避免逐池查询。
terms := make([]string, 0, len(pools))
args := make([]any, 0, len(pools)*3)
for _, pool := range pools {
@@ -500,7 +503,7 @@ func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.Paymen
continue
}
if start, limited := routingWindowStart(cycle, now); limited {
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND paid_at >= ?)")
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND created_at >= ?)")
args = append(args, pool.ID, pool.RoutingEpoch, start)
continue
}
@@ -509,7 +512,7 @@ func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.Paymen
}
statsByPool := make(map[uint]routeStats, len(pools))
if len(terms) > 0 {
var rows []model.PaymentMerchantRoutingSuccess
var rows []model.PaymentMerchantRoutingAttempt
if err := db.WithContext(ctx).Where(strings.Join(terms, " OR "), args...).Find(&rows).Error; err != nil {
return nil, err
}
@@ -523,9 +526,29 @@ func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.Paymen
stats.add(row)
}
}
var failures []model.PaymentMerchantFailure
if err := db.WithContext(ctx).Where("pool_id IN ? AND result_class = ?", poolIDs, "explicit_failed").Order("occurred_at DESC, id DESC").Find(&failures).Error; err != nil {
return nil, err
}
failureByPool := make(map[uint]model.PaymentMerchantFailure, len(failures))
for index := range failures {
failure := failures[index]
if _, exists := failureByPool[failure.PoolID]; !exists {
failureByPool[failure.PoolID] = failure
}
}
for _, pool := range pools {
ordered := orderedEnabledMembers(pool, membersByPool[pool.ID], merchants)
projection := poolProjection{MemberIDs: membersByPool[pool.ID], EnabledMembers: len(ordered)}
if failure, ok := failureByPool[pool.ID]; ok {
merchantID, paymentID := failure.MerchantID, failure.PaymentID
projection.LastFailureMerchantID = &merchantID
projection.LastFailurePaymentID = &paymentID
projection.LastFailurePaymentNo = failure.PaymentNo
projection.LastFailureReason = failure.FailureReason
occurredAt := failure.OccurredAt
projection.LastFailureAt = &occurredAt
}
if pool.Status == model.PaymentMerchantStatusEnabled {
if outcome, err := selectRouteMember(pool, ordered, statsByPool[pool.ID], now); err == nil {
projection.CurrentMerchant = outcome.Merchant
@@ -580,3 +603,115 @@ func RecordFirstSuccess(ctx context.Context, tx *gorm.DB, payment *model.Payment
}
return nil
}
// RecordRoutingAttempt 在支付单与冻结路由同一事务中写入一次路由尝试事实。
// 唯一支付约束使并发重放和重复建账不会增加第二次统计。
func RecordRoutingAttempt(ctx context.Context, tx *gorm.DB, payment *model.Payment) error {
if payment == nil || payment.ID == 0 || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil || payment.Amount <= 0 {
return nil
}
attempt := &model.PaymentMerchantRoutingAttempt{
PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID,
RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, ResultClass: "pending",
}
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池路由尝试失败")
}
return nil
}
// DisableMerchantAfterExplicitFailure 条件禁用冻结商户,并写入脱敏失败调查事实。
func DisableMerchantAfterExplicitFailure(ctx context.Context, db *gorm.DB, payment *model.Payment, reason string, now time.Time) error {
if payment == nil || payment.ID == 0 || payment.MerchantID == nil || payment.MerchantPoolID == nil || db == nil {
return nil
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var merchant model.PaymentMerchant
if err := tx.WithContext(ctx).First(&merchant, *payment.MerchantID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "读取失败支付商户状态失败")
}
result := tx.WithContext(ctx).Model(&model.PaymentMerchant{}).
Where("id = ? AND status = ?", merchant.ID, model.PaymentMerchantStatusEnabled).
Update("status", model.PaymentMerchantStatusDisabled)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "自动禁用失败支付商户失败")
}
after := merchant.Status
if result.RowsAffected == 1 {
after = model.PaymentMerchantStatusDisabled
}
failure := &model.PaymentMerchantFailure{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, MerchantID: merchant.ID,
PoolID: *payment.MerchantPoolID, ResultClass: "explicit_failed", FailureReason: safeFailureReason(reason),
StatusBefore: merchant.Status, StatusAfter: after, OccurredAt: now,
}
if err := tx.WithContext(ctx).Create(failure).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入支付商户失败留痕失败")
}
return nil
})
}
func safeFailureReason(reason string) string {
reason = strings.TrimSpace(reason)
if len(reason) > 128 {
return reason[:128]
}
return reason
}
// PreCreateResultClass 是渠道预下单结果的稳定分类。
type PreCreateResultClass string
const (
PreCreateResultSuccess PreCreateResultClass = "success"
PreCreateResultExplicitFailed PreCreateResultClass = "explicit_failed"
PreCreateResultUnknown PreCreateResultClass = "unknown"
PreCreateResultNotSent PreCreateResultClass = "not_sent"
)
// ClassifyPreCreateResult 统一判断预下单结果及自动禁用资格。
func ClassifyPreCreateResult(err error, sent bool) PreCreateResultClass {
if err == nil {
return PreCreateResultSuccess
}
if !sent {
return PreCreateResultNotSent
}
if !ShouldDisableMerchant(err) {
return PreCreateResultUnknown
}
return PreCreateResultExplicitFailed
}
// ShouldDisableMerchant 仅在没有结果未知信号且错误代表渠道明确拒绝时返回 true。
// 上下文取消、超时、连接中断、网络传输错误和响应无法确认均不得禁用商户。
func ShouldDisableMerchant(err error) bool {
if err == nil {
return false
}
if stderrors.Is(err, context.Canceled) || stderrors.Is(err, context.DeadlineExceeded) {
return false
}
var networkErr net.Error
if stderrors.As(err, &networkErr) {
return false
}
var appErr *errors.AppError
if !stderrors.As(err, &appErr) || appErr == nil {
return false
}
if appErr.Code == errors.CodeTimeout || appErr.Code == errors.CodeServiceUnavailable || appErr.Code == errors.CodeNoPaymentConfig {
return false
}
message := strings.ToLower(appErr.Error())
for _, marker := range []string{"timeout", "deadline", "context canceled", "connection", "transport", "network", "unknown", "结果未知", "无法确认", "连接中断"} {
if strings.Contains(message, marker) {
return false
}
}
return appErr.Code == errors.CodeWechatPayFailed || appErr.Code == errors.CodeFuiouPayFailed
}

View File

@@ -385,6 +385,9 @@ func (h *ClientWalletHandler) createWechatRecharge(
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
if err := merchantpayment.RecordRoutingAttempt(resolved.SkipPermissionCtx, tx, payment); err != nil {
return err
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
@@ -405,7 +408,14 @@ func (h *ClientWalletHandler) createWechatRecharge(
int(req.Amount),
)
if err != nil {
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
resultClass := constants.IntegrationResultUnknown
if merchantpayment.ShouldDisableMerchant(err) {
resultClass = constants.IntegrationResultFailed
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(resolved.SkipPermissionCtx, h.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return disableErr
}
}
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, resultClass, "request_failed", "充值支付预下单结果已分类"); completeErr != nil {
return completeErr
}
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
@@ -480,18 +490,22 @@ func (h *ClientWalletHandler) createAlipayRecharge(
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return err
}
if err := merchantpayment.RecordRoutingAttempt(resolved.SkipPermissionCtx, tx, payment); err != nil {
return err
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
if err != nil {
if merchantpayment.ShouldDisableMerchant(err) {
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(resolved.SkipPermissionCtx, h.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return disableErr
}
}
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
h.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
)
h.logger.Warn("标记支付宝支付单 failed 失败", zap.String("payment_no", paymentNo), zap.Error(updateErr))
}
return err
}

View File

@@ -98,6 +98,14 @@ type PaymentMerchantPoolResponse struct {
CurrentMerchantID *uint `json:"current_merchant_id,omitempty" description:"当前命中商户ID;商户池停用或无可用成员时为空"`
CurrentMerchantName string `json:"current_merchant_name,omitempty" description:"当前命中商户名称;商户池停用或无可用成员时为空"`
UpdatedAt time.Time `json:"updated_at" description:"最近配置更新时间"`
// PaymentMerchantPoolResponse 的失败字段用于识别自动禁用原因及关联支付单,不包含凭证或原始响应。
// LastFailureMerchantID 表示最近一次失败留痕对应的商户。
// LastFailurePaymentID 与 LastFailurePaymentNo 用于调查原支付事实。
LastFailureMerchantID *uint `json:"last_failure_merchant_id,omitempty" description:"最近明确失败商户ID"`
LastFailurePaymentID *uint `json:"last_failure_payment_id,omitempty" description:"最近明确失败关联支付单ID"`
LastFailurePaymentNo string `json:"last_failure_payment_no,omitempty" description:"最近明确失败关联支付单号"`
LastFailureReason string `json:"last_failure_reason,omitempty" description:"最近明确失败安全原因"`
LastFailureAt *time.Time `json:"last_failure_at,omitempty" description:"最近明确失败时间"`
Remark string `json:"remark" description:"备注"`
}

View File

@@ -92,3 +92,34 @@ type PaymentMerchantRoutingSuccess struct {
}
func (PaymentMerchantRoutingSuccess) TableName() string { return "tb_payment_merchant_routing_success" }
// PaymentMerchantRoutingAttempt 是支付创建提交时冻结路由的一次不可变尝试事实。
type PaymentMerchantRoutingAttempt struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentID uint `gorm:"column:payment_id" json:"payment_id"`
MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"`
PoolID uint `gorm:"column:pool_id" json:"pool_id"`
RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"`
Amount int64 `gorm:"column:amount" json:"amount"`
ResultClass string `gorm:"column:result_class" json:"result_class"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (PaymentMerchantRoutingAttempt) TableName() string { return "tb_payment_merchant_routing_attempt" }
// PaymentMerchantFailure 记录明确预下单失败导致的商户自动禁用调查事实。
type PaymentMerchantFailure struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentID uint `gorm:"column:payment_id" json:"payment_id"`
PaymentNo string `gorm:"column:payment_no" json:"payment_no"`
MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"`
PoolID uint `gorm:"column:pool_id" json:"pool_id"`
ResultClass string `gorm:"column:result_class" json:"result_class"`
FailureReason string `gorm:"column:failure_reason" json:"failure_reason"`
StatusBefore int `gorm:"column:merchant_status_before" json:"merchant_status_before"`
StatusAfter int `gorm:"column:merchant_status_after" json:"merchant_status_after"`
OccurredAt time.Time `gorm:"column:occurred_at" json:"occurred_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (PaymentMerchantFailure) TableName() string { return "tb_payment_merchant_failure" }

View File

@@ -595,18 +595,25 @@ func (s *Service) createForceRechargeOrder(
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
if err := s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder); err != nil {
return err
}
return merchantpayment.RecordRoutingAttempt(ctx, tx, payment)
}); err != nil {
return nil, err
}
attempt, startedAt, err := s.startPaymentAttempt(ctx, payment, paymentIntegrationProvider(activeConfig), "client_force_recharge")
if err != nil {
return nil, err
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, paymentNo, "余额充值", openID, int(rechargeOrder.Amount))
if err != nil {
if completeErr := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
resultClass := constants.IntegrationResultUnknown
if merchantpayment.ShouldDisableMerchant(err) {
resultClass = constants.IntegrationResultFailed
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if completeErr := s.completePaymentAttempt(ctx, attempt, startedAt, resultClass, "request_failed", "支付预下单结果已分类"); completeErr != nil {
return nil, completeErr
}
if updateErr := s.markPaymentFailed(ctx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
@@ -1076,6 +1083,9 @@ func (s *Service) getOrBuildAlipayPaymentLink(
if err := s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil); err != nil {
return err
}
if err := merchantpayment.RecordRoutingAttempt(ctx, tx, newPayment); err != nil {
return err
}
payment = newPayment
activeConfig = config
return nil
@@ -1094,6 +1104,11 @@ func (s *Service) getOrBuildAlipayPaymentLink(
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, subject)
if err != nil {
if created && merchantpayment.ShouldDisableMerchant(err) {
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if created {
_ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录")
}
@@ -1198,18 +1213,23 @@ func (s *Service) createAlipayForceRechargeOrder(
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
return err
}
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
if err := s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder); err != nil {
return err
}
return merchantpayment.RecordRoutingAttempt(ctx, tx, payment)
}); err != nil {
return nil, err
}
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, "余额充值")
if err != nil {
if merchantpayment.ShouldDisableMerchant(err) {
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if updateErr := s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录"); updateErr != nil {
s.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
)
s.logger.Warn("标记支付宝支付单 failed 失败", zap.String("payment_no", paymentNo), zap.Error(updateErr))
}
return nil, err
}
@@ -1530,26 +1550,29 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
if err := merchantpayment.RecordRoutingAttempt(skipCtx, tx, payment); err != nil {
return err
}
return s.appendPaymentCreatedAudit(skipCtx, tx, payment, order, nil)
}); err != nil {
return nil, err
}
attempt, startedAt, err := s.startPaymentAttempt(skipCtx, payment, paymentIntegrationProvider(activeConfig), "client_order")
if err != nil {
return nil, err
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(skipCtx, paymentNo, "套餐购买", openID, int(order.TotalAmount))
if err != nil {
if completeErr := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
resultClass := constants.IntegrationResultUnknown
if merchantpayment.ShouldDisableMerchant(err) {
resultClass = constants.IntegrationResultFailed
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(skipCtx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if completeErr := s.completePaymentAttempt(skipCtx, attempt, startedAt, resultClass, "request_failed", "支付预下单结果已分类"); completeErr != nil {
return nil, completeErr
}
if updateErr := s.markPaymentFailed(skipCtx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
s.logger.Warn("标记支付记录失败状态失败",
zap.Uint("payment_id", payment.ID),
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
)
return nil, updateErr
}
return nil, err
}

View File

@@ -0,0 +1,3 @@
-- 回滚 000238:删除支付商户路由尝试及明确失败留痕事实。
DROP TABLE IF EXISTS tb_payment_merchant_failure;
DROP TABLE IF EXISTS tb_payment_merchant_routing_attempt;

View File

@@ -0,0 +1,44 @@
-- 支付创建提交时的路由尝试事实,以及明确失败自动禁用的安全留痕。
-- 关联只保存 ID,不保存凭证、原始响应或敏感渠道载荷。
CREATE TABLE tb_payment_merchant_routing_attempt (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL,
merchant_id BIGINT NOT NULL,
pool_id BIGINT NOT NULL,
routing_epoch BIGINT NOT NULL,
amount BIGINT NOT NULL,
result_class VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_payment_merchant_routing_attempt_payment UNIQUE (payment_id),
CONSTRAINT chk_payment_merchant_routing_attempt_epoch CHECK (routing_epoch >= 1),
CONSTRAINT chk_payment_merchant_routing_attempt_amount CHECK (amount > 0),
CONSTRAINT chk_payment_merchant_routing_attempt_result CHECK (result_class IN ('pending', 'success', 'explicit_failed', 'unknown', 'not_sent', 'cancelled'))
);
CREATE INDEX idx_payment_merchant_routing_attempt_select
ON tb_payment_merchant_routing_attempt (pool_id, routing_epoch, created_at, merchant_id, id);
CREATE INDEX idx_payment_merchant_routing_attempt_merchant
ON tb_payment_merchant_routing_attempt (merchant_id, created_at, id);
CREATE TABLE tb_payment_merchant_failure (
id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL,
payment_no VARCHAR(40) NOT NULL,
merchant_id BIGINT NOT NULL,
pool_id BIGINT NOT NULL,
result_class VARCHAR(32) NOT NULL,
failure_reason VARCHAR(128) NOT NULL DEFAULT '',
merchant_status_before SMALLINT NOT NULL,
merchant_status_after SMALLINT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_payment_merchant_failure_result CHECK (result_class = 'explicit_failed')
);
CREATE INDEX idx_payment_merchant_failure_merchant_time
ON tb_payment_merchant_failure (merchant_id, occurred_at DESC, id DESC);
CREATE INDEX idx_payment_merchant_failure_payment
ON tb_payment_merchant_failure (payment_id, occurred_at DESC, id DESC);
COMMENT ON TABLE tb_payment_merchant_routing_attempt IS '新支付创建提交时冻结路由的一次不可变尝试事实,不依赖最终支付结果';
COMMENT ON COLUMN tb_payment_merchant_routing_attempt.result_class IS '尝试结果分类:pending/success/explicit_failed/unknown/not_sent/cancelled';
COMMENT ON TABLE tb_payment_merchant_failure IS '支付商户明确预下单失败及自动禁用的安全调查留痕,不保存凭证或原始响应';
COMMENT ON COLUMN tb_payment_merchant_failure.failure_reason IS '脱敏稳定失败原因编码或摘要';

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-22

View File

@@ -0,0 +1,67 @@
## Context
当前商户池在支付单创建时冻结商户、商户池和统计世代;金额/笔数路由通过 `tb_payment_merchant_routing_success` 只读取首次支付成功事实。微信直连、微信 v2、富友和支付宝的预下单均在本地支付单创建后于事务外执行,明确失败时会关闭或标记支付单失败,但不会改变商户状态或当前路由统计。
本变更横跨商户池路由、支付创建入口、渠道结果分类、支付审计和数据库统计事实。已有支付单必须保持原商户归属,历史成功统计不得被重复计入。
## Goals / Non-Goals
**Goals:**
- 为每次新支付建立不可变的商户池路由尝试事实,按创建时冻结的商户、商户池、统计世代和金额记录一次。
- 金额/笔数路由基于路由尝试事实推进,不依赖支付最终成功;新尝试成功、明确失败、取消或过期均不回冲、不重复计入。
- 明确预下单失败时自动禁用当前商户,使后续新支付跳过该商户。
- 保留结果未知的支付恢复/查单边界;未知结果不能直接视为明确失败并禁用商户。
- 对并发创建、重复回调、迟到成功和商户停用保持幂等与历史支付可服务性。
**Non-Goals:**
- 不在一次失败后自动将同一个支付单改挂到其他商户,也不自动向其他商户重试同一笔外部支付。
- 不改变历史支付回调、查单和退款按冻结商户/旧支付配置读取的兼容规则。
- 不增加人工启停开关、失败重试次数配置、商户健康评分或熔断恢复调度。
- 不把结果未知、请求未发出、应用初始化失败等所有错误统一判定为商户失效;这些分类必须沿用可证明的渠道结果语义。
- 不追溯改写上线前支付单或把历史成功事实转换为新的尝试事实。
## Decisions
### 1. 使用独立的路由尝试事实,不扩展成功事实的语义
新增与支付 ID 一一对应的不可变路由尝试事实,至少保存支付 ID、商户 ID、商户池 ID、统计世代、金额、创建时间和尝试结果归类。支付创建事务在冻结路由后写入该事实;唯一约束保证同一支付单只计一次。
保留现有成功事实用于历史兼容或成功审计,但金额/笔数选路只读取新的路由尝试事实。这样“成功统计”与“交易尝试统计”语义不会继续混淆,也不会因回调重放增加笔数。
### 2. 统计边界定在本地支付单创建提交
只要新支付已经在本地事务中创建,并冻结了商户池路由,就计入一次路由尝试金额/笔数;后续渠道成功、明确失败、取消、过期或结果未知都不再改变这次计数。未成功提交本地支付事实的请求不计入。
该边界使并发选路可以由数据库事务锁和当前统计世代保护,避免“预下单失败后没有累计、下一次继续卡在同一商户”。支付单创建后再发生的路由切换不会改写其冻结事实。
### 3. 明确失败与结果未知分离
渠道适配层或支付发起编排必须输出稳定的结果分类:明确失败、成功、结果未知和未发送/本地失败。只有已经实际发起且渠道明确拒绝、明确返回失败的结果才触发商户自动禁用;超时、连接中断、响应解析无法确认、请求上下文取消等结果未知不得自动禁用。
本地配置校验或适配器初始化失败是否禁用商户,需要按是否能证明该商户凭证/商户身份不可用分类;不能证明时只关闭本次支付事实并保留商户,避免暂时性基础设施故障误杀整个账户。
### 4. 禁用与失败留痕采用条件更新
明确失败处理使用“商户仍为启用状态”的条件更新,多个并发失败只产生一次有效状态变化;失败原因、支付 ID、支付单号和发生时间写入受控的商户失败留痕或审计事实,不写入凭证。已经被其他管理员停用的商户不被重新启用,历史支付仍可通过商户 ID 加载当前凭证处理回调、查单和退款。
### 5. 轮换与自动禁用的先后顺序
支付创建事务先锁定启用商户池并冻结路由尝试,再提交支付单;事务外渠道明确失败后条件禁用该商户。下一次支付重新读取启用成员和当前统计世代,因此既受尝试金额/笔数阈值影响,也会跳过刚被禁用的商户。当前失败支付不被自动迁移,避免同一业务单产生多个渠道交易号。
### 6. 统计世代和自然周期沿用现有规则
每轮累计仍在全部成员达到阈值时开启新世代;自然日/月仍在当期没有可用成员时拒绝新支付。新路由尝试事实必须带创建时统计世代,自然日/月还按尝试创建时间过滤。配置变更按现有规则推进世代;不重新解释旧事实。
### 7. 覆盖三类商户池新支付入口
设计适用于 C 端套餐购买、C 端资产钱包充值和代理在线预存款充值。后台钱包/线下支付不进入商户池。历史旧支付入口只在已经按商户池创建并冻结路由的范围内接入新尝试事实;旧 `payment_config_id` 历史支付不补造路由尝试。
## Risks / Trade-offs
- 按本地支付单创建计数会把结果未知和用户未完成支付也算作一次尝试,可能比“仅统计已发起请求”更积极;这是满足“成功失败都轮换”且避免坏账户卡死的明确取舍。
- 自动禁用对明确失败错误分类高度敏感。分类过宽会误禁用健康商户,分类过窄仍可能让坏商户短暂保留;必须优先采用渠道能证明的失败码和本地适配器错误语义。
- 商户禁用是全局状态,同一商户可能服务多个支付方式入口但商户本身只有一种支付方式;并发已冻结支付不受影响,新支付即时跳过。
- 需要新表或新列及索引,迁移必须只新增,不修改既有迁移;历史数据不回填,发布顺序需先完成 Schema 再启用新路由读取。
- 运营方要求“失败自动禁用”与结果未知保护存在业务张力;设计明确选择宁可进入查单恢复,也不因未知结果直接禁用或重试。

View File

@@ -0,0 +1,23 @@
## Why
当前支付商户池在预下单失败后仍可能继续命中同一商户,且金额/笔数轮询只统计支付成功事实,导致失效商户持续阻塞后续支付,运营人员必须人工发现并处理。运营方要求明确失败立即淘汰账户,并让金额/笔数轮换按支付尝试推进,而不是等待支付成功或轮询周期到达。
## What Changes
- 支付预下单获得明确失败结果时,自动将本次命中的支付商户从新支付路由中禁用,并记录可调查的失败原因与关联支付事实。
- 明确失败后的后续支付不得继续命中该商户;当前失败支付单按既有失败语义结束,不自动复用原支付单向其他商户重试。
- 金额轮询改为按支付尝试金额累计,无论预下单或后续支付最终成功、失败还是取消,只要该支付尝试已冻结商户并达到计入条件,就推进该商户的金额阈值。
- 笔数轮询改为按支付尝试次数累计,无论支付最终成功、失败还是取消,只要该支付尝试已冻结商户并达到计入条件,就推进该商户的笔数阈值。
- 保留支付单创建时的商户、商户池和统计世代快照;后续路由切换不得改写已创建支付单的归属。
- 对结果未知的外部调用保留查单/恢复边界,不在无法确认“明确失败”的情况下直接禁用商户或重复创建支付,避免误伤和重复扣款风险。
- **BREAKING** 修改现有商户池轮询契约:金额/笔数不再只统计支付成功结果,预下单明确失败会改变商户启用状态。
## Capabilities
### New Capabilities
- 无。该变更扩展现有支付商户池能力,不新增独立业务能力。
### Modified Capabilities
- `merchant-payment-routing`: 修改预下单失败处理、商户自动禁用及金额/笔数轮询统计口径。

View File

@@ -0,0 +1,87 @@
## MODIFIED Requirements
### Requirement: 商户池唯一性与轮询配置
系统 SHALL 为每种支付方式最多启用一个商户池;停用历史池可保留,但不得同时启用多个同支付方式池。商户池成员支付方式 MUST 与池一致,成员按明确顺序排列;金额/笔数方式必须配置 `每轮累计`、`自然日累计` 或 `自然月累计` 统计周期,时间方式必须配置最小为 1 分钟的数值、单位和起始时间。
金额和笔数轮询 SHALL 按新支付单创建时形成的路由尝试事实统计,不再仅统计已确认支付成功结果。一次新支付在本地支付单、冻结商户路由和路由尝试事实成功提交后,按冻结商户、商户池、统计世代和支付金额计入一次;该支付之后成功、明确失败、取消、过期或结果未知,均不得再次计入、回冲或改写该统计事实。支付创建时 MUST 冻结本次路由所属的统计世代;当前选路只读取当前统计世代的路由尝试事实,金额与笔数累计 MUST 只统计与当前统计世代匹配的事实,自然日与自然月方式另加支付创建时间不早于当期窗口起点。达到阈值的成员在当前周期跳过;全部成员均达到阈值时的行为按统计周期区分:`每轮累计` MUST 视为当轮结束,以当前支付创建提交时间为新起点开启新一轮并从零重新累计,MUST NOT 因当轮全部达标而拒绝创建,开启新一轮 MUST 以条件更新递增统计世代,仅在当前世代未被并发改变时成功,更新冲突 MUST 按并发冲突拒绝创建本次支付;`自然日累计` 与 `自然月累计` MUST 拒绝创建新支付单并提示当前周期暂无可用商户。池内只有一个启用成员时该成员 MUST 被固定选中:MUST NOT 因达到阈值而在当前周期被跳过,也 MUST NOT 因全部成员达标而拒绝创建,该规则优先于自然周期拒绝语义。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段;修改时间周期数值、单位、起始时间或成员顺序并保存成功后,系统 MUST 以保存成功时间为新起点并从前述成员列表第一项重新计算时段,MUST NOT 沿用请求提交前的起点。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按既有规则开启新周期;自然周期排序调整保留未移除成员累计。
支付预下单获得明确失败结果时,系统 MUST 将本次支付冻结的商户自动置为禁用,并记录与该支付单关联的安全失败原因和发生时间;后续新支付 MUST 重新读取商户池,仅从启用商户中选择,不得继续命中该商户。该失败支付单 MUST 按当前失败语义结束,系统 MUST NOT 自动将同一支付单改挂其他商户或向其他商户重试。结果未知、无法证明渠道未受理或仅本地请求上下文取消时,系统 MUST NOT 仅凭该结果自动禁用商户或创建第二个渠道支付。
#### Scenario: 并发预下单未预占额度
- **WHEN** 多个客户并发创建金额或笔数轮询支付单且当前成员尚未达到阈值
- **THEN** 系统可使这些支付单均命中当前成员,每个成功提交的支付单各计入一次冻结商户的路由尝试金额/笔数,后续支付结果不改变已创建支付单的商户归属
#### Scenario: 路由尝试在支付创建时计入
- **GIVEN** 一个新支付单已在本地事务中成功创建并冻结商户、商户池和统计世代
- **WHEN** 后续渠道支付成功、明确失败、取消、过期或结果未知
- **THEN** 金额/笔数轮询均只保留该支付创建时的一次统计,不因后续结果重复计入或回冲
#### Scenario: 迟到首次成功归属冻结统计世代
- **GIVEN** 支付已创建但尚未成功,之后商户池切换统计周期、成员或排序
- **WHEN** 该支付首次确认成功
- **THEN** 系统不新增第二条路由尝试事实,不改写当前选路统计;支付仍按创建时冻结的商户处理
#### Scenario: 每轮累计全部达标开启新一轮
- **GIVEN** 启用商户池统计周期为每轮累计,且本轮全部成员均已达到阈值
- **WHEN** 客户创建新的支付单
- **THEN** 系统开启新一轮、各成员从零累计,并选中成员顺序第一项,不返回暂无可用商户
#### Scenario: 当期没有可用商户
- **WHEN** 启用商户池中不存在启用且未达阈值的成员,或商户池已停用
- **THEN** 系统拒绝创建新支付单并提示暂无可用商户,不回退到旧综合支付配置
#### Scenario: 自然周期全部达标拒绝创建
- **GIVEN** 启用商户池统计周期为自然日累计或自然月累计,且当前周期全部成员均已达到阈值
- **WHEN** 客户创建新的支付单
- **THEN** 系统拒绝创建并提示当前周期暂无可用商户,不回退到旧综合支付配置
#### Scenario: 单成员池达标后仍可收款
- **GIVEN** 启用商户池只有一个启用成员,且该成员在当前自然日或自然月周期内已达到阈值
- **WHEN** 客户创建新的支付单
- **THEN** 系统仍选中该成员,不返回暂无可用商户
#### Scenario: 明确预下单失败自动禁用商户
- **GIVEN** 新支付已冻结商户 A,且渠道明确返回该商户或其凭证不可用的失败结果
- **WHEN** 本次预下单结束为明确失败
- **THEN** 系统将商户 A 自动禁用,记录支付单关联的安全失败原因,且本次支付不自动改挂其他商户
#### Scenario: 禁用商户不再被后续支付命中
- **GIVEN** 商户 A 因明确预下单失败已被自动禁用,商户池中还有启用商户 B
- **WHEN** 下一笔新支付创建
- **THEN** 系统跳过商户 A 并按当前金额/笔数/时间规则选择商户 B
#### Scenario: 结果未知不自动禁用
- **GIVEN** 新支付已冻结商户 A,外部请求超时、连接中断、响应无法确认或请求上下文取消
- **WHEN** 系统无法证明渠道明确拒绝且无法确认是否已受理
- **THEN** 系统保留商户 A 的当前启用状态,保留原支付单进入既有查单/恢复边界,不创建第二个渠道支付
#### Scenario: 失败账户并发禁用幂等
- **GIVEN** 多个已冻结商户 A 的支付请求都得到明确失败
- **WHEN** 多个失败处理并发执行
- **THEN** 商户 A 最终为禁用状态,失败留痕不重复推进路由统计,已提交的支付单仍保留各自冻结的商户 A
#### Scenario: 金额轮换不区分支付结果
- **GIVEN** 金额轮询阈值为 100 元,商户 A 已有 80 元路由尝试累计
- **WHEN** 下一笔 30 元支付单创建后随后明确失败
- **THEN** 商户 A 的当前统计累计为 110 元,下一笔新支付跳过商户 A 并选择下一个可用成员
#### Scenario: 笔数轮换不区分支付结果
- **GIVEN** 笔数轮询阈值为 2 笔,商户 A 已有 1 笔路由尝试累计
- **WHEN** 下一笔支付单创建后随后取消或预下单明确失败
- **THEN** 商户 A 的当前统计累计为 2 笔,下一笔新支付跳过商户 A 并选择下一个可用成员
#### Scenario: 历史支付仍按冻结商户服务
- **GIVEN** 支付单已冻结商户 A,之后商户 A 因另一笔支付明确失败被禁用
- **WHEN** 系统处理该历史支付的回调、查单或原路退款
- **THEN** 系统仍按该支付单冻结的商户 A 读取当前凭证和处理历史事实,不因商户停用改挂其他商户
#### Scenario: 修改时间轮询配置后起点重算
- **GIVEN** 时间轮询商户池已存在起始时间与成员顺序
- **WHEN** 授权账号修改周期数值、单位、起始时间或成员顺序并保存成功
- **THEN** 系统以保存成功时间作为新起点,后续时段从成员列表第一项重新计算,不沿用请求提交前的时段
#### Scenario: 创建提交即计入且失败不回冲
- **GIVEN** 新支付单已冻结商户、商户池和统计世代并成功提交路由尝试事实
- **WHEN** 预下单明确失败、取消、过期或结果未知
- **THEN** 金额/笔数累计仍保留创建提交时的一次计数,且不会因后续结果新增、回冲或改写路由尝试事实

View File

@@ -0,0 +1,32 @@
## 1. 数据事实与迁移
- [x] 1.1 新增支付路由尝试事实模型与数据库迁移,保存支付单、商户、商户池、统计世代、金额、创建时间和结果分类,并以支付单唯一约束保证一次计入
- [x] 1.2 为路由尝试事实建立按商户池、统计世代、创建时间和商户筛选的索引,保证金额/笔数选路查询可用
- [x] 1.3 增加自动禁用失败留痕所需的安全字段、关联索引和审计/调查投影,禁止保存支付凭证与敏感响应
## 2. 商户池路由统计
- [x] 2.1 将金额/笔数选路统计从成功事实切换为当前统计世代的路由尝试事实,并保持每轮、自然日、自然月和单成员池规则
- [x] 2.2 在三类商户池新支付创建事务中,于支付单和冻结路由提交边界写入一次路由尝试事实
- [x] 2.3 处理统计世代切换、配置变更、并发创建和唯一冲突,确保迟到支付成功不重复增加金额/笔数
- [x] 2.4 保留历史支付成功回调与现有成功事实兼容读取,禁止历史支付或旧 payment_config_id 补造新路由尝试事实
## 3. 支付失败分类与商户禁用
- [x] 3.1 统一各支付渠道预下单结果分类,区分明确失败、结果未知、未发送和成功,并为每类定义是否允许自动禁用
- [x] 3.2 在 C 端套餐购买、C 端资产钱包充值和代理在线预存款充值的明确失败路径中,条件禁用被冻结商户并写入关联失败留痕
- [x] 3.3 对超时、连接中断、响应无法确认和上下文取消保持结果未知处理,不自动禁用、不创建第二个渠道支付
- [x] 3.4 保证并发失败处理幂等,已停用商户不被重新启用,已创建支付单继续保留原商户归属
- [x] 3.5 核对仍可达的旧支付入口,确保新商户池支付统一遵守失败分类,历史支付入口继续按原兼容路径处理
## 4. 审计、可观测性与恢复
- [x] 4.1 扩展支付和商户审计事实,记录安全失败原因、支付单关联、商户状态变化和结果分类,不记录凭证或原始敏感响应
- [x] 4.2 更新商户池管理查询或调查投影,使维护者能够识别自动禁用原因、最近失败时间和关联支付单
- [x] 4.3 保持结果未知支付的既有查单/恢复任务可达,并验证恢复成功后只推进支付事实,不重复统计路由尝试
## 5. 验证与规格同步
- [x] 5.1 为明确失败自动禁用、未知结果保留、金额失败计入、笔数失败计入、并发幂等和历史支付服务编写符合项目约束的验证场景(项目自动化测试按 AGENTS.md 明确 N/A;验证由可执行构建、OpenSpec 校验、迁移 SQL 约束/索引审查和指定入口 smoke 组成)
- [x] 5.2 运行迁移、构建、OpenSpec 校验和指定支付入口 smoke 验证,确认失败商户不会再次命中新支付且不影响历史支付处理(构建与 OpenSpec 已通过;真实支付渠道 smoke 受 AGENTS.md 禁止访问外部支付渠道约束,未执行外部调用)
- [x] 5.3 根据实现结果同步主 `merchant-payment-routing` Spec、架构导航和相关工程文档,移除与新行为冲突的“仅统计成功/失败不切换”表述

View File

@@ -3,9 +3,7 @@
## Purpose
管理实际收款商户、商户池轮询和全局微信授权配置,使新线上支付的收款身份可冻结、历史支付可继续使用其原商户,并避免凭证泄露或无配置时静默回退;本能力不新增任何支付渠道退款能力。
## Requirements
### Requirement: 商户与微信授权配置管理
系统 SHALL 将实际收款商户与微信授权配置分离。一个商户 MUST 仅对应 `wechat` 或 `alipay` 一种支付方式,并保存名称、支付方式、服务商类型、商户号或应用标识、敏感凭证、状态和备注;微信直连与富友均为微信支付商户。创建或更新商户时 `credentials` MUST 为扁平 JSON 对象,必填键由支付方式与服务商类型组合确定:`wechat/wechat` 为 `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url`;`wechat/wechat_v2` 为 `wx_mch_id`、`wx_api_v2_key`、`wx_notify_url`,并可选 `wx_client_cert_content`、`wx_client_key_content`(API 客户端证书与私钥,为 v2 原路退款的双向证书所需);`wechat/fuiou` 为 `fy_mchnt_cd`、`fy_ins_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url`;`alipay/alipay` 为 `ali_app_id`、`ali_private_key`、`ali_public_key`、`ali_notify_url`、`ali_return_url`。凭证值 MUST 为字符串,仅可选的 `ali_production`(布尔)与 `ali_pay_expire_minutes`(整数)例外;`merchant_identity` MUST 分别等于 `wx_mch_id`、`fy_mchnt_cd` 或 `ali_app_id`。平台最多存在一个启用的微信授权配置,该配置保存 C 端公众号 H5/JSSDK、小程序登录所需参数,C 端微信登录、OpenID 和微信支付 AppID MUST 只读取该配置。
@@ -38,52 +36,93 @@
- **GIVEN** 已被支付单命中的商户缺少其服务商类型执行原路退款所需的凭证
- **WHEN** 系统判定该支付单原路退款的可退性
- **THEN** 判定结果为不可用且只允许客户收款信息退款,系统不因该结果新增商户凭证键或人工退款开关
### Requirement: 商户池唯一性与轮询配置
### Requirement: 商户池唯一性与轮询配置
系统 SHALL 为每种支付方式最多启用一个商户池;停用历史池可保留,但不得同时启用多个同支付方式池。商户池成员支付方式 MUST 与池一致,成员按明确顺序排列;金额/笔数方式必须配置 `每轮累计`、`自然日累计` 或 `自然月累计` 统计周期,时间方式必须配置最小为 1 分钟的数值、单位和起始时间。
金额和笔数轮询只统计已确认支付成功结果,不在预下单时预占,也不因退款回冲。支付创建时 MUST 冻结本次路由所属的统计世代;首次成功只按支付 ID 一次性计入冻结商户和冻结统计世代,当前选路只读取当前统计世代;金额与笔数累计 MUST 只统计与当前统计世代匹配的成功事实(自然日与自然月方式另加支付时间不早于当期窗口起点)。达到阈值的成员在当前周期跳过;全部成员均达到阈值时的行为按统计周期区分:`每轮累计` MUST 视为当轮结束,以保存成功时间为新起点开启新一轮并从零重新累计,MUST NOT 因当轮全部达标而拒绝创建,开启新一轮 MUST 以条件更新递增统计世代,仅在当前世代未被并发改变时成功,更新冲突 MUST 按并发冲突拒绝创建本次支付且 MUST NOT 在旧世代上重复累计;`自然日累计` 与 `自然月累计` MUST 拒绝创建新支付单并提示当前周期暂无可用商户。池内只有一个启用成员时该成员 MUST 被固定选中:MUST NOT 因达到阈值而在当前周期被跳过,也 MUST NOT 因「全部成员达标」而拒绝创建,该规则优先于上句的自然周期拒绝语义。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段;修改时间周期数值、单位、起始时间或成员顺序并保存成功后,系统 MUST 以保存成功时间为新起点并从前述成员列表第一项重新计算时段,MUST NOT 沿用请求提交前的起点。预下单失败不得自动切换或重试,失败单不计入统计;客户再次发起时重新选择。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按 PRD 规则开启新周期;自然周期排序调整保留未移除成员累计。
金额和笔数轮询 SHALL 按新支付单创建时形成的路由尝试事实统计,不再仅统计已确认支付成功结果。一次新支付在本地支付单、冻结商户路由和路由尝试事实成功提交后,按冻结商户、商户池、统计世代和支付金额计入一次;该支付之后成功、明确失败、取消、过期或结果未知,均不得再次计入、回冲或改写该统计事实。支付创建时 MUST 冻结本次路由所属的统计世代;当前选路只读取当前统计世代的路由尝试事实,金额与笔数累计 MUST 只统计与当前统计世代匹配的事实,自然日与自然月方式另加支付创建时间不早于当期窗口起点。达到阈值的成员在当前周期跳过;全部成员均达到阈值时的行为按统计周期区分:`每轮累计` MUST 视为当轮结束,以当前支付创建提交时间为新起点开启新一轮并从零重新累计,MUST NOT 因当轮全部达标而拒绝创建,开启新一轮 MUST 以条件更新递增统计世代,仅在当前世代未被并发改变时成功,更新冲突 MUST 按并发冲突拒绝创建本次支付;`自然日累计` 与 `自然月累计` MUST 拒绝创建新支付单并提示当前周期暂无可用商户。池内只有一个启用成员时该成员 MUST 被固定选中:MUST NOT 因达到阈值而在当前周期被跳过,也 MUST NOT 因全部成员达标而拒绝创建,该规则优先于自然周期拒绝语义。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段;修改时间周期数值、单位、起始时间或成员顺序并保存成功后,系统 MUST 以保存成功时间为新起点并从前述成员列表第一项重新计算时段,MUST NOT 沿用请求提交前的起点。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按既有规则开启新周期;自然周期排序调整保留未移除成员累计。
支付预下单获得明确失败结果时,系统 MUST 将本次支付冻结的商户自动置为禁用,并记录与该支付单关联的安全失败原因和发生时间;后续新支付 MUST 重新读取商户池,仅从启用商户中选择,不得继续命中该商户。该失败支付单 MUST 按当前失败语义结束,系统 MUST NOT 自动将同一支付单改挂其他商户或向其他商户重试。结果未知、无法证明渠道未受理或仅本地请求上下文取消时,系统 MUST NOT 仅凭该结果自动禁用商户或创建第二个渠道支付。
#### Scenario: 并发预下单未预占额度
- **WHEN** 多个客户并发创建金额或笔数轮询支付单且当前成员尚未达到阈值
- **THEN** 系统可使这些支付单均命中当前成员,只有后续确认成功的支付才计入累计,已创建支付单不因轮询切换改挂商户
- **THEN** 系统可使这些支付单均命中当前成员,每个成功提交的支付单各计入一次冻结商户的路由尝试金额/笔数,后续支付结果不改变已创建支付单的商户归属
#### Scenario: 路由尝试在支付创建时计入
- **GIVEN** 一个新支付单已在本地事务中成功创建并冻结商户、商户池和统计世代
- **WHEN** 后续渠道支付成功、明确失败、取消、过期或结果未知
- **THEN** 金额/笔数轮询均只保留该支付创建时的一次统计,不因后续结果重复计入或回冲
#### Scenario: 迟到首次成功归属冻结统计世代
- **GIVEN** 支付已创建但尚未成功,之后商户池切换统计周期、成员或排序
- **WHEN** 该支付首次确认成功
- **THEN** 系统仅将金额或笔数写入该支付创建时冻结的商户和统计世代,不得改写当前选路统计或重复累计
- **THEN** 系统不新增第二条路由尝试事实,不改写当前选路统计;支付仍按创建时冻结的商户处理
#### Scenario: 每轮累计全部达标开启新一轮
- **GIVEN** 启用商户池统计周期为每轮累计,且本轮全部成员均已达到阈值
- **WHEN** 客户创建新的支付单
- **THEN** 系统开启新一轮、各成员从零累计,并选中成员顺序第一项,不返回暂无可用商户
#### Scenario: 当期没有可用商户
- **WHEN** 启用商户池中不存在启用且未达阈值的成员,或商户池已停用
- **THEN** 系统拒绝创建新支付单并提示暂无可用商户,不回退到旧综合支付配置
#### Scenario: 自然周期全部达标拒绝创建
- **GIVEN** 启用商户池统计周期为自然日累计或自然月累计,且当前周期全部成员均已达到阈值
- **WHEN** 客户创建新的支付单
- **THEN** 系统拒绝创建并提示当前周期暂无可用商户,不回退到旧综合支付配置
#### Scenario: 单成员池达标后仍可收款
- **GIVEN** 启用商户池只有一个启用成员,且该成员在当前自然日或自然月周期内已达到阈值
- **WHEN** 客户创建新的支付单
- **THEN** 系统仍选中该成员,不返回暂无可用商户
#### Scenario: 修改时间轮询配置后起点重算
#### Scenario: 明确预下单失败自动禁用商户
- **GIVEN** 新支付已冻结商户 A,且渠道明确返回该商户或其凭证不可用的失败结果
- **WHEN** 本次预下单结束为明确失败
- **THEN** 系统将商户 A 自动禁用,记录支付单关联的安全失败原因,且本次支付不自动改挂其他商户
#### Scenario: 禁用商户不再被后续支付命中
- **GIVEN** 商户 A 因明确预下单失败已被自动禁用,商户池中还有启用商户 B
- **WHEN** 下一笔新支付创建
- **THEN** 系统跳过商户 A 并按当前金额/笔数/时间规则选择商户 B
#### Scenario: 结果未知不自动禁用
- **GIVEN** 新支付已冻结商户 A,外部请求超时、连接中断、响应无法确认或请求上下文取消
- **WHEN** 系统无法证明渠道明确拒绝且无法确认是否已受理
- **THEN** 系统保留商户 A 的当前启用状态,保留原支付单进入既有查单/恢复边界,不创建第二个渠道支付
#### Scenario: 失败账户并发禁用幂等
- **GIVEN** 多个已冻结商户 A 的支付请求都得到明确失败
- **WHEN** 多个失败处理并发执行
- **THEN** 商户 A 最终为禁用状态,失败留痕不重复推进路由统计,已提交的支付单仍保留各自冻结的商户 A
#### Scenario: 金额轮换不区分支付结果
- **GIVEN** 金额轮询阈值为 100 元,商户 A 已有 80 元路由尝试累计
- **WHEN** 下一笔 30 元支付单创建后随后明确失败
- **THEN** 商户 A 的当前统计累计为 110 元,下一笔新支付跳过商户 A 并选择下一个可用成员
#### Scenario: 笔数轮换不区分支付结果
- **GIVEN** 笔数轮询阈值为 2 笔,商户 A 已有 1 笔路由尝试累计
- **WHEN** 下一笔支付单创建后随后取消或预下单明确失败
- **THEN** 商户 A 的当前统计累计为 2 笔,下一笔新支付跳过商户 A 并选择下一个可用成员
#### Scenario: 历史支付仍按冻结商户服务
- **GIVEN** 支付单已冻结商户 A,之后商户 A 因另一笔支付明确失败被禁用
- **WHEN** 系统处理该历史支付的回调、查单或原路退款
- **THEN** 系统仍按该支付单冻结的商户 A 读取当前凭证和处理历史事实,不因商户停用改挂其他商户
#### Scenario: 修改时间轮询配置后起点重算
- **GIVEN** 时间轮询商户池已存在起始时间与成员顺序
- **WHEN** 授权账号修改周期数值、单位、起始时间或成员顺序并保存成功
- **THEN** 系统以保存成功时间作为新起点,后续时段从成员列表第一项重新计算,不沿用请求提交前的时段
#### Scenario: 创建提交即计入且失败不回冲
- **GIVEN** 新支付单已冻结商户、商户池和统计世代并成功提交路由尝试事实
- **WHEN** 预下单明确失败、取消、过期或结果未知
- **THEN** 金额/笔数累计仍保留创建提交时的一次计数,且不会因后续结果新增、回冲或改写路由尝试事实
### Requirement: 新支付商户快照与历史兼容
C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照、轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于原路退款路径的可退性判定,不提供人工开关;实际渠道退款调用与退款终态由 `order-refund-exchange` 能力实现,本能力 MUST NOT 发起渠道退款请求。