189 lines
8.1 KiB
Go
189 lines
8.1 KiB
Go
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"
|
||
}
|