Ver Fonte

Feature #TASK_QT-34190 init

Carl há 2 dias atrás
pai
commit
6decef0852
3 ficheiros alterados com 399 adições e 82 exclusões
  1. 361 80
      handlers/license.go
  2. 31 1
      handlers/verify.go
  3. 7 1
      web/index.html

+ 361 - 80
handlers/license.go

@@ -1,11 +1,14 @@
 package handlers
 
 import (
+	"encoding/csv"
 	"encoding/json"
 	"license-admin/models"
 	"math/rand"
 	"net/http"
 	"strconv"
+	"strings"
+	"time"
 
 	"github.com/gin-gonic/gin"
 	"gorm.io/gorm"
@@ -13,30 +16,30 @@ import (
 
 // CreateLicenseRequest 创建 License 请求结构
 type CreateLicenseRequest struct {
-	Key        string `json:"key" binding:"required"`        // 激活码(必填)
-	MaxDevices int    `json:"max_devices"`                    // 最大设备数(可选,默认2)
-	BoundDevices string `json:"bound_devices,omitempty"`      // 初始绑定设备列表(可选,默认为空数组)
+	Key          string `json:"key" binding:"required"`  // 激活码(必填)
+	MaxDevices   int    `json:"max_devices"`             // 最大设备数(可选,默认2)
+	BoundDevices string `json:"bound_devices,omitempty"` // 初始绑定设备列表(可选,默认为空数组)
 }
 
 // UpdateLicenseRequest 更新 License 请求结构
 type UpdateLicenseRequest struct {
-	Key        string `json:"key,omitempty"`        // 激活码(可选)
-	MaxDevices *int   `json:"max_devices,omitempty"` // 最大设备数(可选,使用指针以区分零值)
+	Key          string `json:"key,omitempty"`           // 激活码(可选)
+	MaxDevices   *int   `json:"max_devices,omitempty"`   // 最大设备数(可选,使用指针以区分零值)
 	BoundDevices string `json:"bound_devices,omitempty"` // 绑定设备列表(可选)
 }
 
 // LicenseResponse License 响应结构
 type LicenseResponse struct {
-	Code int             `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string          `json:"msg"`  // 响应消息
+	Code int             `json:"code"`           // 状态码:0 表示成功,非0 表示失败
+	Msg  string          `json:"msg"`            // 响应消息
 	Data *models.License `json:"data,omitempty"` // License 数据
 }
 
 // LicenseListResponse License 列表响应结构
 type LicenseListResponse struct {
-	Code int               `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string            `json:"msg"`  // 响应消息
-	Data []models.License  `json:"data"` // License 列表
+	Code  int              `json:"code"`  // 状态码:0 表示成功,非0 表示失败
+	Msg   string           `json:"msg"`   // 响应消息
+	Data  []models.License `json:"data"`  // License 列表
 	Total int64            `json:"total"` // 总数
 }
 
@@ -53,10 +56,17 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 			return
 		}
 
-		// 检查 Key 是否已存在
-		var existingLicense models.License
-		result := db.Where("license_key = ?", req.Key).First(&existingLicense)
-		if result.Error == nil {
+		// 检查 Key 是否已存在(使用 Count 更高效,不会产生 record not found 日志)
+		var count int64
+		if err := db.Model(&models.License{}).Where("license_key = ?", req.Key).Count(&count).Error; err != nil {
+			// 数据库查询错误
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "数据库查询错误: " + err.Error(),
+			})
+			return
+		}
+		if count > 0 {
 			// Key 已存在
 			c.JSON(http.StatusBadRequest, LicenseResponse{
 				Code: 400,
@@ -64,14 +74,6 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 			})
 			return
 		}
