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

@@ -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