复用 customer_id/同名客户,禁止重复插入空 email;重签前端带回原客户。 Co-authored-by: Cursor <cursoragent@cursor.com>
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package license
|
|
|
|
import (
|
|
"backend/internal/dto"
|
|
"backend/internal/model"
|
|
"backend/utils"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// LicenseService License 业务逻辑层 // Logan.Li
|
|
type LicenseService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewLicenseService 创建 service 实例 // Logan.Li
|
|
func NewLicenseService(db *gorm.DB) *LicenseService {
|
|
return &LicenseService{db: db}
|
|
}
|
|
|
|
// ListQuery 列表查询参数 // Logan.Li
|
|
type ListQuery struct {
|
|
Keyword string
|
|
Status string
|
|
CustomerID uint
|
|
ProductID uint
|
|
Channel string // online | offline | ""(全部)
|
|
}
|
|
|
|
// List 分页查询 License // Logan.Li
|
|
func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseResponse, int64, error) {
|
|
var licenses []model.License
|
|
var total int64
|
|
|
|
query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product").Preload("Activations")
|
|
if q.Keyword != "" {
|
|
query = query.Where("license_key LIKE ?", "%"+q.Keyword+"%")
|
|
}
|
|
if q.Status != "" {
|
|
query = query.Where("status = ?", q.Status)
|
|
}
|
|
if q.CustomerID > 0 {
|
|
query = query.Where("customer_id = ?", q.CustomerID)
|
|
}
|
|
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").
|
|
Offset((page - 1) * pageSize).Limit(pageSize).
|
|
Find(&licenses).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
results := make([]dto.LicenseResponse, 0, len(licenses))
|
|
for _, l := range licenses {
|
|
results = append(results, toLicenseResponse(l))
|
|
}
|
|
return results, total, nil
|
|
}
|
|
|
|
// GetByID 获取 License 详情(含激活记录) // Logan.Li
|
|
func (s *LicenseService) GetByID(id uint) (*dto.LicenseResponse, []dto.ActivationResponse, error) {
|
|
var lic model.License
|
|
if err := s.db.Preload("Customer").Preload("Product").Preload("Activations").
|
|
First(&lic, id).Error; err != nil {
|
|
return nil, nil, errors.New("License 不存在")
|
|
}
|
|
|
|
resp := toLicenseResponse(lic)
|
|
activations := make([]dto.ActivationResponse, 0, len(lic.Activations))
|
|
for _, a := range lic.Activations {
|
|
activations = append(activations, toActivationResponse(a))
|
|
}
|
|
return &resp, activations, nil
|
|
}
|
|
|
|
// Create 创建 License(支持批量) // Logan.Li
|
|
func (s *LicenseService) Create(req dto.LicenseCreate) ([]dto.LicenseResponse, error) {
|
|
// 校验客户与产品 // Logan.Li
|
|
var customer model.Customer
|
|
if err := s.db.First(&customer, req.CustomerID).Error; err != nil {
|
|
return nil, errors.New("客户不存在")
|
|
}
|
|
var product model.Product
|
|
if err := s.db.First(&product, req.ProductID).Error; err != nil {
|
|
return nil, errors.New("产品不存在")
|
|
}
|
|
|
|
if req.MaxActivations <= 0 {
|
|
req.MaxActivations = 1
|
|
}
|
|
if req.Count <= 0 {
|
|
req.Count = 1
|
|
}
|
|
if req.Count > 100 {
|
|
req.Count = 100
|
|
}
|
|
|
|
// 计算到期日期 // Logan.Li
|
|
var endDate time.Time
|
|
if req.Duration == model.DurationForever {
|
|
endDate = model.PermanentEndDate
|
|
} else {
|
|
endDate = time.Now().AddDate(0, 0, req.Duration)
|
|
}
|
|
|
|
licenses := make([]model.License, 0, req.Count)
|
|
for i := 0; i < req.Count; i++ {
|
|
licenses = append(licenses, model.License{
|
|
LicenseKey: utils.GenerateLicenseKey(),
|
|
CustomerID: req.CustomerID,
|
|
ProductID: req.ProductID,
|
|
Status: model.LicenseStatusInactive,
|
|
StartDate: time.Now(),
|
|
EndDate: endDate,
|
|
Duration: req.Duration,
|
|
MaxActivations: req.MaxActivations,
|
|
IsTrial: req.IsTrial,
|
|
TrialDays: req.TrialDays,
|
|
Notes: req.Notes,
|
|
})
|
|
}
|
|
|
|
if err := s.db.Create(&licenses).Error; err != nil {
|
|
return nil, errors.New("创建 License 失败")
|
|
}
|
|
|
|
results := make([]dto.LicenseResponse, 0, len(licenses))
|
|
for _, l := range licenses {
|
|
l.Customer = customer
|
|
l.Product = product
|
|
results = append(results, toLicenseResponse(l))
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// Update 更新 License // Logan.Li
|
|
func (s *LicenseService) Update(id uint, updates map[string]interface{}) (*dto.LicenseResponse, error) {
|
|
var lic model.License
|
|
if err := s.db.First(&lic, id).Error; err != nil {
|
|
return nil, errors.New("License 不存在")
|
|
}
|
|
if err := s.db.Model(&lic).Updates(updates).Error; err != nil {
|
|
return nil, errors.New("更新 License 失败")
|
|
}
|
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
|
resp := toLicenseResponse(lic)
|
|
return &resp, nil
|
|
}
|
|
|
|
// Delete 删除 License(软删除) // Logan.Li
|
|
func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
|
var lic model.License
|
|
if err := s.db.First(&lic, id).Error; err != nil {
|
|
return nil, errors.New("License 不存在")
|
|
}
|
|
if err := s.db.Delete(&model.License{}, id).Error; err != nil {
|
|
return nil, errors.New("删除 License 失败")
|
|
}
|
|
return &lic, nil
|
|
}
|
|
|
|
// 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 {
|
|
newEndDate = model.PermanentEndDate
|
|
} else {
|
|
// 续期在原到期日基础上叠加(若已过期则从当前起算) // Logan.Li
|
|
base := lic.EndDate
|
|
if utils.IsLicenseExpired(lic.EndDate) {
|
|
base = time.Now()
|
|
}
|
|
newEndDate = base.AddDate(0, 0, duration)
|
|
}
|
|
|
|
lic.EndDate = newEndDate
|
|
lic.Duration = duration
|
|
lic.Status = model.LicenseStatusActive
|
|
if err := s.db.Save(&lic).Error; err != nil {
|
|
return nil, errors.New("续期失败")
|
|
}
|
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
|
resp := toLicenseResponse(lic)
|
|
return &resp, nil
|
|
}
|
|
|
|
// Revoke 吊销(同时停用所有激活) // Logan.Li
|
|
func (s *LicenseService) Revoke(id uint, reason string) (*dto.LicenseResponse, error) {
|
|
var lic model.License
|
|
if err := s.db.First(&lic, id).Error; err != nil {
|
|
return nil, errors.New("License 不存在")
|
|
}
|
|
lic.Status = model.LicenseStatusRevoked
|
|
lic.RevokeReason = reason
|
|
if err := s.db.Save(&lic).Error; err != nil {
|
|
return nil, errors.New("吊销失败")
|
|
}
|
|
// 级联停用所有激活记录 // Logan.Li
|
|
s.db.Model(&model.Activation{}).Where("license_id = ?", id).Update("status", "inactive")
|
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
|
resp := toLicenseResponse(lic)
|
|
return &resp, nil
|
|
}
|
|
|
|
// SetStatus 设置状态(暂停/激活) // Logan.Li
|
|
func (s *LicenseService) SetStatus(id uint, status string) (*dto.LicenseResponse, error) {
|
|
var lic model.License
|
|
if err := s.db.First(&lic, id).Error; err != nil {
|
|
return nil, errors.New("License 不存在")
|
|
}
|
|
lic.Status = status
|
|
if err := s.db.Save(&lic).Error; err != nil {
|
|
return nil, errors.New("状态更新失败")
|
|
}
|
|
s.db.Preload("Customer").Preload("Product").First(&lic, id)
|
|
resp := toLicenseResponse(lic)
|
|
return &resp, nil
|
|
}
|
|
|
|
// GetActivations 获取激活记录 // Logan.Li
|
|
func (s *LicenseService) GetActivations(licenseID uint) ([]dto.ActivationResponse, error) {
|
|
var activations []model.Activation
|
|
if err := s.db.Where("license_id = ?", licenseID).Order("created_at DESC").Find(&activations).Error; err != nil {
|
|
return nil, errors.New("获取激活记录失败")
|
|
}
|
|
results := make([]dto.ActivationResponse, 0, len(activations))
|
|
for _, a := range activations {
|
|
results = append(results, toActivationResponse(a))
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// ForceOffline 强制下线设备 // Logan.Li
|
|
func (s *LicenseService) ForceOffline(licenseID, activationID uint) error {
|
|
result := s.db.Model(&model.Activation{}).
|
|
Where("id = ? AND license_id = ?", activationID, licenseID).
|
|
Updates(map[string]interface{}{"status": "inactive", "is_online": false})
|
|
if result.Error != nil {
|
|
return errors.New("强制下线失败")
|
|
}
|
|
if result.RowsAffected > 0 {
|
|
var lic model.License
|
|
s.db.First(&lic, licenseID)
|
|
if lic.CurrentActivations > 0 {
|
|
lic.CurrentActivations--
|
|
s.db.Save(&lic)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExportCSV 导出 License 列表为 CSV // Logan.Li
|
|
func (s *LicenseService) ExportCSV(q ListQuery) (string, error) {
|
|
var licenses []model.License
|
|
query := s.db.Model(&model.License{}).Preload("Customer").Preload("Product")
|
|
if q.Status != "" {
|
|
query = query.Where("status = ?", q.Status)
|
|
}
|
|
if q.CustomerID > 0 {
|
|
query = query.Where("customer_id = ?", q.CustomerID)
|
|
}
|
|
if q.ProductID > 0 {
|
|
query = query.Where("product_id = ?", q.ProductID)
|
|
}
|
|
if err := query.Find(&licenses).Error; err != nil {
|
|
return "", errors.New("导出失败")
|
|
}
|
|
|
|
csv := "License Key,产品,客户,状态,到期时间,激活数/最大激活数,创建时间\n"
|
|
for _, l := range licenses {
|
|
csv += l.LicenseKey + ","
|
|
csv += l.Product.Name + ","
|
|
csv += l.Customer.Name + ","
|
|
csv += l.Status + ","
|
|
csv += l.EndDate.Format("2006-01-02") + ","
|
|
csv += strconv.Itoa(l.CurrentActivations) + "/" + strconv.Itoa(l.MaxActivations) + ","
|
|
csv += l.CreatedAt.Format("2006-01-02 15:04:05") + "\n"
|
|
}
|
|
return csv, nil
|
|
}
|
|
|
|
// toLicenseResponse 模型转响应 DTO // Logan.Li
|
|
func toLicenseResponse(l model.License) dto.LicenseResponse {
|
|
customerName := ""
|
|
customerEmail := ""
|
|
if l.Customer.ID > 0 {
|
|
customerName = l.Customer.Name
|
|
if !strings.HasSuffix(l.Customer.Email, "@local.invalid") {
|
|
customerEmail = l.Customer.Email
|
|
}
|
|
}
|
|
productName := ""
|
|
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,
|
|
CustomerEmail: customerEmail,
|
|
ProductID: l.ProductID,
|
|
ProductName: productName,
|
|
Status: l.Status,
|
|
StartDate: l.StartDate.Format("2006-01-02 15:04:05"),
|
|
EndDate: l.EndDate.Format("2006-01-02 15:04:05"),
|
|
Duration: l.Duration,
|
|
MaxActivations: l.MaxActivations,
|
|
CurrentActivations: l.CurrentActivations,
|
|
IsTrial: l.IsTrial,
|
|
DaysLeft: utils.CalculateDaysLeft(l.EndDate),
|
|
Notes: l.Notes,
|
|
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
}
|
|
|
|
// 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{
|
|
ID: a.ID,
|
|
LicenseID: a.LicenseID,
|
|
DeviceID: a.DeviceID,
|
|
DeviceName: a.DeviceName,
|
|
DeviceInfo: a.DeviceInfo,
|
|
IP: a.IP,
|
|
Status: a.Status,
|
|
IsOnline: a.IsOnline,
|
|
ActivatedAt: a.ActivatedAt.Format("2006-01-02 15:04:05"),
|
|
LastSeenAt: a.LastSeenAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
}
|
|
|
|
// MarshalForAudit 序列化为 JSON 用于审计日志 // Logan.Li
|
|
func MarshalForAudit(v interface{}) string {
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|