feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0966375076
commit
8cc2c83f9d
41 files changed
+3177
-332
No files matched your search
@@ -3,6 +3,9 @@ package analytics
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -21,16 +24,26 @@ func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
|
||||
// GetDashboardStats 仪表板统计 // Logan.Li
|
||||
func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) {
|
||||
var stats dto.DashboardStats
|
||||
now := time.Now()
|
||||
|
||||
s.db.Model(&model.License{}).Count(&stats.TotalLicenses)
|
||||
s.db.Model(&model.License{}).Where("status = ?", model.LicenseStatusActive).Count(&stats.ActiveLicenses)
|
||||
|
||||
// 本月新增 // Logan.Li
|
||||
now := time.Now()
|
||||
// 已过期:显式 expired,或到期日已过且非永久(end < 2090) // Logan.Li
|
||||
s.db.Model(&model.License{}).Where(
|
||||
"status = ? OR (end_date < ? AND end_date < ? AND status NOT IN ?)",
|
||||
model.LicenseStatusExpired,
|
||||
now,
|
||||
time.Date(2090, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
[]string{model.LicenseStatusRevoked},
|
||||
).Count(&stats.ExpiredLicenses)
|
||||
|
||||
s.db.Model(&model.License{}).Where("license_key LIKE ?", "LICOFF-%").Count(&stats.OfflineLicenses)
|
||||
s.db.Model(&model.License{}).Where("license_key NOT LIKE ?", "LICOFF-%").Count(&stats.OnlineLicenses)
|
||||
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
s.db.Model(&model.License{}).Where("created_at >= ?", monthStart).Count(&stats.NewThisMonth)
|
||||
|
||||
// 即将到期(30 天内) // Logan.Li
|
||||
thirtyDaysLater := now.AddDate(0, 0, 30)
|
||||
s.db.Model(&model.License{}).
|
||||
Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, thirtyDaysLater).
|
||||
@@ -39,15 +52,17 @@ func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) {
|
||||
s.db.Model(&model.Customer{}).Count(&stats.TotalCustomers)
|
||||
s.db.Model(&model.Product{}).Count(&stats.TotalProducts)
|
||||
|
||||
// 在线设备(5 分钟内有心跳) // Logan.Li
|
||||
fiveMinAgo := now.Add(-5 * time.Minute)
|
||||
s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&stats.OnlineDevices)
|
||||
|
||||
// 本月收入 // Logan.Li
|
||||
s.db.Model(&model.Order{}).
|
||||
Where("status = ? AND paid_at >= ?", "paid", monthStart).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&stats.MonthlyRevenue)
|
||||
s.db.Model(&model.Order{}).
|
||||
Where("status = ?", "paid").
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&stats.TotalRevenue)
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
@@ -69,7 +84,7 @@ func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseI
|
||||
for _, l := range licenses {
|
||||
results = append(results, dto.ExpiringLicenseItem{
|
||||
ID: l.ID,
|
||||
LicenseKey: l.LicenseKey,
|
||||
LicenseKey: shortKey(l.LicenseKey),
|
||||
CustomerName: l.Customer.Name,
|
||||
ProductName: l.Product.Name,
|
||||
EndDate: l.EndDate.Format("2006-01-02"),
|
||||
@@ -79,6 +94,14 @@ func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseI
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func shortKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if strings.HasPrefix(key, "LICOFF-") && len(key) > 28 {
|
||||
return key[:14] + "…" + key[len(key)-8:]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// GetRecentActivities 最近操作活动 // Logan.Li
|
||||
func (s *AnalyticsService) GetRecentActivities(limit int) ([]dto.RecentActivityItem, error) {
|
||||
if limit <= 0 {
|
||||
@@ -113,9 +136,9 @@ func (s *AnalyticsService) GetOnlineStats() (map[string]interface{}, error) {
|
||||
s.db.Model(&model.Activation{}).Where("status = ?", "active").Count(&totalActivations)
|
||||
|
||||
return map[string]interface{}{
|
||||
"online_devices": onlineDevices,
|
||||
"total_activations": totalActivations,
|
||||
"offline_devices": totalActivations - onlineDevices,
|
||||
"online_devices": onlineDevices,
|
||||
"total_activations": totalActivations,
|
||||
"offline_devices": totalActivations - onlineDevices,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -139,17 +162,36 @@ func (s *AnalyticsService) GetRevenueStats() ([]dto.RevenuePoint, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetLicenseGrowth License 增长趋势(近 6 个月) // Logan.Li
|
||||
// GetLicenseGrowth License 增长趋势(近 12 个月累计) // Logan.Li
|
||||
func (s *AnalyticsService) GetLicenseGrowth() ([]dto.LicenseGrowthPoint, error) {
|
||||
now := time.Now()
|
||||
results := make([]dto.LicenseGrowthPoint, 0, 6)
|
||||
for i := 5; i >= 0; i-- {
|
||||
monthEnd := time.Date(now.Year(), now.Month()-time.Month(i)+1, 0, 23, 59, 59, 0, now.Location())
|
||||
var count int64
|
||||
s.db.Model(&model.License{}).Where("created_at <= ?", monthEnd).Count(&count)
|
||||
detail, err := s.GetLicenseGrowthDetail()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]dto.LicenseGrowthPoint, 0, len(detail))
|
||||
for _, row := range detail {
|
||||
results = append(results, dto.LicenseGrowthPoint{
|
||||
Date: monthEnd.Format("2006-01"),
|
||||
Count: count,
|
||||
Date: row["date"].(string),
|
||||
Count: row["cumulative"].(int64),
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetLicenseGrowthDetail 含当月新增的增长序列 // Logan.Li
|
||||
func (s *AnalyticsService) GetLicenseGrowthDetail() ([]map[string]interface{}, error) {
|
||||
now := time.Now()
|
||||
results := make([]map[string]interface{}, 0, 12)
|
||||
for i := 11; i >= 0; i-- {
|
||||
monthStart := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location())
|
||||
nextMonth := monthStart.AddDate(0, 1, 0)
|
||||
var cumulative, created int64
|
||||
s.db.Model(&model.License{}).Where("created_at < ?", nextMonth).Count(&cumulative)
|
||||
s.db.Model(&model.License{}).Where("created_at >= ? AND created_at < ?", monthStart, nextMonth).Count(&created)
|
||||
results = append(results, map[string]interface{}{
|
||||
"date": monthStart.Format("2006-01"),
|
||||
"cumulative": cumulative,
|
||||
"created": created,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
@@ -165,3 +207,105 @@ func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution,
|
||||
Scan(&results)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetGeoDistribution 地理/网络分布(device_info.country 优先,否则按 IP 粗分) // Logan.Li
|
||||
func (s *AnalyticsService) GetGeoDistribution() ([]dto.GeoDistribution, error) {
|
||||
var acts []model.Activation
|
||||
s.db.Where("status = ?", "active").Find(&acts)
|
||||
counts := map[string]int64{}
|
||||
for _, a := range acts {
|
||||
region := regionFromActivation(a)
|
||||
counts[region]++
|
||||
}
|
||||
results := make([]dto.GeoDistribution, 0, len(counts))
|
||||
for region, count := range counts {
|
||||
results = append(results, dto.GeoDistribution{Region: region, Count: count})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetPlatformDistribution 客户端形态分布(web/pc/mobile) // Logan.Li
|
||||
func (s *AnalyticsService) GetPlatformDistribution() ([]dto.PlatformDistribution, error) {
|
||||
var acts []model.Activation
|
||||
s.db.Where("status = ?", "active").Find(&acts)
|
||||
counts := map[string]int64{}
|
||||
for _, a := range acts {
|
||||
p := platformFromActivation(a)
|
||||
counts[p]++
|
||||
}
|
||||
order := []string{"pc", "web", "mobile", "unknown"}
|
||||
results := make([]dto.PlatformDistribution, 0, len(order))
|
||||
for _, p := range order {
|
||||
if counts[p] > 0 {
|
||||
results = append(results, dto.PlatformDistribution{Platform: p, Count: counts[p]})
|
||||
}
|
||||
}
|
||||
for p, c := range counts {
|
||||
if p == "pc" || p == "web" || p == "mobile" || p == "unknown" {
|
||||
continue
|
||||
}
|
||||
results = append(results, dto.PlatformDistribution{Platform: p, Count: c})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func regionFromActivation(a model.Activation) string {
|
||||
if info := parseDeviceInfo(a.DeviceInfo); info != nil {
|
||||
for _, k := range []string{"country", "region", "locale", "geo"} {
|
||||
if v, ok := info[k].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
ip := strings.TrimSpace(a.IP)
|
||||
if ip == "" {
|
||||
return "未知"
|
||||
}
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
return "未知"
|
||||
}
|
||||
if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() {
|
||||
return "内网"
|
||||
}
|
||||
return "公网"
|
||||
}
|
||||
|
||||
func platformFromActivation(a model.Activation) string {
|
||||
if info := parseDeviceInfo(a.DeviceInfo); info != nil {
|
||||
for _, k := range []string{"platform", "client", "os", "channel"} {
|
||||
if v, ok := info[k].(string); ok {
|
||||
p := strings.ToLower(strings.TrimSpace(v))
|
||||
switch {
|
||||
case strings.Contains(p, "ios"), strings.Contains(p, "android"), strings.Contains(p, "mobile"):
|
||||
return "mobile"
|
||||
case strings.Contains(p, "web"), strings.Contains(p, "browser"), strings.Contains(p, "h5"):
|
||||
return "web"
|
||||
case strings.Contains(p, "windows"), strings.Contains(p, "mac"), strings.Contains(p, "linux"),
|
||||
strings.Contains(p, "desktop"), strings.Contains(p, "tauri"), strings.Contains(p, "pc"):
|
||||
return "pc"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
name := strings.ToLower(a.DeviceName)
|
||||
switch {
|
||||
case strings.Contains(name, "iphone"), strings.Contains(name, "android"):
|
||||
return "mobile"
|
||||
case strings.Contains(name, "opsxshell"), strings.Contains(name, "desktop"):
|
||||
return "pc"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func parseDeviceInfo(raw string) map[string]interface{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -28,6 +29,7 @@ type ListQuery struct {
|
||||
Status string
|
||||
CustomerID uint
|
||||
ProductID uint
|
||||
Channel string // online | offline | ""(全部)
|
||||
}
|
||||
|
||||
// List 分页查询 License // Logan.Li
|
||||
@@ -35,7 +37,7 @@ func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseRes
|
||||
var licenses []model.License
|
||||
var total int64
|
||||
|
||||
query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product")
|
||||
query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product").Preload("Activations")
|
||||
if q.Keyword != "" {
|
||||
query = query.Where("license_key LIKE ?", "%"+q.Keyword+"%")
|
||||
}
|
||||
@@ -48,6 +50,12 @@ func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseRes
|
||||
if q.ProductID > 0 {
|
||||
query = query.Where("product_id = ?", q.ProductID)
|
||||
}
|
||||
switch strings.TrimSpace(q.Channel) {
|
||||
case "offline":
|
||||
query = query.Where("license_key LIKE ?", "LICOFF-%")
|
||||
case "online":
|
||||
query = query.Where("license_key NOT LIKE ?", "LICOFF-%")
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
if err := query.Order("created_at DESC").
|
||||
@@ -165,12 +173,15 @@ func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// Renew 续期 // Logan.Li
|
||||
// Renew 续期(仅在线证;离线证签名自包含,到期后应重新签发) // Logan.Li
|
||||
func (s *LicenseService) Renew(id uint, duration int) (*dto.LicenseResponse, error) {
|
||||
var lic model.License
|
||||
if err := s.db.First(&lic, id).Error; err != nil {
|
||||
return nil, errors.New("License 不存在")
|
||||
}
|
||||
if utils.IsOfflineLicenseKey(lic.LicenseKey) {
|
||||
return nil, errors.New("离线许可证不可续期:到期后请重新签发新序列号(绑定同一设备 ID)")
|
||||
}
|
||||
|
||||
var newEndDate time.Time
|
||||
if duration == model.DurationForever {
|
||||
@@ -300,9 +311,25 @@ func toLicenseResponse(l model.License) dto.LicenseResponse {
|
||||
if l.Product.ID > 0 {
|
||||
productName = l.Product.Name
|
||||
}
|
||||
offline := utils.IsOfflineLicenseKey(l.LicenseKey)
|
||||
channel := "online"
|
||||
if offline {
|
||||
channel = "offline"
|
||||
}
|
||||
deviceID := ""
|
||||
for _, a := range l.Activations {
|
||||
if strings.TrimSpace(a.DeviceID) != "" {
|
||||
deviceID = a.DeviceID
|
||||
break
|
||||
}
|
||||
}
|
||||
return dto.LicenseResponse{
|
||||
ID: l.ID,
|
||||
LicenseKey: l.LicenseKey,
|
||||
LicenseKeyShort: shortLicenseKey(l.LicenseKey),
|
||||
Channel: channel,
|
||||
IsOffline: offline,
|
||||
DeviceID: deviceID,
|
||||
CustomerID: l.CustomerID,
|
||||
CustomerName: customerName,
|
||||
ProductID: l.ProductID,
|
||||
@@ -320,6 +347,18 @@ func toLicenseResponse(l model.License) dto.LicenseResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// shortLicenseKey 表格短展示:在线保留全文;离线保留前缀+尾部。 // Logan.Li
|
||||
func shortLicenseKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
if !utils.IsOfflineLicenseKey(key) {
|
||||
return key
|
||||
}
|
||||
if len(key) <= 28 {
|
||||
return key
|
||||
}
|
||||
return key[:14] + "…" + key[len(key)-8:]
|
||||
}
|
||||
|
||||
// toActivationResponse 模型转响应 DTO // Logan.Li
|
||||
func toActivationResponse(a model.Activation) dto.ActivationResponse {
|
||||
return dto.ActivationResponse{
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"backend/config"
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"backend/utils"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OfflineIssuer 离线许可证签发(绑定 device_id,最长 365 天) // Logan.Li
|
||||
type OfflineIssuer struct {
|
||||
db *gorm.DB
|
||||
kp *utils.OfflineKeyPair
|
||||
}
|
||||
|
||||
// NewOfflineIssuer 创建离线签发器;seed 为空时返回可用实例但 Issue 会失败 // Logan.Li
|
||||
func NewOfflineIssuer(db *gorm.DB, cfg *config.Config) *OfflineIssuer {
|
||||
issuer := &OfflineIssuer{db: db}
|
||||
if cfg == nil || strings.TrimSpace(cfg.OfflineLicenseSeed) == "" {
|
||||
return issuer
|
||||
}
|
||||
kp, err := utils.DeriveOfflineKeyPair(cfg.OfflineLicenseSeed)
|
||||
if err == nil {
|
||||
issuer.kp = kp
|
||||
}
|
||||
return issuer
|
||||
}
|
||||
|
||||
// PublicKeyHex 返回当前公钥 hex(桌面嵌入对照) // Logan.Li
|
||||
func (s *OfflineIssuer) PublicKeyHex() string {
|
||||
if s == nil || s.kp == nil {
|
||||
return ""
|
||||
}
|
||||
return s.kp.OfflinePublicKeyHex()
|
||||
}
|
||||
|
||||
// Issue 签发离线序列号并落库审计记录 // Logan.Li
|
||||
func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicenseResponse, error) {
|
||||
if s == nil || s.kp == nil {
|
||||
return nil, errors.New("未配置 OFFLINE_LICENSE_SEED,无法签发离线证")
|
||||
}
|
||||
|
||||
deviceID := strings.TrimSpace(req.DeviceID)
|
||||
if deviceID == "" {
|
||||
return nil, errors.New("device_id 不能为空")
|
||||
}
|
||||
|
||||
planType := strings.TrimSpace(req.PlanType)
|
||||
if planType == "" {
|
||||
planType = "yearly"
|
||||
}
|
||||
if planType != "trial" && planType != "yearly" {
|
||||
return nil, errors.New("离线证仅允许体验(trial)或包年(yearly),禁止永久")
|
||||
}
|
||||
|
||||
duration := req.DurationDays
|
||||
if duration <= 0 {
|
||||
duration = utils.OfflineMaxDurationDays
|
||||
}
|
||||
if duration > utils.OfflineMaxDurationDays {
|
||||
return nil, errors.New("离线证有效期不得超过 365 天")
|
||||
}
|
||||
if planType == "trial" && duration > 180 {
|
||||
duration = 180
|
||||
}
|
||||
|
||||
productCode := strings.TrimSpace(req.ProductCode)
|
||||
if productCode == "" {
|
||||
productCode = "opsxshell"
|
||||
}
|
||||
|
||||
var product model.Product
|
||||
if err := s.db.Where("code = ?", productCode).First(&product).Error; err != nil {
|
||||
return nil, errors.New("产品不存在: " + productCode)
|
||||
}
|
||||
|
||||
var customer model.Customer
|
||||
if req.CustomerID > 0 {
|
||||
if err := s.db.First(&customer, req.CustomerID).Error; err != nil {
|
||||
return nil, errors.New("客户不存在")
|
||||
}
|
||||
} else {
|
||||
email := strings.TrimSpace(req.Email)
|
||||
name := strings.TrimSpace(req.CustomerName)
|
||||
if name == "" {
|
||||
name = "离线客户"
|
||||
}
|
||||
if email != "" {
|
||||
err := s.db.Where("email = ?", email).First(&customer).Error
|
||||
if err != nil {
|
||||
customer = model.Customer{Name: name, Email: email, Status: 1}
|
||||
if err := s.db.Create(&customer).Error; err != nil {
|
||||
return nil, errors.New("创建客户失败")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
customer = model.Customer{Name: name, Email: "", Status: 1}
|
||||
if err := s.db.Create(&customer).Error; err != nil {
|
||||
return nil, errors.New("创建客户失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
exp := now.AddDate(0, 0, duration)
|
||||
claims := utils.OfflineLicenseClaims{
|
||||
V: 1,
|
||||
ProductCode: product.Code,
|
||||
DeviceID: deviceID,
|
||||
Email: strings.TrimSpace(req.Email),
|
||||
PlanType: planType,
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: exp.Unix(),
|
||||
MaxDevices: 1,
|
||||
CustomerName: customer.Name,
|
||||
}
|
||||
|
||||
serial, err := utils.SignOfflineLicense(s.kp, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notes := strings.TrimSpace(req.Notes)
|
||||
if notes == "" {
|
||||
notes = "离线许可证"
|
||||
}
|
||||
notes = notes + " | device=" + deviceID + " | plan=" + planType
|
||||
|
||||
lic := model.License{
|
||||
LicenseKey: serial,
|
||||
CustomerID: customer.ID,
|
||||
ProductID: product.ID,
|
||||
Status: model.LicenseStatusActive,
|
||||
StartDate: now,
|
||||
EndDate: exp,
|
||||
Duration: duration,
|
||||
MaxActivations: 1,
|
||||
CurrentActivations: 1,
|
||||
IsTrial: planType == "trial",
|
||||
TrialDays: duration,
|
||||
Notes: notes,
|
||||
}
|
||||
if err := s.db.Create(&lic).Error; err != nil {
|
||||
return nil, errors.New("保存离线 License 失败")
|
||||
}
|
||||
|
||||
activation := model.Activation{
|
||||
LicenseID: lic.ID,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: "offline",
|
||||
Status: "active",
|
||||
IsOnline: false,
|
||||
ActivatedAt: now,
|
||||
ExpiresAt: &exp,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
_ = s.db.Create(&activation).Error
|
||||
|
||||
return &dto.OfflineLicenseResponse{
|
||||
Serial: serial,
|
||||
DeviceID: deviceID,
|
||||
Email: claims.Email,
|
||||
PlanType: planType,
|
||||
ProductCode: product.Code,
|
||||
IssuedAt: now.Format(time.RFC3339),
|
||||
ExpiresAt: exp.Format(time.RFC3339),
|
||||
Duration: duration,
|
||||
MaxDevices: 1,
|
||||
LicenseID: lic.ID,
|
||||
PublicKey: s.kp.OfflinePublicKeyHex(),
|
||||
}, nil
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge
|
||||
var checkoutURL string
|
||||
if gateway.Gateway == "waffo" {
|
||||
url, err := payment.BuildCheckoutURL(
|
||||
payment.LoadWaffoConfig(),
|
||||
payment.LoadWaffoConfig(s.db),
|
||||
order.OrderNo,
|
||||
strings.TrimSpace(req.DeviceID),
|
||||
req.CustomerEmail,
|
||||
@@ -311,12 +311,40 @@ func (s *OrderService) ClaimPurchase(req dto.ClaimPurchaseRequest) (*dto.ClaimPu
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveWebhookOrder finds local order by merchant external id, Waffo order id, or buyer email. // Logan.Li
|
||||
func (s *OrderService) resolveWebhookOrder(req dto.PaymentWebhookRequest) (model.Order, error) {
|
||||
var order model.Order
|
||||
if no := strings.TrimSpace(req.OrderNo); no != "" {
|
||||
if err := s.db.Where("order_no = ?", no).First(&order).Error; err == nil {
|
||||
return order, nil
|
||||
}
|
||||
// Hosted-store mistaken: Waffo ORD_ put in OrderNo — try gateway column. // Logan.Li
|
||||
if err := s.db.Where("gateway_order_no = ?", no).First(&order).Error; err == nil {
|
||||
return order, nil
|
||||
}
|
||||
}
|
||||
if g := strings.TrimSpace(req.GatewayOrderNo); g != "" {
|
||||
if err := s.db.Where("gateway_order_no = ?", g).First(&order).Error; err == nil {
|
||||
return order, nil
|
||||
}
|
||||
}
|
||||
if email := strings.TrimSpace(req.CustomerEmail); email != "" {
|
||||
var customer model.Customer
|
||||
if err := s.db.Where("email = ?", email).First(&customer).Error; err == nil {
|
||||
if err := s.db.Where("customer_id = ? AND status = ?", customer.ID, "pending").
|
||||
Order("created_at DESC").First(&order).Error; err == nil {
|
||||
return order, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return order, errors.New("订单不存在")
|
||||
}
|
||||
|
||||
// PaymentWebhook 支付回调(幂等 + 事务) // Logan.Li
|
||||
func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP string) error {
|
||||
// 幂等检查 // Logan.Li
|
||||
var order model.Order
|
||||
if err := s.db.Where("order_no = ?", req.OrderNo).First(&order).Error; err != nil {
|
||||
return errors.New("订单不存在")
|
||||
order, err := s.resolveWebhookOrder(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if order.Status == "paid" || order.Status == "refunded" {
|
||||
return nil // 已处理,幂等返回
|
||||
|
||||
@@ -1,35 +1,91 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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 // fallback / default
|
||||
CurrencyYearly string
|
||||
CurrencyPerpetual string
|
||||
Environment string
|
||||
SuccessURL string
|
||||
CheckoutURL string
|
||||
StoreSlug string
|
||||
StoreID string
|
||||
MerchantID string
|
||||
ProductID string
|
||||
ProductIDYearly string
|
||||
ProductIDPerpetual string
|
||||
// ProductIDs 可扩展 plan_type → Product ID;优先于固定 yearly/perpetual 字段 // Logan.Li
|
||||
ProductIDs map[string]string
|
||||
Currency string // fallback / default
|
||||
CurrencyYearly string
|
||||
CurrencyPerpetual string
|
||||
Environment string
|
||||
SuccessURL string
|
||||
// PrivateKey RSA 私钥原文(PEM 或无头 Base64);仅运行时使用,勿写入 API 响应 // Logan.Li
|
||||
PrivateKey string
|
||||
}
|
||||
|
||||
// LoadWaffoConfig reads WAFFO_* env vars (same names as OpsXShell legacy license-server). // Logan.Li
|
||||
func LoadWaffoConfig() WaffoConfig {
|
||||
// WaffoProductMapping 可扩展计划 key → Product ID(与管理端表格对齐) // Logan.Li
|
||||
type WaffoProductMapping struct {
|
||||
PlanKey string `json:"plan_key"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
// WaffoPublicMeta 存入 payment_gateway_configs.public_key 的 JSON(无私钥) // Logan.Li
|
||||
type WaffoPublicMeta struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
StoreID string `json:"store_id"`
|
||||
StoreSlug string `json:"store_slug"`
|
||||
ProductID string `json:"product_id"`
|
||||
ProductIDYearly string `json:"product_id_yearly"`
|
||||
ProductIDPerpetual string `json:"product_id_perpetual"`
|
||||
ProductIDs map[string]string `json:"product_ids,omitempty"` // 可扩展映射,兼容旧字段 // Logan.Li
|
||||
ProductMappings []WaffoProductMapping `json:"product_mappings,omitempty"` // 前端表格(含 label) // Logan.Li
|
||||
SuccessURL string `json:"success_url"`
|
||||
Currency string `json:"currency"`
|
||||
CurrencyYearly string `json:"currency_yearly"`
|
||||
CurrencyPerpetual string `json:"currency_perpetual"`
|
||||
Environment string `json:"environment"`
|
||||
CheckoutURL string `json:"checkout_url"`
|
||||
}
|
||||
|
||||
// DefaultWebhookURL 对外 Webhook 地址(只读提示) // Logan.Li
|
||||
const DefaultWebhookURL = "https://l123.91ai.io/api/v1/webhooks/waffo"
|
||||
|
||||
// DefaultSuccessURL 支付成功回跳默认地址 // Logan.Li
|
||||
const DefaultSuccessURL = "https://l123.91ai.io/api/v1/purchase/success"
|
||||
|
||||
// HasAPIKeyAuth 是否具备商户 API Key 鉴权(Merchant ID + 私钥) // Logan.Li
|
||||
func (c WaffoConfig) HasAPIKeyAuth() bool {
|
||||
return strings.TrimSpace(c.MerchantID) != "" && strings.TrimSpace(c.PrivateKey) != ""
|
||||
}
|
||||
|
||||
// LoadWaffoConfig 读取配置:后台 payment_gateway_configs 优先,.env 兜底 // Logan.Li
|
||||
func LoadWaffoConfig(db *gorm.DB) WaffoConfig {
|
||||
cfg := loadWaffoFromEnv()
|
||||
if db != nil {
|
||||
mergeWaffoFromDB(db, &cfg)
|
||||
}
|
||||
normalizeWaffoConfig(&cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func loadWaffoFromEnv() WaffoConfig {
|
||||
env := strings.TrimSpace(os.Getenv("WAFFO_ENVIRONMENT"))
|
||||
if env == "" {
|
||||
env = "test"
|
||||
}
|
||||
success := strings.TrimSpace(os.Getenv("SUCCESS_URL"))
|
||||
if success == "" {
|
||||
success = "http://l123.91ai.io/api/v1/purchase/success"
|
||||
success = DefaultSuccessURL
|
||||
}
|
||||
currency := strings.TrimSpace(os.Getenv("WAFFO_CURRENCY"))
|
||||
if currency == "" {
|
||||
@@ -52,9 +108,16 @@ func LoadWaffoConfig() WaffoConfig {
|
||||
if perpetual == "" {
|
||||
perpetual = defaultProduct
|
||||
}
|
||||
priv := firstNonEmpty(
|
||||
os.Getenv("WAFFO_PRIVATE_KEY"),
|
||||
os.Getenv("WAFFO_PRIVATE_KEY_PEM"),
|
||||
os.Getenv("WAFFO_PRIVATE_KEY_BASE64"),
|
||||
)
|
||||
return WaffoConfig{
|
||||
CheckoutURL: strings.TrimSpace(os.Getenv("WAFFO_CHECKOUT_URL")),
|
||||
StoreSlug: strings.TrimSpace(os.Getenv("WAFFO_STORE_SLUG")),
|
||||
StoreID: strings.TrimSpace(os.Getenv("WAFFO_STORE_ID")),
|
||||
MerchantID: strings.TrimSpace(os.Getenv("WAFFO_MERCHANT_ID")),
|
||||
ProductID: defaultProduct,
|
||||
ProductIDYearly: yearly,
|
||||
ProductIDPerpetual: perpetual,
|
||||
@@ -63,27 +126,244 @@ func LoadWaffoConfig() WaffoConfig {
|
||||
CurrencyPerpetual: currencyPerpetual,
|
||||
Environment: env,
|
||||
SuccessURL: success,
|
||||
PrivateKey: strings.TrimSpace(priv),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildCheckoutURL returns a Waffo hosted checkout URL with order/device/email metadata. // Logan.Li
|
||||
func mergeWaffoFromDB(db *gorm.DB, cfg *WaffoConfig) {
|
||||
var gw model.PaymentGatewayConfig
|
||||
if err := db.Where("gateway = ?", "waffo").First(&gw).Error; err != nil {
|
||||
return
|
||||
}
|
||||
meta := ParseWaffoPublicMeta(gw.PublicKey)
|
||||
if meta.MerchantID != "" {
|
||||
cfg.MerchantID = meta.MerchantID
|
||||
}
|
||||
if meta.StoreID != "" {
|
||||
cfg.StoreID = meta.StoreID
|
||||
}
|
||||
if meta.StoreSlug != "" {
|
||||
cfg.StoreSlug = meta.StoreSlug
|
||||
}
|
||||
if meta.ProductID != "" {
|
||||
cfg.ProductID = meta.ProductID
|
||||
}
|
||||
if meta.ProductIDYearly != "" {
|
||||
cfg.ProductIDYearly = meta.ProductIDYearly
|
||||
}
|
||||
if meta.ProductIDPerpetual != "" {
|
||||
cfg.ProductIDPerpetual = meta.ProductIDPerpetual
|
||||
}
|
||||
if len(meta.ProductIDs) > 0 {
|
||||
cfg.ProductIDs = cloneStringMap(meta.ProductIDs)
|
||||
}
|
||||
// product_mappings 优先补齐映射(可扩展 key) // Logan.Li
|
||||
if len(meta.ProductMappings) > 0 {
|
||||
if cfg.ProductIDs == nil {
|
||||
cfg.ProductIDs = map[string]string{}
|
||||
}
|
||||
for _, m := range meta.ProductMappings {
|
||||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||||
v := strings.TrimSpace(m.ProductID)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
cfg.ProductIDs[k] = v
|
||||
}
|
||||
}
|
||||
if meta.SuccessURL != "" {
|
||||
cfg.SuccessURL = meta.SuccessURL
|
||||
}
|
||||
if meta.Currency != "" {
|
||||
cfg.Currency = meta.Currency
|
||||
}
|
||||
if meta.CurrencyYearly != "" {
|
||||
cfg.CurrencyYearly = meta.CurrencyYearly
|
||||
}
|
||||
if meta.CurrencyPerpetual != "" {
|
||||
cfg.CurrencyPerpetual = meta.CurrencyPerpetual
|
||||
}
|
||||
if meta.CheckoutURL != "" {
|
||||
cfg.CheckoutURL = meta.CheckoutURL
|
||||
}
|
||||
if meta.Environment != "" {
|
||||
cfg.Environment = meta.Environment
|
||||
} else if gw.SandboxMode {
|
||||
cfg.Environment = "test"
|
||||
} else {
|
||||
cfg.Environment = "live"
|
||||
}
|
||||
if strings.TrimSpace(gw.Currency) != "" && cfg.Currency == "" {
|
||||
cfg.Currency = gw.Currency
|
||||
}
|
||||
if strings.TrimSpace(gw.SecretKey) != "" {
|
||||
cfg.PrivateKey = strings.TrimSpace(gw.SecretKey)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeWaffoConfig(cfg *WaffoConfig) {
|
||||
if cfg.Currency == "" {
|
||||
cfg.Currency = "CNY"
|
||||
}
|
||||
if cfg.CurrencyYearly == "" {
|
||||
cfg.CurrencyYearly = cfg.Currency
|
||||
}
|
||||
if cfg.CurrencyPerpetual == "" {
|
||||
cfg.CurrencyPerpetual = cfg.Currency
|
||||
}
|
||||
if cfg.ProductIDs == nil {
|
||||
cfg.ProductIDs = map[string]string{}
|
||||
}
|
||||
// 旧字段回填到可扩展映射,便于统一解析 // Logan.Li
|
||||
if cfg.ProductIDYearly != "" {
|
||||
if _, ok := cfg.ProductIDs["yearly"]; !ok {
|
||||
cfg.ProductIDs["yearly"] = cfg.ProductIDYearly
|
||||
}
|
||||
if _, ok := cfg.ProductIDs["monthly"]; !ok {
|
||||
cfg.ProductIDs["monthly"] = cfg.ProductIDYearly
|
||||
}
|
||||
}
|
||||
if cfg.ProductIDPerpetual != "" {
|
||||
if _, ok := cfg.ProductIDs["perpetual"]; !ok {
|
||||
cfg.ProductIDs["perpetual"] = cfg.ProductIDPerpetual
|
||||
}
|
||||
if _, ok := cfg.ProductIDs["lifetime"]; !ok {
|
||||
cfg.ProductIDs["lifetime"] = cfg.ProductIDPerpetual
|
||||
}
|
||||
}
|
||||
if cfg.ProductID != "" {
|
||||
if _, ok := cfg.ProductIDs["default"]; !ok {
|
||||
cfg.ProductIDs["default"] = cfg.ProductID
|
||||
}
|
||||
}
|
||||
// 映射回写固定字段,保持 GET 响应兼容 // Logan.Li
|
||||
if v := cfg.ProductIDs["yearly"]; v != "" {
|
||||
cfg.ProductIDYearly = v
|
||||
}
|
||||
if v := cfg.ProductIDs["perpetual"]; v != "" {
|
||||
cfg.ProductIDPerpetual = v
|
||||
}
|
||||
if cfg.ProductIDYearly == "" {
|
||||
cfg.ProductIDYearly = cfg.ProductID
|
||||
}
|
||||
if cfg.ProductIDPerpetual == "" {
|
||||
cfg.ProductIDPerpetual = cfg.ProductID
|
||||
}
|
||||
if cfg.SuccessURL == "" {
|
||||
cfg.SuccessURL = DefaultSuccessURL
|
||||
}
|
||||
env := strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||
switch env {
|
||||
case "prod", "production", "live":
|
||||
cfg.Environment = "live"
|
||||
default:
|
||||
cfg.Environment = "test"
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
v = strings.TrimSpace(v)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseWaffoPublicMeta 解析 public_key:JSON 或纯 Merchant ID 字符串 // Logan.Li
|
||||
func ParseWaffoPublicMeta(raw string) WaffoPublicMeta {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return WaffoPublicMeta{}
|
||||
}
|
||||
if strings.HasPrefix(raw, "{") {
|
||||
var meta WaffoPublicMeta
|
||||
if err := json.Unmarshal([]byte(raw), &meta); err == nil {
|
||||
return meta
|
||||
}
|
||||
}
|
||||
// 兼容旧值:public_key 直接存商户 ID // Logan.Li
|
||||
if strings.HasPrefix(raw, "MER_") {
|
||||
return WaffoPublicMeta{MerchantID: raw}
|
||||
}
|
||||
return WaffoPublicMeta{}
|
||||
}
|
||||
|
||||
// EncodeWaffoPublicMeta 序列化公开配置 JSON // Logan.Li
|
||||
func EncodeWaffoPublicMeta(meta WaffoPublicMeta) (string, error) {
|
||||
b, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化 Waffo 配置失败: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildCheckoutURL returns a Waffo checkout URL via official create-session API
|
||||
// (orderMerchantExternalId + metadata). Hosted store page is last-resort fallback only. // 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)
|
||||
}
|
||||
success = appendQuery(success, map[string]string{
|
||||
"orderNo": orderNo,
|
||||
"deviceId": deviceID,
|
||||
"email": email,
|
||||
"planType": planType,
|
||||
})
|
||||
|
||||
productID := productIDForPlan(cfg, planType)
|
||||
|
||||
// Prefer create-session whenever Store Slug or API Key is available. // Logan.Li
|
||||
if cfg.HasAPIKeyAuth() || strings.TrimSpace(cfg.StoreSlug) != "" {
|
||||
if productID == "" {
|
||||
return "", fmt.Errorf("未配置包年/永久 Product ID")
|
||||
}
|
||||
session, err := CreateCheckoutSession(cfg, CreateSessionRequest{
|
||||
ProductID: productID,
|
||||
ProductType: checkoutTypeForPlan(planType),
|
||||
Currency: currencyForPlan(cfg, planType),
|
||||
BuyerEmail: email,
|
||||
SuccessURL: success,
|
||||
OrderMerchantExternalID: orderNo,
|
||||
Metadata: map[string]string{
|
||||
"orderNo": orderNo,
|
||||
"deviceId": deviceID,
|
||||
"email": email,
|
||||
"planType": planType,
|
||||
},
|
||||
StoreSlug: cfg.StoreSlug,
|
||||
Environment: cfg.Environment,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return session.CheckoutURL, nil
|
||||
}
|
||||
|
||||
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,
|
||||
}))
|
||||
q.Set("successUrl", success)
|
||||
}
|
||||
if deviceID != "" {
|
||||
q.Set("deviceId", deviceID)
|
||||
@@ -101,18 +381,39 @@ func BuildCheckoutURL(cfg WaffoConfig, orderNo, deviceID, email, returnURL, plan
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// ResolveProductID 按 plan_type 解析 Waffo Product ID(可扩展映射优先) // Logan.Li
|
||||
func ResolveProductID(cfg WaffoConfig, planType string) string {
|
||||
return productIDForPlan(cfg, planType)
|
||||
}
|
||||
|
||||
func productIDForPlan(cfg WaffoConfig, planType string) string {
|
||||
switch strings.TrimSpace(planType) {
|
||||
plan := strings.TrimSpace(strings.ToLower(planType))
|
||||
if plan == "" {
|
||||
plan = "default"
|
||||
}
|
||||
if id := strings.TrimSpace(cfg.ProductIDs[plan]); id != "" {
|
||||
return id
|
||||
}
|
||||
switch plan {
|
||||
case "yearly", "monthly":
|
||||
return cfg.ProductIDYearly
|
||||
if cfg.ProductIDYearly != "" {
|
||||
return cfg.ProductIDYearly
|
||||
}
|
||||
case "perpetual", "lifetime":
|
||||
return cfg.ProductIDPerpetual
|
||||
default:
|
||||
if cfg.ProductIDPerpetual != "" {
|
||||
return cfg.ProductIDPerpetual
|
||||
}
|
||||
}
|
||||
if id := strings.TrimSpace(cfg.ProductIDs["default"]); id != "" {
|
||||
return id
|
||||
}
|
||||
if cfg.ProductID != "" {
|
||||
return cfg.ProductID
|
||||
}
|
||||
if cfg.ProductIDPerpetual != "" {
|
||||
return cfg.ProductIDPerpetual
|
||||
}
|
||||
return cfg.ProductIDYearly
|
||||
}
|
||||
|
||||
func currencyForPlan(cfg WaffoConfig, planType string) string {
|
||||
@@ -139,7 +440,6 @@ func checkoutTypeForPlan(planType string) string {
|
||||
}
|
||||
|
||||
func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error) {
|
||||
// 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)
|
||||
@@ -159,7 +459,7 @@ func resolveBaseCheckoutURL(cfg WaffoConfig, planType string) (*url.URL, error)
|
||||
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)")
|
||||
return nil, fmt.Errorf("未配置 StoreSlug 与包年/永久 Product ID(或 CheckoutURL)")
|
||||
}
|
||||
|
||||
func appendQuery(raw string, values map[string]string) string {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const waffoAPIBase = "https://api.waffo.ai"
|
||||
|
||||
// CreateSessionRequest API Key / Store Slug checkout session (official create-session). // Logan.Li
|
||||
type CreateSessionRequest struct {
|
||||
ProductID string `json:"productId"`
|
||||
ProductType string `json:"productType"`
|
||||
Currency string `json:"currency"`
|
||||
BuyerEmail string `json:"buyerEmail,omitempty"`
|
||||
SuccessURL string `json:"successUrl,omitempty"`
|
||||
OrderMerchantExternalID string `json:"orderMerchantExternalId,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
ExpiresInSeconds int `json:"expiresInSeconds,omitempty"`
|
||||
// Auth overrides (optional). // Logan.Li
|
||||
StoreSlug string `json:"-"`
|
||||
Environment string `json:"-"`
|
||||
}
|
||||
|
||||
// CreateSessionResult checkout session response. // Logan.Li
|
||||
type CreateSessionResult struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
CheckoutURL string `json:"checkoutUrl"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// CreateCheckoutSession creates a Waffo checkout session with orderMerchantExternalId.
|
||||
// Prefers API Key (Merchant ID + RSA); falls back to Store Slug + X-Environment. // Logan.Li
|
||||
func CreateCheckoutSession(cfg WaffoConfig, req CreateSessionRequest) (*CreateSessionResult, error) {
|
||||
if strings.TrimSpace(req.ProductID) == "" || strings.TrimSpace(req.Currency) == "" {
|
||||
return nil, fmt.Errorf("productId 与 currency 必填")
|
||||
}
|
||||
if req.ProductType == "" {
|
||||
req.ProductType = "onetime"
|
||||
}
|
||||
if req.ExpiresInSeconds <= 0 {
|
||||
req.ExpiresInSeconds = 2700
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := "/v1/actions/checkout/create-session"
|
||||
httpReq, err := http.NewRequest(http.MethodPost, waffoAPIBase+path, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if cfg.HasAPIKeyAuth() {
|
||||
timestamp := fmt.Sprintf("%d", time.Now().Unix())
|
||||
sig, err := signAPIRequest(cfg.PrivateKey, "POST", path, timestamp, bodyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("X-Merchant-Id", strings.TrimSpace(cfg.MerchantID))
|
||||
httpReq.Header.Set("X-Timestamp", timestamp)
|
||||
httpReq.Header.Set("X-Signature", sig)
|
||||
} else {
|
||||
slug := strings.TrimSpace(req.StoreSlug)
|
||||
if slug == "" {
|
||||
slug = strings.TrimSpace(cfg.StoreSlug)
|
||||
}
|
||||
if slug == "" {
|
||||
return nil, fmt.Errorf("未配置商户 ID/私钥,且缺少 Store Slug,无法创建收银台会话")
|
||||
}
|
||||
env := strings.TrimSpace(req.Environment)
|
||||
if env == "" {
|
||||
env = strings.TrimSpace(cfg.Environment)
|
||||
}
|
||||
if env == "" {
|
||||
env = "test"
|
||||
}
|
||||
httpReq.Header.Set("X-Store-Slug", slug)
|
||||
httpReq.Header.Set("X-Environment", env)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Waffo create-session 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var envelope struct {
|
||||
Data *CreateSessionResult `json:"data"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("Waffo 响应无法解析 (%d): %s", resp.StatusCode, truncate(string(respBody), 200))
|
||||
}
|
||||
if resp.StatusCode >= 300 || envelope.Data == nil || envelope.Data.CheckoutURL == "" {
|
||||
msg := strings.TrimSpace(string(respBody))
|
||||
if len(envelope.Errors) > 0 && envelope.Errors[0].Message != "" {
|
||||
msg = envelope.Errors[0].Message
|
||||
}
|
||||
return nil, fmt.Errorf("Waffo create-session 失败 (%d): %s", resp.StatusCode, truncate(msg, 300))
|
||||
}
|
||||
return envelope.Data, nil
|
||||
}
|
||||
|
||||
func signAPIRequest(privateKeyMaterial, method, path, timestamp string, body []byte) (string, error) {
|
||||
priv, err := ParseMerchantPrivateKey(privateKeyMaterial)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
bodyHash := base64.StdEncoding.EncodeToString(sum[:])
|
||||
canonical := method + "\n" + path + "\n" + timestamp + "\n" + bodyHash
|
||||
hashed := sha256.Sum256([]byte(canonical))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hashed[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("RSA 签名失败: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// ParseMerchantPrivateKey 解析 RSA 私钥:PEM、无头 Base64(PKCS8/PKCS1 DER)均支持 // Logan.Li
|
||||
func ParseMerchantPrivateKey(raw string) (*rsa.PrivateKey, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("私钥为空")
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, `\n`, "\n")
|
||||
|
||||
if key, err := parseRSAFromPEMOrDER([]byte(raw)); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// 纯 Base64 体(Dashboard「复制密钥」常见形态) // Logan.Li
|
||||
compact := strings.Map(func(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, raw)
|
||||
if decoded, err := base64.StdEncoding.DecodeString(compact); err == nil {
|
||||
if key, err2 := parseRSAFromPEMOrDER(decoded); err2 == nil {
|
||||
return key, nil
|
||||
}
|
||||
// 包一层 PKCS8 PEM 再试 // Logan.Li
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: decoded})
|
||||
if key, err2 := parseRSAFromPEMOrDER(pemBytes); err2 == nil {
|
||||
return key, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 无头 Base64 拼成 PEM // Logan.Li
|
||||
wrapped := "-----BEGIN PRIVATE KEY-----\n" + chunkBase64(compact, 64) + "\n-----END PRIVATE KEY-----\n"
|
||||
if key, err := parseRSAFromPEMOrDER([]byte(wrapped)); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("无法解析 RSA 私钥(请粘贴 PEM 或 Dashboard Base64 密钥)")
|
||||
}
|
||||
|
||||
func parseRSAFromPEMOrDER(raw []byte) (*rsa.PrivateKey, error) {
|
||||
if block, _ := pem.Decode(raw); block != nil {
|
||||
return parseRSAKeyBytes(block.Bytes)
|
||||
}
|
||||
return parseRSAKeyBytes(raw)
|
||||
}
|
||||
|
||||
func parseRSAKeyBytes(der []byte) (*rsa.PrivateKey, error) {
|
||||
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||
return rsaKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("私钥不是 RSA")
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
return nil, fmt.Errorf("不支持的私钥格式")
|
||||
}
|
||||
|
||||
func chunkBase64(s string, size int) string {
|
||||
if size <= 0 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i += size {
|
||||
end := i + size
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(s[i:end])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
|
||||
func TestResolveBaseCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
|
||||
cfg := WaffoConfig{
|
||||
StoreSlug: "opsxshell-pwsi6m4n",
|
||||
ProductIDYearly: "PROD_1BIezCi932IbgCzRIV8hcJ",
|
||||
@@ -17,16 +17,12 @@ func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
|
||||
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)
|
||||
u, err := resolveBaseCheckoutURL(cfg, "yearly")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(u.Path, "PROD_1BIezCi932IbgCzRIV8hcJ") {
|
||||
t.Fatalf("yearly product missing: %s", raw)
|
||||
t.Fatalf("yearly product missing: %s", u.String())
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("type") != "onetime" {
|
||||
@@ -37,7 +33,7 @@ func TestBuildCheckoutURL_YearlyOnetimeCNY(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
|
||||
func TestResolveBaseCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
|
||||
cfg := WaffoConfig{
|
||||
StoreSlug: "opsxshell-pwsi6m4n",
|
||||
ProductIDYearly: "PROD_1BIezCi932IbgCzRIV8hcJ",
|
||||
@@ -48,16 +44,12 @@ func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
|
||||
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)
|
||||
u, err := resolveBaseCheckoutURL(cfg, "perpetual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(u.Path, "PROD_0pcnzzT4nalEkkcdyIUoD5") {
|
||||
t.Fatalf("perpetual product missing: %s", raw)
|
||||
t.Fatalf("perpetual product missing: %s", u.String())
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("type") != "onetime" {
|
||||
@@ -67,3 +59,21 @@ func TestBuildCheckoutURL_PerpetualOnetimeCNY(t *testing.T) {
|
||||
t.Fatalf("currency=%s want CNY", q.Get("currency"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCheckoutURL_HostedFallbackWithoutSlug(t *testing.T) {
|
||||
cfg := WaffoConfig{
|
||||
CheckoutURL: "https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_1BIezCi932IbgCzRIV8hcJ?type=onetime¤cy=CNY&test=true",
|
||||
SuccessURL: "http://l123.91ai.io/api/v1/purchase/success",
|
||||
}
|
||||
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 u.Query().Get("orderNo") != "ORD1" {
|
||||
t.Fatalf("orderNo missing: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func (s *PlanService) List(page, pageSize int, productID uint) ([]dto.PlanRespon
|
||||
// GetByProduct 查询某产品的上架计划(公开接口) // Logan.Li
|
||||
func (s *PlanService) GetByProduct(productID uint) ([]dto.PlanResponse, error) {
|
||||
var plans []model.Plan
|
||||
if err := s.db.Where("product_id = ? AND status = ?", productID, 1).
|
||||
if err := s.db.Preload("Product").Where("product_id = ? AND status = ?", productID, 1).
|
||||
Order("sort_order ASC").Find(&plans).Error; err != nil {
|
||||
return nil, errors.New("获取定价计划失败")
|
||||
}
|
||||
@@ -88,6 +88,7 @@ func (s *PlanService) Create(req dto.PlanCreate) (*dto.PlanResponse, error) {
|
||||
if err := s.db.Create(&plan).Error; err != nil {
|
||||
return nil, errors.New("创建定价计划失败")
|
||||
}
|
||||
plan.Product = product
|
||||
resp := toPlanResponse(plan)
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -95,7 +96,7 @@ func (s *PlanService) Create(req dto.PlanCreate) (*dto.PlanResponse, error) {
|
||||
// Update 更新定价计划 // Logan.Li
|
||||
func (s *PlanService) Update(id uint, req dto.PlanUpdate) (*dto.PlanResponse, error) {
|
||||
var plan model.Plan
|
||||
if err := s.db.First(&plan, id).Error; err != nil {
|
||||
if err := s.db.Preload("Product").First(&plan, id).Error; err != nil {
|
||||
return nil, errors.New("定价计划不存在")
|
||||
}
|
||||
|
||||
@@ -128,7 +129,7 @@ func (s *PlanService) Update(id uint, req dto.PlanUpdate) (*dto.PlanResponse, er
|
||||
if err := s.db.Model(&plan).Updates(updates).Error; err != nil {
|
||||
return nil, errors.New("更新定价计划失败")
|
||||
}
|
||||
s.db.First(&plan, id)
|
||||
s.db.Preload("Product").First(&plan, id)
|
||||
resp := toPlanResponse(plan)
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -146,16 +147,17 @@ func (s *PlanService) ToggleStatus(id uint, status int) error {
|
||||
// toPlanResponse 模型转响应 DTO // Logan.Li
|
||||
func toPlanResponse(p model.Plan) dto.PlanResponse {
|
||||
return dto.PlanResponse{
|
||||
ID: p.ID,
|
||||
ProductID: p.ProductID,
|
||||
Name: p.Name,
|
||||
PlanType: p.PlanType,
|
||||
Price: p.Price,
|
||||
Currency: p.Currency,
|
||||
Duration: p.Duration,
|
||||
MaxDevices: p.MaxDevices,
|
||||
Features: utils.ParseFeatures(p.Features),
|
||||
SortOrder: p.SortOrder,
|
||||
Status: p.Status,
|
||||
ID: p.ID,
|
||||
ProductID: p.ProductID,
|
||||
ProductName: p.Product.Name,
|
||||
Name: p.Name,
|
||||
PlanType: p.PlanType,
|
||||
Price: p.Price,
|
||||
Currency: p.Currency,
|
||||
Duration: p.Duration,
|
||||
MaxDevices: p.MaxDevices,
|
||||
Features: utils.ParseFeatures(p.Features),
|
||||
SortOrder: p.SortOrder,
|
||||
Status: p.Status,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,13 @@ package setting
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"backend/internal/service/payment"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -63,6 +69,423 @@ func (s *SettingService) Update(req dto.SettingUpdate) (*model.Setting, error) {
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// GetWaffo 获取 Waffo 支付集成配置(私钥仅返回是否已配置) // Logan.Li
|
||||
func (s *SettingService) GetWaffo() (*dto.WaffoSettingsResponse, error) {
|
||||
cfg := payment.LoadWaffoConfig(s.db)
|
||||
var gw model.PaymentGatewayConfig
|
||||
dbHas := s.db.Where("gateway = ?", "waffo").First(&gw).Error == nil
|
||||
enabled := true
|
||||
if dbHas {
|
||||
enabled = gw.Enabled
|
||||
}
|
||||
hint := "env"
|
||||
if dbHas && strings.TrimSpace(gw.PublicKey) != "" {
|
||||
hint = "db"
|
||||
} else if dbHas {
|
||||
hint = "mixed"
|
||||
}
|
||||
|
||||
meta := payment.ParseWaffoPublicMeta(gw.PublicKey)
|
||||
mappings := buildProductMappings(cfg, meta.ProductMappings)
|
||||
productIDs := cloneProductIDs(cfg.ProductIDs)
|
||||
|
||||
return &dto.WaffoSettingsResponse{
|
||||
Enabled: enabled,
|
||||
MerchantID: cfg.MerchantID,
|
||||
StoreID: cfg.StoreID,
|
||||
StoreSlug: cfg.StoreSlug,
|
||||
Environment: cfg.Environment,
|
||||
SandboxMode: cfg.Environment == "test",
|
||||
Currency: cfg.Currency,
|
||||
ProductID: cfg.ProductID,
|
||||
ProductIDYearly: cfg.ProductIDYearly,
|
||||
ProductIDPerpetual: cfg.ProductIDPerpetual,
|
||||
ProductIDs: productIDs,
|
||||
ProductMappings: mappings,
|
||||
SuccessURL: cfg.SuccessURL,
|
||||
WebhookURL: payment.DefaultWebhookURL,
|
||||
PrivateKeyConfigured: strings.TrimSpace(cfg.PrivateKey) != "",
|
||||
SourceHint: hint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateWaffo 保存 Waffo 配置到 payment_gateway_configs(后台优先于 .env) // Logan.Li
|
||||
func (s *SettingService) UpdateWaffo(req dto.WaffoSettingsUpdate) (*dto.WaffoSettingsResponse, error) {
|
||||
var gw model.PaymentGatewayConfig
|
||||
err := s.db.Where("gateway = ?", "waffo").First(&gw).Error
|
||||
if err != nil {
|
||||
gw = model.PaymentGatewayConfig{
|
||||
Gateway: "waffo",
|
||||
Enabled: true,
|
||||
SandboxMode: true,
|
||||
Currency: "CNY",
|
||||
Priority: 0,
|
||||
}
|
||||
}
|
||||
|
||||
productIDs, mappings := mergeProductIDInputs(req)
|
||||
|
||||
meta := payment.WaffoPublicMeta{
|
||||
MerchantID: strings.TrimSpace(req.MerchantID),
|
||||
StoreID: strings.TrimSpace(req.StoreID),
|
||||
StoreSlug: strings.TrimSpace(req.StoreSlug),
|
||||
ProductID: strings.TrimSpace(req.ProductID),
|
||||
ProductIDYearly: strings.TrimSpace(req.ProductIDYearly),
|
||||
ProductIDPerpetual: strings.TrimSpace(req.ProductIDPerpetual),
|
||||
ProductIDs: productIDs,
|
||||
ProductMappings: toPaymentMappings(mappings),
|
||||
SuccessURL: strings.TrimSpace(req.SuccessURL),
|
||||
Currency: strings.TrimSpace(req.Currency),
|
||||
}
|
||||
if meta.Currency == "" {
|
||||
meta.Currency = "CNY"
|
||||
}
|
||||
meta.CurrencyYearly = meta.Currency
|
||||
meta.CurrencyPerpetual = meta.Currency
|
||||
|
||||
// 兼容字段与可扩展映射对齐 // Logan.Li
|
||||
if meta.ProductIDYearly == "" {
|
||||
meta.ProductIDYearly = productIDs["yearly"]
|
||||
}
|
||||
if meta.ProductIDPerpetual == "" {
|
||||
meta.ProductIDPerpetual = firstNonEmpty(productIDs["perpetual"], productIDs["lifetime"])
|
||||
}
|
||||
if meta.ProductID == "" {
|
||||
meta.ProductID = firstNonEmpty(meta.ProductIDPerpetual, meta.ProductIDYearly, productIDs["default"])
|
||||
}
|
||||
|
||||
env := strings.ToLower(strings.TrimSpace(req.Environment))
|
||||
switch env {
|
||||
case "live", "prod", "production":
|
||||
meta.Environment = "live"
|
||||
gw.SandboxMode = false
|
||||
default:
|
||||
meta.Environment = "test"
|
||||
gw.SandboxMode = true
|
||||
}
|
||||
|
||||
encoded, err := payment.EncodeWaffoPublicMeta(meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gw.PublicKey = encoded
|
||||
gw.Currency = meta.Currency
|
||||
gw.Priority = 0
|
||||
if req.Enabled != nil {
|
||||
gw.Enabled = *req.Enabled
|
||||
} else {
|
||||
gw.Enabled = true
|
||||
}
|
||||
|
||||
if req.ClearPrivateKey {
|
||||
gw.SecretKey = ""
|
||||
} else if pk := strings.TrimSpace(req.PrivateKey); pk != "" {
|
||||
if _, err := payment.ParseMerchantPrivateKey(pk); err != nil {
|
||||
return nil, errors.New("私钥无法解析: " + err.Error())
|
||||
}
|
||||
gw.SecretKey = pk
|
||||
}
|
||||
|
||||
if err := s.db.Save(&gw).Error; err != nil {
|
||||
return nil, errors.New("保存 Waffo 配置失败")
|
||||
}
|
||||
_ = s.db.Model(&model.PaymentGatewayConfig{}).
|
||||
Where("gateway <> ? AND enabled = ?", "waffo", true).
|
||||
Update("priority", 100).Error
|
||||
|
||||
return s.GetWaffo()
|
||||
}
|
||||
|
||||
// DebugWaffo 用当前 DB/env 配置轻量探测 create-session(不回显私钥) // Logan.Li
|
||||
func (s *SettingService) DebugWaffo(req dto.WaffoDebugRequest) *dto.WaffoDebugResponse {
|
||||
cfg := payment.LoadWaffoConfig(s.db)
|
||||
planType := strings.TrimSpace(strings.ToLower(req.PlanType))
|
||||
if planType == "" {
|
||||
planType = "yearly"
|
||||
}
|
||||
productID := strings.TrimSpace(req.ProductID)
|
||||
if productID == "" {
|
||||
productID = payment.ResolveProductID(cfg, planType)
|
||||
}
|
||||
|
||||
authMode := "none"
|
||||
if cfg.HasAPIKeyAuth() {
|
||||
authMode = "api_key"
|
||||
} else if strings.TrimSpace(cfg.StoreSlug) != "" {
|
||||
authMode = "store_slug"
|
||||
}
|
||||
|
||||
out := &dto.WaffoDebugResponse{
|
||||
AuthMode: authMode,
|
||||
Environment: cfg.Environment,
|
||||
PlanType: planType,
|
||||
ProductIDUsed: productID,
|
||||
PrivateKeyPresent: strings.TrimSpace(cfg.PrivateKey) != "",
|
||||
MerchantIDPresent: strings.TrimSpace(cfg.MerchantID) != "",
|
||||
StoreSlugPresent: strings.TrimSpace(cfg.StoreSlug) != "",
|
||||
}
|
||||
|
||||
if productID == "" {
|
||||
out.OK = false
|
||||
out.ErrorSummary = "未配置可用的 Waffo Product ID"
|
||||
return out
|
||||
}
|
||||
if authMode == "none" {
|
||||
out.OK = false
|
||||
out.ErrorSummary = "未配置商户 ID/私钥,且缺少 Store Slug"
|
||||
return out
|
||||
}
|
||||
|
||||
buyer := strings.TrimSpace(req.BuyerEmail)
|
||||
if buyer == "" {
|
||||
buyer = "debug@opsxshell.local"
|
||||
}
|
||||
orderNo := fmt.Sprintf("debug-%d", time.Now().Unix())
|
||||
successURL := cfg.SuccessURL
|
||||
if successURL == "" {
|
||||
successURL = payment.DefaultSuccessURL
|
||||
}
|
||||
|
||||
session, err := payment.CreateCheckoutSession(cfg, payment.CreateSessionRequest{
|
||||
ProductID: productID,
|
||||
ProductType: "onetime",
|
||||
Currency: firstNonEmpty(cfg.Currency, "CNY"),
|
||||
BuyerEmail: buyer,
|
||||
SuccessURL: successURL,
|
||||
OrderMerchantExternalID: orderNo,
|
||||
Metadata: map[string]string{
|
||||
"debug": "true",
|
||||
"planType": planType,
|
||||
"orderNo": orderNo,
|
||||
},
|
||||
ExpiresInSeconds: 300,
|
||||
StoreSlug: cfg.StoreSlug,
|
||||
Environment: cfg.Environment,
|
||||
})
|
||||
if err != nil {
|
||||
out.OK = false
|
||||
out.ErrorSummary = sanitizeDebugError(err.Error())
|
||||
out.HTTPStatus = extractHTTPStatus(err.Error())
|
||||
return out
|
||||
}
|
||||
|
||||
out.OK = true
|
||||
out.HTTPStatus = 200
|
||||
out.SessionID = session.SessionID
|
||||
if u, err := url.Parse(session.CheckoutURL); err == nil && u.Host != "" {
|
||||
out.CheckoutURLHost = u.Host
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeProductIDInputs(req dto.WaffoSettingsUpdate) (map[string]string, []dto.WaffoProductMapping) {
|
||||
ids := map[string]string{}
|
||||
var mappings []dto.WaffoProductMapping
|
||||
|
||||
if len(req.ProductMappings) > 0 {
|
||||
for _, m := range req.ProductMappings {
|
||||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||||
v := strings.TrimSpace(m.ProductID)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
mappings = append(mappings, dto.WaffoProductMapping{
|
||||
PlanKey: k,
|
||||
Label: strings.TrimSpace(m.Label),
|
||||
ProductID: v,
|
||||
})
|
||||
if v != "" {
|
||||
ids[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range req.ProductIDs {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
v = strings.TrimSpace(v)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
ids[k] = v
|
||||
}
|
||||
|
||||
if y := strings.TrimSpace(req.ProductIDYearly); y != "" {
|
||||
ids["yearly"] = y
|
||||
}
|
||||
if p := strings.TrimSpace(req.ProductIDPerpetual); p != "" {
|
||||
ids["perpetual"] = p
|
||||
if _, ok := ids["lifetime"]; !ok {
|
||||
ids["lifetime"] = p
|
||||
}
|
||||
}
|
||||
if d := strings.TrimSpace(req.ProductID); d != "" {
|
||||
if _, ok := ids["default"]; !ok {
|
||||
ids["default"] = d
|
||||
}
|
||||
if _, ok := ids["perpetual"]; !ok {
|
||||
ids["perpetual"] = d
|
||||
}
|
||||
}
|
||||
|
||||
if len(mappings) == 0 {
|
||||
mappings = mappingsFromIDs(ids, nil)
|
||||
} else {
|
||||
// 用最终 ids 回填表格行 product_id,避免只改了兼容字段 // Logan.Li
|
||||
for i := range mappings {
|
||||
if v := ids[mappings[i].PlanKey]; v != "" {
|
||||
mappings[i].ProductID = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids, mappings
|
||||
}
|
||||
|
||||
func buildProductMappings(cfg payment.WaffoConfig, stored []payment.WaffoProductMapping) []dto.WaffoProductMapping {
|
||||
labelHint := map[string]string{}
|
||||
for _, m := range stored {
|
||||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if l := strings.TrimSpace(m.Label); l != "" {
|
||||
labelHint[k] = l
|
||||
}
|
||||
}
|
||||
return mappingsFromIDs(cfg.ProductIDs, labelHint)
|
||||
}
|
||||
|
||||
func mappingsFromIDs(ids map[string]string, labels map[string]string) []dto.WaffoProductMapping {
|
||||
if len(ids) == 0 {
|
||||
return []dto.WaffoProductMapping{
|
||||
{PlanKey: "yearly", Label: "包年"},
|
||||
{PlanKey: "perpetual", Label: "永久"},
|
||||
}
|
||||
}
|
||||
defaultLabels := map[string]string{
|
||||
"yearly": "包年",
|
||||
"monthly": "包月",
|
||||
"perpetual": "永久",
|
||||
"lifetime": "终身",
|
||||
"trial": "试用",
|
||||
"default": "默认",
|
||||
}
|
||||
// 稳定顺序:常用 key 优先,其余按字典序 // Logan.Li
|
||||
order := []string{"trial", "yearly", "monthly", "perpetual", "lifetime", "default"}
|
||||
seen := map[string]bool{}
|
||||
out := make([]dto.WaffoProductMapping, 0, len(ids))
|
||||
appendOne := func(k string) {
|
||||
v := strings.TrimSpace(ids[k])
|
||||
if v == "" || seen[k] {
|
||||
return
|
||||
}
|
||||
seen[k] = true
|
||||
label := ""
|
||||
if labels != nil {
|
||||
label = labels[k]
|
||||
}
|
||||
if label == "" {
|
||||
label = defaultLabels[k]
|
||||
}
|
||||
out = append(out, dto.WaffoProductMapping{PlanKey: k, Label: label, ProductID: v})
|
||||
}
|
||||
for _, k := range order {
|
||||
appendOne(k)
|
||||
}
|
||||
rest := make([]string, 0)
|
||||
for k := range ids {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
if k == "" || seen[k] || strings.TrimSpace(ids[k]) == "" {
|
||||
continue
|
||||
}
|
||||
rest = append(rest, k)
|
||||
}
|
||||
for i := 0; i < len(rest); i++ {
|
||||
for j := i + 1; j < len(rest); j++ {
|
||||
if rest[j] < rest[i] {
|
||||
rest[i], rest[j] = rest[j], rest[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range rest {
|
||||
appendOne(k)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []dto.WaffoProductMapping{
|
||||
{PlanKey: "yearly", Label: "包年"},
|
||||
{PlanKey: "perpetual", Label: "永久"},
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toPaymentMappings(in []dto.WaffoProductMapping) []payment.WaffoProductMapping {
|
||||
out := make([]payment.WaffoProductMapping, 0, len(in))
|
||||
for _, m := range in {
|
||||
k := strings.TrimSpace(strings.ToLower(m.PlanKey))
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, payment.WaffoProductMapping{
|
||||
PlanKey: k,
|
||||
Label: strings.TrimSpace(m.Label),
|
||||
ProductID: strings.TrimSpace(m.ProductID),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneProductIDs(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return map[string]string{}
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
v = strings.TrimSpace(v)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var httpStatusRe = regexp.MustCompile(`\((\d{3})\)`)
|
||||
|
||||
func extractHTTPStatus(msg string) int {
|
||||
m := httpStatusRe.FindStringSubmatch(msg)
|
||||
if len(m) < 2 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
fmt.Sscanf(m[1], "%d", &n)
|
||||
return n
|
||||
}
|
||||
|
||||
func sanitizeDebugError(msg string) string {
|
||||
msg = strings.TrimSpace(msg)
|
||||
lower := strings.ToLower(msg)
|
||||
// 防止误回显 PEM / 私钥片段 // Logan.Li
|
||||
if strings.Contains(lower, "private key") || strings.Contains(msg, "BEGIN ") {
|
||||
if idx := strings.Index(lower, "private"); idx >= 0 {
|
||||
return "私钥相关错误(详情已隐藏)"
|
||||
}
|
||||
}
|
||||
if len(msg) > 300 {
|
||||
return msg[:300]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// ============ 审计日志 ============
|
||||
|
||||
// ListAuditLogs 分页查询审计日志 // Logan.Li
|
||||
|
||||
Reference in new issue
Block a user