Files
Qian 2ef5a40c26 新的调度方式 (#14)
重构模型渠道调度系统
2026-08-01 22:55:08 +08:00

189 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package payment
import (
"context"
"net/http"
"sync"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
)
// 订单级互斥锁,防止回调重复处理。
//
// 采用 sharded mutex(按 orderNo 哈希到固定数量分片)替代原先 sync.Map + 手工引用计数
// 的实现:原 UnlockOrder 在 rcm.mu.Unlock() 之后才 refCount--,存在「归零后误 Delete」的
// 竞态窗口——并发 LockOrder 在 Delete 前后可能拿到不同的 rcm 对象,导致两路回调各自持有
// 「订单锁」却互不互斥。sharded 方案无动态分配、无引用计数、无 Delete,内存恒定为分片数,
// 且同一 orderNo 必然命中同一分片 → 互斥语义对同一订单始终正确。跨实例并发由 DB 条件更新
// (WHERE status='pending')兜底,进程内锁仅用于降低同实例并发回调时的 DB 竞争。
const orderLockShardCount = 256
var orderLockShards [orderLockShardCount]sync.Mutex
// orderLockShardIndex 按 orderNo 选定分片。FNV-1a 简单快速、分布均匀,避免不同订单扎堆。
func orderLockShardIndex(orderNo string) int {
const (
offset32 = uint32(2166136261)
prime32 = uint32(16777619)
)
h := offset32
for i := 0; i < len(orderNo); i++ {
h ^= uint32(orderNo[i])
h *= prime32
}
return int(h % orderLockShardCount)
}
// paymentAmountTolerance 回调金额与订单金额容差(0.01 CNY)。
// 用 decimal 表达,避免 float64 在 0.01 这种边界值上的二进制表示误差导致误判。
var paymentAmountTolerance = decimal.NewFromFloat(0.01)
// LockOrder 对订单号加锁(按 orderNo 哈希到固定分片,同订单始终命中同一分片)。
func LockOrder(orderNo string) {
orderLockShards[orderLockShardIndex(orderNo)].Lock()
}
// UnlockOrder 释放订单锁。
func UnlockOrder(orderNo string) {
orderLockShards[orderLockShardIndex(orderNo)].Unlock()
}
// ProcessCallback 统一回调处理流程。
// channelType 为渠道类型字符串(如 "epay")。
func ProcessCallback(ctx context.Context, r *http.Request, channelType string) error {
// 1. 从 sys_options 加载渠道配置
cfg, err := GetChannelConfigAndProvider(ctx, channelType)
if err != nil {
return gerror.Wrapf(err, "加载支付渠道配置失败")
}
// 2. 获取 Provider
provider := GetProvider(channelType)
if provider == nil {
return gerror.Newf("不支持的支付渠道: %s", channelType)
}
// 3. 调用 Provider 验签并解析回调
result, err := provider.HandleCallback(ctx, r, cfg)
if err != nil {
return gerror.Wrapf(err, "回调验证失败")
}
// 4. 订单级加锁
LockOrder(result.OrderNo)
defer UnlockOrder(result.OrderNo)
// 5. 读取订单当前状态与支付信息(可履约性判断见下方 claimable)
var order *struct {
ID int64 `json:"id"`
Status string `json:"status"`
FinalAmount float64 `json:"final_amount"`
PaymentNo string `json:"payment_no"`
ExpiredAt *gtime.Time `json:"expired_at"`
}
err = dao.OrdOrders.Ctx(ctx).
Where("order_no", result.OrderNo).Scan(&order)
if err != nil {
return gerror.Wrapf(err, "查询订单失败")
}
if order == nil {
return common.NewNotFoundError("订单")
}
// 5. 幂等 / 可履约状态检查。
// 可履约状态:pending,以及「支付成功但订单已被过期任务置为 expired」——用户在渠道
// 完成扣款可能晚于订单有效期(或回调延迟到达),此时渠道已实际收款,拒绝入账会造成
// 用户已付款却得不到权益,只能人工兜底;因此过期订单的成功回调照常履约,仅记告警日志。
// 其余状态(paid/fulfilled/cancelled/refunded 等)幂等返回。
claimable := order.Status == "pending" || (result.Success && order.Status == "expired")
if !claimable {
if result.Success && order.Status == "cancelled" {
// 渠道已收款但订单已被取消:无法自动履约,记录告警等待人工处理(线下退款或补单)
g.Log().Errorf(ctx, "[Payment] order=%s status=cancelled but received SUCCESS callback (trade_no=%s amount=%.2f), money captured without fulfillment, manual intervention required",
result.OrderNo, result.TradeNo, result.PaidAmount)
}
// 重复支付检测:订单已 paid/fulfilled,又收到【不同流水号】的成功回调——说明用户
// 对同一订单在多个渠道/多次完成了支付,渠道端已重复收款,第二笔在系统内无对应
// 入账,必须人工在渠道侧退回。幂等吞掉不留痕会造成资金无声滞留。
if result.Success && (order.Status == "paid" || order.Status == "fulfilled") &&
result.TradeNo != "" && result.TradeNo != order.PaymentNo {
g.Log().Errorf(ctx, "[Payment] order=%s already %s with trade_no=%s but received ANOTHER success callback trade_no=%s amount=%.2f, duplicate payment captured, manual refund required",
result.OrderNo, order.Status, order.PaymentNo, result.TradeNo, result.PaidAmount)
}
return nil // 已处理或不可履约终态,幂等返回
}
if result.Success && (order.Status == "expired" ||
(order.ExpiredAt != nil && !order.ExpiredAt.IsZero() && order.ExpiredAt.Before(gtime.Now()))) {
g.Log().Warningf(ctx, "[Payment] order=%s paid after expiration, proceed with fulfillment", result.OrderNo)
}
// 6. 金额校验:回调金额与订单金额必须一致(容差 0.01 元,CNY)
// 用 decimal 比较,避免 float64 直接相减在边界值(如 0.01)上的二进制误差误判。
if result.Success && result.PaidAmount > 0 && order.FinalAmount > 0 {
paid := billing.NewFromFloat(result.PaidAmount)
expected := billing.NewFromFloat(order.FinalAmount)
diff := paid.Sub(expected).Abs()
if diff.GreaterThan(paymentAmountTolerance) {
g.Log().Warningf(ctx, "[Payment] amount mismatch: order=%s expected=%.2f received=%.2f",
result.OrderNo, order.FinalAmount, result.PaidAmount)
return gerror.Newf("支付金额不一致: 期望 %.2f 实付 %.2f", order.FinalAmount, result.PaidAmount)
}
}
if result.Success {
// 7. 原子领取订单:pending/expired → paid。
// 该条件更新的 RowsAffected 是【跨实例】幂等闸门:多实例并发回调时,两个回调都会
// 通过上面第 5 步的状态检查,但条件原子更新只有一个能命中(另一个在行锁释放后重读
// 到 status 已是 paid → 0 行)。据此仅让真正把订单翻成 paid 的那次回调去履约,
// 杜绝重复入账/重复发套餐。进程内 orderLockShards 仅单实例有效,不能作为并发保护依赖。
// expired 一并纳入条件:过期订单的成功回调照常入账(见第 5 步说明),
// 同时覆盖「第 5 步读到 pending、过期任务随后将其置 expired」的竞态窗口。
res, err := dao.OrdOrders.Ctx(ctx).
Where("id", order.ID).
WhereIn("status", g.Slice{"pending", "expired"}).
Data(do.OrdOrders{
Status: "paid",
PaidAt: gtime.Now(),
PaymentNo: result.TradeNo,
}).Update()
if err != nil {
return gerror.Wrapf(err, "更新订单状态失败")
}
affected, err := res.RowsAffected()
if err != nil {
return gerror.Wrapf(err, "确认订单状态更新结果失败")
}
if affected == 0 {
// 另一并发回调已抢先领取并负责履约,本次幂等返回,绝不重复履约
g.Log().Infof(ctx, "[Payment] order=%s already claimed by a concurrent callback, skip duplicate fulfill", result.OrderNo)
return nil
}
// 8. 履约(仅领取成功者执行)
return FulfillOrder(ctx, order.ID)
}
return nil
}
// QueryOrderPaid 查询订单是否已完成支付(paid 或 fulfilled)。
// 用于浏览器同步回跳时展示结果,不参与履约处理。
func QueryOrderPaid(ctx context.Context, orderNo string) bool {
if orderNo == "" {
return false
}
var status string
_ = dao.OrdOrders.Ctx(ctx).
Where("order_no", orderNo).
Fields("status").
Scan(&status)
return status == "paid" || status == "fulfilled"
}