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
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
|
|
}
|
|
|