feat: 客户端授权防护——响应签名/激活令牌/宽限期/换座限制/在线清扫/疑似共享标记
This commit is contained in:
1 parent
37d9e8ca56
commit
b76111dfc3
11 files changed
+437
-54
No files matched your search
@@ -20,6 +20,17 @@ JWT_SECRET=your-super-secret-key-change-this-in-production
|
|||||||
# 当前生产公钥(桌面已嵌入): 108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d
|
# 当前生产公钥(桌面已嵌入): 108375d485c58a1a738f93c2cbb838f76331de981ee5da13d90a1f280672131d
|
||||||
# OFFLINE_LICENSE_SEED=
|
# 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
|
# 可选: sqlite / mysql / postgres
|
||||||
DB_DRIVER=sqlite
|
DB_DRIVER=sqlite
|
||||||
|
|||||||
+46
-11
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -30,6 +31,12 @@ type Config struct {
|
|||||||
// 离线许可证 Ed25519 seed(32 字节 hex;生产必须固定,勿轮换除非同步桌面公钥) // Logan.Li
|
// 离线许可证 Ed25519 seed(32 字节 hex;生产必须固定,勿轮换除非同步桌面公钥) // Logan.Li
|
||||||
OfflineLicenseSeed string
|
OfflineLicenseSeed string
|
||||||
|
|
||||||
|
// 客户端授权防护配置 // Logan.Li
|
||||||
|
GracePeriodDays int // 过期宽限天数(仅商业过期;吊销/暂停无宽限)
|
||||||
|
ActivateDailyLimit int // 单序列号 24 小时内新设备激活上限(0 = 不限制)
|
||||||
|
OnlineTTLMinutes int // 激活记录在线状态有效期(last_seen 超过即视为离线)
|
||||||
|
DeactivateRequireToken bool // 解绑是否强制校验设备激活令牌
|
||||||
|
|
||||||
// CORS 配置
|
// CORS 配置
|
||||||
CORSOrigins []string
|
CORSOrigins []string
|
||||||
}
|
}
|
||||||
@@ -42,17 +49,21 @@ func Load() *Config {
|
|||||||
_ = godotenv.Load("../../.env")
|
_ = godotenv.Load("../../.env")
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: getEnv("PORT", "9001"),
|
Port: getEnv("PORT", "9001"),
|
||||||
Mode: getEnv("GIN_MODE", "debug"),
|
Mode: getEnv("GIN_MODE", "debug"),
|
||||||
DBDriver: getEnv("DB_DRIVER", "sqlite"),
|
DBDriver: getEnv("DB_DRIVER", "sqlite"),
|
||||||
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
DBHost: getEnv("DB_HOST", "127.0.0.1"),
|
||||||
DBPort: getEnv("DB_PORT", "3306"),
|
DBPort: getEnv("DB_PORT", "3306"),
|
||||||
DBUser: getEnv("DB_USER", "root"),
|
DBUser: getEnv("DB_USER", "root"),
|
||||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||||
DBName: getEnv("DB_NAME", "db.sqlite3"),
|
DBName: getEnv("DB_NAME", "db.sqlite3"),
|
||||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||||
OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""),
|
OfflineLicenseSeed: getEnv("OFFLINE_LICENSE_SEED", ""),
|
||||||
CORSOrigins: defaultCORSOrigins(),
|
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 != "" {
|
if raw := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); raw != "" {
|
||||||
@@ -122,3 +133,27 @@ func getEnv(key, defaultValue string) string {
|
|||||||
}
|
}
|
||||||
return defaultValue
|
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
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ type LicenseController struct {
|
|||||||
// NewLicenseController 创建控制器实例 // Logan.Li
|
// NewLicenseController 创建控制器实例 // Logan.Li
|
||||||
func NewLicenseController(db *gorm.DB, cfg *config.Config) *LicenseController {
|
func NewLicenseController(db *gorm.DB, cfg *config.Config) *LicenseController {
|
||||||
return &LicenseController{
|
return &LicenseController{
|
||||||
svc: licenseservice.NewLicenseService(db),
|
svc: licenseservice.NewLicenseService(db, cfg),
|
||||||
offline: licenseservice.NewOfflineIssuer(db, cfg),
|
offline: licenseservice.NewOfflineIssuer(db, cfg),
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ func (ctrl *LicenseController) BatchDelete(c *gin.Context) {
|
|||||||
utils.LogAction(ctrl.db, c, "batch_delete", "license", 0, "", "", "",
|
utils.LogAction(ctrl.db, c, "batch_delete", "license", 0, "", "", "",
|
||||||
"批量删除 License deleted="+strconv.Itoa(result.Deleted)+" skipped="+strconv.Itoa(result.Skipped))
|
"批量删除 License deleted="+strconv.Itoa(result.Deleted)+" skipped="+strconv.Itoa(result.Skipped))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"message": "批量删除完成",
|
"message": "批量删除完成",
|
||||||
"data": dto.LicenseBatchDeleteResponse{
|
"data": dto.LicenseBatchDeleteResponse{
|
||||||
Deleted: result.Deleted,
|
Deleted: result.Deleted,
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ type LicenseResponse struct {
|
|||||||
DaysLeft int `json:"days_left"`
|
DaysLeft int `json:"days_left"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
CreatedAt string `json:"created_at"`
|
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
|
// LicenseVerifyResponse 验证响应 // Logan.Li
|
||||||
type LicenseVerifyResponse struct {
|
type LicenseVerifyResponse struct {
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
LicenseKey string `json:"license_key,omitempty"`
|
LicenseKey string `json:"license_key,omitempty"`
|
||||||
ProductID uint `json:"product_id,omitempty"`
|
ProductID uint `json:"product_id,omitempty"`
|
||||||
ProductName string `json:"product_name,omitempty"`
|
ProductName string `json:"product_name,omitempty"`
|
||||||
ProductCode string `json:"product_code,omitempty"`
|
ProductCode string `json:"product_code,omitempty"`
|
||||||
Features []string `json:"features,omitempty"`
|
Features []string `json:"features,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
DaysLeft int `json:"days_left,omitempty"`
|
DaysLeft int `json:"days_left,omitempty"`
|
||||||
MaxDevices int `json:"max_devices,omitempty"`
|
MaxDevices int `json:"max_devices,omitempty"`
|
||||||
PlanType string `json:"plan_type,omitempty"`
|
PlanType string `json:"plan_type,omitempty"`
|
||||||
Message string `json:"message,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
|
// LicenseActivateRequest 激活请求(客户端调用) // Logan.Li
|
||||||
@@ -92,8 +100,9 @@ type LicenseActivateRequest struct {
|
|||||||
|
|
||||||
// LicenseDeactivateRequest 停用请求(客户端调用) // Logan.Li
|
// LicenseDeactivateRequest 停用请求(客户端调用) // Logan.Li
|
||||||
type LicenseDeactivateRequest struct {
|
type LicenseDeactivateRequest struct {
|
||||||
LicenseKey string `json:"license_key" binding:"required"`
|
LicenseKey string `json:"license_key" binding:"required"`
|
||||||
DeviceID string `json:"device_id" binding:"required"`
|
DeviceID string `json:"device_id" binding:"required"`
|
||||||
|
ActivationToken string `json:"activation_token"` // 本机激活时下发的令牌;解绑他机需联系管理员
|
||||||
}
|
}
|
||||||
|
|
||||||
// LicenseHeartbeatRequest 心跳请求(客户端调用) // Logan.Li
|
// LicenseHeartbeatRequest 心跳请求(客户端调用) // Logan.Li
|
||||||
@@ -123,7 +132,7 @@ type OfflineLicenseCreate struct {
|
|||||||
CustomerID uint `json:"customer_id"`
|
CustomerID uint `json:"customer_id"`
|
||||||
CustomerName string `json:"customer_name"`
|
CustomerName string `json:"customer_name"`
|
||||||
ProductCode string `json:"product_code"`
|
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
|
DurationDays int `json:"duration_days"` // 1–365,默认 365;trial 上限 180
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
@@ -142,4 +151,3 @@ type OfflineLicenseResponse struct {
|
|||||||
LicenseID uint `json:"license_id"`
|
LicenseID uint `json:"license_id"`
|
||||||
PublicKey string `json:"public_key_hex"`
|
PublicKey string `json:"public_key_hex"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ func (License) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Activation 激活记录模型(机器绑定) // Logan.Li
|
// Activation 激活记录模型(机器绑定) // Logan.Li
|
||||||
|
// Activation 设备激活记录 // Logan.Li
|
||||||
type Activation struct {
|
type Activation struct {
|
||||||
ID uint `json:"id" gorm:"primaryKey"`
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
LicenseID uint `json:"license_id" gorm:"uniqueIndex:idx_lic_device,priority:1"`
|
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"`
|
IP string `json:"ip" gorm:"size:50"`
|
||||||
Status string `json:"status" gorm:"size:20;default:'active'"` // active / inactive
|
Status string `json:"status" gorm:"size:20;default:'active'"` // active / inactive
|
||||||
IsOnline bool `json:"is_online" gorm:"default:false"`
|
IsOnline bool `json:"is_online" gorm:"default:false"`
|
||||||
|
Token string `json:"-" gorm:"size:64"` // 设备激活令牌:解绑需持有,防止仅凭序列号偷座 // Logan.Li
|
||||||
ActivatedAt time.Time `json:"activated_at"`
|
ActivatedAt time.Time `json:"activated_at"`
|
||||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
LastSeenAt time.Time `json:"last_seen_at"`
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
|||||||
@@ -29,40 +29,73 @@ func inferPlanType(lic model.License) string {
|
|||||||
|
|
||||||
func licenseClientPayload(lic model.License) map[string]interface{} {
|
func licenseClientPayload(lic model.License) map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
|
"license_key": lic.LicenseKey, // 签名字段:客户端凭响应重建验签串,必须自带 // Logan.Li
|
||||||
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
|
"expires_at": lic.EndDate.Format("2006-01-02 15:04:05"),
|
||||||
"days_left": utils.CalculateDaysLeft(lic.EndDate),
|
"days_left": utils.CalculateDaysLeft(lic.EndDate),
|
||||||
"max_devices": lic.MaxActivations,
|
"max_devices": lic.MaxActivations,
|
||||||
|
"status": lic.Status,
|
||||||
"plan_type": inferPlanType(lic),
|
"plan_type": inferPlanType(lic),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify 客户端 License 验证 // Logan.Li
|
// Verify 客户端 License 验证(含过期宽限与响应签名) // Logan.Li
|
||||||
func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerifyResponse, error) {
|
func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerifyResponse, error) {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := s.db.Preload("Product").Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
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 {
|
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 {
|
switch lic.Status {
|
||||||
case model.LicenseStatusRevoked:
|
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:
|
case model.LicenseStatusSuspended:
|
||||||
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已被暂停"}, nil
|
resp := &dto.LicenseVerifyResponse{Valid: false, Status: lic.Status, Message: "License 已被暂停"}
|
||||||
case model.LicenseStatusExpired:
|
s.signVerifyResponse(resp)
|
||||||
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if utils.IsLicenseExpired(lic.EndDate) {
|
// 商业过期:宽限期内放行并提示续费,超出宽限才拒绝 // Logan.Li
|
||||||
lic.Status = model.LicenseStatusExpired
|
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
||||||
s.db.Save(&lic)
|
if lic.Status != model.LicenseStatusExpired {
|
||||||
return &dto.LicenseVerifyResponse{Valid: false, Message: "License 已过期"}, nil
|
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,
|
Valid: true,
|
||||||
LicenseKey: lic.LicenseKey,
|
LicenseKey: lic.LicenseKey,
|
||||||
ProductID: lic.ProductID,
|
ProductID: lic.ProductID,
|
||||||
@@ -75,12 +108,16 @@ func (s *LicenseService) Verify(req dto.LicenseVerifyRequest) (*dto.LicenseVerif
|
|||||||
MaxDevices: lic.MaxActivations,
|
MaxDevices: lic.MaxActivations,
|
||||||
PlanType: inferPlanType(lic),
|
PlanType: inferPlanType(lic),
|
||||||
Message: "验证成功",
|
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) {
|
func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP string) (map[string]interface{}, error) {
|
||||||
var payload map[string]interface{}
|
var payload map[string]interface{}
|
||||||
|
var newToken string
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新设备占座前校验 24 小时换座上限,抑制序列号共享轮换 // Logan.Li
|
||||||
|
if err := s.checkDailyActivationLimit(tx, lic.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
res := tx.Model(&model.License{}).
|
res := tx.Model(&model.License{}).
|
||||||
Where("id = ? AND current_activations < max_activations", lic.ID).
|
Where("id = ? AND current_activations < max_activations", lic.ID).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
@@ -139,6 +181,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
|
|||||||
return errors.New("已达到最大激活数量限制")
|
return errors.New("已达到最大激活数量限制")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newToken = generateActivationToken()
|
||||||
if err := tx.Create(&model.Activation{
|
if err := tx.Create(&model.Activation{
|
||||||
LicenseID: lic.ID,
|
LicenseID: lic.ID,
|
||||||
DeviceID: req.DeviceID,
|
DeviceID: req.DeviceID,
|
||||||
@@ -147,6 +190,7 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
|
|||||||
IP: clientIP,
|
IP: clientIP,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
IsOnline: true,
|
IsOnline: true,
|
||||||
|
Token: newToken,
|
||||||
ActivatedAt: now,
|
ActivatedAt: now,
|
||||||
LastSeenAt: now,
|
LastSeenAt: now,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
@@ -161,16 +205,31 @@ func (s *LicenseService) Activate(req dto.LicenseActivateRequest, clientIP strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if newToken != "" {
|
||||||
|
// 令牌仅在本机首次占座时下发一次;客户端须持久保存用于后续解绑 // Logan.Li
|
||||||
|
payload["activation_token"] = newToken
|
||||||
|
}
|
||||||
|
s.signPayloadMap(payload, req.LicenseKey)
|
||||||
return payload, nil
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deactivate 客户端停用(仅 active→inactive 才减席位) // Logan.Li
|
// Deactivate 客户端停用(仅 active→inactive 才减席位);
|
||||||
|
// 新设备记录强制校验激活令牌,防止仅凭序列号偷座/踢座。 // Logan.Li
|
||||||
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
|
func (s *LicenseService) Deactivate(req dto.LicenseDeactivateRequest) error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
if err := tx.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
||||||
return errors.New("无效的 License")
|
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{}).
|
res := tx.Model(&model.Activation{}).
|
||||||
Where("license_id = ? AND device_id = ? AND status = ?", lic.ID, req.DeviceID, "active").
|
Where("license_id = ? AND device_id = ? AND status = ?", lic.ID, req.DeviceID, "active").
|
||||||
Updates(map[string]interface{}{"status": "inactive", "is_online": false})
|
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) {
|
func (s *LicenseService) Heartbeat(req dto.LicenseHeartbeatRequest) (map[string]interface{}, error) {
|
||||||
var lic model.License
|
var lic model.License
|
||||||
if err := s.db.Where("license_key = ?", req.LicenseKey).First(&lic).Error; err != nil {
|
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 已被吊销或暂停")
|
return nil, errors.New("License 已被吊销或暂停")
|
||||||
}
|
}
|
||||||
if lic.Status == model.LicenseStatusExpired || utils.IsLicenseExpired(lic.EndDate) {
|
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 已过期")
|
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).
|
Where("license_id = ? AND device_id = ?", lic.ID, req.DeviceID).
|
||||||
Updates(map[string]interface{}{"last_seen_at": time.Now(), "is_online": true})
|
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
|
// ListDevicesByKey returns activations for a license key (client self-service). // Logan.Li
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package license
|
package license
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/config"
|
||||||
"backend/internal/dto"
|
"backend/internal/dto"
|
||||||
"backend/internal/model"
|
"backend/internal/model"
|
||||||
customersvc "backend/internal/service/customer"
|
customersvc "backend/internal/service/customer"
|
||||||
"backend/utils"
|
"backend/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,12 +18,32 @@ import (
|
|||||||
|
|
||||||
// LicenseService License 业务逻辑层 // Logan.Li
|
// LicenseService License 业务逻辑层 // Logan.Li
|
||||||
type LicenseService struct {
|
type LicenseService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
cfg *config.Config
|
||||||
|
guard *clientGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLicenseService 创建 service 实例 // Logan.Li
|
// NewLicenseService 创建 service 实例(cfg 提供防护配置与响应签名密钥) // Logan.Li
|
||||||
func NewLicenseService(db *gorm.DB) *LicenseService {
|
func NewLicenseService(db *gorm.DB, cfg *config.Config) *LicenseService {
|
||||||
return &LicenseService{db: db}
|
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
|
// 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))
|
results := make([]dto.LicenseResponse, 0, len(licenses))
|
||||||
for _, l := range licenses {
|
for _, l := range licenses {
|
||||||
results = append(results, toLicenseResponse(l))
|
resp := toLicenseResponse(l)
|
||||||
|
attachAbuseStats(&resp, l)
|
||||||
|
results = append(results, resp)
|
||||||
}
|
}
|
||||||
return results, total, nil
|
return results, total, nil
|
||||||
}
|
}
|
||||||
@@ -81,6 +105,7 @@ func (s *LicenseService) GetByID(id uint) (*dto.LicenseResponse, []dto.Activatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := toLicenseResponse(lic)
|
resp := toLicenseResponse(lic)
|
||||||
|
attachAbuseStats(&resp, lic)
|
||||||
activations := make([]dto.ActivationResponse, 0, len(lic.Activations))
|
activations := make([]dto.ActivationResponse, 0, len(lic.Activations))
|
||||||
for _, a := range lic.Activations {
|
for _, a := range lic.Activations {
|
||||||
activations = append(activations, toActivationResponse(a))
|
activations = append(activations, toActivationResponse(a))
|
||||||
@@ -180,9 +205,9 @@ func (s *LicenseService) Delete(id uint) (*model.License, error) {
|
|||||||
|
|
||||||
// BatchDeleteResult 批量删除结果 // Logan.Li
|
// BatchDeleteResult 批量删除结果 // Logan.Li
|
||||||
type BatchDeleteResult struct {
|
type BatchDeleteResult struct {
|
||||||
Deleted int `json:"deleted"`
|
Deleted int `json:"deleted"`
|
||||||
Skipped int `json:"skipped"`
|
Skipped int `json:"skipped"`
|
||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
SkippedIDs []uint `json:"skipped_ids,omitempty"`
|
SkippedIDs []uint `json:"skipped_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"backend/config"
|
"backend/config"
|
||||||
"backend/database"
|
"backend/database"
|
||||||
|
licenseservice "backend/internal/service/license"
|
||||||
"backend/router"
|
"backend/router"
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
@@ -23,6 +24,9 @@ func main() {
|
|||||||
log.Fatal("数据库初始化失败:", err)
|
log.Fatal("数据库初始化失败:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 启动在线状态清扫(超时未心跳的激活记录置为离线) // Logan.Li
|
||||||
|
licenseservice.StartOnlineSweeper(db, cfg.OnlineTTLMinutes)
|
||||||
|
|
||||||
// 初始化路由 // Logan.Li
|
// 初始化路由 // Logan.Li
|
||||||
r := router.SetupRouter(db, cfg)
|
r := router.SetupRouter(db, cfg)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ func (k *OfflineKeyPair) OfflinePublicKeyHex() string {
|
|||||||
return hex.EncodeToString(k.Public)
|
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
|
// IsOfflineLicenseKey 判断是否为离线序列号 // Logan.Li
|
||||||
func IsOfflineLicenseKey(key string) bool {
|
func IsOfflineLicenseKey(key string) bool {
|
||||||
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
|
return strings.HasPrefix(strings.TrimSpace(key), "LICOFF-")
|
||||||
|
|||||||
@@ -50,6 +50,18 @@
|
|||||||
<el-table-column label="激活" width="80">
|
<el-table-column label="激活" width="80">
|
||||||
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
|
<template #default="{ row }">{{ row.current_activations }}/{{ row.max_activations }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="共享" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.abuse_suspected"
|
||||||
|
:content="`不重复 IP ${row.distinct_ips} 个,近 7 天新增设备 ${row.new_devices_7d} 台`"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag type="danger" size="small">疑似共享</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else style="color: var(--el-text-color-placeholder)">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="320" fixed="right">
|
<el-table-column label="操作" width="320" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
||||||
|
|||||||
Reference in new issue
Block a user