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 }