From 95405de11924cbfe4ab46a4d48d6ba517c1456d1 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 12 Aug 2026 11:42:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=B9=E9=BD=90=20Waffo=20=E5=8C=85?= =?UTF-8?q?=E5=B9=B4/=E6=B0=B8=E4=B9=85=E7=BB=93=E8=B4=A6=E4=B8=8E?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=B8=AD=E4=BD=8D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 OpsXShell 档位定价与 Waffo 商品映射,补齐 checkout/webhook/设备列表契约,便于桌面端购买与满席释放。 Co-authored-by: Cursor --- .env.example | 19 ++ backend/config/config.go | 12 +- backend/database/seed.go | 206 +++++++++++++-- .../controller/license/client_controller.go | 48 ++-- .../controller/order/order_controller.go | 199 ++++++++++++--- .../controller/product/product_controller.go | 53 ++-- backend/internal/dto/commerce.go | 39 ++- backend/internal/dto/license.go | 1 + .../service/license/client_service.go | 66 +++-- .../internal/service/order/order_service.go | 238 ++++++++++++++++-- backend/internal/service/payment/waffo.go | 153 +++++++++++ .../internal/service/payment/waffo_test.go | 64 +++++ .../service/product/product_service.go | 10 + backend/router/router.go | 5 + 14 files changed, 958 insertions(+), 155 deletions(-) create mode 100644 backend/internal/service/payment/waffo.go create mode 100644 backend/internal/service/payment/waffo_test.go diff --git a/.env.example b/.env.example index aad1ec4..d602a4b 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,25 @@ DB_NAME=db.sqlite3 # CORS 配置(多个用逗号分隔) # CORS_ORIGINS=http://localhost:5181,http://127.0.0.1:5181 +# ============================================ +# Waffo 支付(OpsXShell 收银台 · pancake.waffo.ai) +# ============================================ +# 店铺: https://pancake.waffo.ai/store/opsxshell-pwsi6m4n +# 包年订阅: PROD_2WmkUVoYWt0NN8OVEyC6n3 (type=subscription) +# 永久买断: PROD_0pcnzzT4nalEkkcdyIUoD5 (type=onetime) +# 勿设置单一 WAFFO_CHECKOUT_URL,否则无法按档位分流商品 +WAFFO_STORE_SLUG=opsxshell-pwsi6m4n +WAFFO_PRODUCT_ID=PROD_0pcnzzT4nalEkkcdyIUoD5 +WAFFO_PRODUCT_ID_YEARLY=PROD_2WmkUVoYWt0NN8OVEyC6n3 +WAFFO_PRODUCT_ID_PERPETUAL=PROD_0pcnzzT4nalEkkcdyIUoD5 +WAFFO_CURRENCY=CNY +# test | live(test 时自动附加 test=true) +WAFFO_ENVIRONMENT=test +# 支付成功回跳(桌面端 deep link;CreateOrder/Checkout 的 return_url 可覆盖) +SUCCESS_URL=opsxshell://pro/activated +# PEM 私钥仅保存在服务端,切勿下发到客户端或写入仓库 +# WAFFO_PRIVATE_KEY_PEM= + # ============================================ # 前端配置 (frontend) # ============================================ diff --git a/backend/config/config.go b/backend/config/config.go index adfd4c2..ae20d5c 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -33,8 +33,10 @@ type Config struct { // Load 从环境变量加载配置 // Logan.Li func Load() *Config { - // 加载 .env 文件(如果存在) // Logan.Li - _ = godotenv.Load() + // 加载 .env:backend/.env 与仓库根 .env 各自尝试(缺文件不中断) // Logan.Li + _ = godotenv.Load(".env") + _ = godotenv.Load("../.env") + _ = godotenv.Load("../../.env") cfg := &Config{ Port: getEnv("PORT", "9001"), @@ -51,6 +53,12 @@ func Load() *Config { "http://127.0.0.1:5181", "http://localhost:3000", "http://127.0.0.1:3000", + // OpsXShell Tauri / Vite dev origins + "http://localhost:1420", + "http://127.0.0.1:1420", + "http://tauri.localhost", + "https://tauri.localhost", + "tauri://localhost", }, } diff --git a/backend/database/seed.go b/backend/database/seed.go index a875af7..f0244c3 100644 --- a/backend/database/seed.go +++ b/backend/database/seed.go @@ -25,6 +25,12 @@ func SeedDefaults(db *gorm.DB) error { if err := seedSampleData(db); err != nil { return err } + if err := ensureOpsXShellPlanPricing(db); err != nil { + return err + } + if err := ensureWaffoPaymentGateway(db); err != nil { + return err + } return nil } @@ -73,7 +79,7 @@ func seedSetting(db *gorm.DB) error { return nil } -// seedSampleData 初始化示例业务数据(产品/客户/计划/支付网关),便于开发联调 // Logan.Li +// seedSampleData 初始化 OpsXShell 产品 / 计划 / Waffo 网关,便于桌面端联调 // Logan.Li func seedSampleData(db *gorm.DB) error { var productCount int64 db.Model(&model.Product{}).Count(&productCount) @@ -81,38 +87,67 @@ func seedSampleData(db *gorm.DB) error { return nil } - // 示例产品 // Logan.Li product := model.Product{ - Name: "OpsX Tools Suite", - Code: "opsx-tools", - Version: "1.0.0", - Description: "OpsX 工具集 - 一站式运维工具箱", - Features: `["CLI","GUI","Web Console","API"]`, - Price: 99.00, - PriceModel: "subscription", + Name: "OpsXShell", + Code: "opsxshell", + Version: "2.0.0", + Description: "OpsXShell Pro — SSH / SFTP / RDP / VNC / AI", + Features: `["ssh","sftp","rdp","vnc","ai","portForward","proxy","serial","batchExec"]`, + Price: 199.00, + PriceModel: "perpetual", Status: 1, } if err := db.Create(&product).Error; err != nil { return err } - // 示例客户 // Logan.Li customer := model.Customer{ - Name: "示例客户", - Company: "Acme Inc.", - Email: "demo@acme.com", - Phone: "13800000000", + Name: "Demo", + Company: "OpsX", + Email: "demo@opsxshell.local", Status: 1, } if err := db.Create(&customer).Error; err != nil { return err } - // 示例定价计划(买断 + 月订阅 + 年订阅) // Logan.Li plans := []model.Plan{ - {ProductID: product.ID, Name: "个人永久版", PlanType: "perpetual", Price: 199, Currency: "USD", Duration: 0, MaxDevices: 1, Features: `["全部功能","永久使用","1 设备"]`, SortOrder: 1, Status: 1}, - {ProductID: product.ID, Name: "专业版(月)", PlanType: "monthly", Price: 19, Currency: "USD", Duration: 30, MaxDevices: 3, Features: `["全部功能","按月订阅","3 设备"]`, SortOrder: 2, Status: 1}, - {ProductID: product.ID, Name: "企业版(年)", PlanType: "yearly", Price: 199, Currency: "USD", Duration: 365, MaxDevices: 10, Features: `["全部功能","按年订阅优惠","10 设备","优先支持"]`, SortOrder: 3, Status: 1}, + { + ProductID: product.ID, + Name: "Yearly", + PlanType: "yearly", + Price: 99, + Currency: "CNY", + Duration: 365, + MaxDevices: 2, + Features: `["专业功能","包年","2 设备"]`, + SortOrder: 1, + Status: 1, + }, + { + ProductID: product.ID, + Name: "Perpetual", + PlanType: "perpetual", + Price: 199, + Currency: "CNY", + Duration: 0, + MaxDevices: 5, + Features: `["专业功能","永久授权","5 设备"]`, + SortOrder: 2, + Status: 1, + }, + { + ProductID: product.ID, + Name: "Trial 180d", + PlanType: "trial", + Price: 0, + Currency: "USD", + Duration: 180, + MaxDevices: 1, + Features: `["专业功能","半年体验","1 设备"]`, + SortOrder: 0, + Status: 1, + }, } for i := range plans { if err := db.Create(&plans[i]).Error; err != nil { @@ -120,7 +155,6 @@ func seedSampleData(db *gorm.DB) error { } } - // 示例 License // Logan.Li now := time.Now() license := model.License{ LicenseKey: utils.GenerateLicenseKey(), @@ -128,18 +162,17 @@ func seedSampleData(db *gorm.DB) error { ProductID: product.ID, Status: model.LicenseStatusActive, StartDate: now, - EndDate: now.AddDate(0, 0, 365), - Duration: 365, - MaxActivations: 3, - Notes: "示例授权,初始化生成", + EndDate: model.PermanentEndDate, + Duration: 0, + MaxActivations: 5, + Notes: "开发示例授权", } if err := db.Create(&license).Error; err != nil { return err } - // 示例支付网关配置(沙箱) // Logan.Li gateway := model.PaymentGatewayConfig{ - Gateway: "creem", + Gateway: "waffo", Enabled: true, SandboxMode: true, Currency: "USD", @@ -149,6 +182,127 @@ func seedSampleData(db *gorm.DB) error { return err } - log.Println("[初始化] 已创建示例产品、客户、计划、License、支付网关") + log.Printf("[初始化] OpsXShell 产品已就绪 (code=opsxshell),示例 License: %s", license.LicenseKey) + return nil +} + +// ensureOpsXShellPlanPricing ensures opsxshell product exists and syncs plan price/seats. // Logan.Li +func ensureOpsXShellPlanPricing(db *gorm.DB) error { + var product model.Product + err := db.Where("code = ?", "opsxshell").First(&product).Error + if err != nil { + // Prefer renaming legacy demo product so existing plans/licenses stay attached. + if renameErr := db.Where("code = ?", "opsx-tools").First(&product).Error; renameErr == nil { + product.Code = "opsxshell" + product.Name = "OpsXShell" + product.Description = "OpsXShell Pro — SSH / SFTP / RDP / VNC / AI" + product.Features = `["ssh","sftp","rdp","vnc","ai","portForward","proxy","serial","batchExec"]` + product.Price = 199 + product.PriceModel = "perpetual" + product.Status = 1 + if err := db.Save(&product).Error; err != nil { + return err + } + log.Println("[初始化] 已将 opsx-tools 迁移为 opsxshell") + } else { + product = model.Product{ + Name: "OpsXShell", + Code: "opsxshell", + Version: "2.0.0", + Description: "OpsXShell Pro — SSH / SFTP / RDP / VNC / AI", + Features: `["ssh","sftp","rdp","vnc","ai","portForward","proxy","serial","batchExec"]`, + Price: 199, + PriceModel: "perpetual", + Status: 1, + } + if err := db.Create(&product).Error; err != nil { + return err + } + log.Println("[初始化] 已创建 opsxshell 产品") + } + } + type planSpec struct { + planType string + name string + price float64 + currency string + duration int + maxDevices int + features string + sortOrder int + } + updates := []planSpec{ + {"yearly", "Yearly", 99, "CNY", 365, 2, "[\"专业功能\",\"包年\",\"2 设备\"]", 1}, + {"perpetual", "Perpetual", 199, "CNY", 0, 5, "[\"专业功能\",\"永久授权\",\"5 设备\"]", 2}, + {"trial", "Trial 180d", 0, "CNY", 180, 1, "[\"专业功能\",\"半年体验\",\"1 设备\"]", 0}, + } + for _, u := range updates { + var plan model.Plan + err := db.Where("product_id = ? AND plan_type = ?", product.ID, u.planType).First(&plan).Error + if err != nil { + plan = model.Plan{ + ProductID: product.ID, + Name: u.name, + PlanType: u.planType, + Price: u.price, + Currency: u.currency, + Duration: u.duration, + MaxDevices: u.maxDevices, + Features: u.features, + SortOrder: u.sortOrder, + Status: 1, + } + if err := db.Create(&plan).Error; err != nil { + return err + } + continue + } + plan.Name = u.name + plan.Price = u.price + plan.Currency = u.currency + plan.Duration = u.duration + plan.MaxDevices = u.maxDevices + plan.Features = u.features + plan.SortOrder = u.sortOrder + plan.Status = 1 + if err := db.Save(&plan).Error; err != nil { + return err + } + } + log.Printf("[初始化] OpsXShell 套餐已同步: 包年¥99/2台, 永久¥199/5台, 体验180天/1台") + return nil +} + +// ensureWaffoPaymentGateway makes pancake.waffo.ai the preferred checkout gateway. // Logan.Li +func ensureWaffoPaymentGateway(db *gorm.DB) error { + var existing model.PaymentGatewayConfig + err := db.Where("gateway = ?", "waffo").First(&existing).Error + if err != nil { + gw := model.PaymentGatewayConfig{ + Gateway: "waffo", + Enabled: true, + SandboxMode: true, + Currency: "CNY", + Priority: 0, + } + if err := db.Create(&gw).Error; err != nil { + return err + } + } else { + existing.Enabled = true + existing.SandboxMode = true + existing.Currency = "CNY" + existing.Priority = 0 + if err := db.Save(&existing).Error; err != nil { + return err + } + } + // Lower priority for other enabled gateways (ASC pick prefers Waffo). + if err := db.Model(&model.PaymentGatewayConfig{}). + Where("gateway <> ? AND enabled = ?", "waffo", true). + Update("priority", 100).Error; err != nil { + return err + } + log.Println("[初始化] 支付网关已优先 Waffo(包年 subscription / 永久 onetime)") return nil } diff --git a/backend/internal/controller/license/client_controller.go b/backend/internal/controller/license/client_controller.go index 07b04e3..0dda756 100644 --- a/backend/internal/controller/license/client_controller.go +++ b/backend/internal/controller/license/client_controller.go @@ -1,6 +1,7 @@ package license import ( + "backend/internal/dto" "net/http" "github.com/gin-gonic/gin" @@ -8,13 +9,7 @@ import ( // Verify 客户端 License 验证 // Logan.Li func (ctrl *LicenseController) Verify(c *gin.Context) { - var req struct { - LicenseKey string `json:"license_key" binding:"required"` - DeviceID string `json:"device_id"` - DeviceName string `json:"device_name"` - DeviceInfo string `json:"device_info"` - ProductID uint `json:"product_id"` - } + var req dto.LicenseVerifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -25,12 +20,7 @@ func (ctrl *LicenseController) Verify(c *gin.Context) { // ActivateDevice 客户端激活(机器绑定) // Logan.Li func (ctrl *LicenseController) ActivateDevice(c *gin.Context) { - var req struct { - LicenseKey string `json:"license_key" binding:"required"` - DeviceID string `json:"device_id" binding:"required"` - DeviceName string `json:"device_name"` - DeviceInfo string `json:"device_info"` - } + var req dto.LicenseActivateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -45,10 +35,7 @@ func (ctrl *LicenseController) ActivateDevice(c *gin.Context) { // Deactivate 客户端停用 // Logan.Li func (ctrl *LicenseController) Deactivate(c *gin.Context) { - var req struct { - LicenseKey string `json:"license_key" binding:"required"` - DeviceID string `json:"device_id" binding:"required"` - } + var req dto.LicenseDeactivateRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return @@ -62,10 +49,7 @@ func (ctrl *LicenseController) Deactivate(c *gin.Context) { // Heartbeat 客户端心跳 // Logan.Li func (ctrl *LicenseController) Heartbeat(c *gin.Context) { - var req struct { - LicenseKey string `json:"license_key" binding:"required"` - DeviceID string `json:"device_id" binding:"required"` - } + var req dto.LicenseHeartbeatRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return @@ -77,3 +61,25 @@ func (ctrl *LicenseController) Heartbeat(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"code": 0, "message": "心跳成功", "data": data}) } + +// ListDevices 客户端查询已绑定设备(用于席位满时释放) // Logan.Li +func (ctrl *LicenseController) ListDevices(c *gin.Context) { + var req struct { + LicenseKey string `json:"license_key" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return + } + devices, maxDevices, err := ctrl.svc.ListDevicesByKey(req.LicenseKey) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{ + "devices": devices, + "max_devices": maxDevices, + "used": len(devices), + }}) +} + diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index a51c0bc..a5e996d 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -4,8 +4,11 @@ import ( "backend/internal/dto" orderservice "backend/internal/service/order" "backend/utils" + "encoding/json" + "io" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -53,15 +56,7 @@ func (ctrl *OrderController) GetDetail(c *gin.Context) { // CreateOrder 客户端下单(公开接口) // Logan.Li func (ctrl *OrderController) CreateOrder(c *gin.Context) { - var req struct { - CustomerEmail string `json:"customer_email" binding:"required,email"` - CustomerName string `json:"customer_name"` - ProductID uint `json:"product_id" binding:"required"` - PlanID uint `json:"plan_id" binding:"required"` - DeviceID string `json:"device_id"` - ReturnURL string `json:"return_url"` - CancelURL string `json:"cancel_url"` - } + var req dto.CreateOrderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -74,20 +69,39 @@ func (ctrl *OrderController) CreateOrder(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp}) } +// Checkout desktop-friendly checkout entry. // Logan.Li +func (ctrl *OrderController) Checkout(c *gin.Context) { + var req dto.CheckoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) + return + } + resp, err := ctrl.svc.Checkout(req, utils.GetClientIP(c), c.GetHeader("User-Agent")) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 2001, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "订单创建成功", "data": resp}) +} + +// ClaimPurchase fetch license after payment. // Logan.Li +func (ctrl *OrderController) ClaimPurchase(c *gin.Context) { + var req dto.ClaimPurchaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) + return + } + resp, err := ctrl.svc.ClaimPurchase(req) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 2002, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) +} + // PaymentWebhook 支付回调(公开接口,供支付渠道调用) // Logan.Li func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { - var req struct { - EventType string `json:"event_type" binding:"required"` - OrderNo string `json:"order_no" binding:"required"` - GatewayOrderNo string `json:"gateway_order_no"` - Status string `json:"status" binding:"required"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - CustomerEmail string `json:"customer_email"` - Gateway string `json:"gateway" binding:"required"` - Signature string `json:"signature"` - Payload string `json:"payload"` - } + var req dto.PaymentWebhookRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid payload"}) return @@ -99,16 +113,133 @@ func (ctrl *OrderController) PaymentWebhook(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"}) } +// WaffoWebhook accepts loosely-shaped Waffo payment events and maps to PaymentWebhook. // Logan.Li +func (ctrl *OrderController) WaffoWebhook(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "无法读取请求体"}) + return + } + // Optional: accept X-Waffo-Signature if present (no strict verification yet). // Logan.Li + _ = c.GetHeader("X-Waffo-Signature") + + var payload map[string]interface{} + if len(body) > 0 { + if err := json.Unmarshal(body, &payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "Invalid JSON"}) + return + } + } else { + payload = map[string]interface{}{} + } + + orderNo := firstNonEmpty( + asString(payload["order_no"]), + asString(payload["orderNo"]), + asString(payload["order_id"]), + c.Query("order_no"), + c.Query("orderNo"), + ) + if orderNo == "" { + if meta, ok := payload["metadata"].(map[string]interface{}); ok { + orderNo = firstNonEmpty(asString(meta["orderNo"]), asString(meta["order_no"])) + } + } + if orderNo == "" { + if data, ok := payload["data"].(map[string]interface{}); ok { + orderNo = firstNonEmpty(asString(data["order_no"]), asString(data["orderNo"])) + if orderNo == "" { + if meta, ok := data["metadata"].(map[string]interface{}); ok { + orderNo = firstNonEmpty(asString(meta["orderNo"]), asString(meta["order_no"])) + } + } + } + } + if orderNo == "" { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "missing order_no"}) + return + } + + eventType := firstNonEmpty( + asString(payload["event_type"]), + asString(payload["eventType"]), + asString(payload["type"]), + asString(payload["event"]), + "payment", + ) + statusRaw := strings.ToLower(firstNonEmpty( + asString(payload["status"]), + asString(payload["payment_status"]), + asString(payload["paymentStatus"]), + )) + gatewayOrderNo := firstNonEmpty( + asString(payload["gateway_order_no"]), + asString(payload["gatewayOrderNo"]), + asString(payload["transaction_id"]), + asString(payload["transactionId"]), + asString(payload["id"]), + ) + + mappedStatus := "paid" + switch { + case strings.Contains(statusRaw, "fail"), strings.Contains(statusRaw, "cancel"), + strings.Contains(eventType, "fail"), strings.Contains(eventType, "cancel"): + mappedStatus = "failed" + case strings.Contains(statusRaw, "refund"), strings.Contains(eventType, "refund"): + mappedStatus = "refunded" + case statusRaw == "" || strings.Contains(statusRaw, "paid") || strings.Contains(statusRaw, "complete") || + strings.Contains(statusRaw, "success") || strings.Contains(eventType, "complete") || + strings.Contains(eventType, "paid") || strings.Contains(eventType, "success"): + mappedStatus = "paid" + default: + // Treat unknown completed-like events as paid for now. // Logan.Li + mappedStatus = "paid" + } + + req := dto.PaymentWebhookRequest{ + EventType: eventType, + OrderNo: orderNo, + GatewayOrderNo: gatewayOrderNo, + Status: mappedStatus, + Gateway: "waffo", + Payload: string(body), + Signature: c.GetHeader("X-Waffo-Signature"), + } + if err := ctrl.svc.PaymentWebhook(req, utils.GetClientIP(c)); err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "message": "Webhook processed"}) +} + +func asString(v interface{}) string { + switch t := v.(type) { + case string: + return strings.TrimSpace(t) + case float64: + if t == float64(int64(t)) { + return strconv.FormatInt(int64(t), 10) + } + return strconv.FormatFloat(t, 'f', -1, 64) + case json.Number: + return t.String() + default: + return "" + } +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + // ManualGrant 手动发放 License(管理后台) // Logan.Li func (ctrl *OrderController) ManualGrant(c *gin.Context) { - var req struct { - CustomerEmail string `json:"customer_email" binding:"required,email"` - CustomerName string `json:"customer_name"` - ProductID uint `json:"product_id" binding:"required"` - PlanID uint `json:"plan_id" binding:"required"` - DeviceID string `json:"device_id"` - Notes string `json:"notes"` - } + var req dto.ManualGrantRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -125,10 +256,7 @@ func (ctrl *OrderController) ManualGrant(c *gin.Context) { // CheckDuplicatePurchase 防重复购买检测(公开接口) // Logan.Li func (ctrl *OrderController) CheckDuplicatePurchase(c *gin.Context) { - var req struct { - CustomerEmail string `json:"customer_email" binding:"required,email"` - ProductID uint `json:"product_id" binding:"required"` - } + var req dto.DuplicatePurchaseCheckRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return @@ -151,10 +279,7 @@ func (ctrl *OrderController) CheckDuplicatePurchase(c *gin.Context) { // CheckPurchaseStatus 查询购买状态(公开接口) // Logan.Li func (ctrl *OrderController) CheckPurchaseStatus(c *gin.Context) { - var req struct { - CustomerEmail string `json:"customer_email" binding:"required,email"` - ProductID uint `json:"product_id" binding:"required"` - } + var req dto.CheckPurchaseStatusRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return diff --git a/backend/internal/controller/product/product_controller.go b/backend/internal/controller/product/product_controller.go index 257196e..e714f9a 100644 --- a/backend/internal/controller/product/product_controller.go +++ b/backend/internal/controller/product/product_controller.go @@ -1,6 +1,8 @@ package product import ( + "backend/internal/dto" + planservice "backend/internal/service/plan" productservice "backend/internal/service/product" "backend/utils" "fmt" @@ -13,13 +15,18 @@ import ( // ProductController 产品控制器 // Logan.Li type ProductController struct { - svc *productservice.ProductService - db *gorm.DB + svc *productservice.ProductService + planSvc *planservice.PlanService + db *gorm.DB } // NewProductController 创建控制器实例 // Logan.Li func NewProductController(db *gorm.DB) *ProductController { - return &ProductController{svc: productservice.NewProductService(db), db: db} + return &ProductController{ + svc: productservice.NewProductService(db), + planSvc: planservice.NewPlanService(db), + db: db, + } } // List 产品列表 // Logan.Li @@ -60,18 +67,28 @@ func (ctrl *ProductController) Get(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "data": product}) } +// GetByCode 按产品编码返回产品及上架计划(公开接口) // Logan.Li +func (ctrl *ProductController) GetByCode(c *gin.Context) { + code := c.Param("code") + product, err := ctrl.svc.GetByCode(code) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 404, "message": err.Error()}) + return + } + plans, err := ctrl.planSvc.GetByProduct(product.ID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": dto.ProductWithPlansResponse{ + Product: *product, + Plans: plans, + }}) +} + // Create 创建产品 // Logan.Li func (ctrl *ProductController) Create(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - Code string `json:"code" binding:"required"` - Version string `json:"version"` - Description string `json:"description"` - Features []string `json:"features"` - Price float64 `json:"price"` - PriceModel string `json:"price_model"` - Status *int `json:"status"` - } + var req dto.ProductCreate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -88,15 +105,7 @@ func (ctrl *ProductController) Create(c *gin.Context) { // Update 更新产品 // Logan.Li func (ctrl *ProductController) Update(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 32) - var req struct { - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Features []string `json:"features"` - Price *float64 `json:"price"` - PriceModel string `json:"price_model"` - Status *int `json:"status"` - } + var req dto.ProductUpdate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return diff --git a/backend/internal/dto/commerce.go b/backend/internal/dto/commerce.go index d35196f..07095ed 100644 --- a/backend/internal/dto/commerce.go +++ b/backend/internal/dto/commerce.go @@ -64,8 +64,10 @@ type OrderResponse struct { type CreateOrderRequest struct { CustomerEmail string `json:"customer_email" binding:"required,email"` CustomerName string `json:"customer_name"` - ProductID uint `json:"product_id" binding:"required"` - PlanID uint `json:"plan_id" binding:"required"` + ProductID uint `json:"product_id"` + PlanID uint `json:"plan_id"` + ProductCode string `json:"product_code"` // preferred by desktop clients + PlanType string `json:"plan_type"` // e.g. perpetual DeviceID string `json:"device_id"` ReturnURL string `json:"return_url"` CancelURL string `json:"cancel_url"` @@ -82,6 +84,39 @@ type CreateOrderResponse struct { ExpiresAt string `json:"expires_at"` } +// CheckoutRequest desktop-friendly checkout (product_code + email). // Logan.Li +type CheckoutRequest struct { + CustomerEmail string `json:"customer_email" binding:"required,email"` + CustomerName string `json:"customer_name"` + ProductCode string `json:"product_code"` + PlanType string `json:"plan_type"` + DeviceID string `json:"device_id"` + ReturnURL string `json:"return_url"` + CancelURL string `json:"cancel_url"` +} + +// ClaimPurchaseRequest fetch license after payment. // Logan.Li +type ClaimPurchaseRequest struct { + CustomerEmail string `json:"customer_email"` + OrderNo string `json:"order_no"` + ProductCode string `json:"product_code"` + DeviceID string `json:"device_id"` +} + +// ClaimPurchaseResponse returned after successful payment. // Logan.Li +type ClaimPurchaseResponse struct { + LicenseKey string `json:"license_key"` + OrderNo string `json:"order_no,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// ProductWithPlansResponse for GET /v1/products/code/:code // Logan.Li +type ProductWithPlansResponse struct { + Product ProductResponse `json:"product"` + Plans []PlanResponse `json:"plans"` +} + // PaymentWebhookRequest 支付回调请求 // Logan.Li type PaymentWebhookRequest struct { EventType string `json:"event_type" binding:"required"` diff --git a/backend/internal/dto/license.go b/backend/internal/dto/license.go index fe9bd5d..696caf4 100644 --- a/backend/internal/dto/license.go +++ b/backend/internal/dto/license.go @@ -65,6 +65,7 @@ type LicenseVerifyResponse struct { ExpiresAt string `json:"expires_at,omitempty"` DaysLeft int `json:"days_left,omitempty"` MaxDevices int `json:"max_devices,omitempty"` + PlanType string `json:"plan_type,omitempty"` Message string `json:"message,omitempty"` } diff --git a/backend/internal/service/license/client_service.go b/backend/internal/service/license/client_service.go index 8c8cd72..882405b 100644 --- a/backend/internal/service/license/client_service.go +++ b/backend/internal/service/license/client_service.go @@ -8,6 +8,31 @@ import ( "time" ) +func inferPlanType(lic model.License) string { + if lic.IsTrial { + return "trial" + } + if lic.Duration <= 0 || lic.EndDate.Equal(model.PermanentEndDate) || lic.EndDate.Year() >= 2099 { + return "perpetual" + } + if lic.Duration <= 180 { + return "trial" + } + if lic.Duration <= 31 { + return "monthly" + } + return "yearly" +} + +func licenseClientPayload(lic model.License) map[string]interface{} { + return map[string]interface{}{ + "expires_at": lic.EndDate.Format("2006-01-02 15:04:05"), + "days_left": utils.CalculateDaysLeft(lic.EndDate), + "max_devices": lic.MaxActivations, + "plan_type": inferPlanType(lic), + } +} + // Verify 客户端 License 验证 // Logan.Li func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerifyResponse, error) { var lic model.License @@ -15,12 +40,10 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif return &dto.LicenseVerifyResponse{Valid: false, Message: "无效的 License"}, nil } - // 校验产品归属 // Logan.Li if req.ProductID > 0 && lic.ProductID != req.ProductID { return &dto.LicenseVerifyResponse{Valid: false, Message: "License 与产品不匹配"}, nil } - // 状态检查 // Logan.Li switch lic.Status { case model.LicenseStatusRevoked: return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被吊销"}, nil @@ -30,7 +53,6 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil } - // 过期检查(惰性更新状态) // Logan.Li if utils.IsLicenseExpired(lic.EndDate) { lic.Status = model.LicenseStatusExpired s.db.Save(&lic) @@ -48,6 +70,7 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif ExpiresAt: lic.EndDate.Format("2006-01-02 15:04:05"), DaysLeft: utils.CalculateDaysLeft(lic.EndDate), MaxDevices: lic.MaxActivations, + PlanType: inferPlanType(lic), Message: "验证成功", }, nil } @@ -66,7 +89,6 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin return nil, errors.New("License 已过期") } - // 同设备重复激活视为重新激活,不消耗额度 // Logan.Li var existing model.Activation if err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).First(&existing).Error; err == nil { existing.LastSeenAt = time.Now() @@ -75,18 +97,13 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin existing.DeviceInfo = req.DeviceInfo existing.IP = clientIP s.db.Save(&existing) - return map[string]interface{}{ - "expires_at": lic.EndDate.Format("2006-01-02 15:04:05"), - "days_left": utils.CalculateDaysLeft(lic.EndDate), - }, nil + return licenseClientPayload(lic), nil } - // 检查激活额度 // Logan.Li if lic.CurrentActivations >= lic.MaxActivations { return nil, errors.New("已达到最大激活数量限制") } - // 创建激活记录 // Logan.Li activation := model.Activation{ LicenseID: lic.ID, DeviceID: req.DeviceID, @@ -108,10 +125,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin } s.db.Save(&lic) - return map[string]interface{}{ - "expires_at": lic.EndDate.Format("2006-01-02 15:04:05"), - "days_left": utils.CalculateDaysLeft(lic.EndDate), - }, nil + return licenseClientPayload(lic), nil } // Deactivate 客户端停用 // Logan.Li @@ -146,13 +160,27 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string] return nil, errors.New("License 已过期") } - // 更新心跳时间与在线状态 // Logan.Li s.db.Model(&model.Activation{}). Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID). Updates(map[string]interface{}{"last_seen_at": time.Now(), "is_online": true}) - return map[string]interface{}{ - "expires_at": lic.EndDate.Format("2006-01-02 15:04:05"), - "days_left": utils.CalculateDaysLeft(lic.EndDate), - }, nil + return licenseClientPayload(lic), nil } + +// ListDevicesByKey returns activations for a license key (client self-service). // Logan.Li +func (s *LicenseService) ListDevicesByKey(licenseKey string) ([]dto.ActivationResponse, int, error) { + var lic model.License + if err := s.db.Where("license_key = ?", licenseKey).First(&lic).Error; err != nil { + return nil, 0, errors.New("无效的 License") + } + var activations []model.Activation + if err := s.db.Where("license_id = ? AND status = ?", lic.ID, "active").Order("activated_at DESC").Find(&activations).Error; err != nil { + return nil, 0, errors.New("获取设备列表失败") + } + results := make([]dto.ActivationResponse, 0, len(activations)) + for _, a := range activations { + results = append(results, toActivationResponse(a)) + } + return results, lic.MaxActivations, nil +} + diff --git a/backend/internal/service/order/order_service.go b/backend/internal/service/order/order_service.go index 34b4c8c..eb8019f 100644 --- a/backend/internal/service/order/order_service.go +++ b/backend/internal/service/order/order_service.go @@ -3,9 +3,11 @@ package order import ( "backend/internal/dto" "backend/internal/model" + "backend/internal/service/payment" "backend/utils" "errors" "fmt" + "strings" "time" "gorm.io/gorm" @@ -73,46 +75,91 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge } } - // 2. 校验产品与计划 // Logan.Li + // 2. 解析产品与计划:优先 product_code / plan_type,否则要求 ProductID+PlanID // Logan.Li var product model.Product - if err := s.db.First(&product, req.ProductID).Error; err != nil { - return nil, errors.New("产品不存在") - } var plan model.Plan - if err := s.db.First(&plan, req.PlanID).Error; err != nil { - return nil, errors.New("定价计划不存在") + productCode := strings.TrimSpace(req.ProductCode) + planType := strings.TrimSpace(req.PlanType) + + if productCode != "" { + if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil { + return nil, errors.New("产品不存在") + } + if planType == "" { + planType = "perpetual" + } + if err := s.db.Where("product_id = ? AND plan_type = ? AND status = ?", product.ID, planType, 1). + Order("sort_order ASC").First(&plan).Error; err != nil { + return nil, fmt.Errorf("未找到计划: product=%s plan_type=%s", productCode, planType) + } + } else { + if req.ProductID == 0 || req.PlanID == 0 { + return nil, errors.New("请提供 product_code/plan_type 或 product_id/plan_id") + } + if err := s.db.First(&product, req.ProductID).Error; err != nil { + return nil, errors.New("产品不存在") + } + if err := s.db.First(&plan, req.PlanID).Error; err != nil { + return nil, errors.New("定价计划不存在") + } + if plan.ProductID != product.ID { + return nil, errors.New("定价计划与产品不匹配") + } } // 3. 防重复购买检查 // Logan.Li - checkResult := s.CheckDuplicatePurchase(customer.ID, req.ProductID, plan.PlanType) + checkResult := s.CheckDuplicatePurchase(customer.ID, product.ID, plan.PlanType) if !checkResult.AllowPurchase { return nil, fmt.Errorf("重复购买拦截: %s", checkResult.Reason) } - // 4. 创建订单 // Logan.Li - order := model.Order{ - OrderNo: utils.GenerateOrderNo(), - CustomerID: customer.ID, - ProductID: req.ProductID, - PlanID: req.PlanID, - Amount: plan.Price, - Currency: plan.Currency, - Status: "pending", - ClientIP: clientIP, - UserAgent: userAgent, - } - if err := s.db.Create(&order).Error; err != nil { - return nil, errors.New("创建订单失败") - } - - // 5. 选取可用支付渠道 // Logan.Li + // 4. 选取可用支付渠道(先选网关,写入订单) // Logan.Li var gateway model.PaymentGatewayConfig if err := s.db.Where("enabled = ?", true).Order("priority ASC").First(&gateway).Error; err != nil { return nil, errors.New("暂无可用的支付渠道") } - // 6. 生成收银台地址(占位) // Logan.Li - checkoutURL := fmt.Sprintf("https://checkout.%s.com/?order_no=%s", gateway.Gateway, order.OrderNo) + notes := "" + if deviceID := strings.TrimSpace(req.DeviceID); deviceID != "" { + notes = fmt.Sprintf(`{"device_id":"%s"}`, deviceID) + } + + // 5. 创建订单 // Logan.Li + order := model.Order{ + OrderNo: utils.GenerateOrderNo(), + CustomerID: customer.ID, + ProductID: product.ID, + PlanID: plan.ID, + Amount: plan.Price, + Currency: plan.Currency, + Status: "pending", + PaymentGateway: gateway.Gateway, + ClientIP: clientIP, + UserAgent: userAgent, + Notes: notes, + } + if err := s.db.Create(&order).Error; err != nil { + return nil, errors.New("创建订单失败") + } + + // 6. 生成收银台地址 // Logan.Li + var checkoutURL string + if gateway.Gateway == "waffo" { + url, err := payment.BuildCheckoutURL( + payment.LoadWaffoConfig(), + order.OrderNo, + strings.TrimSpace(req.DeviceID), + req.CustomerEmail, + req.ReturnURL, + plan.PlanType, + ) + if err != nil { + return nil, fmt.Errorf("Waffo 收银台配置错误: %v", err) + } + checkoutURL = url + } else { + checkoutURL = fmt.Sprintf("https://checkout.%s.com/?order_no=%s", gateway.Gateway, order.OrderNo) + } return &dto.CreateOrderResponse{ OrderNo: order.OrderNo, @@ -125,6 +172,145 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge }, nil } +// Checkout desktop-friendly entry: defaults product_code=opsxshell, plan_type=perpetual. // Logan.Li +func (s *OrderService) Checkout(req dto.CheckoutRequest, clientIP, userAgent string) (*dto.CreateOrderResponse, error) { + productCode := strings.TrimSpace(req.ProductCode) + if productCode == "" { + productCode = "opsxshell" + } + planType := strings.TrimSpace(req.PlanType) + if planType == "" { + planType = "perpetual" + } + return s.CreateOrder(dto.CreateOrderRequest{ + CustomerEmail: req.CustomerEmail, + CustomerName: req.CustomerName, + ProductCode: productCode, + PlanType: planType, + DeviceID: req.DeviceID, + ReturnURL: req.ReturnURL, + CancelURL: req.CancelURL, + }, clientIP, userAgent) +} + +// ClaimPurchase fetch license after payment by order_no or email+product. // Logan.Li +func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPurchaseResponse, error) { + orderNo := strings.TrimSpace(req.OrderNo) + email := strings.TrimSpace(req.CustomerEmail) + productCode := strings.TrimSpace(req.ProductCode) + if productCode == "" { + productCode = "opsxshell" + } + + if orderNo != "" { + var order model.Order + if err := s.db.Where("order_no = ?", orderNo).First(&order).Error; err != nil { + return nil, errors.New("订单不存在") + } + switch order.Status { + case "pending": + return &dto.ClaimPurchaseResponse{ + OrderNo: order.OrderNo, + Status: "pending", + Message: "订单尚未支付完成,请稍后再试", + }, nil + case "paid": + licenseKey := "" + if order.LicenseID > 0 { + var lic model.License + if err := s.db.First(&lic, order.LicenseID).Error; err == nil { + licenseKey = lic.LicenseKey + } + } + if licenseKey == "" { + var ent model.CustomerEntitlement + if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", order.CustomerID, order.ProductID, "active"). + First(&ent).Error; err == nil { + licenseKey = ent.LicenseKey + } + } + if licenseKey == "" { + return &dto.ClaimPurchaseResponse{ + OrderNo: order.OrderNo, + Status: "paid", + Message: "订单已支付,但 License 尚未就绪", + }, nil + } + return &dto.ClaimPurchaseResponse{ + LicenseKey: licenseKey, + OrderNo: order.OrderNo, + Status: "paid", + Message: "ok", + }, nil + default: + return &dto.ClaimPurchaseResponse{ + OrderNo: order.OrderNo, + Status: order.Status, + Message: fmt.Sprintf("订单状态为 %s,无法领取授权", order.Status), + }, nil + } + } + + if email == "" { + return nil, errors.New("请提供 order_no 或 customer_email") + } + + var product model.Product + if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil { + return nil, errors.New("产品不存在") + } + + var customer model.Customer + if err := s.db.Where("email = ?", email).First(&customer).Error; err != nil { + return &dto.ClaimPurchaseResponse{ + Status: "not_found", + Message: "未找到购买记录", + }, nil + } + + var ent model.CustomerEntitlement + if err := s.db.Where("customer_id = ? AND product_id = ?", customer.ID, product.ID). + Order("updated_at DESC").First(&ent).Error; err != nil { + // 可能有未支付订单 // Logan.Li + var pending model.Order + if err2 := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", customer.ID, product.ID, "pending"). + Order("created_at DESC").First(&pending).Error; err2 == nil { + return &dto.ClaimPurchaseResponse{ + OrderNo: pending.OrderNo, + Status: "pending", + Message: "订单尚未支付完成,请稍后再试", + }, nil + } + return &dto.ClaimPurchaseResponse{ + Status: "not_found", + Message: "未找到购买记录", + }, nil + } + + if ent.Status == "active" && ent.LicenseKey != "" { + return &dto.ClaimPurchaseResponse{ + LicenseKey: ent.LicenseKey, + Status: "paid", + Message: "ok", + }, nil + } + + var pending model.Order + if err := s.db.Where("customer_id = ? AND product_id = ? AND status = ?", customer.ID, product.ID, "pending"). + Order("created_at DESC").First(&pending).Error; err == nil { + return &dto.ClaimPurchaseResponse{ + OrderNo: pending.OrderNo, + Status: "pending", + Message: "订单尚未支付完成,请稍后再试", + }, nil + } + + return &dto.ClaimPurchaseResponse{ + Status: ent.Status, + Message: "暂无可用授权", + }, nil +} + // PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error { // 幂等检查 // Logan.Li diff --git a/backend/internal/service/payment/waffo.go b/backend/internal/service/payment/waffo.go new file mode 100644 index 0000000..4bf48ac --- /dev/null +++ b/backend/internal/service/payment/waffo.go @@ -0,0 +1,153 @@ +package payment + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +// WaffoConfig hosted checkout settings for pancake.waffo.ai // Logan.Li +type WaffoConfig struct { + CheckoutURL string + StoreSlug string + ProductID string + ProductIDYearly string + ProductIDPerpetual string + Currency string + Environment string + SuccessURL string +} + +// LoadWaffoConfig reads WAFFO_* env vars (same names as OpsXShell legacy license-server). // Logan.Li +func LoadWaffoConfig() WaffoConfig { + env := strings.TrimSpace(os.Getenv("WAFFO_ENVIRONMENT")) + if env == "" { + env = "test" + } + success := strings.TrimSpace(os.Getenv("SUCCESS_URL")) + if success == "" { + success = "opsxshell://pro/activated" + } + currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY")) + if currency == "" { + currency = "CNY" + } + defaultProduct := strings.TrimSpace(os.Getenv("WAFFO_PRODUCT_ID")) + yearly := strings.TrimSpace(os.Getenv("WAFFO_PRODUCT_ID_YEARLY")) + perpetual := strings.TrimSpace(os.Getenv("WAFFO_PRODUCT_ID_PERPETUAL")) + if yearly == "" { + yearly = defaultProduct + } + if perpetual == "" { + perpetual = defaultProduct + } + return WaffoConfig{ + CheckoutURL: strings.TrimSpace(os.Getenv("WAFFO_CHECKOUT_URL")), + StoreSlug: strings.TrimSpace(os.Getenv("WAFFO_STORE_SLUG")), + ProductID: defaultProduct, + ProductIDYearly: yearly, + ProductIDPerpetual: perpetual, + Currency: currency, + Environment: env, + SuccessURL: success, + } +} + +// BuildCheckoutURL returns a Waffo hosted checkout URL with order/device/email metadata. // Logan.Li +func BuildCheckoutURL(cfg WaffoConfig, orderNo, deviceID, email, returnURL, planType string) (string, error) { + success := cfg.SuccessURL + if strings.TrimSpace(returnURL) != "" { + success = strings.TrimSpace(returnURL) + } + u, err := resolveBaseCheckoutURL(cfg, planType) + if err != nil { + return "", err + } + q := u.Query() + if q.Get("successUrl") == "" { + q.Set("successUrl", appendQuery(success, map[string]string{ + "orderNo": orderNo, + "deviceId": deviceID, + "email": email, + "planType": planType, + })) + } + if deviceID != "" { + q.Set("deviceId", deviceID) + } + if orderNo != "" { + q.Set("orderNo", orderNo) + } + if email != "" { + q.Set("email", email) + } + if planType != "" { + q.Set("planType", planType) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func productIDForPlan(cfg WaffoConfig, planType string) string { + switch strings.TrimSpace(planType) { + case "yearly", "monthly": + return cfg.ProductIDYearly + case "perpetual", "lifetime": + return cfg.ProductIDPerpetual + default: + if cfg.ProductIDPerpetual != "" { + return cfg.ProductIDPerpetual + } + return cfg.ProductID + } +} + +func checkoutTypeForPlan(planType string) string { + switch strings.TrimSpace(planType) { + case "yearly", "monthly": + return "subscription" + default: + return "onetime" + } +} + +func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error) { + // Single WAFFO_CHECKOUT_URL only as absolute fallback when no store/products configured. + // Prefer store + per-plan product IDs so yearly/perpetual map to different Waffo SKUs. + productID := productIDForPlan(cfg, planType) + if cfg.StoreSlug != "" && productID != "" { + raw := fmt.Sprintf("https://pancake.waffo.ai/store/%s/product/%s", cfg.StoreSlug, productID) + u, err := url.Parse(raw) + if err != nil { + return nil, err + } + q := u.Query() + q.Set("type", checkoutTypeForPlan(planType)) + q.Set("currency", cfg.Currency) + if cfg.Environment == "test" { + q.Set("test", "true") + } + u.RawQuery = q.Encode() + return u, nil + } + if cfg.CheckoutURL != "" { + return url.Parse(cfg.CheckoutURL) + } + return nil, fmt.Errorf("未配置 WAFFO_STORE_SLUG 与 WAFFO_PRODUCT_ID_YEARLY / WAFFO_PRODUCT_ID_PERPETUAL(或 WAFFO_CHECKOUT_URL)") +} + +func appendQuery(raw string, values map[string]string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + q := u.Query() + for k, v := range values { + if v != "" { + q.Set(k, v) + } + } + u.RawQuery = q.Encode() + return u.String() +} diff --git a/backend/internal/service/payment/waffo_test.go b/backend/internal/service/payment/waffo_test.go new file mode 100644 index 0000000..33b7753 --- /dev/null +++ b/backend/internal/service/payment/waffo_test.go @@ -0,0 +1,64 @@ +package payment + +import ( + "net/url" + "strings" + "testing" +) + +func TestBuildCheckoutURL_YearlySubscription(t *testing.T) { + cfg := WaffoConfig{ + StoreSlug: "opsxshell-pwsi6m4n", + ProductIDYearly: "PROD_2WmkUVoYWt0NN8OVEyC6n3", + ProductIDPerpetual: "PROD_0pcnzzT4nalEkkcdyIUoD5", + Currency: "CNY", + Environment: "test", + SuccessURL: "opsxshell://pro/activated", + } + raw, err := BuildCheckoutURL(cfg, "ORD1", "dev-1", "a@b.c", "", "yearly") + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(u.Path, "PROD_2WmkUVoYWt0NN8OVEyC6n3") { + t.Fatalf("yearly product missing: %s", raw) + } + q := u.Query() + if q.Get("type") != "subscription" { + t.Fatalf("type=%s want subscription", q.Get("type")) + } + if q.Get("currency") != "CNY" { + t.Fatalf("currency=%s", q.Get("currency")) + } + if q.Get("test") != "true" { + t.Fatalf("test=%s", q.Get("test")) + } +} + +func TestBuildCheckoutURL_PerpetualOnetime(t *testing.T) { + cfg := WaffoConfig{ + StoreSlug: "opsxshell-pwsi6m4n", + ProductIDYearly: "PROD_2WmkUVoYWt0NN8OVEyC6n3", + ProductIDPerpetual: "PROD_0pcnzzT4nalEkkcdyIUoD5", + Currency: "CNY", + Environment: "test", + SuccessURL: "opsxshell://pro/activated", + } + raw, err := BuildCheckoutURL(cfg, "ORD2", "dev-1", "a@b.c", "", "perpetual") + if err != nil { + t.Fatal(err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(u.Path, "PROD_0pcnzzT4nalEkkcdyIUoD5") { + t.Fatalf("perpetual product missing: %s", raw) + } + if u.Query().Get("type") != "onetime" { + t.Fatalf("type=%s want onetime", u.Query().Get("type")) + } +} diff --git a/backend/internal/service/product/product_service.go b/backend/internal/service/product/product_service.go index 3afaa85..9eaaf1d 100644 --- a/backend/internal/service/product/product_service.go +++ b/backend/internal/service/product/product_service.go @@ -66,6 +66,16 @@ func (s *ProductService) GetByID(id uint) (*dto.ProductResponse, error) { return &resp, nil } +// GetByCode 根据产品编码获取产品 // Logan.Li +func (s *ProductService) GetByCode(code string) (*dto.ProductResponse, error) { + var product model.Product + if err := s.db.Where("code = ?", code).First(&product).Error; err != nil { + return nil, errors.New("产品不存在") + } + resp := toProductResponse(product) + return &resp, nil +} + // Create 创建产品 // Logan.Li func (s *ProductService) Create(req dto.ProductCreate) (*dto.ProductResponse, error) { // 检查 code 唯一性 // Logan.Li diff --git a/backend/router/router.go b/backend/router/router.go index 7218bfd..78f7b2a 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -98,13 +98,18 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { register(clientAPI, "POST", "/license/activate", licenseController.ActivateDevice) register(clientAPI, "POST", "/license/deactivate", licenseController.Deactivate) register(clientAPI, "POST", "/license/heartbeat", licenseController.Heartbeat) + register(clientAPI, "POST", "/license/devices", licenseController.ListDevices) // 产品定价查询(展示用) + register(clientAPI, "GET", "/products/code/:code", productController.GetByCode) clientAPI.GET("/products/:product_id/plans", planController.GetByProduct) // 订单与支付(客户端购买入口 + 支付回调) register(clientAPI, "POST", "/orders", orderController.CreateOrder) + register(clientAPI, "POST", "/checkout", orderController.Checkout) + register(clientAPI, "POST", "/purchase/claim", orderController.ClaimPurchase) register(clientAPI, "POST", "/webhooks/payment", orderController.PaymentWebhook) + register(clientAPI, "POST", "/webhooks/waffo", orderController.WaffoWebhook) register(clientAPI, "POST", "/purchase/check", orderController.CheckDuplicatePurchase) register(clientAPI, "POST", "/purchase/status", orderController.CheckPurchaseStatus)