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
+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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user