Files
opsx-licence/backend/internal/service/license/offline_service.go
T
Logan.LiandCursor f26e787cfc fix: 离线证到期重签空邮箱撞唯一索引
复用 customer_id/同名客户,禁止重复插入空 email;重签前端带回原客户。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 21:49:55 +08:00

205 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package license
import (
"backend/config"
"backend/internal/dto"
"backend/internal/model"
"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 失败")
}
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
}