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