fix: 离线证到期重签空邮箱撞唯一索引

复用 customer_id/同名客户,禁止重复插入空 email;重签前端带回原客户。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 21:49:55 +08:00
1 parent bac72e6945
commit f26e787cfc
4 files changed
+62 -24

No files matched your search

+1
View File
@@ -32,6 +32,7 @@ type LicenseResponse struct {
DeviceID string `json:"device_id,omitempty"` // 离线证绑定设备 DeviceID string `json:"device_id,omitempty"` // 离线证绑定设备
CustomerID uint `json:"customer_id"` CustomerID uint `json:"customer_id"`
CustomerName string `json:"customer_name"` CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email,omitempty"`
ProductID uint `json:"product_id"` ProductID uint `json:"product_id"`
ProductName string `json:"product_name"` ProductName string `json:"product_name"`
Status string `json:"status"` Status string `json:"status"`
@@ -304,8 +304,12 @@ func (s *LicenseService) ExportCSV(q ListQuery) (string, error) {
// toLicenseResponse 模型转响应 DTO // Logan.Li // toLicenseResponse 模型转响应 DTO // Logan.Li
func toLicenseResponse(l model.License) dto.LicenseResponse { func toLicenseResponse(l model.License) dto.LicenseResponse {
customerName := "" customerName := ""
customerEmail := ""
if l.Customer.ID > 0 { if l.Customer.ID > 0 {
customerName = l.Customer.Name customerName = l.Customer.Name
if !strings.HasSuffix(l.Customer.Email, "@local.invalid") {
customerEmail = l.Customer.Email
}
} }
productName := "" productName := ""
if l.Product.ID > 0 { if l.Product.ID > 0 {
@@ -332,6 +336,7 @@ func toLicenseResponse(l model.License) dto.LicenseResponse {
DeviceID: deviceID, DeviceID: deviceID,
CustomerID: l.CustomerID, CustomerID: l.CustomerID,
CustomerName: customerName, CustomerName: customerName,
CustomerEmail: customerEmail,
ProductID: l.ProductID, ProductID: l.ProductID,
ProductName: productName, ProductName: productName,
Status: l.Status, Status: l.Status,
@@ -6,6 +6,7 @@ import (
"backend/internal/model" "backend/internal/model"
"backend/utils" "backend/utils"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -85,34 +86,24 @@ func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicense
return nil, errors.New("客户不存在") return nil, errors.New("客户不存在")
} }
} else { } else {
email := strings.TrimSpace(req.Email) var err error
name := strings.TrimSpace(req.CustomerName) customer, err = s.resolveOrCreateCustomer(req)
if name == "" { if err != nil {
name = "离线客户" return nil, err
}
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() now := time.Now().UTC()
exp := now.AddDate(0, 0, duration) 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{ claims := utils.OfflineLicenseClaims{
V: 1, V: 1,
ProductCode: product.Code, ProductCode: product.Code,
DeviceID: deviceID, DeviceID: deviceID,
Email: strings.TrimSpace(req.Email), Email: claimEmail,
PlanType: planType, PlanType: planType,
IssuedAt: now.Unix(), IssuedAt: now.Unix(),
ExpiresAt: exp.Unix(), ExpiresAt: exp.Unix(),
@@ -175,3 +166,39 @@ func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicense
PublicKey: s.kp.OfflinePublicKeyHex(), PublicKey: s.kp.OfflinePublicKeyHex(),
}, nil }, 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
}
+10 -5
View File
@@ -135,7 +135,7 @@ const issueVisible = ref(false)
const issuing = ref(false) const issuing = ref(false)
const formRef = ref(null) const formRef = ref(null)
const form = reactive({ const form = reactive({
device_id: '', email: '', customer_name: '', product_code: 'opsxshell', device_id: '', email: '', customer_name: '', customer_id: 0, product_code: 'opsxshell',
plan_type: 'yearly', duration_days: 365, notes: '', plan_type: 'yearly', duration_days: 365, notes: '',
}) })
const result = ref(null) const result = ref(null)
@@ -167,7 +167,7 @@ async function loadData() {
function openIssue() { function openIssue() {
Object.assign(form, { Object.assign(form, {
device_id: '', email: '', customer_name: '', product_code: 'opsxshell', device_id: '', email: '', customer_name: '', customer_id: 0, product_code: 'opsxshell',
plan_type: 'yearly', duration_days: 365, notes: '', plan_type: 'yearly', duration_days: 365, notes: '',
}) })
result.value = null result.value = null
@@ -177,8 +177,9 @@ function openIssue() {
function reissue(row) { function reissue(row) {
Object.assign(form, { Object.assign(form, {
device_id: row.device_id || '', device_id: row.device_id || '',
email: '', email: row.customer_email || '',
customer_name: row.customer_name || '', customer_name: row.customer_name || '',
customer_id: row.customer_id || 0,
product_code: 'opsxshell', product_code: 'opsxshell',
plan_type: row.is_trial ? 'trial' : 'yearly', plan_type: row.is_trial ? 'trial' : 'yearly',
duration_days: row.is_trial ? 180 : 365, duration_days: row.is_trial ? 180 : 365,
@@ -193,7 +194,7 @@ async function submitIssue() {
if (!valid) return if (!valid) return
issuing.value = true issuing.value = true
try { try {
const res = await issueOfflineLicense({ const payload = {
device_id: form.device_id.trim(), device_id: form.device_id.trim(),
email: form.email.trim() || undefined, email: form.email.trim() || undefined,
customer_name: form.customer_name.trim() || undefined, customer_name: form.customer_name.trim() || undefined,
@@ -201,7 +202,11 @@ async function submitIssue() {
duration_days: form.duration_days, duration_days: form.duration_days,
notes: form.notes, notes: form.notes,
product_code: form.product_code.trim() || 'opsxshell', product_code: form.product_code.trim() || 'opsxshell',
}) }
if (form.customer_id > 0) {
payload.customer_id = form.customer_id
}
const res = await issueOfflineLicense(payload)
result.value = res.data result.value = res.data
loadData() loadData()
ElMessage.success('已签发(请复制序列号;列表仅显示短码)') ElMessage.success('已签发(请复制序列号;列表仅显示短码)')