新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。 Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
5.7 KiB
Go
207 lines
5.7 KiB
Go
package license
|
||
|
||
import (
|
||
"backend/config"
|
||
"backend/internal/dto"
|
||
"backend/internal/model"
|
||
customersvc "backend/internal/service/customer"
|
||
"backend/utils"
|
||
"errors"
|
||
"fmt"
|
||
"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 {
|
||
var err error
|
||
customer, err = s.resolveOrCreateCustomer(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
exp := now.AddDate(0, 0, duration)
|
||
claimEmail := strings.TrimSpace(req.Email)
|
||
if claimEmail == "" && customer.Email != "" && !strings.HasSuffix(customer.Email, "@local.invalid") {
|
||
claimEmail = customer.Email
|
||
}
|
||
claims := utils.OfflineLicenseClaims{
|
||
V: 1,
|
||
ProductCode: product.Code,
|
||
DeviceID: deviceID,
|
||
Email: claimEmail,
|
||
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 失败")
|
||
}
|
||
_ = customersvc.AttachProduct(s.db, customer.ID, product.ID)
|
||
|
||
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
|
||
}
|
||
|
||
// resolveOrCreateCustomer 解析或创建离线签发客户;禁止重复插入空邮箱(uniqueIndex) // Logan.Li
|
||
func (s *OfflineIssuer) resolveOrCreateCustomer(req dto.OfflineLicenseCreate) (model.Customer, error) {
|
||
email := strings.TrimSpace(req.Email)
|
||
name := strings.TrimSpace(req.CustomerName)
|
||
if name == "" {
|
||
name = "离线客户"
|
||
}
|
||
|
||
var customer model.Customer
|
||
if email != "" {
|
||
if err := s.db.Where("email = ?", email).First(&customer).Error; err == nil {
|
||
return customer, nil
|
||
}
|
||
customer = model.Customer{Name: name, Email: email, Status: 1}
|
||
if err := s.db.Create(&customer).Error; err != nil {
|
||
// 并发下可能已被创建,再查一次 // Logan.Li
|
||
if err2 := s.db.Where("email = ?", email).First(&customer).Error; err2 == nil {
|
||
return customer, nil
|
||
}
|
||
return customer, errors.New("创建客户失败")
|
||
}
|
||
return customer, nil
|
||
}
|
||
|
||
// 无邮箱:优先复用同名客户,避免空邮箱触发 UNIQUE 冲突 // Logan.Li
|
||
if err := s.db.Where("name = ?", name).Order("id ASC").First(&customer).Error; err == nil {
|
||
return customer, nil
|
||
}
|
||
placeholder := fmt.Sprintf("offline+%d@local.invalid", time.Now().UnixNano())
|
||
customer = model.Customer{Name: name, Email: placeholder, Status: 1}
|
||
if err := s.db.Create(&customer).Error; err != nil {
|
||
return customer, errors.New("创建客户失败")
|
||
}
|
||
return customer, nil
|
||
}
|