feat: 对齐 Waffo 包年/永久结账与客户端席位 API

同步 OpsXShell 档位定价与 Waffo 商品映射,补齐 checkout/webhook/设备列表契约,便于桌面端购买与满席释放。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 11:42:56 +08:00
1 parent 4ae318335c
commit 95405de119
14 files changed
+958 -155

No files matched your search

+10 -2
View File
@@ -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",
},
}
+180 -26
View File
@@ -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
}
@@ -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),
}})
}
@@ -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
@@ -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
+37 -2
View File
@@ -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"`
+1
View File
@@ -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"`
}
@@ -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
}
+212 -26
View File
@@ -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
+153
View File
@@ -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()
}
@@ -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"))
}
}
@@ -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
+5
View File
@@ -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)