feat: 对齐 Waffo 包年/永久结账与客户端席位 API
同步 OpsXShell 档位定价与 Waffo 商品映射,补齐 checkout/webhook/设备列表契约,便于桌面端购买与满席释放。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4ae318335c
commit
95405de119
14 files changed
+952
-149
No files matched your search
@@ -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)
|
||||
# ============================================
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -14,12 +16,17 @@ import (
|
||||
// ProductController 产品控制器 // Logan.Li
|
||||
type ProductController struct {
|
||||
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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
var plan model.Plan
|
||||
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("产品不存在")
|
||||
}
|
||||
var plan model.Plan
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user