feat: 支付成功页与友好 webhook 落地页
浏览器回跳展示许可证并深链回 App;补齐包年订单因未收到 Waffo POST 未发码的体验。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
554998f4b7
commit
0966375076
4 files changed
+197
-9
No files matched your search
@@ -2,9 +2,12 @@ package order
|
||||
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
orderservice "backend/internal/service/order"
|
||||
"backend/utils"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -113,14 +116,198 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) {
|
||||
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) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "waffo webhook ready",
|
||||
"methods": []string{"POST"},
|
||||
"hint": "Payment events must POST JSON with order_no / orderNo",
|
||||
})
|
||||
orderNo := firstNonEmpty(c.Query("order_no"), c.Query("orderNo"))
|
||||
if orderNo != "" {
|
||||
// Browser return with orderNo → show success/claim page.
|
||||
c.Redirect(http.StatusFound, "/api/v1/purchase/success?"+c.Request.URL.RawQuery)
|
||||
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
|
||||
|
||||
@@ -29,7 +29,7 @@ func LoadWaffoConfig() WaffoConfig {
|
||||
}
|
||||
success := strings.TrimSpace(os.Getenv("SUCCESS_URL"))
|
||||
if success == "" {
|
||||
success = "opsxshell://pro/activated"
|
||||
success = "http://l123.91ai.io/api/v1/purchase/success"
|
||||
}
|
||||
currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY"))
|
||||
if currency == "" {
|
||||
|
||||
@@ -111,6 +111,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
||||
register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook)
|
||||
register(clientAPI, "GET", "/webhooks/waffo", orderController.WaffoWebhookReady)
|
||||
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/status", orderController.CheckPurchaseStatus)
|
||||
|
||||
|
||||
Reference in new issue
Block a user