Files
opsx-licence/backend/internal/service/license/offline_service.go
T
Logan.LiandCursor 8cc2c83f9d feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 20:25:46 +08:00

178 lines
4.6 KiB
Go

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
}