You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

196 lines
7.3 KiB

package pay
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"yunyan/comm"
"yunyan/lego/sys/log"
"yunyan/pb"
"yunyan/sys/pay/paypal"
"yunyan/utils"
)
// @Summary 处理PayPal支付异步通知 v2 [内部RPC]
// @Description 接收由HTTP回调网关转发来的支付通知,验签后按管理后台的商品配置发货
// @Tags Pay
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param notify body pb.PayPayPalNotifyReq true "PayPal 异步通知参数"
// @Success 200 {object} comm.HttpResult "处理结果"
// @Router /api/home/pay_paypalnotifyv2 [post]
//
// PayPalNotifyV2 与 v1(PayPalNotify)流程一致,差别只有一处、但是关键的一处:
// **发货口径改成读管理后台的商品配置** —— console 公共库的 app_goods 表,
// 按本部署的应用名(comm.AppName() / 环境变量 ANALYZE_APP_NAME)隔离,
// 而不是各业务库自己维护的 goods 表。这样商品(VIP 时长、AI/会议/翻译点数)在后台
// 「应用商品配置」页改完即生效,多区域部署共用一份,不必再逐个业务库同步。
//
// 幂等:订单已不是「待支付」就直接回 SUCCESS,PayPal 重推不会重复发货。
// 后台没配该商品时**不发货、也不改订单状态**,回 Fail 让 PayPal 重试——
// 补好配置后自然会补发,比发一笔空货再人工补要好。
func (this *apiComp) PayPalNotifyV2(session comm.IUserSession, req *pb.PayPayPalNotifyReq) (resp []byte, errdata *pb.ErrorData) {
var (
order *pb.DBPayOrder
goods *comm.AppGoods
user *pb.DBUser
ok bool
err error
)
// 1. 验签:webhook 的可信来源判断全靠它,失败一律不处理
headers := map[string]string{
"PayPal-Transmission-Id": req.TransmissionId,
"PayPal-Transmission-Time": req.TransmissionTime,
"PayPal-Cert-Url": req.CertUrl,
"PayPal-Auth-Algo": req.AuthAlgo,
"PayPal-Transmission-Sig": req.TransmissionSig,
"Webhook-Id": req.WebhookId,
}
if ok, err = paypal.VerifyWebhook(headers, []byte(req.Body)); !ok || err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_PayNotifyDecryptFail,
Message: "验证PayPal通知失败:" + fmt.Sprintf("%v", err),
}
return
}
// 2. 解析通知体,只认支付完成事件;其余事件回 SUCCESS 让 PayPal 别再推
var body map[string]any
if err = json.Unmarshal([]byte(req.Body), &body); err != nil {
resp = payNotifyResp("Fail", "通知体解析失败")
return
}
if eventType, _ := body["event_type"].(string); eventType != "PAYMENT.CAPTURE.COMPLETED" {
resp = payNotifyResp("SUCCESS", "忽略非支付完成事件")
return
}
resource, _ := body["resource"].(map[string]any)
outTradeNo := paypalOutTradeNo(resource)
if outTradeNo == "" {
resp = payNotifyResp("Fail", "无法识别订单号")
return
}
// 3. 订单:不存在回 Fail(等 PayPal 重试),已处理过直接回 SUCCESS(幂等)
if order, err = this.module.model.getPayOrder(outTradeNo); err != nil {
resp = payNotifyResp("Fail", fmt.Sprintf("订单不存在: %s", outTradeNo))
return
}
if order.Status != pb.PayOrderStatus_PAY_ORDER_PENDING {
resp = payNotifyResp("SUCCESS", "成功")
return
}
// 金额对账:验签已保证通知来自 PayPal,这里对不上多半是配置/口径问题,
// 记日志留痕但不拦截发货(用户确实付了钱);要改成严格拒绝,把下面的日志换成 return 即可。
if cents, ok2 := paypalAmountCents(resource); ok2 && cents != order.Amount {
this.module.Error("PayPalNotifyV2 金额与订单不一致",
log.Field{Key: "order", Value: order.OrderId},
log.Field{Key: "order_amount", Value: order.Amount},
log.Field{Key: "notify_amount", Value: cents})
}
// 4. 商品:读后台配置(按应用隔离)。没配就不发货、不改状态,等补好配置后 PayPal 重推
if goods, err = this.module.model.appGoods(order.ProductId); err != nil {
resp = payNotifyResp("Fail", "查询商品配置失败: "+err.Error())
return
}
if goods == nil {
this.module.Error("PayPalNotifyV2 后台未配置该商品,暂不发货",
log.Field{Key: "app", Value: comm.AppName()},
log.Field{Key: "order", Value: order.OrderId},
log.Field{Key: "goods_id", Value: order.ProductId})
resp = payNotifyResp("Fail", fmt.Sprintf("应用 %s 未配置商品 %s", comm.AppName(), order.ProductId))
return
}
if !goods.Enable {
// 已下架的商品仍然照常发货:钱已经收了,下架只影响后续售卖
this.module.Warnf("PayPalNotifyV2 商品已下架仍按订单发货: app=%s order=%s goods=%s",
comm.AppName(), order.OrderId, order.ProductId)
}
// 5. 用户 → 发货 → 落订单状态
if user, err = this.module.model.getUser(order.Uid); err != nil {
errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError,
Message: "查询用户信息失败:" + err.Error(),
}
return
}
order.Status = pb.PayOrderStatus_PAY_ORDER_PAID
order.PayTime = time.Now().Unix()
if order.ProductName == "" {
order.ProductName = goods.Name // 老订单没存商品名时,用后台配置补上,便于对账
}
if id, _ := resource["id"].(string); id != "" {
order.TransactionId = id
}
if err = this.deliverGrant(user, order, grantOfAppGoods(goods)); err != nil {
resp = payNotifyResp("Fail", fmt.Sprintf("发货资源失败: %v", err))
return
}
if err = this.module.model.updatePayOrder(order); err != nil {
resp = payNotifyResp("Fail", fmt.Sprintf("更新订单状态失败: %v", err))
return
}
this.module.Debug("PayPalNotifyV2 发货完成",
log.Field{Key: "app", Value: comm.AppName()},
log.Field{Key: "order", Value: order.OrderId},
log.Field{Key: "uid", Value: order.Uid},
log.Field{Key: "goods_id", Value: goods.GoodsId})
resp = payNotifyResp("SUCCESS", "成功")
return
}
// payNotifyResp PayPal 回调的应答体(与 v1 保持同样的形状)。
func payNotifyResp(code, message string) []byte {
return []byte(utils.ToString(map[string]string{"code": code, "message": message}))
}
// paypalOutTradeNo 从通知的 resource 里取我们自己的商户订单号:
// 下单时写在 custom_id;取不到再退而求其次找 purchase_units[0].reference_id / invoice_id。
func paypalOutTradeNo(resource map[string]any) string {
if resource == nil {
return ""
}
if v, _ := resource["custom_id"].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
if pus, _ := resource["purchase_units"].([]any); len(pus) > 0 {
if unit, _ := pus[0].(map[string]any); unit != nil {
if v, _ := unit["reference_id"].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
}
if v, _ := resource["invoice_id"].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return ""
}
// paypalAmountCents 取通知里的实付金额并换算成「分」(订单 Amount 的单位)。
// PayPal 的 amount.value 是字符串金额(如 "9.99");解析不出来返回 ok=false,此时不做对账。
func paypalAmountCents(resource map[string]any) (int64, bool) {
if resource == nil {
return 0, false
}
amount, _ := resource["amount"].(map[string]any)
if amount == nil {
return 0, false
}
value, _ := amount["value"].(string)
if strings.TrimSpace(value) == "" {
return 0, false
}
f, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return 0, false
}
return int64(math.Round(f * 100)), true
}