From d0e1ca3729acfd48f9b23d0ded4044684e6aad95 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 12 Aug 2026 21:53:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20License=20=E6=94=AF=E6=8C=81=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E5=A4=9A=E9=80=89=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅已吊销或已过期可删,有效授权自动跳过;单条删除同步限制。 Co-authored-by: Cursor --- .../controller/license/license_controller.go | 28 ++++++++- backend/internal/dto/license.go | 14 ++++- .../service/license/license_service.go | 56 +++++++++++++++++- backend/router/router.go | 1 + frontend/src/api/license.js | 4 ++ frontend/src/views/Licenses.vue | 58 +++++++++++++++++-- 6 files changed, 152 insertions(+), 9 deletions(-) diff --git a/backend/internal/controller/license/license_controller.go b/backend/internal/controller/license/license_controller.go index b2932fb..dd1c7cf 100644 --- a/backend/internal/controller/license/license_controller.go +++ b/backend/internal/controller/license/license_controller.go @@ -135,7 +135,7 @@ func (ctrl *LicenseController) Update(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "更新成功", "data": lic}) } -// Delete 删除 License // Logan.Li +// Delete 删除 License(仅已吊销/已过期) // Logan.Li func (ctrl *LicenseController) Delete(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 32) lic, err := ctrl.svc.Delete(uint(id)) @@ -147,6 +147,32 @@ func (ctrl *LicenseController) Delete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "message": "删除成功"}) } +// BatchDelete 批量删除:非吊销/未到期自动跳过 // Logan.Li +func (ctrl *LicenseController) BatchDelete(c *gin.Context) { + var req dto.LicenseBatchDeleteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: 请提供 ids"}) + return + } + result, err := ctrl.svc.BatchDelete(req.IDs) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) + return + } + utils.LogAction(ctrl.db, c, "batch_delete", "license", 0, "", "", "", + "批量删除 License deleted="+strconv.Itoa(result.Deleted)+" skipped="+strconv.Itoa(result.Skipped)) + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "message": "批量删除完成", + "data": dto.LicenseBatchDeleteResponse{ + Deleted: result.Deleted, + Skipped: result.Skipped, + Failed: result.Failed, + SkippedIDs: result.SkippedIDs, + }, + }) +} + // Renew 续期 // Logan.Li func (ctrl *LicenseController) Renew(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 32) diff --git a/backend/internal/dto/license.go b/backend/internal/dto/license.go index c12d248..9859e4a 100644 --- a/backend/internal/dto/license.go +++ b/backend/internal/dto/license.go @@ -17,9 +17,17 @@ type LicenseRenew struct { Duration int `json:"duration" binding:"required"` } -// LicenseRevoke 吊销请求 // Logan.Li -type LicenseRevoke struct { - Reason string `json:"reason"` +// LicenseBatchDeleteRequest 批量删除请求 // Logan.Li +type LicenseBatchDeleteRequest struct { + IDs []uint `json:"ids" binding:"required,min=1"` +} + +// LicenseBatchDeleteResponse 批量删除响应 // Logan.Li +type LicenseBatchDeleteResponse struct { + Deleted int `json:"deleted"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + SkippedIDs []uint `json:"skipped_ids,omitempty"` } // LicenseResponse License 响应 // Logan.Li diff --git a/backend/internal/service/license/license_service.go b/backend/internal/service/license/license_service.go index 4bde05d..ffa6715 100644 --- a/backend/internal/service/license/license_service.go +++ b/backend/internal/service/license/license_service.go @@ -161,18 +161,72 @@ func (s *LicenseService) Update(id uint, updates map[string]interface{}) (*dto.L return &resp, nil } -// Delete 删除 License(软删除) // Logan.Li +// 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 !isDeletableLicense(lic) { + return nil, errors.New("仅已吊销或已过期的 License 可删除") + } if err := s.db.Delete(&model.License{}, id).Error; err != nil { return nil, errors.New("删除 License 失败") } return &lic, nil } +// BatchDeleteResult 批量删除结果 // Logan.Li +type BatchDeleteResult struct { + Deleted int `json:"deleted"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + SkippedIDs []uint `json:"skipped_ids,omitempty"` +} + +// BatchDelete 批量删除:仅删已吊销/已过期,其余跳过 // Logan.Li +func (s *LicenseService) BatchDelete(ids []uint) (*BatchDeleteResult, error) { + result := &BatchDeleteResult{} + if len(ids) == 0 { + return result, nil + } + seen := map[uint]struct{}{} + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + + var lic model.License + if err := s.db.First(&lic, id).Error; err != nil { + result.Failed++ + continue + } + if !isDeletableLicense(lic) { + result.Skipped++ + result.SkippedIDs = append(result.SkippedIDs, id) + continue + } + if err := s.db.Delete(&model.License{}, id).Error; err != nil { + result.Failed++ + continue + } + result.Deleted++ + } + return result, nil +} + +// isDeletableLicense 仅吊销或到期可物理清理(软删) // Logan.Li +func isDeletableLicense(lic model.License) bool { + if lic.Status == model.LicenseStatusRevoked || lic.Status == model.LicenseStatusExpired { + return true + } + return utils.IsLicenseExpired(lic.EndDate) +} + // Renew 续期(仅在线证;离线证签名自包含,到期后应重新签发) // Logan.Li func (s *LicenseService) Renew(id uint, duration int) (*dto.LicenseResponse, error) { var lic model.License diff --git a/backend/router/router.go b/backend/router/router.go index 364d28e..9d1e007 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -174,6 +174,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { register(licenses, "POST", "/offline", licenseController.IssueOffline) register(licenses, "GET", "/offline/public-key", licenseController.OfflinePublicKey) register(licenses, "GET", "/export", licenseController.Export) + register(licenses, "POST", "/batch-delete", licenseController.BatchDelete) register(licenses, "GET", "/:id", licenseController.Get) register(licenses, "PUT", "/:id", licenseController.Update) register(licenses, "DELETE", "/:id", licenseController.Delete) diff --git a/frontend/src/api/license.js b/frontend/src/api/license.js index 05c8f8b..a5aab96 100644 --- a/frontend/src/api/license.js +++ b/frontend/src/api/license.js @@ -22,6 +22,10 @@ export function deleteLicense(id) { return request({ url: `/licenses/${id}`, method: 'delete' }) } +export function batchDeleteLicenses(ids) { + return request({ url: '/licenses/batch-delete', method: 'post', data: { ids } }) +} + export function renewLicense(id, duration) { return request({ url: `/licenses/${id}/renew`, method: 'post', data: { duration } }) } diff --git a/frontend/src/views/Licenses.vue b/frontend/src/views/Licenses.vue index 4a55cab..2a73195 100644 --- a/frontend/src/views/Licenses.vue +++ b/frontend/src/views/Licenses.vue @@ -12,6 +12,12 @@
+ 批量删除 导出 CSV 生成 License
@@ -21,11 +27,12 @@ type="info" :closable="false" show-icon - title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。" + title="本页默认仅展示在线 License。离线序列号请前往「授权管理 → 离线 License」。仅「已吊销 / 已过期」可删除;有效授权会自动跳过。" style="margin-bottom: 12px" /> - + + @@ -43,7 +50,7 @@ - + @@ -126,7 +140,7 @@ import { ref, reactive, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { getLicenses, createLicense, renewLicense, revokeLicense, suspendLicense, - activateLicense, getLicense, forceOffline, exportLicenses, + activateLicense, getLicense, forceOffline, exportLicenses, deleteLicense, batchDeleteLicenses, } from '@/api/license' import { getAllProducts } from '@/api/product' import { getAllCustomers } from '@/api/customer' @@ -141,6 +155,7 @@ const productFilter = ref('') const page = ref(1) const pageSize = ref(10) const total = ref(0) +const selectedRows = ref([]) const statusOptions = [ { label: '未激活', value: 'inactive' }, { label: '已激活', value: 'active' }, @@ -157,6 +172,15 @@ function statusTag(s) { return { active: 'success', inactive: 'info', expired: 'warning', revoked: 'danger', suspended: 'warning' }[s] || '' } +// 仅已吊销 / 已过期(含剩余天数<=0)可删除 // Logan.Li +function canDelete(row) { + return row.status === 'revoked' || row.status === 'expired' || row.days_left <= 0 +} + +function onSelectionChange(rows) { + selectedRows.value = rows || [] +} + const createVisible = ref(false) const formRef = ref(null) const form = reactive({ customer_id: null, product_id: null, duration: 365, max_activations: 1, count: 1, notes: '' }) @@ -220,6 +244,32 @@ async function revoke(row) { loadData() } +async function removeOne(row) { + if (!canDelete(row)) { + ElMessage.warning('仅已吊销或已过期的 License 可删除') + return + } + await ElMessageBox.confirm(`确认删除 License「${row.license_key}」?此操作不可恢复。`, '删除', { type: 'warning' }) + await deleteLicense(row.id) + loadData() +} + +async function batchDelete() { + const ids = selectedRows.value.map((r) => r.id).filter(Boolean) + if (!ids.length) return + const deletable = selectedRows.value.filter(canDelete).length + await ElMessageBox.confirm( + `已选 ${ids.length} 条,其中约 ${deletable} 条可删(仅已吊销/已过期);其余将跳过。确认继续?`, + '批量删除', + { type: 'warning' }, + ) + const res = await batchDeleteLicenses(ids) + const data = res.data || {} + ElMessage.success(`删除 ${data.deleted || 0} 条,跳过 ${data.skipped || 0} 条${data.failed ? `,失败 ${data.failed}` : ''}`) + selectedRows.value = [] + loadData() +} + async function toggle(row, status) { if (status === 'active') await activateLicense(row.id) else await suspendLicense(row.id)