Files
junhong_cmp_fiber/internal/application/merchantpayment/routing.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

583 lines
24 KiB
Go

package merchantpayment
import (
"context"
"fmt"
"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(&current, 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(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, 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(&current).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(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, 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.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 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("paid_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingSuccess
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 是按顺序排列的成员商户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 {
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
}