-		if result.Error != gorm.ErrRecordNotFound {
-			// 数据库查询错误
-			c.JSON(http.StatusInternalServerError, LicenseResponse{
-				Code: 500,
-				Msg:  "数据库查询错误: " + result.Error.Error(),
-			})
-			return
-		}
 
 		// 设置默认值
 		if req.MaxDevices <= 0 {
@@ -105,12 +107,14 @@ func CreateLicense(db *gorm.DB) gin.HandlerFunc {
 }
 
 // GetLicenseList 获取 License 列表
-// GET /api/licenses?page=1&page_size=10
+// GET /api/licenses?page=1&page_size=10&search=keyword
 func GetLicenseList(db *gorm.DB) gin.HandlerFunc {
 	return func(c *gin.Context) {
 		// 解析分页参数
 		page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
 		pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
+		search := c.Query("search") // 搜索关键词
+		status := c.Query("status") // 状态筛选:activated(已激活)、unactivated(未激活)
 
 		if page < 1 {
 			page = 1
@@ -122,33 +126,69 @@ func GetLicenseList(db *gorm.DB) gin.HandlerFunc {
 		var licenses []models.License
 		var total int64
 
+		// 构建查询
+		query := db.Model(&models.License{})
+
+		// 如果有搜索关键词,添加搜索条件(按 license_key 模糊匹配)
+		if search != "" {
+			query = query.Where("license_key LIKE ?", "%"+search+"%")
+		}
+
+		// 状态筛选
+		if status == "activated" {
+			// 已激活:有绑定设备
+			query = query.Where("JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0")
+		} else if status == "unactivated" {
+			// 未激活:没有绑定设备
+			query = query.Where("JSON_LENGTH(COALESCE(bound_devices, '[]')) = 0 OR bound_devices IS NULL OR bound_devices = ''")
+		}
+
 		// 获取总数
-		if err := db.Model(&models.License{}).Count(&total).Error; err != nil {
+		if err := query.Count(&total).Error; err != nil {
 			c.JSON(http.StatusInternalServerError, LicenseListResponse{
-				Code: 500,
-				Msg:  "查询总数失败: " + err.Error(),
-				Data: []models.License{},
+				Code:  500,
+				Msg:   "查询总数失败: " + err.Error(),
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
 		}
 
+		// 使用 SQL 排序:
+		// 1. 有绑定设备的优先(bound_devices 不为空且不是 '[]')
+		// 2. 然后按心跳时间最新的排序(从 device_heartbeats JSON 中提取最新时间)
+		//
+		// 使用 MySQL JSON 函数来提取最新的心跳时间:
+		// - 对于 MySQL 5.7+,我们可以使用 JSON_EXTRACT 和 JSON_KEYS 来遍历
+		// - 但由于提取所有值并找最大值在 SQL 中比较复杂,我们使用一个更实用的方法:
+		//   使用 updated_at 字段,因为每次心跳更新(UpdateDeviceHeartbeat)都会更新 updated_at
+		//   这样 updated_at 可以准确反映最后心跳时间
+		//
+		// 排序 SQL:
+		// 1. 有设备的排在前面:CASE WHEN JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0 THEN 0 ELSE 1 END
+		// 2. 然后按 updated_at 倒序(最新的心跳会更新 updated_at)
+		orderBy := `CASE 
+			WHEN JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0 THEN 0 
+			ELSE 1 
+		END ASC, 
+		updated_at DESC`
+
 		// 分页查询
 		offset := (page - 1) * pageSize
-		if err := db.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&licenses).Error; err != nil {
+		if err := query.Offset(offset).Limit(pageSize).Order(orderBy).Find(&licenses).Error; err != nil {
 			c.JSON(http.StatusInternalServerError, LicenseListResponse{
-				Code: 500,
-				Msg:  "查询 License 列表失败: " + err.Error(),
-				Data: []models.License{},
+				Code:  500,
+				Msg:   "查询 License 列表失败: " + err.Error(),
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
 		}
 
 		c.JSON(http.StatusOK, LicenseListResponse{
-			Code: 0,
-			Msg:  "success",
-			Data: licenses,
+			Code:  0,
+			Msg:   "success",
+			Data:  licenses,
 			Total: total,
 		})
 	}
@@ -235,20 +275,19 @@ func UpdateLicense(db *gorm.DB) gin.HandlerFunc {
 
 		// 更新字段
 		if req.Key != "" {
-			// 检查新 Key 是否与其他 License 冲突
-			var existingLicense models.License
-			checkResult := db.Where("license_key = ? AND id != ?", req.Key, id).First(&existingLicense)
-			if checkResult.Error == nil {
-				c.JSON(http.StatusBadRequest, LicenseResponse{
-					Code: 400,
-					Msg:  "激活码已存在",
+			// 检查新 Key 是否与其他 License 冲突(使用 Count 更高效)
+			var count int64
+			if err := db.Model(&models.License{}).Where("license_key = ? AND id != ?", req.Key, id).Count(&count).Error; err != nil {
+				c.JSON(http.StatusInternalServerError, LicenseResponse{
+					Code: 500,
+					Msg:  "检查激活码失败: " + err.Error(),
 				})
 				return
 			}
-			if checkResult.Error != gorm.ErrRecordNotFound {
-				c.JSON(http.StatusInternalServerError, LicenseResponse{
-					Code: 500,
-					Msg:  "检查激活码失败: " + checkResult.Error.Error(),
+			if count > 0 {
+				c.JSON(http.StatusBadRequest, LicenseResponse{
+					Code: 400,
+					Msg:  "激活码已存在",
 				})
 				return
 			}
@@ -350,9 +389,9 @@ type BatchDeleteLicenseRequest struct {
 
 // BatchDeleteLicenseResponse 批量删除响应结构
 type BatchDeleteLicenseResponse struct {
-	Code int    `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string `json:"msg"`  // 响应消息
-	Total int   `json:"total"` // 成功删除的数量
+	Code  int    `json:"code"`  // 状态码:0 表示成功,非0 表示失败
+	Msg   string `json:"msg"`   // 响应消息
+	Total int    `json:"total"` // 成功删除的数量
 }
 
 // BatchDeleteLicense 批量删除 License
@@ -362,8 +401,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 		var req BatchDeleteLicenseRequest
 		if err := c.ShouldBindJSON(&req); err != nil {
 			c.JSON(http.StatusBadRequest, BatchDeleteLicenseResponse{
-				Code: 400,
-				Msg:  "请求参数错误: " + err.Error(),
+				Code:  400,
+				Msg:   "请求参数错误: " + err.Error(),
 				Total: 0,
 			})
 			return
@@ -372,8 +411,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 		// 验证参数
 		if len(req.IDs) == 0 {
 			c.JSON(http.StatusBadRequest, BatchDeleteLicenseResponse{
-				Code: 400,
-				Msg:  "请至少选择一个 License",
+				Code:  400,
+				Msg:   "请至少选择一个 License",
 				Total: 0,
 			})
 			return
@@ -381,8 +420,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 
 		if len(req.IDs) > 100 {
 			c.JSON(http.StatusBadRequest, BatchDeleteLicenseResponse{
-				Code: 400,
-				Msg:  "一次最多只能删除 100 个 License",
+				Code:  400,
+				Msg:   "一次最多只能删除 100 个 License",
 				Total: 0,
 			})
 			return
@@ -392,8 +431,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 		result := db.Where("id IN ?", req.IDs).Delete(&models.License{})
 		if result.Error != nil {
 			c.JSON(http.StatusInternalServerError, BatchDeleteLicenseResponse{
-				Code: 500,
-				Msg:  "批量删除失败: " + result.Error.Error(),
+				Code:  500,
+				Msg:   "批量删除失败: " + result.Error.Error(),
 				Total: 0,
 			})
 			return
@@ -401,8 +440,8 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 
 		deletedCount := int(result.RowsAffected)
 		c.JSON(http.StatusOK, BatchDeleteLicenseResponse{
-			Code: 0,
-			Msg:  "成功删除 " + strconv.Itoa(deletedCount) + " 个 License",
+			Code:  0,
+			Msg:   "成功删除 " + strconv.Itoa(deletedCount) + " 个 License",
 			Total: deletedCount,
 		})
 	}
@@ -410,16 +449,16 @@ func BatchDeleteLicense(db *gorm.DB) gin.HandlerFunc {
 
 // BatchCreateLicenseRequest 批量创建 License 请求结构
 type BatchCreateLicenseRequest struct {
-	Prefix     string `json:"prefix" binding:"required"`     // 激活码前缀(必填)
-	Count      int    `json:"count" binding:"required"`      // 生成数量(必填)
-	MaxDevices int    `json:"max_devices"`                    // 最大设备数(可选,默认2)
+	Prefix     string `json:"prefix" binding:"required"` // 激活码前缀(必填)
+	Count      int    `json:"count" binding:"required"`  // 生成数量(必填)
+	MaxDevices int    `json:"max_devices"`               // 最大设备数(可选,默认2)
 }
 
 // BatchCreateLicenseResponse 批量创建响应结构
 type BatchCreateLicenseResponse struct {
-	Code int               `json:"code"` // 状态码:0 表示成功,非0 表示失败
-	Msg  string            `json:"msg"`  // 响应消息
-	Data []models.License  `json:"data"` // 创建的 License 列表
+	Code  int              `json:"code"`  // 状态码:0 表示成功,非0 表示失败
+	Msg   string           `json:"msg"`   // 响应消息
+	Data  []models.License `json:"data"`  // 创建的 License 列表
 	Total int              `json:"total"` // 成功创建的数量
 }
 
@@ -430,9 +469,9 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 		var req BatchCreateLicenseRequest
 		if err := c.ShouldBindJSON(&req); err != nil {
 			c.JSON(http.StatusBadRequest, BatchCreateLicenseResponse{
-				Code: 400,
-				Msg:  "请求参数错误: " + err.Error(),
-				Data: []models.License{},
+				Code:  400,
+				Msg:   "请求参数错误: " + err.Error(),
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
@@ -441,9 +480,9 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 		// 验证参数
 		if req.Count <= 0 || req.Count > 1000 {
 			c.JSON(http.StatusBadRequest, BatchCreateLicenseResponse{
-				Code: 400,
-				Msg:  "生成数量必须在 1-1000 之间",
-				Data: []models.License{},
+				Code:  400,
+				Msg:   "生成数量必须在 1-1000 之间",
+				Data:  []models.License{},
 				Total: 0,
 			})
 			return
@@ -463,16 +502,15 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 			// 生成激活码:前缀 + 32位随机字符串
 			key := req.Prefix + "-" + generateRandomKey(32)
 
-			// 检查 Key 是否已存在
-			var existingLicense models.License
-			result := db.Where("license_key = ?", key).First(&existingLicense)
-			if result.Error == nil {
-				// Key 已存在,跳过并记录
+			// 检查 Key 是否已存在(使用 Count 更高效,不会产生 record not found 日志)
+			var count int64
+			if err := db.Model(&models.License{}).Where("license_key = ?", key).Count(&count).Error; err != nil {
+				// 数据库查询错误
 				failedKeys = append(failedKeys, key)
 				continue
 			}
-			if result.Error != gorm.ErrRecordNotFound {
-				// 数据库查询错误
+			if count > 0 {
+				// Key 已存在,跳过并记录
 				failedKeys = append(failedKeys, key)
 				continue
 			}
@@ -500,9 +538,9 @@ func BatchCreateLicense(db *gorm.DB) gin.HandlerFunc {
 		}
 
 		c.JSON(http.StatusOK, BatchCreateLicenseResponse{
-			Code: 0,
-			Msg:  msg,
-			Data: licenses,
+			Code:  0,
+			Msg:   msg,
+			Data:  licenses,
 			Total: successCount,
 		})
 	}
@@ -523,3 +561,246 @@ func getRandomInt(max int) int {
 	return rand.Intn(max)
 }
 
+
+// ExportLicenseRequest 导出请求结构
+type ExportLicenseRequest struct {
+	Format string `json:"format"` // csv 或 json
+}
+
+// ExportLicenses 导出 License 列表
+// GET /api/licenses/export?format=csv
+func ExportLicenses(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		format := c.DefaultQuery("format", "csv")
+
+		var licenses []models.License
+
+		// 使用与列表相同的排序逻辑
+		orderBy := `CASE 
+			WHEN JSON_LENGTH(COALESCE(bound_devices, '[]')) > 0 THEN 0 
+			ELSE 1 
+		END ASC, 
+		updated_at DESC`
+
+		if err := db.Order(orderBy).Find(&licenses).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseListResponse{
+				Code:  500,
+				Msg:   "查询失败: " + err.Error(),
+				Data:  []models.License{},
+				Total: 0,
+			})
+			return
+		}
+
+		if format == "csv" {
+			// 导出 CSV
+			c.Header("Content-Type", "text/csv; charset=utf-8")
+			c.Header("Content-Disposition", "attachment; filename=licenses_"+time.Now().Format("20060102_150405")+".csv")
+			c.Header("Content-Transfer-Encoding", "binary")
+
+			writer := csv.NewWriter(c.Writer)
+			defer writer.Flush()
+
+			// 写入表头
+			headers := []string{"ID", "激活码", "最大设备数", "已绑定设备数", "绑定设备列表", "创建时间", "更新时间"}
+			writer.Write(headers)
+
+			// 写入数据
+			for _, license := range licenses {
+				devices, _ := license.GetBoundDeviceList()
+				deviceList := strings.Join(devices, "; ")
+				row := []string{
+					strconv.FormatUint(uint64(license.ID), 10),
+					license.LicenseKey,
+					strconv.Itoa(license.MaxDevices),
+					strconv.Itoa(len(devices)),
+					deviceList,
+					license.CreatedAt.Format("2006-01-02 15:04:05"),
+					license.UpdatedAt.Format("2006-01-02 15:04:05"),
+				}
+				writer.Write(row)
+			}
+		} else {
+			// 导出 JSON
+			c.Header("Content-Type", "application/json; charset=utf-8")
+			c.Header("Content-Disposition", "attachment; filename=licenses_"+time.Now().Format("20060102_150405")+".json")
+			c.JSON(http.StatusOK, LicenseListResponse{
+				Code:  0,
+				Msg:   "success",
+				Data:  licenses,
+				Total: int64(len(licenses)),
+			})
+		}
+	}
+}
+
+// BatchUpdateMaxDevicesRequest 批量修改最大设备数请求结构
+type BatchUpdateMaxDevicesRequest struct {
+	IDs        []uint `json:"ids" binding:"required"`
+	MaxDevices int    `json:"max_devices" binding:"required,min=1"`
+}
+
+// BatchUpdateMaxDevices 批量修改最大设备数
+// PUT /api/licenses/batch/max-devices
+func BatchUpdateMaxDevices(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		var req BatchUpdateMaxDevicesRequest
+		if err := c.ShouldBindJSON(&req); err != nil {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "请求参数错误: " + err.Error(),
+			})
+			return
+		}
+
+		if len(req.IDs) == 0 {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "请至少选择一个 License",
+			})
+			return
+		}
+
+		// 批量更新
+		result := db.Model(&models.License{}).
+			Where("id IN ?", req.IDs).
+			Update("max_devices", req.MaxDevices)
+
+		if result.Error != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "批量更新失败: " + result.Error.Error(),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, LicenseResponse{
+			Code: 0,
+			Msg:  "成功更新 " + strconv.FormatInt(result.RowsAffected, 10) + " 个 License 的最大设备数",
+		})
+	}
+}
+
+// UnbindDeviceRequest 解绑设备请求结构
+type UnbindDeviceRequest struct {
+	DeviceID string `json:"device_id" binding:"required"`
+}
+
+
+
+
+// UnbindDevice 解绑设备
+// DELETE /api/licenses/:id/devices/:device_id
+func UnbindDevice(db *gorm.DB) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		id, err := strconv.ParseUint(c.Param("id"), 10, 32)
+		if err != nil {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "无效的 ID 参数",
+			})
+			return
+		}
+
+		deviceID := c.Param("device_id")
+		if deviceID == "" {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "设备ID不能为空",
+			})
+			return
+		}
+
+		var license models.License
+		if err := db.First(&license, id).Error; err != nil {
+			if err == gorm.ErrRecordNotFound {
+				c.JSON(http.StatusNotFound, LicenseResponse{
+					Code: 404,
+					Msg:  "License 不存在",
+				})
+				return
+			}
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "查询失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 获取设备列表
+		devices, err := license.GetBoundDeviceList()
+		if err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "解析设备列表失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 移除设备
+		newDevices := []string{}
+		found := false
+		for _, d := range devices {
+			if d != deviceID {
+				newDevices = append(newDevices, d)
+			} else {
+				found = true
+			}
+		}
+
+		if !found {
+			c.JSON(http.StatusBadRequest, LicenseResponse{
+				Code: 400,
+				Msg:  "设备未绑定",
+			})
+			return
+		}
+
+		// 更新设备列表
+		if err := license.SetBoundDeviceList(newDevices); err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "更新设备列表失败: " + err.Error(),
+			})
+			return
+		}
+
+		// 同时清理激活时间和心跳时间
+		var activations map[string]string
+		if license.DeviceActivations != "" {
+			json.Unmarshal([]byte(license.DeviceActivations), &activations)
+		} else {
+			activations = make(map[string]string)
+		}
+		delete(activations, deviceID)
+		if activationsData, err := json.Marshal(activations); err == nil {
+			license.DeviceActivations = string(activationsData)
+		}
+
+		var heartbeats map[string]string
+		if license.DeviceHeartbeats != "" {
+			json.Unmarshal([]byte(license.DeviceHeartbeats), &heartbeats)
+		} else {
+			heartbeats = make(map[string]string)
+		}
+		delete(heartbeats, deviceID)
+		if heartbeatsData, err := json.Marshal(heartbeats); err == nil {
+			license.DeviceHeartbeats = string(heartbeatsData)
+		}
+
+		// 保存到数据库
+		if err := db.Save(&license).Error; err != nil {
+			c.JSON(http.StatusInternalServerError, LicenseResponse{
+				Code: 500,
+				Msg:  "保存失败: " + err.Error(),
+			})
+			return
+		}
+
+		c.JSON(http.StatusOK, LicenseResponse{
+			Code: 0,
+			Msg:  "成功解绑设备",
+			Data: &license,
+		})
+	}
+}

+ 31 - 1
handlers/verify.go

@@ -93,7 +93,7 @@ func VerifyLicense(db *gorm.DB) gin.HandlerFunc {
 			return
 		}
 
-		// 情况A: 如果 device_id 已经在 BoundDevices 列表中 -> 验证成功
+		// 情况A: 如果 device_id 已经在 BoundDevices 列表中 -> 验证成功,更新心跳时间
 		isBound := false
 		for _, deviceID := range boundDevices {
 			if deviceID == req.DeviceID {
@@ -103,6 +103,36 @@ func VerifyLicense(db *gorm.DB) gin.HandlerFunc {
 		}
 
 		if isBound {
+			// 设备已激活,更新心跳时间(不更新激活时间)
+			err = license.UpdateDeviceHeartbeat(req.DeviceID)
+			if err != nil {
+				c.JSON(http.StatusInternalServerError, VerifyResponse{
+					Code: 500,
+					Msg:  "更新心跳时间失败: " + err.Error(),
+					Data: struct {
+						Valid bool `json:"valid"`
+					}{
+						Valid: false,
+					},
+				})
+				return
+			}
+
+			// 保存心跳时间到数据库
+			result = db.Save(&license)
+			if result.Error != nil {
+				c.JSON(http.StatusInternalServerError, VerifyResponse{
+					Code: 500,
+					Msg:  "保存心跳时间失败: " + result.Error.Error(),
+					Data: struct {
+						Valid bool `json:"valid"`
+					}{
+						Valid: false,
+					},
+				})
+				return
+			}
+
 			c.JSON(http.StatusOK, VerifyResponse{
 				Code: 0,
 				Msg:  "success",

+ 7 - 1
web/index.html

@@ -794,7 +794,13 @@
             const selectAll = document.getElementById('select-all');
             const selectAllHeader = document.getElementById('select-all-header');
             const checkboxes = document.querySelectorAll('.license-checkbox');
-            const isChecked = selectAll.checked || selectAllHeader.checked;
+            
+            // 检查当前是否所有复选框都已选中
+            const allChecked = checkboxes.length > 0 && 
+                Array.from(checkboxes).every(checkbox => checkbox.checked);
+            
+            // 如果全部已选中,则取消全选;否则全选
+            const isChecked = !allChecked;
             
             checkboxes.forEach(checkbox => {
                 checkbox.checked = isChecked;