197 lines
6.8 KiB
Go
197 lines
6.8 KiB
Go
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()
|
||
}
|
||
}()
|
||
}
|