task: 完成支付宝回调安全(验签、多维度校验、业务分发)
This commit is contained in:
@@ -3,8 +3,8 @@ package callback
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||||
@@ -184,74 +185,180 @@ func (h *PaymentHandler) wechatV2SuccessResponse(c *fiber.Ctx) error {
|
||||
return c.SendString(`<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`)
|
||||
}
|
||||
|
||||
// parseYuanToFen 将支付宝元为单位的金额字符串转换为分
|
||||
// 支付宝回调金额字段(buyer_pay_amount / total_amount)为字符串格式的元,如 "2.00"
|
||||
// 使用 math.Round 避免浮点运算精度误差
|
||||
func parseYuanToFen(yuan string) int64 {
|
||||
if yuan == "" {
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(yuan, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(f * 100))
|
||||
}
|
||||
|
||||
type AlipayCallbackRequest struct {
|
||||
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
|
||||
BuyerPayAmount string `json:"buyer_pay_amount" form:"buyer_pay_amount"`
|
||||
TotalAmount string `json:"total_amount" form:"total_amount"`
|
||||
}
|
||||
|
||||
// AlipayCallback 支付宝回调
|
||||
// AlipayCallback 支付宝异步回调(带验签与多维度安全校验)
|
||||
// POST /api/callback/alipay
|
||||
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
||||
var req AlipayCallbackRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 从原始请求体解析 form 参数(x-www-form-urlencoded)
|
||||
values, err := url.ParseQuery(string(c.Body()))
|
||||
if err != nil {
|
||||
h.logger.Error("支付宝回调:解析 form 参数失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam, "回调数据格式错误")
|
||||
}
|
||||
|
||||
if req.OrderNo == "" {
|
||||
// 未验签前只读取 out_trade_no,用于回溯创建支付单时所用的配置
|
||||
outTradeNo := values.Get("out_trade_no")
|
||||
if outTradeNo == "" {
|
||||
h.logger.Error("支付宝回调:out_trade_no 为空")
|
||||
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
|
||||
}
|
||||
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 优先取 buyer_pay_amount(买家实付,扣除优惠券后),回退到 total_amount
|
||||
rawAmount := req.BuyerPayAmount
|
||||
if rawAmount == "" {
|
||||
rawAmount = req.TotalAmount
|
||||
// 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置)
|
||||
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo)
|
||||
if err != nil || cfg == nil {
|
||||
h.logger.Error("支付宝回调:加载支付配置失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置不可用")
|
||||
}
|
||||
paidAmount := parseYuanToFen(rawAmount)
|
||||
|
||||
handled, err := h.dispatchPaymentRecordCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, "", paidAmount)
|
||||
// 使用支付宝公钥验签(DecodeNotification 内部完成签名校验)
|
||||
notification, err := alipay.DecodeNotification(ctx, cfg, values)
|
||||
if err != nil {
|
||||
return err
|
||||
h.logger.Error("支付宝回调:验签失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Uint("config_id", cfg.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝回调验签失败")
|
||||
}
|
||||
if handled {
|
||||
|
||||
// 校验 app_id 与配置一致,防止跨商户混用
|
||||
if notification.AppId != cfg.AliAppID {
|
||||
h.logger.Error("支付宝回调:app_id 不匹配",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("notify_app_id", notification.AppId),
|
||||
zap.String("config_app_id", cfg.AliAppID),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝 app_id 校验失败")
|
||||
}
|
||||
|
||||
// 非终态交易(如 WAIT_BUYER_PAY、TRADE_CLOSED)直接返回 success,不推进业务
|
||||
tradeStatus := string(notification.TradeStatus)
|
||||
if tradeStatus != "TRADE_SUCCESS" && tradeStatus != "TRADE_FINISHED" {
|
||||
h.logger.Info("支付宝回调:非成功交易状态,忽略",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_status", tradeStatus),
|
||||
)
|
||||
return c.SendString("success")
|
||||
}
|
||||
|
||||
// 按订单号前缀分发
|
||||
switch {
|
||||
case strings.HasPrefix(req.OrderNo, "ORD"):
|
||||
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, paidAmount); err != nil {
|
||||
return err
|
||||
// 查询支付记录
|
||||
payment, err := h.paymentStore.GetByPaymentNo(ctx, outTradeNo)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
h.logger.Error("支付宝回调:支付记录不存在",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付记录不存在")
|
||||
}
|
||||
case strings.HasPrefix(req.OrderNo, constants.AssetRechargeOrderPrefix):
|
||||
if h.rechargeOrderService != nil {
|
||||
return h.rechargeOrderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentByAlipay, "")
|
||||
h.logger.Error("支付宝回调:查询支付记录失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
|
||||
}
|
||||
|
||||
// 校验支付方式必须为 alipay,防止其他通道的 payment_no 被误用
|
||||
if payment.PaymentMethod != model.PaymentByAlipay {
|
||||
h.logger.Error("支付宝回调:支付方式不匹配",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("payment_method", payment.PaymentMethod),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
|
||||
}
|
||||
|
||||
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
|
||||
if payment.PaymentConfigID == nil {
|
||||
h.logger.Warn("支付宝回调:payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Uint("config_id", cfg.ID),
|
||||
)
|
||||
} else if *payment.PaymentConfigID != cfg.ID {
|
||||
h.logger.Error("支付宝回调:payment_config_id 不匹配",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Uint("payment_config_id", *payment.PaymentConfigID),
|
||||
zap.Uint("callback_config_id", cfg.ID),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置 ID 校验失败")
|
||||
}
|
||||
|
||||
// 校验金额:使用 total_amount(原始订单金额)而非 buyer_pay_amount,
|
||||
// 避免优惠券/积分场景导致内部订单金额被错误记低
|
||||
notifyAmountFen, err := alipay.YuanToFen(notification.TotalAmount)
|
||||
if err != nil {
|
||||
h.logger.Error("支付宝回调:total_amount 转换失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("total_amount", notification.TotalAmount),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额格式错误")
|
||||
}
|
||||
if notifyAmountFen != payment.Amount {
|
||||
h.logger.Error("支付宝回调:金额不一致",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.Int64("notify_amount_fen", notifyAmountFen),
|
||||
zap.Int64("payment_amount_fen", payment.Amount),
|
||||
)
|
||||
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败")
|
||||
}
|
||||
|
||||
// 写入支付宝交易流水号
|
||||
if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil {
|
||||
h.logger.Error("支付宝回调:写入 third_party_trade_no 失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
// 不中断业务,继续分发(幂等业务层会处理状态)
|
||||
}
|
||||
|
||||
// 按支付单 order_type 分发业务
|
||||
switch payment.OrderType {
|
||||
case model.PaymentOrderTypePackage:
|
||||
if err := h.orderService.HandlePaymentRecordCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo, payment.Amount); err != nil {
|
||||
h.logger.Error("支付宝回调:推进套餐订单失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝支付回调失败")
|
||||
}
|
||||
return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", req.OrderNo)
|
||||
case strings.HasPrefix(req.OrderNo, constants.AgentRechargeOrderPrefix):
|
||||
if h.agentRechargeService != nil {
|
||||
if err := h.agentRechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("支付宝回调:套餐订单支付成功",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.String("order_type", payment.OrderType),
|
||||
)
|
||||
|
||||
case model.PaymentOrderTypeRecharge:
|
||||
if h.rechargeOrderService == nil {
|
||||
h.logger.Error("支付宝回调:充值订单服务未配置",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
)
|
||||
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
|
||||
}
|
||||
if err := h.rechargeOrderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo); err != nil {
|
||||
h.logger.Error("支付宝回调:推进充值订单失败",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝充值回调失败")
|
||||
}
|
||||
h.logger.Info("支付宝回调:充值订单支付成功",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("trade_no", notification.TradeNo),
|
||||
zap.String("order_type", payment.OrderType),
|
||||
)
|
||||
|
||||
default:
|
||||
return errors.New(errors.CodeInvalidParam, "未知订单号前缀")
|
||||
h.logger.Error("支付宝回调:未知支付记录类型",
|
||||
zap.String("out_trade_no", outTradeNo),
|
||||
zap.String("order_type", payment.OrderType),
|
||||
)
|
||||
return errors.New(errors.CodeInternalError, "未知支付记录类型")
|
||||
}
|
||||
|
||||
return c.SendString("success")
|
||||
|
||||
Reference in New Issue
Block a user