diff --git a/.env.example b/.env.example index c989193..b6de962 100644 --- a/.env.example +++ b/.env.example @@ -55,7 +55,7 @@ WAFFO_CURRENCY_PERPETUAL=CNY # test | live(test 时自动附加 test=true) WAFFO_ENVIRONMENT=test # 支付成功回跳(桌面端 deep link;CreateOrder/Checkout 的 return_url 可覆盖) -SUCCESS_URL=opsxshell://pro/activated +SUCCESS_URL=http://l123.91ai.io/api/v1/purchase/success # PEM 私钥仅保存在服务端,切勿下发到客户端或写入仓库 # WAFFO_PRIVATE_KEY_PEM= diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index 87ea4e6..4c9de81 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -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 += "

订单号:" + html.EscapeString(orderNo) + if orderStatus != "" { + meta += " · 状态:" + html.EscapeString(orderStatus) + } + meta += "

" + } + if email != "" { + meta += "

邮箱:" + html.EscapeString(email) + "

" + } + + keyBlock := "" + if licenseKey != "" { + keyBlock = `
许可证
` + html.EscapeString(licenseKey) + ` +
` + } + + 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 = `

打开 OpsXShell 激活

` + autoScript = `` + } + return ` + + + + +` + html.EscapeString(title) + ` + + + +
+
OpsXShell
+

` + html.EscapeString(headline) + `

+

` + html.EscapeString(body) + `

+ ` + extraHTML + ` + ` + openBtn + ` +
+ ` + autoScript + ` + +` } // WaffoWebhook accepts loosely-shaped Waffo payment events and maps to PaymentWebhook. // Logan.Li diff --git a/backend/internal/service/payment/waffo.go b/backend/internal/service/payment/waffo.go index 4338abc..95ad185 100644 --- a/backend/internal/service/payment/waffo.go +++ b/backend/internal/service/payment/waffo.go @@ -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 == "" { diff --git a/backend/router/router.go b/backend/router/router.go index bba80dc..549f167 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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)