* fix:修复响应污染问题 * fix:修改存储相关文件为中文注释 * fix:修复二次退款问题 * fix:修复多个考虑不全面问题 1.误删用户数据 2.R2存储默认区域 3.删除失败导致孤儿记录 4.网络抖动事变时重试 5.前端无限次请求获取图片 6.文件管理权限 * fix:修复字符串截断异常问题和文件下载超时问题 * fix:修复多图问题 * fix:异步生成图片时未配置oss提示错误信息 * fix:修复关闭异步后同步生成出错问题 * feat:新增文件存储测试功能,防止设置后不生效 * fix:修复同步图片转异步后计费错误 * fix:修复无下限扣款问题 * fix:修复结算重复扣款问题 * fix:修复计费出错导致的0费用结算 * fix:修复回调双重履约 * fix:修复预扣事务 * fix:修复费用字段类型问题 * fix:修复缓存问题 * fix:修复安全问题 * fix:修复数据库操作事务问题 * refactor:重构事务使用方式,改为官方推荐的dao形式 * fix:修复余额更新隐患 * fix:修复N+1查询问题 * fix:修复密钥安全问题 * fix:结算失败时打印错误日志 * improve:优化扣款 * fix:修复用户接口越权 --------- Co-authored-by: chengqian <wuchengqian@kkidc.com>
169 lines
4.8 KiB
Go
169 lines
4.8 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"
|
|
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
"github.com/qianfree/team-api/internal/logic/common"
|
|
)
|
|
|
|
// 订单级互斥锁,防止回调重复处理。
|
|
var (
|
|
orderLocks sync.Map
|
|
createLock sync.Mutex
|
|
)
|
|
|
|
type refCountedMutex struct {
|
|
mu sync.Mutex
|
|
refCount int
|
|
}
|
|
|
|
// LockOrder 对订单号加锁。
|
|
func LockOrder(orderNo string) {
|
|
createLock.Lock()
|
|
var rcm *refCountedMutex
|
|
if v, ok := orderLocks.Load(orderNo); ok {
|
|
rcm = v.(*refCountedMutex)
|
|
} else {
|
|
rcm = &refCountedMutex{}
|
|
orderLocks.Store(orderNo, rcm)
|
|
}
|
|
rcm.refCount++
|
|
createLock.Unlock()
|
|
rcm.mu.Lock()
|
|
}
|
|
|
|
// UnlockOrder 释放订单锁。
|
|
func UnlockOrder(orderNo string) {
|
|
v, ok := orderLocks.Load(orderNo)
|
|
if !ok {
|
|
return
|
|
}
|
|
rcm := v.(*refCountedMutex)
|
|
rcm.mu.Unlock()
|
|
|
|
createLock.Lock()
|
|
rcm.refCount--
|
|
if rcm.refCount == 0 {
|
|
orderLocks.Delete(orderNo)
|
|
}
|
|
createLock.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. 幂等检查:仅处理 pending 状态
|
|
var order *struct {
|
|
ID int64 `json:"id"`
|
|
Status string `json:"status"`
|
|
FinalAmount float64 `json:"final_amount"`
|
|
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("订单")
|
|
}
|
|
if order.Status != "pending" {
|
|
return nil // 已处理,幂等返回
|
|
}
|
|
if order.ExpiredAt != nil && !order.ExpiredAt.IsZero() && order.ExpiredAt.Before(gtime.Now()) {
|
|
return gerror.Newf("订单已过期: %s", result.OrderNo)
|
|
}
|
|
|
|
// 6. 金额校验:回调金额与订单金额必须一致(容差 0.01 元)
|
|
if result.Success && result.PaidAmount > 0 && order.FinalAmount > 0 {
|
|
diff := result.PaidAmount - order.FinalAmount
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff > 0.01 {
|
|
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 → paid。
|
|
// 该条件更新的 RowsAffected 是【跨实例】幂等闸门:多实例并发回调时,两个回调都会
|
|
// 通过上面第 5 步的 pending 检查,但 "WHERE status='pending'" 的原子更新只有一个能命中
|
|
// (另一个在行锁释放后重读到 status 已是 paid → 0 行)。据此仅让真正把订单从 pending
|
|
// 翻成 paid 的那次回调去履约,杜绝重复入账/重复发套餐。进程内 orderLocks 仅单实例有效,
|
|
// 不能作为并发保护依赖。
|
|
res, err := dao.OrdOrders.Ctx(ctx).
|
|
Where("id", order.ID).
|
|
Where("status", "pending").
|
|
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"
|
|
}
|