feat: 支付成功页与友好 webhook 落地页

浏览器回跳展示许可证并深链回 App;补齐包年订单因未收到 Waffo POST 未发码的体验。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 16:39:13 +08:00
1 parent 554998f4b7
commit 0966375076
4 files changed
+197 -9

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ WAFFO_CURRENCY_PERPETUAL=CNY
# test | live(test 时自动附加 test=true) # test | live(test 时自动附加 test=true)
WAFFO_ENVIRONMENT=test WAFFO_ENVIRONMENT=test
# 支付成功回跳(桌面端 deep link;CreateOrder/Checkout 的 return_url 可覆盖) # 支付成功回跳(桌面端 deep link;CreateOrder/Checkout 的 return_url 可覆盖)
SUCCESS_URL=opsxshell://pro/activated SUCCESS_URL=http://l123.91ai.io/api/v1/purchase/success
# PEM 私钥仅保存在服务端,切勿下发到客户端或写入仓库 # PEM 私钥仅保存在服务端,切勿下发到客户端或写入仓库
# WAFFO_PRIVATE_KEY_PEM= # WAFFO_PRIVATE_KEY_PEM=
@@ -2,9 +2,12 @@ package order
import ( import (
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model"
orderservice "backend/internal/service/order" orderservice "backend/internal/service/order"
"backend/utils" "backend/utils"
"encoding/json" "encoding/json"
"fmt"
"html"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -113,14 +116,198 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"}) c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"})
} }
// WaffoWebhookReady GET/HEAD probe for dashboard URL checks (browser opens as GET). // Logan.Li // WaffoWebhookReady GET/HEAD — browser-friendly landing (not raw JSON). // Logan.Li
func (ctrl *OrderController) WaffoWebhookReady(c *gin.Context) { func (ctrl *OrderController) WaffoWebhookReady(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
"code": 0, if orderNo != "" {
"message": "waffo webhook ready", // Browser return with orderNo → show success/claim page.
"methods": []string{"POST"}, c.Redirect(http.StatusFound, "/api/v1/purchase/success?"+c.Request.URL.RawQuery)
"hint": "Payment events must POST JSON with order_no / orderNo", return
}) }
if prefersJSON(c) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "waffo webhook ready",
"methods": []string{"POST"},
})
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderSimplePage(
"OpsXShell 支付回调",
"接口已就绪",
"这是给支付平台调用的服务端回调地址,请在 Waffo 后台配置为 Webhook(POST)。支付完成后会自动跳转到授权领取页。",
"",
"",
)))
}
// PurchaseSuccess browser success / claim page after Waffo checkout. // Logan.Li
func (ctrl *OrderController) PurchaseSuccess(c *gin.Context) {
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
email := firstNonEmpty(c.Query("email"), c.Query("customer_email"), c.Query("customerEmail"))
planType := firstNonEmpty(c.Query("planType"), c.Query("plan_type"))
// If Waffo bounced here with paid-like query, try to settle once. // Logan.Li
statusHint := strings.ToLower(firstNonEmpty(c.Query("status"), c.Query("payment_status"), c.Query("paymentStatus")))
if orderNo != "" && (strings.Contains(statusHint, "paid") || strings.Contains(statusHint, "success") || strings.Contains(statusHint, "complete")) {
_ = ctrl.svc.PaymentWebhook(dto.PaymentWebhookRequest{
EventType: "browser_return",
OrderNo: orderNo,
Status: "paid",
Gateway: "waffo",
Payload: c.Request.URL.RawQuery,
}, utils.GetClientIP(c))
}
title := "支付结果"
headline := "正在确认支付…"
body := "若你已完成付款,请稍等数秒后刷新本页;或打开 OpsXShell,在「账户」粘贴许可证激活。"
licenseKey := ""
orderStatus := ""
if orderNo != "" {
var order model.Order
if err := ctrl.db.Where("order_no = ?", orderNo).First(&order).Error; err == nil {
orderStatus = order.Status
switch order.Status {
case "paid":
headline = "支付成功"
if order.LicenseID > 0 {
var lic model.License
if err := ctrl.db.First(&lic, order.LicenseID).Error; err == nil {
licenseKey = lic.LicenseKey
}
}
if licenseKey == "" {
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
OrderNo: orderNo,
CustomerEmail: email,
ProductCode: "opsxshell",
})
if err == nil && claimed != nil {
licenseKey = claimed.LicenseKey
}
}
if licenseKey != "" {
body = "请复制下方许可证,回到 OpsXShell → 设置 / 升级页 → 粘贴激活。也可点击「打开应用」自动回跳。"
} else {
body = "订单已支付,许可证生成中,请稍后刷新本页。"
}
case "pending":
headline = "等待支付确认"
body = fmt.Sprintf("订单 %s 尚未收到支付平台回调。若你已付款,通常 1~2 分钟内会自动到账;也可联系客服并提供订单号。", html.EscapeString(orderNo))
default:
headline = "订单状态:" + order.Status
body = "如有疑问请携带订单号联系支持。"
}
} else {
headline = "未找到订单"
body = "请确认是否从 OpsXShell 内发起购买。订单号:" + html.EscapeString(orderNo)
}
} else if email != "" {
claimed, err := ctrl.svc.ClaimPurchase(dto.ClaimPurchaseRequest{
CustomerEmail: email,
ProductCode: "opsxshell",
})
if err == nil && claimed != nil && claimed.LicenseKey != "" {
headline = "支付成功"
licenseKey = claimed.LicenseKey
orderNo = claimed.OrderNo
orderStatus = claimed.Status
body = "已根据邮箱找到可用许可证,请复制后在 OpsXShell 中激活。"
}
}
deepLink := "opsxshell://pro/activated"
q := []string{}
if orderNo != "" {
q = append(q, "orderNo="+orderNo)
}
if email != "" {
q = append(q, "email="+email)
}
if licenseKey != "" {
q = append(q, "licenseKey="+licenseKey)
}
if planType != "" {
q = append(q, "planType="+planType)
}
if len(q) > 0 {
deepLink += "?" + strings.Join(q, "&")
}
meta := ""
if orderNo != "" {
meta += "<p class=\"meta\">订单号:" + html.EscapeString(orderNo)
if orderStatus != "" {
meta += " · 状态:" + html.EscapeString(orderStatus)
}
meta += "</p>"
}
if email != "" {
meta += "<p class=\"meta\">邮箱:" + html.EscapeString(email) + "</p>"
}
keyBlock := ""
if licenseKey != "" {
keyBlock = `<div class="key-box"><div class="key-label">许可证</div><code id="lic">` + html.EscapeString(licenseKey) + `</code>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('lic').innerText)">复制</button></div>`
}
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderSimplePage(
"OpsXShell · "+title,
headline,
body,
meta+keyBlock,
deepLink,
)))
}
func prefersJSON(c *gin.Context) bool {
accept := c.GetHeader("Accept")
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/html")
}
func renderSimplePage(title, headline, body, extraHTML, deepLink string) string {
openBtn := ""
autoScript := ""
if deepLink != "" {
openBtn = `<p><a class="btn" href="` + html.EscapeString(deepLink) + `">打开 OpsXShell 激活</a></p>`
autoScript = `<script>setTimeout(function(){ try{ window.location.href=` + strconv.Quote(deepLink) + `; }catch(e){} }, 800);</script>`
}
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>` + html.EscapeString(title) + `</title>
<style>
:root { color-scheme: dark; }
body{margin:0;min-height:100vh;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:radial-gradient(1200px 600px at 20% -10%,#1e3a5f 0%,#0b1220 55%,#070b14 100%);color:#e8eef7;display:flex;align-items:center;justify-content:center;padding:24px;}
.card{width:min(520px,100%);background:rgba(18,26,40,.92);border:1px solid rgba(255,255,255,.08);border-radius:20px;padding:28px 26px;box-shadow:0 24px 60px rgba(0,0,0,.35);}
.brand{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#7fb0ff;margin-bottom:10px;}
h1{font-size:24px;margin:0 0 10px;font-weight:650;}
p{line-height:1.6;color:#b7c3d6;margin:0 0 12px;}
.meta{font-size:13px;color:#8a98ad;}
.key-box{margin:16px 0;padding:14px;border-radius:12px;background:#0a101a;border:1px solid rgba(127,176,255,.25);}
.key-label{font-size:12px;color:#8a98ad;margin-bottom:6px;}
code{display:block;font-size:15px;word-break:break-all;color:#f4f7fb;margin-bottom:10px;}
button,.btn{appearance:none;border:0;border-radius:10px;padding:10px 14px;background:#3b82f6;color:#fff;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;}
button:hover,.btn:hover{background:#2563eb;}
</style>
</head>
<body>
<div class="card">
<div class="brand">OpsXShell</div>
<h1>` + html.EscapeString(headline) + `</h1>
<p>` + html.EscapeString(body) + `</p>
` + extraHTML + `
` + openBtn + `
</div>
` + autoScript + `
</body>
</html>`
} }
// WaffoWebhook accepts loosely-shaped Waffo payment events and maps to PaymentWebhook. // Logan.Li // WaffoWebhook accepts loosely-shaped Waffo payment events and maps to PaymentWebhook. // Logan.Li
+1 -1
View File
@@ -29,7 +29,7 @@ func LoadWaffoConfig() WaffoConfig {
} }
success := strings.TrimSpace(os.Getenv("SUCCESS_URL")) success := strings.TrimSpace(os.Getenv("SUCCESS_URL"))
if success == "" { if success == "" {
success = "opsxshell://pro/activated" success = "http://l123.91ai.io/api/v1/purchase/success"
} }
currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY")) currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY"))
if currency == "" { if currency == "" {
+1
View File
@@ -111,6 +111,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook) register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook)
register(clientAPI, "GET", "/webhooks/waffo", orderController.WaffoWebhookReady) register(clientAPI, "GET", "/webhooks/waffo", orderController.WaffoWebhookReady)
register(clientAPI, "POST", "/webhooks/waffo", orderController.WaffoWebhook) register(clientAPI, "POST", "/webhooks/waffo", orderController.WaffoWebhook)
register(clientAPI, "GET", "/purchase/success", orderController.PurchaseSuccess)
register(clientAPI, "POST", "/purchase/check", orderController.CheckDuplicatePurchase) register(clientAPI, "POST", "/purchase/check", orderController.CheckDuplicatePurchase)
register(clientAPI, "POST", "/purchase/status", orderController.CheckPurchaseStatus) register(clientAPI, "POST", "/purchase/status", orderController.CheckPurchaseStatus)