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() } }() }