Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 5h7m21s
718 lines
29 KiB
Go
718 lines
29 KiB
Go
package merchantpayment
|
|
|
|
import (
|
|
"context"
|
|
stderrors "errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/bytedance/sonic"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
)
|
|
|
|
// RouteSelection is the non-sensitive route frozen onto a new payment.
|
|
type RouteSelection struct {
|
|
Merchant *model.PaymentMerchant
|
|
Pool *model.PaymentMerchantPool
|
|
}
|
|
|
|
// RuntimeLoader loads current merchant and authorization credentials by version.
|
|
type RuntimeLoader struct {
|
|
db *gorm.DB
|
|
redis *redis.Client
|
|
}
|
|
|
|
// merchantCachePayload is used only for the internal versioned Redis cache and deliberately includes credentials.
|
|
// It must never be used for DTOs, logs, audits, or payment snapshots.
|
|
type merchantCachePayload struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
PaymentMethod string `json:"payment_method"`
|
|
ProviderType string `json:"provider_type"`
|
|
MerchantIdentity string `json:"merchant_identity"`
|
|
Credentials model.JSONB `json:"credentials"`
|
|
CredentialVersion int64 `json:"credential_version"`
|
|
Status int `json:"status"`
|
|
Remark string `json:"remark"`
|
|
}
|
|
|
|
func merchantCachePayloadFrom(merchant *model.PaymentMerchant) merchantCachePayload {
|
|
return merchantCachePayload{ID: merchant.ID, Name: merchant.Name, PaymentMethod: merchant.PaymentMethod, ProviderType: merchant.ProviderType, MerchantIdentity: merchant.MerchantIdentity, Credentials: merchant.Credentials, CredentialVersion: merchant.CredentialVersion, Status: merchant.Status, Remark: merchant.Remark}
|
|
}
|
|
|
|
func (p merchantCachePayload) merchant() *model.PaymentMerchant {
|
|
return &model.PaymentMerchant{Model: gorm.Model{ID: p.ID}, Name: p.Name, PaymentMethod: p.PaymentMethod, ProviderType: p.ProviderType, MerchantIdentity: p.MerchantIdentity, Credentials: p.Credentials, CredentialVersion: p.CredentialVersion, Status: p.Status, Remark: p.Remark}
|
|
}
|
|
|
|
// authorizationCachePayload is used only for the internal versioned Redis cache and deliberately includes secrets.
|
|
// It must never be used for DTOs, logs, audits, or payment snapshots.
|
|
type authorizationCachePayload struct {
|
|
ID uint `json:"id"`
|
|
OaAppID string `json:"oa_app_id"`
|
|
OaAppSecret string `json:"oa_app_secret"`
|
|
OaToken string `json:"oa_token"`
|
|
OaAesKey string `json:"oa_aes_key"`
|
|
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url"`
|
|
MiniappAppID string `json:"miniapp_app_id"`
|
|
MiniappAppSecret string `json:"miniapp_app_secret"`
|
|
CredentialVersion int64 `json:"credential_version"`
|
|
Status int `json:"status"`
|
|
}
|
|
|
|
func authorizationCachePayloadFrom(authorization *model.WechatAuthorization) authorizationCachePayload {
|
|
return authorizationCachePayload{ID: authorization.ID, OaAppID: authorization.OaAppID, OaAppSecret: authorization.OaAppSecret, OaToken: authorization.OaToken, OaAesKey: authorization.OaAesKey, OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, MiniappAppID: authorization.MiniappAppID, MiniappAppSecret: authorization.MiniappAppSecret, CredentialVersion: authorization.CredentialVersion, Status: authorization.Status}
|
|
}
|
|
|
|
func (p authorizationCachePayload) authorization() *model.WechatAuthorization {
|
|
return &model.WechatAuthorization{Model: gorm.Model{ID: p.ID}, OaAppID: p.OaAppID, OaAppSecret: p.OaAppSecret, OaToken: p.OaToken, OaAesKey: p.OaAesKey, OaOAuthRedirectURL: p.OaOAuthRedirectURL, MiniappAppID: p.MiniappAppID, MiniappAppSecret: p.MiniappAppSecret, CredentialVersion: p.CredentialVersion, Status: p.Status}
|
|
}
|
|
|
|
func NewRuntimeLoader(db *gorm.DB, redis *redis.Client) *RuntimeLoader {
|
|
return &RuntimeLoader{db: db, redis: redis}
|
|
}
|
|
|
|
// LoadMerchant first reads the current version from the primary database, then uses only that version's cache entry.
|
|
// Disabled merchants remain loadable for frozen historical payments.
|
|
func (l *RuntimeLoader) LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) {
|
|
if l == nil || l.db == nil {
|
|
return nil, errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
|
|
}
|
|
return l.loadMerchant(ctx, l.db, id)
|
|
}
|
|
|
|
// loadMerchant 先从当前事务或主库读取版本,再仅命中该版本的缓存。
|
|
// 版本在凭证事务提交时递增,因此提交前遗留的旧缓存永远不会被新读取命中。
|
|
func (l *RuntimeLoader) loadMerchant(ctx context.Context, db *gorm.DB, id uint) (*model.PaymentMerchant, error) {
|
|
var current model.PaymentMerchant
|
|
if err := db.WithContext(ctx).First(¤t, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
|
|
}
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取支付商户失败")
|
|
}
|
|
key := fmt.Sprintf("payment:merchant:%d:%d", current.ID, current.CredentialVersion)
|
|
if l.redis != nil {
|
|
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
|
|
var cached merchantCachePayload
|
|
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
|
|
return cached.merchant(), nil
|
|
}
|
|
}
|
|
}
|
|
if l.redis != nil {
|
|
if text, err := sonic.MarshalString(merchantCachePayloadFrom(¤t)); err == nil {
|
|
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
|
|
}
|
|
}
|
|
return ¤t, nil
|
|
}
|
|
|
|
// LoadAuthorization first reads the current enabled version and only then resolves its versioned cache entry.
|
|
func (l *RuntimeLoader) LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error) {
|
|
if l == nil || l.db == nil {
|
|
return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置")
|
|
}
|
|
var current model.WechatAuthorization
|
|
if err := l.db.WithContext(ctx).Where("status = ?", model.PaymentMerchantStatusEnabled).First(¤t).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
|
|
}
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取微信授权配置失败")
|
|
}
|
|
key := fmt.Sprintf("payment:wechat-authorization:%d:%d", current.ID, current.CredentialVersion)
|
|
if l.redis != nil {
|
|
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
|
|
var cached authorizationCachePayload
|
|
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
|
|
return cached.authorization(), nil
|
|
}
|
|
}
|
|
}
|
|
if l.redis != nil {
|
|
if text, err := sonic.MarshalString(authorizationCachePayloadFrom(¤t)); err == nil {
|
|
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
|
|
}
|
|
}
|
|
return ¤t, nil
|
|
}
|
|
|
|
// MerchantConfig adapts the merchant credential payload to existing channel constructors without persisting credentials in a payment snapshot.
|
|
func MerchantConfig(merchant *model.PaymentMerchant, authorization *model.WechatAuthorization) (*model.WechatConfig, error) {
|
|
if merchant == nil {
|
|
return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在")
|
|
}
|
|
if authorization == nil {
|
|
authorization = &model.WechatAuthorization{}
|
|
}
|
|
raw, err := sonic.Marshal(merchant.Credentials)
|
|
if err != nil {
|
|
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
|
|
}
|
|
var cfg model.WechatConfig
|
|
if err := sonic.Unmarshal(raw, &cfg); err != nil {
|
|
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
|
|
}
|
|
cfg.ID = merchant.ID
|
|
cfg.ProviderType = merchant.ProviderType
|
|
cfg.IsActive = true
|
|
if authorization != nil {
|
|
cfg.OaAppID = authorization.OaAppID
|
|
cfg.OaAppSecret = authorization.OaAppSecret
|
|
cfg.OaToken = authorization.OaToken
|
|
cfg.OaAesKey = authorization.OaAesKey
|
|
cfg.OaOAuthRedirectURL = authorization.OaOAuthRedirectURL
|
|
cfg.MiniappAppID = authorization.MiniappAppID
|
|
cfg.MiniappAppSecret = authorization.MiniappAppSecret
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// MerchantConfigWithAuthorization 在需要 AppID 的渠道实例前,按当前版本加载全局微信授权配置。
|
|
// 授权字段只进入内存中的渠道配置,绝不写入支付快照、普通 DTO、日志、审计或导出。
|
|
func (l *RuntimeLoader) MerchantConfigWithAuthorization(ctx context.Context, merchant *model.PaymentMerchant) (*model.WechatConfig, error) {
|
|
authorization, err := l.LoadAuthorization(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return MerchantConfig(merchant, authorization)
|
|
}
|
|
|
|
// SelectForNewPayment atomically reads the active pool and chooses its current eligible member.
|
|
func (l *RuntimeLoader) SelectForNewPayment(ctx context.Context, paymentMethod string, now time.Time) (*RouteSelection, error) {
|
|
if l == nil || l.db == nil {
|
|
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
|
|
}
|
|
var out *RouteSelection
|
|
err := l.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var err error
|
|
out, err = l.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, now)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SelectForNewPaymentWithTx chooses an eligible merchant while retaining the caller's business transaction.
|
|
func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.DB, paymentMethod string, now time.Time) (*RouteSelection, error) {
|
|
if l == nil || tx == nil {
|
|
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
|
|
}
|
|
var pool model.PaymentMerchantPool
|
|
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_method = ? AND status = ?", paymentMethod, model.PaymentMerchantStatusEnabled).First(&pool).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
|
}
|
|
return nil, err
|
|
}
|
|
var members []model.PaymentMerchantPoolMember
|
|
if err := tx.WithContext(ctx).Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(members) == 0 {
|
|
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
|
}
|
|
ids := make([]uint, 0, len(members))
|
|
for _, member := range members {
|
|
ids = append(ids, member.MerchantID)
|
|
}
|
|
var merchants []model.PaymentMerchant
|
|
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND payment_method = ? AND status = ?", ids, pool.PaymentMethod, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
byID := make(map[uint]*model.PaymentMerchant, len(merchants))
|
|
for i := range merchants {
|
|
byID[merchants[i].ID] = &merchants[i]
|
|
}
|
|
ordered := make([]*model.PaymentMerchant, 0, len(members))
|
|
for _, member := range members {
|
|
if merchant := byID[member.MerchantID]; merchant != nil {
|
|
ordered = append(ordered, merchant)
|
|
}
|
|
}
|
|
chosen, err := chooseMerchant(ctx, tx, &pool, ordered, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 新支付在冻结前也按“商户 ID + 当前版本”读取缓存;事务锁保证本次
|
|
// 选择与凭证版本属于同一提交边界,避免新建支付误用旧版本缓存。
|
|
chosen, err = l.loadMerchant(ctx, tx, chosen.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
|
|
}
|
|
|
|
// 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.PaymentMerchantRoutingAttempt) {
|
|
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 routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
|
}
|
|
if pool.Strategy == model.PaymentMerchantStrategyTime {
|
|
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 routingOutcome{}, 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(cycle, now); limited {
|
|
query = query.Where("created_at >= ?", start)
|
|
}
|
|
var rows []model.PaymentMerchantRoutingAttempt
|
|
if err := query.Find(&rows).Error; err != nil {
|
|
return routeStats{}, err
|
|
}
|
|
stats := newRouteStats()
|
|
for index := range rows {
|
|
stats.add(&rows[index])
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
|
if pool.TimePeriodStartedAt == nil || pool.TimePeriodValue == nil || pool.TimePeriodUnit == nil {
|
|
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询配置不完整")
|
|
}
|
|
unit := time.Minute
|
|
switch *pool.TimePeriodUnit {
|
|
case "hour":
|
|
unit = time.Hour
|
|
case "day":
|
|
unit = 24 * time.Hour
|
|
case "minute":
|
|
default:
|
|
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询单位无效")
|
|
}
|
|
period := unit * time.Duration(*pool.TimePeriodValue)
|
|
if period <= 0 {
|
|
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询周期无效")
|
|
}
|
|
slot := now.Sub(*pool.TimePeriodStartedAt) / period
|
|
if slot < 0 {
|
|
slot = 0
|
|
}
|
|
return merchants[int(slot%time.Duration(len(merchants)))], nil
|
|
}
|
|
|
|
func routingWindowStart(cycle string, now time.Time) (time.Time, bool) {
|
|
local := now.In(now.Location())
|
|
switch cycle {
|
|
case "day":
|
|
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()), true
|
|
case "month":
|
|
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, local.Location()), true
|
|
default:
|
|
return time.Time{}, false
|
|
}
|
|
}
|
|
|
|
func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool) error {
|
|
next := pool.RoutingEpoch + 1
|
|
result := tx.WithContext(ctx).Model(&model.PaymentMerchantPool{}).Where("id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch).Update("routing_epoch", next)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return errors.New(errors.CodeConflict, "商户池统计世代已变化")
|
|
}
|
|
pool.RoutingEpoch = next
|
|
return nil
|
|
}
|
|
|
|
// poolProjection 是商户池列表的只读计算结果。
|
|
type poolProjection struct {
|
|
MemberIDs []uint
|
|
EnabledMembers int
|
|
CurrentMerchant *model.PaymentMerchant
|
|
LastFailureMerchantID *uint
|
|
LastFailurePaymentID *uint
|
|
LastFailurePaymentNo string
|
|
LastFailureReason string
|
|
LastFailureAt *time.Time
|
|
}
|
|
|
|
// 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 created_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.PaymentMerchantRoutingAttempt
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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 {
|
|
return
|
|
}
|
|
payment.MerchantID = &route.Merchant.ID
|
|
payment.MerchantPoolID = &route.Pool.ID
|
|
payment.MerchantIdentity = route.Merchant.MerchantIdentity
|
|
payment.MerchantNameSnapshot = route.Merchant.Name
|
|
payment.MerchantPaymentMethodSnapshot = route.Merchant.PaymentMethod
|
|
payment.MerchantProviderTypeSnapshot = route.Merchant.ProviderType
|
|
payment.MerchantPoolNameSnapshot = route.Pool.Name
|
|
payment.RoutingStrategySnapshot = route.Pool.Strategy
|
|
epoch := route.Pool.RoutingEpoch
|
|
payment.RoutingEpoch = &epoch
|
|
}
|
|
|
|
// RecordFirstSuccess 在支付成功事务内写入支付不可变的路由事实。
|
|
func RecordFirstSuccess(ctx context.Context, tx *gorm.DB, payment *model.Payment, paidAt time.Time) error {
|
|
if payment == nil || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil {
|
|
return nil
|
|
}
|
|
fact := model.PaymentMerchantRoutingSuccess{PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID, RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, PaidAt: paidAt}
|
|
if err := tx.WithContext(ctx).Create(&fact).Error; err != nil {
|
|
if strings.Contains(err.Error(), "duplicate key") {
|
|
return nil
|
|
}
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池成功统计失败")
|
|
}
|
|
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
|
|
}
|