diff --git a/.env.example b/.env.example
index bdf082b..21e93e8 100644
--- a/.env.example
+++ b/.env.example
@@ -20,6 +20,17 @@ JWT_SECRET=your-super-secret-key-change-this-in-production
# 当前生产公钥(桌面已嵌入): 108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d
# OFFLINE_LICENSE_SEED=
+# 客户端授权防护
+# 过期宽限天数(仅商业过期;吊销/暂停无宽限,立即生效)
+GRACE_PERIOD_DAYS=15
+# 单序列号 24 小时内新设备激活上限(0 = 不限制),抑制换座轮换共享
+ACTIVATE_DAILY_LIMIT=5
+# 激活记录在线状态有效期(分钟)。默认 8 天,适配客户端周检节奏;
+# 如客户端心跳更频繁可调小(如 30 = 在线即 30 分钟内活跃)
+ONLINE_TTL_MINUTES=11520
+# 解绑是否强制校验设备激活令牌(新激活的设备必须持令牌才能解绑,防偷座)
+DEACTIVATE_REQUIRE_TOKEN=true
+
# 数据库配置
# 可选: sqlite / mysql / postgres
DB_DRIVER=sqlite
diff --git a/backend/config/config.go b/backend/config/config.go
index c310841..d59638a 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"log"
"os"
+ "strconv"
"strings"
"github.com/joho/godotenv"
@@ -30,6 +31,12 @@ type Config struct {
// 离线许可证 Ed25519 seed(32 字节 hex;生产必须固定,勿轮换除非同步桌面公钥) // Logan.Li
OfflineLicenseSeed string
+ // 客户端授权防护配置 // Logan.Li
+ GracePeriodDays int // 过期宽限天数(仅商业过期;吊销/暂停无宽限)
+ ActivateDailyLimit int // 单序列号 24 小时内新设备激活上限(0 = 不限制)
+ OnlineTTLMinutes int // 激活记录在线状态有效期(last_seen 超过即视为离线)
+ DeactivateRequireToken bool // 解绑是否强制校验设备激活令牌
+
// CORS 配置
CORSOrigins []string
}
@@ -42,17 +49,21 @@ func Load() *Config {
_ = godotenv.Load("../../.env")
cfg := &Config{
- Port: getEnv("PORT", "9001"),
- Mode: getEnv("GIN_MODE", "debug"),
- DBDriver: getEnv("DB_DRIVER", "sqlite"),
- DBHost: getEnv("DB_HOST", "127.0.0.1"),
- DBPort: getEnv("DB_PORT", "3306"),
- DBUser: getEnv("DB_USER", "root"),
- DBPassword: getEnv("DB_PASSWORD", ""),
- DBName: getEnv("DB_NAME", "db.sqlite3"),
- JWTSecret: getEnv("JWT_SECRET", ""),
- OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""),
- CORSOrigins: defaultCORSOrigins(),
+ Port: getEnv("PORT", "9001"),
+ Mode: getEnv("GIN_MODE", "debug"),
+ DBDriver: getEnv("DB_DRIVER", "sqlite"),
+ DBHost: getEnv("DB_HOST", "127.0.0.1"),
+ DBPort: getEnv("DB_PORT", "3306"),
+ DBUser: getEnv("DB_USER", "root"),
+ DBPassword: getEnv("DB_PASSWORD", ""),
+ DBName: getEnv("DB_NAME", "db.sqlite3"),
+ JWTSecret: getEnv("JWT_SECRET", ""),
+ OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""),
+ GracePeriodDays: getEnvInt("GRACE_PERIOD_DAYS", 15),
+ ActivateDailyLimit: getEnvInt("ACTIVATE_DAILY_LIMIT", 5),
+ OnlineTTLMinutes: getEnvInt("ONLINE_TTL_MINUTES", 11520), // 默认 8 天,适配周检节奏
+ DeactivateRequireToken: getEnvBool("DEACTIVATE_REQUIRE_TOKEN", true),
+ CORSOrigins: defaultCORSOrigins(),
}
if raw := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); raw != "" {
@@ -122,3 +133,27 @@ func getEnv(key, defaultValue string) string {
}
return defaultValue
}
+
+// getEnvInt 读取整型环境变量,不存在或非法时返回默认值 // Logan.Li
+func getEnvInt(key string, defaultValue int) int {
+ if raw := strings.TrimSpace(os.Getenv(key)); raw != "" {
+ if v, err := strconv.Atoi(raw); err == nil {
+ return v
+ }
+ log.Printf("[配置] %s 不是合法整数,使用默认值 %d", key, defaultValue)
+ }
+ return defaultValue
+}
+
+// getEnvBool 读取布尔环境变量(true/false/1/0),不存在或非法时返回默认值 // Logan.Li
+func getEnvBool(key string, defaultValue bool) bool {
+ if raw := strings.TrimSpace(os.Getenv(key)); raw != "" {
+ switch strings.ToLower(raw) {
+ case "true", "1", "yes":
+ return true
+ case "false", "0", "no":
+ return false
+ }
+ }
+ return defaultValue
+}
diff --git a/backend/internal/controller/license/license_controller.go b/backend/internal/controller/license/license_controller.go
index dd1c7cf..ae4781a 100644
--- a/backend/internal/controller/license/license_controller.go
+++ b/backend/internal/controller/license/license_controller.go
@@ -22,7 +22,7 @@ type LicenseController struct {
// NewLicenseController 创建控制器实例 // Logan.Li
func NewLicenseController(db *gorm.DB, cfg *config.Config) *LicenseController {
return &LicenseController{
- svc: licenseservice.NewLicenseService(db),
+ svc: licenseservice.NewLicenseService(db, cfg),
offline: licenseservice.NewOfflineIssuer(db, cfg),
db: db,
}
@@ -162,7 +162,7 @@ func (ctrl *LicenseController) BatchDelete(c *gin.Context) {
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,
+ "code": 0,
"message": "批量删除完成",
"data": dto.LicenseBatchDeleteResponse{
Deleted: result.Deleted,
diff --git a/backend/internal/dto/license.go b/backend/internal/dto/license.go
index 9859e4a..52040a5 100644
--- a/backend/internal/dto/license.go
+++ b/backend/internal/dto/license.go
@@ -53,6 +53,9 @@ type LicenseResponse struct {
DaysLeft int `json:"days_left"`
Notes string `json:"notes"`
CreatedAt string `json:"created_at"`
+ DistinctIPs int `json:"distinct_ips"` // 激活记录中的不重复 IP 数(疑似共享信号)
+ NewDevices7d int `json:"new_devices_7d"` // 近 7 天新增设备数(换座频次信号)
+ AbuseSuspected bool `json:"abuse_suspected"` // 疑似共享/滥用标记
}
// ============ 客户端授权契约(无需认证) ============
@@ -68,18 +71,23 @@ type LicenseVerifyRequest struct {
// LicenseVerifyResponse 验证响应 // Logan.Li
type LicenseVerifyResponse struct {
- Valid bool `json:"valid"`
- LicenseKey string `json:"license_key,omitempty"`
- ProductID uint `json:"product_id,omitempty"`
- ProductName string `json:"product_name,omitempty"`
- ProductCode string `json:"product_code,omitempty"`
- Features []string `json:"features,omitempty"`
- Status string `json:"status,omitempty"`
- ExpiresAt string `json:"expires_at,omitempty"`
- DaysLeft int `json:"days_left,omitempty"`
- MaxDevices int `json:"max_devices,omitempty"`
- PlanType string `json:"plan_type,omitempty"`
- Message string `json:"message,omitempty"`
+ Valid bool `json:"valid"`
+ LicenseKey string `json:"license_key,omitempty"`
+ ProductID uint `json:"product_id,omitempty"`
+ ProductName string `json:"product_name,omitempty"`
+ ProductCode string `json:"product_code,omitempty"`
+ Features []string `json:"features,omitempty"`
+ Status string `json:"status,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+ DaysLeft int `json:"days_left,omitempty"`
+ MaxDevices int `json:"max_devices,omitempty"`
+ PlanType string `json:"plan_type,omitempty"`
+ Message string `json:"message,omitempty"`
+ InGrace bool `json:"in_grace,omitempty"` // 宽限期内(仅商业过期,吊销/暂停无宽限)
+ GraceDaysLeft int `json:"grace_days_left,omitempty"` // 宽限剩余天数
+ ExpiresAtUnix int64 `json:"expires_at_unix,omitempty"` // 到期时间 unix 秒(签名字段,客户端直接回用免时区换算)
+ ServerTime int64 `json:"server_time,omitempty"` // 服务端时间(unix 秒),客户端据此防时钟回拨
+ Sig string `json:"sig,omitempty"` // Ed25519 签名(hex),客户端用内嵌公钥验签防假服务器
}
// LicenseActivateRequest 激活请求(客户端调用) // Logan.Li
@@ -92,8 +100,9 @@ type LicenseActivateRequest struct {
// LicenseDeactivateRequest 停用请求(客户端调用) // Logan.Li
type LicenseDeactivateRequest struct {
- LicenseKey string `json:"license_key" binding:"required"`
- DeviceID string `json:"device_id" binding:"required"`
+ LicenseKey string `json:"license_key" binding:"required"`
+ DeviceID string `json:"device_id" binding:"required"`
+ ActivationToken string `json:"activation_token"` // 本机激活时下发的令牌;解绑他机需联系管理员
}
// LicenseHeartbeatRequest 心跳请求(客户端调用) // Logan.Li
@@ -123,7 +132,7 @@ type OfflineLicenseCreate struct {
CustomerID uint `json:"customer_id"`
CustomerName string `json:"customer_name"`
ProductCode string `json:"product_code"`
- PlanType string `json:"plan_type"` // trial | yearly(禁止 perpetual)
+ PlanType string `json:"plan_type"` // trial | yearly(禁止 perpetual)
DurationDays int `json:"duration_days"` // 1–365,默认 365;trial 上限 180
Notes string `json:"notes"`
}
@@ -142,4 +151,3 @@ type OfflineLicenseResponse struct {
LicenseID uint `json:"license_id"`
PublicKey string `json:"public_key_hex"`
}
-
diff --git a/backend/internal/model/license.go b/backend/internal/model/license.go
index 462816f..5597ab7 100644
--- a/backend/internal/model/license.go
+++ b/backend/internal/model/license.go
@@ -60,6 +60,7 @@ func (License) TableName() string {
}
// Activation 激活记录模型(机器绑定) // Logan.Li
+// Activation 设备激活记录 // Logan.Li
type Activation struct {
ID uint `json:"id" gorm:"primaryKey"`
LicenseID uint `json:"license_id" gorm:"uniqueIndex:idx_lic_device,priority:1"`
@@ -70,6 +71,7 @@ type Activation struct {
IP string `json:"ip" gorm:"size:50"`
Status string `json:"status" gorm:"size:20;default:'active'"` // active / inactive
IsOnline bool `json:"is_online" gorm:"default:false"`
+ Token string `json:"-" gorm:"size:64"` // 设备激活令牌:解绑需持有,防止仅凭序列号偷座 // Logan.Li
ActivatedAt time.Time `json:"activated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastSeenAt time.Time `json:"last_seen_at"`
diff --git a/backend/internal/service/license/client_service.go b/backend/internal/service/license/client_service.go
index 3dee2e7..74e51be 100644
--- a/backend/internal/service/license/client_service.go
+++ b/backend/internal/service/license/client_service.go
@@ -29,40 +29,73 @@ func inferPlanType(lic model.License) string {
func licenseClientPayload(lic model.License) map[string]interface{} {
return map[string]interface{}{
+ "license_key": lic.LicenseKey, // 签名字段:客户端凭响应重建验签串,必须自带 // Logan.Li
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
"days_left": utils.CalculateDaysLeft(lic.EndDate),
"max_devices": lic.MaxActivations,
+ "status": lic.Status,
"plan_type": inferPlanType(lic),
}
}
-// Verify 客户端 License 验证 // Logan.Li
+// Verify 客户端 License 验证(含过期宽限与响应签名) // Logan.Li
func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerifyResponse, error) {
var lic model.License
if err := s.db.Preload("Product").Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
- return &dto.LicenseVerifyResponse{Valid: false, Message: "无效的 License"}, nil
+ resp := &dto.LicenseVerifyResponse{Valid: false, Message: "无效的 License"}
+ s.signVerifyResponse(resp)
+ return resp, nil
}
if req.ProductID > 0 && lic.ProductID != req.ProductID {
- return &dto.LicenseVerifyResponse{Valid: false, Message: "License 与产品不匹配"}, nil
+ resp := &dto.LicenseVerifyResponse{Valid: false, Message: "License 与产品不匹配"}
+ s.signVerifyResponse(resp)
+ return resp, nil
}
+ // 吊销/暂停是滥用处置,立即生效、无宽限 // Logan.Li
switch lic.Status {
case model.LicenseStatusRevoked:
- return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被吊销"}, nil
+ resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已被吊销"}
+ s.signVerifyResponse(resp)
+ return resp, nil
case model.LicenseStatusSuspended:
- return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被暂停"}, nil
- case model.LicenseStatusExpired:
- return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
+ resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已被暂停"}
+ s.signVerifyResponse(resp)
+ return resp, nil
}
- if utils.IsLicenseExpired(lic.EndDate) {
- lic.Status = model.LicenseStatusExpired
- s.db.Save(&lic)
- return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
+ // 商业过期:宽限期内放行并提示续费,超出宽限才拒绝 // Logan.Li
+ if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
+ if lic.Status != model.LicenseStatusExpired {
+ lic.Status = model.LicenseStatusExpired
+ s.db.Save(&lic)
+ }
+ if inGrace, graceDays := s.inGraceInfo(lic); inGrace {
+ resp := &dto.LicenseVerifyResponse{
+ Valid: true,
+ LicenseKey: lic.LicenseKey,
+ ProductID: lic.ProductID,
+ ProductName: lic.Product.Name,
+ ProductCode: lic.Product.Code,
+ Features: utils.ParseFeatures(lic.Product.Features),
+ Status: lic.Status,
+ ExpiresAt: lic.EndDate.Format("2006-01-02 15:04:05"),
+ MaxDevices: lic.MaxActivations,
+ PlanType: inferPlanType(lic),
+ Message: "License 已过期,处于宽限期,请尽快续费",
+ InGrace: true,
+ GraceDaysLeft: graceDays,
+ }
+ s.signVerifyResponse(resp)
+ return resp, nil
+ }
+ resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已过期"}
+ s.signVerifyResponse(resp)
+ return resp, nil
}
- return &dto.LicenseVerifyResponse{
+ resp := &dto.LicenseVerifyResponse{
Valid: true,
LicenseKey: lic.LicenseKey,
ProductID: lic.ProductID,
@@ -75,12 +108,16 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif
MaxDevices: lic.MaxActivations,
PlanType: inferPlanType(lic),
Message: "验证成功",
- }, nil
+ }
+ s.signVerifyResponse(resp)
+ return resp, nil
}
-// Activate 客户端激活 License(事务 + 条件占座,消除 TOCTOU) // Logan.Li
+// Activate 客户端激活 License(事务 + 条件占座,消除 TOCTOU);
+// 新设备激活时下发设备激活令牌,并对响应签名。 // Logan.Li
func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) {
var payload map[string]interface{}
+ var newToken string
err := s.db.Transaction(func(tx *gorm.DB) error {
var lic model.License
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
@@ -126,6 +163,11 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
return nil
}
+ // 新设备占座前校验 24 小时换座上限,抑制序列号共享轮换 // Logan.Li
+ if err := s.checkDailyActivationLimit(tx, lic.ID); err != nil {
+ return err
+ }
+
res := tx.Model(&model.License{}).
Where("id = ? AND current_activations < max_activations", lic.ID).
Updates(map[string]interface{}{
@@ -139,6 +181,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
return errors.New("已达到最大激活数量限制")
}
+ newToken = generateActivationToken()
if err := tx.Create(&model.Activation{
LicenseID: lic.ID,
DeviceID: req.DeviceID,
@@ -147,6 +190,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
IP: clientIP,
Status: "active",
IsOnline: true,
+ Token: newToken,
ActivatedAt: now,
LastSeenAt: now,
}).Error; err != nil {
@@ -161,16 +205,31 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
if err != nil {
return nil, err
}
+ if newToken != "" {
+ // 令牌仅在本机首次占座时下发一次;客户端须持久保存用于后续解绑 // Logan.Li
+ payload["activation_token"] = newToken
+ }
+ s.signPayloadMap(payload, req.LicenseKey)
return payload, nil
}
-// Deactivate 客户端停用(仅 active→inactive 才减席位) // Logan.Li
+// Deactivate 客户端停用(仅 active→inactive 才减席位);
+// 新设备记录强制校验激活令牌,防止仅凭序列号偷座/踢座。 // Logan.Li
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var lic model.License
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
return errors.New("无效的 License")
}
+ var act model.Activation
+ hasRow := tx.Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
+ First(&act).Error == nil
+ // 存量记录 Token 为空(升级前激活),保持旧协议放行以兼容老客户端 // Logan.Li
+ if hasRow && act.Token != "" && s.guard != nil && s.guard.deactivateRequireToken {
+ if req.ActivationToken == "" || req.ActivationToken != act.Token {
+ return errors.New("解绑校验失败:请在对应设备的工具内解绑,或联系管理员处理")
+ }
+ }
res := tx.Model(&model.Activation{}).
Where("license_id = ? AND device_id = ? AND status = ?", lic.ID, req.DeviceID, "active").
Updates(map[string]interface{}{"status": "inactive", "is_online": false})
@@ -186,7 +245,7 @@ func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
})
}
-// Heartbeat 客户端心跳 // Logan.Li
+// Heartbeat 客户端心跳(含宽限期放行与响应签名) // Logan.Li
func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]interface{}, error) {
var lic model.License
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
@@ -197,6 +256,15 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]
return nil, errors.New("License 已被吊销或暂停")
}
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
+ // 宽限期内心跳照常放行,超出宽限才中断 // Logan.Li
+ if inGrace, graceDays := s.inGraceInfo(lic); inGrace {
+ payload := licenseClientPayload(lic)
+ payload["in_grace"] = true
+ payload["grace_days_left"] = graceDays
+ payload["message"] = "License 已过期,处于宽限期,请尽快续费"
+ s.signPayloadMap(payload, req.LicenseKey)
+ return payload, nil
+ }
return nil, errors.New("License 已过期")
}
@@ -204,7 +272,9 @@ func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]
Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
Updates(map[string]interface{}{"last_seen_at": time.Now(), "is_online": true})
- return licenseClientPayload(lic), nil
+ payload := licenseClientPayload(lic)
+ s.signPayloadMap(payload, req.LicenseKey)
+ return payload, nil
}
// ListDevicesByKey returns activations for a license key (client self-service). // Logan.Li
diff --git a/backend/internal/service/license/guard.go b/backend/internal/service/license/guard.go
new file mode 100644
index 0000000..c2f8890
--- /dev/null
+++ b/backend/internal/service/license/guard.go
@@ -0,0 +1,196 @@
+package license
+
+import (
+ "backend/internal/dto"
+ "backend/internal/model"
+ "backend/utils"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "log"
+ "strconv"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// clientRespTimeLayout 客户端响应中 expires_at 的统一时间格式 // Logan.Li
+const clientRespTimeLayout = "2006-01-02 15:04:05"
+
+// errDailyActivationLimit 单序列号 24 小时新设备激活超限(防换座轮换共享) // Logan.Li
+var errDailyActivationLimit = errors.New("该序列号 24 小时内新设备激活数已达上限,请明日再试或联系客服")
+
+// graceDeadline 计算宽限期截止时间 = 授权到期时间 + 配置宽限天数 // Logan.Li
+func (s *LicenseService) graceDeadline(lic model.License) time.Time {
+ return lic.EndDate.AddDate(0, 0, s.guard.gracePeriodDays)
+}
+
+// guard 是客户端授权防护配置与签名密钥的聚合,避免 LicenseService 字段膨胀 // Logan.Li
+type clientGuard struct {
+ kp *utils.OfflineKeyPair
+ gracePeriodDays int
+ activateDailyLimit int
+ onlineTTLMinutes int
+ deactivateRequireToken bool
+}
+
+// inGraceInfo 判断授权是否处于宽限期,返回(是否在宽限期内, 宽限剩余天数)。
+// 仅适用于商业过期;吊销/暂停不调用本函数,直接拒绝且无宽限。 // Logan.Li
+func (s *LicenseService) inGraceInfo(lic model.License) (bool, int) {
+ deadline := s.graceDeadline(lic)
+ if time.Now().After(deadline) {
+ return false, 0
+ }
+ days := int(time.Until(deadline).Hours() / 24)
+ if days < 0 {
+ days = 0
+ }
+ return true, days
+}
+
+// signClientResponse 为客户端验证/心跳响应生成服务端时间与 Ed25519 签名。
+// 签名串为固定顺序的竖线拼接(expires 用 unix 秒,随响应一并下发,客户端免时区换算):
+// v1|license_key|valid|status|expires_at_unix|server_time|in_grace|max_devices|plan_type
+// 桌面端按同序重组即可验签(防假服务器/防篡改缓存)。
+// 未配置离线 seed 时返回零值,响应退化为无签名字段。 // Logan.Li
+func (s *LicenseService) signClientResponse(licenseKey string, valid bool, status string,
+ expiresUnix int64, inGrace bool, maxDevices int, planType string) (int64, string) {
+
+ if s.guard == nil || s.guard.kp == nil {
+ return 0, ""
+ }
+ serverTime := time.Now().Unix()
+ msg := "v1|" + licenseKey + "|" +
+ strconv.FormatBool(valid) + "|" +
+ status + "|" +
+ strconv.FormatInt(expiresUnix, 10) + "|" +
+ strconv.FormatInt(serverTime, 10) + "|" +
+ strconv.FormatBool(inGrace) + "|" +
+ strconv.Itoa(maxDevices) + "|" +
+ planType
+ return serverTime, s.guard.kp.SignHexString(msg)
+}
+
+// parseClientRespTime 解析响应中的 expires_at 字符串(本地时区,与序列化端一致) // Logan.Li
+func parseClientRespTime(v string) time.Time {
+ if v == "" {
+ return time.Time{}
+ }
+ t, err := time.ParseInLocation(clientRespTimeLayout, v, time.Local)
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
+
+// signVerifyResponse 为 DTO 形式的验证响应补充 server_time、expires_at_unix 与 sig 字段 // Logan.Li
+func (s *LicenseService) signVerifyResponse(resp *dto.LicenseVerifyResponse) {
+ if resp == nil {
+ return
+ }
+ var expiresUnix int64
+ if t := parseClientRespTime(resp.ExpiresAt); !t.IsZero() {
+ expiresUnix = t.Unix()
+ resp.ExpiresAtUnix = expiresUnix
+ }
+ resp.ServerTime, resp.Sig = s.signClientResponse(resp.LicenseKey, resp.Valid, resp.Status,
+ expiresUnix, resp.InGrace, resp.MaxDevices, resp.PlanType)
+}
+
+// signPayloadMap 为 map 形式的客户端响应(激活/心跳)补充 server_time、expires_at_unix 与 sig 字段 // Logan.Li
+func (s *LicenseService) signPayloadMap(m map[string]interface{}, licenseKey string) {
+ if m == nil {
+ return
+ }
+ valid, _ := m["valid"].(bool)
+ status, _ := m["status"].(string)
+ expiresStr, _ := m["expires_at"].(string)
+ maxDevices, _ := m["max_devices"].(int)
+ inGrace, _ := m["in_grace"].(bool)
+ planType, _ := m["plan_type"].(string)
+ var expiresUnix int64
+ if t := parseClientRespTime(expiresStr); !t.IsZero() {
+ expiresUnix = t.Unix()
+ m["expires_at_unix"] = expiresUnix
+ }
+ m["server_time"], m["sig"] = s.signClientResponse(licenseKey, valid, status,
+ expiresUnix, inGrace, maxDevices, planType)
+}
+
+// generateActivationToken 生成 32 字节随机设备激活令牌(hex) // Logan.Li
+func generateActivationToken() string {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ // 随机源失败属致命异常,直接 panic 比下发可预测令牌更安全 // Logan.Li
+ log.Fatal("[安全] 生成激活令牌失败:", err)
+ }
+ return hex.EncodeToString(buf)
+}
+
+// checkDailyActivationLimit 校验单序列号 24 小时内新增设备数是否超限(防换座轮换共享)。
+// 统计口径为 activated_at(仅新建设备行会刷新),同设备重激活不计数。 // Logan.Li
+func (s *LicenseService) checkDailyActivationLimit(tx *gorm.DB, licenseID uint) error {
+ limit := s.guard.activateDailyLimit
+ if limit <= 0 {
+ return nil
+ }
+ var count int64
+ if err := tx.Model(&model.Activation{}).
+ Where("license_id = ? AND activated_at > ?", licenseID, time.Now().Add(-24*time.Hour)).
+ Count(&count).Error; err != nil {
+ return err
+ }
+ if count >= int64(limit) {
+ return errDailyActivationLimit
+ }
+ return nil
+}
+
+// attachAbuseStats 基于预加载的激活记录计算疑似共享信号(不重复 IP 数、7 天新增设备数)。
+// 判定规则:不重复 IP 超过席位数+2,或 7 天新增设备超过 6 台。 // Logan.Li
+func attachAbuseStats(resp *dto.LicenseResponse, lic model.License) {
+ if resp == nil {
+ return
+ }
+ ips := make(map[string]struct{})
+ weekAgo := time.Now().AddDate(0, 0, -7)
+ newDevices := 0
+ for _, a := range lic.Activations {
+ if a.IP != "" {
+ ips[a.IP] = struct{}{}
+ }
+ if a.ActivatedAt.After(weekAgo) {
+ newDevices++
+ }
+ }
+ resp.DistinctIPs = len(ips)
+ resp.NewDevices7d = newDevices
+ resp.AbuseSuspected = len(ips) > lic.MaxActivations+2 || newDevices > 6
+}
+
+// StartOnlineSweeper 启动在线状态清扫任务:last_seen_at 超过 TTL 的激活记录置为离线,
+// 保证后台“在线数量”反映真实活跃而非历史峰值。每 10 分钟执行一轮。 // Logan.Li
+func StartOnlineSweeper(db *gorm.DB, ttlMinutes int) {
+ if db == nil || ttlMinutes <= 0 {
+ return
+ }
+ go func() {
+ ticker := time.NewTicker(10 * time.Minute)
+ defer ticker.Stop()
+ sweep := func() {
+ cutoff := time.Now().Add(-time.Duration(ttlMinutes) * time.Minute)
+ res := db.Model(&model.Activation{}).
+ Where("is_online = ? AND last_seen_at < ?", true, cutoff).
+ Updates(map[string]interface{}{"is_online": false})
+ if res.Error != nil {
+ log.Printf("[清扫] 在线状态更新失败: %v", res.Error)
+ } else if res.RowsAffected > 0 {
+ log.Printf("[清扫] 已将 %d 条超时激活记录置为离线", res.RowsAffected)
+ }
+ }
+ sweep() // 启动即执行一轮,纠正重启期间的历史状态 // Logan.Li
+ for range ticker.C {
+ sweep()
+ }
+ }()
+}
diff --git a/backend/internal/service/license/license_service.go b/backend/internal/service/license/license_service.go
index 90e96de..d3e5be8 100644
--- a/backend/internal/service/license/license_service.go
+++ b/backend/internal/service/license/license_service.go
@@ -1,12 +1,14 @@
package license
import (
+ "backend/config"
"backend/internal/dto"
"backend/internal/model"
customersvc "backend/internal/service/customer"
"backend/utils"
"encoding/json"
"errors"
+ "log"
"strconv"
"strings"
"time"
@@ -16,12 +18,32 @@ import (
// LicenseService License 业务逻辑层 // Logan.Li
type LicenseService struct {
- db *gorm.DB
+ db *gorm.DB
+ cfg *config.Config
+ guard *clientGuard
}
-// NewLicenseService 创建 service 实例 // Logan.Li
-func NewLicenseService(db *gorm.DB) *LicenseService {
- return &LicenseService{db: db}
+// NewLicenseService 创建 service 实例(cfg 提供防护配置与响应签名密钥) // Logan.Li
+func NewLicenseService(db *gorm.DB, cfg *config.Config) *LicenseService {
+ svc := &LicenseService{db: db, cfg: cfg}
+ if cfg != nil {
+ g := &clientGuard{
+ gracePeriodDays: cfg.GracePeriodDays,
+ activateDailyLimit: cfg.ActivateDailyLimit,
+ onlineTTLMinutes: cfg.OnlineTTLMinutes,
+ deactivateRequireToken: cfg.DeactivateRequireToken,
+ }
+ if seed := strings.TrimSpace(cfg.OfflineLicenseSeed); seed != "" {
+ if kp, err := utils.DeriveOfflineKeyPair(seed); err == nil {
+ g.kp = kp
+ } else {
+ // seed 非法只降级签名能力,不阻断启动(离线签发侧会再次报错) // Logan.Li
+ log.Printf("[安全] 离线 seed 派生失败,响应签名将不可用: %v", err)
+ }
+ }
+ svc.guard = g
+ }
+ return svc
}
// ListQuery 列表查询参数 // Logan.Li
@@ -67,7 +89,9 @@ func (s *LicenseService) List(page, pageSize int, q ListQuery) ([]dto.LicenseRes
results := make([]dto.LicenseResponse, 0, len(licenses))
for _, l := range licenses {
- results = append(results, toLicenseResponse(l))
+ resp := toLicenseResponse(l)
+ attachAbuseStats(&resp, l)
+ results = append(results, resp)
}
return results, total, nil
}
@@ -81,6 +105,7 @@ func (s *LicenseService) GetByID(id uint) (*dto.LicenseResponse, []dto.Activatio
}
resp := toLicenseResponse(lic)
+ attachAbuseStats(&resp, lic)
activations := make([]dto.ActivationResponse, 0, len(lic.Activations))
for _, a := range lic.Activations {
activations = append(activations, toActivationResponse(a))
@@ -180,9 +205,9 @@ func (s *LicenseService) Delete(id uint) (*model.License, error) {
// BatchDeleteResult 批量删除结果 // Logan.Li
type BatchDeleteResult struct {
- Deleted int `json:"deleted"`
- Skipped int `json:"skipped"`
- Failed int `json:"failed"`
+ Deleted int `json:"deleted"`
+ Skipped int `json:"skipped"`
+ Failed int `json:"failed"`
SkippedIDs []uint `json:"skipped_ids,omitempty"`
}
diff --git a/backend/main.go b/backend/main.go
index 625554d..8d4e059 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -3,6 +3,7 @@ package main
import (
"backend/config"
"backend/database"
+ licenseservice "backend/internal/service/license"
"backend/router"
"context"
"log"
@@ -23,6 +24,9 @@ func main() {
log.Fatal("数据库初始化失败:", err)
}
+ // 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li
+ licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes)
+
// 初始化路由 // Logan.Li
r := router.SetupRouter(db, cfg)
diff --git a/backend/utils/offline_license.go b/backend/utils/offline_license.go
index 4489841..963bb59 100644
--- a/backend/utils/offline_license.go
+++ b/backend/utils/offline_license.go
@@ -56,6 +56,26 @@ func (k *OfflineKeyPair) OfflinePublicKeyHex() string {
return hex.EncodeToString(k.Public)
}
+// SignHexString 对任意字符串签名,返回 hex 编码的 Ed25519 签名(用于在线验证响应防篡改) // Logan.Li
+func (k *OfflineKeyPair) SignHexString(msg string) string {
+ if k == nil || len(k.Private) == 0 {
+ return ""
+ }
+ return hex.EncodeToString(ed25519.Sign(k.Private, []byte(msg)))
+}
+
+// VerifyHexString 校验 SignHexString 产生的 hex 签名(供测试与桌面端对照实现) // Logan.Li
+func (k *OfflineKeyPair) VerifyHexString(msg, sigHex string) bool {
+ if k == nil || len(k.Public) == 0 {
+ return false
+ }
+ sig, err := hex.DecodeString(strings.TrimSpace(sigHex))
+ if err != nil || len(sig) != ed25519.SignatureSize {
+ return false
+ }
+ return ed25519.Verify(k.Public, []byte(msg), sig)
+}
+
// IsOfflineLicenseKey 判断是否为离线序列号 // Logan.Li
func IsOfflineLicenseKey(key string) bool {
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
diff --git a/frontend/src/views/Licenses.vue b/frontend/src/views/Licenses.vue
index 2a73195..61cc003 100644
--- a/frontend/src/views/Licenses.vue
+++ b/frontend/src/views/Licenses.vue
@@ -50,6 +50,18 @@
{{ row.current_activations }}/{{ row.max_activations }}
+
+
+
+ 疑似共享
+
+ —
+
+
详情