Merge branch 'main' of https://github.com/cedar-v/license-manager
This commit is contained in:
commit
49e5952e1d
38 files changed
+2069
-200
No files matched your search
@@ -0,0 +1,121 @@
|
||||
# 软件授权客户端示例程序
|
||||
|
||||
演示如何与许可证管理系统集成,包括硬件指纹采集、授权激活和心跳检测功能。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 编译
|
||||
|
||||
```bash
|
||||
cd backend/cmd/client-demo
|
||||
go build -o client-demo main.go
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 使用命令行参数
|
||||
./client-demo -server http://localhost:18888
|
||||
|
||||
# 或使用配置文件 client_config.json
|
||||
./client-demo
|
||||
```
|
||||
|
||||
## 授权码配置
|
||||
|
||||
将授权码保存到 `license_code/AUTH_CODE` 文件中,程序会自动读取。如果文件不存在或授权码为空,程序在需要激活时会退出并提示错误。
|
||||
|
||||
## 命令行参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `-server` | 服务器地址 | http://localhost:18888 |
|
||||
| `-version` | 软件版本 | 1.0.0 |
|
||||
| `-interval` | 心跳间隔(秒) | 300 |
|
||||
| `-activate-only` | 仅激活,不启动心跳 | false |
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量名 | 说明 |
|
||||
|--------|------|
|
||||
| `LICENSE_ENCRYPTION_KEY` | 许可证文件加密密钥(默认使用服务器默认密钥,长度需 16/24/32 字节) |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. **启动时**
|
||||
- 采集硬件指纹(MAC 地址、CPU、主机ID)
|
||||
- 检查 `license_code/LICENSE` 文件是否存在
|
||||
- 如果文件存在,解析并验证有效期(start_date 和 end_date)
|
||||
|
||||
2. **需要激活时**(文件不存在或过期)
|
||||
- 从 `license_code/AUTH_CODE` 文件读取授权码
|
||||
- 调用激活接口获取许可证
|
||||
- 保存许可证到 `license_code/LICENSE`
|
||||
- 再次验证许可证
|
||||
|
||||
3. **验证通过后**
|
||||
- 启动心跳服务(每 300 秒或按服务器指定间隔)
|
||||
- 自动接收并更新许可证文件
|
||||
|
||||
## 硬件指纹
|
||||
|
||||
程序自动采集以下信息生成硬件指纹:
|
||||
|
||||
- MAC 地址(第一个非虚拟网络接口)
|
||||
- CPU 型号名称
|
||||
- 主机 ID(系统唯一标识符)
|
||||
|
||||
格式(随意,只要能唯一标识设备即可,哈希值也行):`MAC:xx:xx:xx:xx:xx:xx,CPU:xxx,HOSTID:xxx`
|
||||
|
||||
## 许可证文件
|
||||
|
||||
许可证文件保存在 `license_code/LICENSE`,使用 AES-GCM 加密,包含:
|
||||
- 许可证密钥和硬件指纹
|
||||
- 有效期(开始/结束日期)
|
||||
- 功能配置和使用限制
|
||||
|
||||
**重要**:许可证绑定硬件指纹,更换硬件需重新激活。
|
||||
|
||||
## 示例输出
|
||||
|
||||
**首次运行**(自动激活)
|
||||
```
|
||||
=== 软件授权客户端示例程序 ===
|
||||
硬件指纹: MAC:00:11:22:33:44:55,CPU:Intel(R) Core(TM) i7-8700,HOSTID:abc123
|
||||
许可证文件检查失败: 许可证文件不存在
|
||||
尝试重新激活...
|
||||
✓ 激活成功!许可证文件已保存到 license_code/LICENSE
|
||||
✓ 激活后验证通过
|
||||
[心跳] 启动心跳服务...
|
||||
✓ 心跳成功 [14:30:00] - 状态: active
|
||||
```
|
||||
|
||||
**后续运行**(使用本地许可证)
|
||||
```
|
||||
=== 软件授权客户端示例程序 ===
|
||||
硬件指纹: MAC:00:11:22:33:44:55,CPU:Intel(R) Core(TM) i7-8700,HOSTID:abc123
|
||||
✓ 许可证验证通过
|
||||
[心跳] 启动心跳服务...
|
||||
✓ 心跳成功 [14:30:00] - 状态: active
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **验证优先级**:优先检查本地许可证文件,文件不存在或过期时才重新激活
|
||||
- **错误处理**:许可证验证失败会退出程序;心跳失败仅记录日志
|
||||
- **授权码**:激活时需要授权码,请将授权码保存到 `license_code/AUTH_CODE` 文件
|
||||
|
||||
## 故障排查
|
||||
|
||||
**激活失败**
|
||||
- 检查服务器地址和授权码是否正确
|
||||
- 确认 `license_code/AUTH_CODE` 文件存在且包含有效的授权码
|
||||
- 查看服务器日志了解详细错误
|
||||
|
||||
**心跳失败**
|
||||
- 检查网络连接
|
||||
- 确认许可证密钥和硬件指纹是否正确
|
||||
|
||||
**硬件指纹变更**
|
||||
- 更换硬件需重新激活
|
||||
- 联系管理员释放旧的激活记录
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"server_url": "http://localhost:18888",
|
||||
"authorization_code": "LIC-B597-EFr0tbfAooQF-uUAA",
|
||||
"software_version": "1.0.0",
|
||||
"heartbeat_interval": 300,
|
||||
"license_key": "LIC-DEVICE-68DF8AFEF41CA26F248D7D55",
|
||||
"config_updated_at": "2025-11-01T17:54:30+08:00",
|
||||
"license_file": "eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
LIC-B597-EFr0tbfAooQF-uUAA
|
||||
@@ -0,0 +1 @@
|
||||
eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9
|
||||
@@ -0,0 +1,682 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
)
|
||||
|
||||
// ClientConfig 客户端配置
|
||||
type ClientConfig struct {
|
||||
ServerURL string `json:"server_url"` // 服务器地址
|
||||
AuthorizationCode string `json:"authorization_code"` // 授权码
|
||||
SoftwareVersion string `json:"software_version"` // 软件版本
|
||||
HeartbeatInterval int `json:"heartbeat_interval"` // 心跳间隔(秒),默认300
|
||||
LicenseKey string `json:"license_key"` // 许可证密钥(激活后保存)
|
||||
ConfigUpdatedAt string `json:"config_updated_at"` // 配置更新时间
|
||||
LicenseFile string `json:"license_file"` // 许可证文件内容
|
||||
}
|
||||
|
||||
// ActivateRequest 激活请求
|
||||
type ActivateRequest struct {
|
||||
AuthorizationCode string `json:"authorization_code"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
DeviceInfo map[string]interface{} `json:"device_info,omitempty"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateResponse 激活响应
|
||||
type ActivateResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
LicenseFile string `json:"license_file"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// HeartbeatRequest 心跳请求
|
||||
type HeartbeatRequest struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
ConfigUpdatedAt *string `json:"config_updated_at,omitempty"`
|
||||
UsageData map[string]interface{} `json:"usage_data,omitempty"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty"`
|
||||
}
|
||||
|
||||
// HeartbeatResponse 心跳响应
|
||||
type HeartbeatResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
Status string `json:"status"`
|
||||
ConfigUpdated bool `json:"config_updated"`
|
||||
LicenseFile *string `json:"license_file,omitempty"`
|
||||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ErrorResponse 错误响应
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// LicenseFileData 许可证文件数据结构
|
||||
type LicenseFileData struct {
|
||||
LicenseKey string `json:"license_key"`
|
||||
AuthorizationCodeID string `json:"authorization_code_id"`
|
||||
HardwareFingerprint string `json:"hardware_fingerprint"`
|
||||
Status string `json:"status"`
|
||||
ActivatedAt *string `json:"activated_at"`
|
||||
ConfigUpdatedAt *string `json:"config_updated_at"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
AuthorizationCode *string `json:"authorization_code"`
|
||||
StartDate *string `json:"start_date"`
|
||||
EndDate *string `json:"end_date"`
|
||||
DeploymentType *string `json:"deployment_type"`
|
||||
MaxActivations *int `json:"max_activations"`
|
||||
FeatureConfig map[string]interface{} `json:"feature_config,omitempty"`
|
||||
UsageLimits map[string]interface{} `json:"usage_limits,omitempty"`
|
||||
CustomParameters map[string]interface{} `json:"custom_parameters,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
configFile = "client_config.json"
|
||||
licenseDir = "license_code"
|
||||
licenseFileName = "LICENSE"
|
||||
)
|
||||
|
||||
var (
|
||||
config ClientConfig
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
serverURL = flag.String("server", "http://localhost:18888", "服务器地址 (例如: http://localhost:18888)")
|
||||
softwareVersion = flag.String("version", "1.0.0", "软件版本")
|
||||
interval = flag.Int("interval", 300, "心跳间隔(秒)")
|
||||
activateOnly = flag.Bool("activate-only", false, "仅执行激活,不启动心跳")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// 加载配置
|
||||
if err := loadConfig(); err != nil {
|
||||
log.Printf("加载配置失败,使用命令行参数: %v", err)
|
||||
}
|
||||
|
||||
// 命令行参数优先
|
||||
if *serverURL != "" {
|
||||
config.ServerURL = *serverURL
|
||||
}
|
||||
// 从 license_code/AUTH_CODE 文件读取授权码
|
||||
authCodeFromFile, err := readAuthCodeFromFile()
|
||||
if err == nil && authCodeFromFile != "" {
|
||||
config.AuthorizationCode = strings.TrimSpace(authCodeFromFile)
|
||||
}
|
||||
if *softwareVersion != "" {
|
||||
config.SoftwareVersion = *softwareVersion
|
||||
}
|
||||
if *interval > 0 {
|
||||
config.HeartbeatInterval = *interval
|
||||
}
|
||||
if config.HeartbeatInterval <= 0 {
|
||||
config.HeartbeatInterval = 300 // 默认5分钟
|
||||
}
|
||||
|
||||
// 验证必要参数
|
||||
if config.ServerURL == "" {
|
||||
log.Fatal("请提供服务器地址 (-server 或配置文件)")
|
||||
}
|
||||
|
||||
log.Println("=== 软件授权客户端示例程序 ===")
|
||||
log.Printf("服务器地址: %s", config.ServerURL)
|
||||
log.Printf("软件版本: %s", config.SoftwareVersion)
|
||||
|
||||
// 初始化RSA公钥(从当前目录的 rsa_public_key.pem 加载)
|
||||
if err := initRSAPublicKey(); err != nil {
|
||||
log.Fatalf("初始化RSA公钥失败: %v", err)
|
||||
}
|
||||
log.Println("✓ RSA公钥加载成功")
|
||||
|
||||
// 采集硬件指纹
|
||||
hardwareFingerprint, deviceInfo := collectHardwareInfo()
|
||||
log.Printf("硬件指纹: %s", hardwareFingerprint)
|
||||
log.Printf("设备信息: CPU=%s, Memory=%s, OS=%s",
|
||||
deviceInfo["cpu"], deviceInfo["memory"], deviceInfo["os"])
|
||||
|
||||
// ===== 新的验证流程 =====
|
||||
// 1. 检查 license_code/LICENSE 文件是否存在
|
||||
licenseFilePath := filepath.Join(licenseDir, licenseFileName)
|
||||
licenseData, err := checkAndParseLicenseFile(licenseFilePath)
|
||||
if err != nil {
|
||||
log.Printf("许可证文件检查失败: %v", err)
|
||||
log.Println("尝试重新激活...")
|
||||
licenseData = nil // 标记需要重新激活
|
||||
}
|
||||
|
||||
// 2. 如果许可证文件存在,验证时间
|
||||
needActivation := false
|
||||
if licenseData != nil {
|
||||
isValid, err := validateLicenseTime(licenseData)
|
||||
if err != nil {
|
||||
log.Printf("许可证时间验证失败: %v", err)
|
||||
needActivation = true
|
||||
} else if !isValid {
|
||||
log.Println("许可证已过期或未生效,需要重新激活")
|
||||
needActivation = true
|
||||
} else {
|
||||
log.Println("✓ 许可证验证通过")
|
||||
// 从许可证文件加载许可证密钥
|
||||
config.LicenseKey = licenseData.LicenseKey
|
||||
config.LicenseFile = "" // 可以从文件读取
|
||||
}
|
||||
} else {
|
||||
needActivation = true
|
||||
}
|
||||
|
||||
// 3. 如果需要激活,使用AUTH_CODE重新获取许可证
|
||||
if needActivation {
|
||||
if config.AuthorizationCode == "" {
|
||||
log.Fatal("需要授权码进行激活,请将授权码保存到 license_code/AUTH_CODE 文件")
|
||||
}
|
||||
|
||||
log.Println("\n[激活] 开始激活授权...")
|
||||
if err := activateLicense(hardwareFingerprint, deviceInfo); err != nil {
|
||||
log.Fatalf("激活失败: %v", err)
|
||||
}
|
||||
log.Println("✓ 激活成功!")
|
||||
log.Printf("许可证密钥: %s", config.LicenseKey)
|
||||
log.Printf("心跳间隔: %d 秒", config.HeartbeatInterval)
|
||||
|
||||
// 保存许可证文件到 license_code/LICENSE
|
||||
if err := saveLicenseFile(licenseFilePath); err != nil {
|
||||
log.Printf("保存许可证文件失败: %v", err)
|
||||
} else {
|
||||
log.Printf("许可证文件已保存到 %s", licenseFilePath)
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
if err := saveConfig(); err != nil {
|
||||
log.Printf("保存配置失败: %v", err)
|
||||
} else {
|
||||
log.Println("配置已保存到 " + configFile)
|
||||
}
|
||||
|
||||
// 再次验证许可证文件
|
||||
licenseData, err = checkAndParseLicenseFile(licenseFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("激活后验证许可证文件失败: %v", err)
|
||||
}
|
||||
isValid, err := validateLicenseTime(licenseData)
|
||||
if err != nil || !isValid {
|
||||
log.Fatalf("激活后验证许可证时间失败: %v", err)
|
||||
}
|
||||
log.Println("✓ 激活后验证通过")
|
||||
}
|
||||
|
||||
if *activateOnly {
|
||||
log.Println("激活完成,退出程序")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 验证通过,启动心跳服务
|
||||
log.Println("\n[心跳] 启动心跳服务...")
|
||||
startHeartbeat(hardwareFingerprint, deviceInfo)
|
||||
}
|
||||
|
||||
// collectHardwareInfo 采集硬件信息
|
||||
func collectHardwareInfo() (string, map[string]interface{}) {
|
||||
var parts []string
|
||||
|
||||
// 1. 获取 MAC 地址
|
||||
if macs, err := getMACAddresses(); err == nil && len(macs) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("MAC:%s", macs[0]))
|
||||
}
|
||||
|
||||
// 2. 获取 CPU 信息
|
||||
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
|
||||
cpuModel := strings.TrimSpace(cpuInfo[0].ModelName)
|
||||
if cpuModel != "" {
|
||||
parts = append(parts, fmt.Sprintf("CPU:%s", cpuModel))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取主板序列号(Windows)或机器ID(Linux)
|
||||
if hostInfo, err := host.Info(); err == nil {
|
||||
if hostInfo.HostID != "" {
|
||||
parts = append(parts, fmt.Sprintf("HOSTID:%s", hostInfo.HostID))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果没有足够信息,使用操作系统信息
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, fmt.Sprintf("OS:%s-%s", runtime.GOOS, runtime.GOARCH))
|
||||
}
|
||||
|
||||
hardwareFingerprint := strings.Join(parts, ",")
|
||||
|
||||
// 收集详细的设备信息
|
||||
deviceInfo := make(map[string]interface{})
|
||||
|
||||
// CPU 信息
|
||||
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
|
||||
deviceInfo["cpu"] = cpuInfo[0].ModelName
|
||||
deviceInfo["cpu_cores"] = runtime.NumCPU()
|
||||
}
|
||||
|
||||
// 内存信息
|
||||
if memInfo, err := mem.VirtualMemory(); err == nil {
|
||||
deviceInfo["memory"] = fmt.Sprintf("%d GB", memInfo.Total/(1024*1024*1024))
|
||||
}
|
||||
|
||||
// 操作系统信息
|
||||
if hostInfo, err := host.Info(); err == nil {
|
||||
deviceInfo["os"] = fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion)
|
||||
}
|
||||
|
||||
deviceInfo["arch"] = runtime.GOARCH
|
||||
|
||||
return hardwareFingerprint, deviceInfo
|
||||
}
|
||||
|
||||
// getMACAddresses 获取 MAC 地址
|
||||
func getMACAddresses() ([]string, error) {
|
||||
var macs []string
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
// 跳过虚拟接口和回环接口
|
||||
if iface.Flags&net.FlagLoopback != 0 ||
|
||||
iface.Flags&net.FlagUp == 0 ||
|
||||
strings.HasPrefix(iface.Name, "veth") ||
|
||||
strings.HasPrefix(iface.Name, "docker") {
|
||||
continue
|
||||
}
|
||||
|
||||
mac := iface.HardwareAddr.String()
|
||||
if mac != "" {
|
||||
macs = append(macs, mac)
|
||||
}
|
||||
}
|
||||
|
||||
return macs, nil
|
||||
}
|
||||
|
||||
// activateLicense 激活授权
|
||||
func activateLicense(hardwareFingerprint string, deviceInfo map[string]interface{}) error {
|
||||
req := ActivateRequest{
|
||||
AuthorizationCode: config.AuthorizationCode,
|
||||
HardwareFingerprint: hardwareFingerprint,
|
||||
DeviceInfo: deviceInfo,
|
||||
}
|
||||
|
||||
if config.SoftwareVersion != "" {
|
||||
req.SoftwareVersion = &config.SoftwareVersion
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/activate", config.ServerURL)
|
||||
log.Printf("POST %s", url)
|
||||
log.Printf("请求体: %s", string(jsonData))
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
return fmt.Errorf("激活失败 [%s]: %s", errResp.Code, errResp.Message)
|
||||
}
|
||||
return fmt.Errorf("激活失败 [HTTP %d]: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var activateResp ActivateResponse
|
||||
if err := json.Unmarshal(body, &activateResp); err != nil {
|
||||
return fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
if activateResp.Code != "000000" {
|
||||
return fmt.Errorf("激活失败 [%s]: %s", activateResp.Code, activateResp.Message)
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
config.LicenseKey = activateResp.Data.LicenseKey
|
||||
config.LicenseFile = activateResp.Data.LicenseFile
|
||||
if activateResp.Data.HeartbeatInterval > 0 {
|
||||
config.HeartbeatInterval = activateResp.Data.HeartbeatInterval
|
||||
}
|
||||
config.ConfigUpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startHeartbeat 启动心跳服务
|
||||
func startHeartbeat(hardwareFingerprint string, deviceInfo map[string]interface{}) {
|
||||
ticker := time.NewTicker(time.Duration(config.HeartbeatInterval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 立即发送一次心跳
|
||||
log.Println("发送初始心跳...")
|
||||
sendHeartbeat(hardwareFingerprint, deviceInfo)
|
||||
|
||||
// 定期发送心跳
|
||||
for range ticker.C {
|
||||
sendHeartbeat(hardwareFingerprint, deviceInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// sendHeartbeat 发送心跳
|
||||
func sendHeartbeat(hardwareFingerprint string, deviceInfo map[string]interface{}) {
|
||||
req := HeartbeatRequest{
|
||||
LicenseKey: config.LicenseKey,
|
||||
HardwareFingerprint: hardwareFingerprint,
|
||||
}
|
||||
|
||||
if config.ConfigUpdatedAt != "" {
|
||||
req.ConfigUpdatedAt = &config.ConfigUpdatedAt
|
||||
}
|
||||
|
||||
// 模拟使用数据
|
||||
req.UsageData = map[string]interface{}{
|
||||
"active_users": 50,
|
||||
"api_calls_today": 5000,
|
||||
"cpu_usage": 25.5,
|
||||
"memory_usage": 45.2,
|
||||
}
|
||||
|
||||
if config.SoftwareVersion != "" {
|
||||
req.SoftwareVersion = &config.SoftwareVersion
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
log.Printf("✗ 心跳失败: 序列化请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 打印完整的请求信息
|
||||
reqJSON, _ := json.MarshalIndent(req, "", " ")
|
||||
log.Printf("心跳请求信息:\n%s", string(reqJSON))
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/heartbeat", config.ServerURL)
|
||||
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
log.Printf("✗ 心跳失败: 请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("✗ 心跳失败: 读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal(body, &errResp); err == nil {
|
||||
log.Printf("✗ 心跳失败 [%s]: %s", errResp.Code, errResp.Message)
|
||||
} else {
|
||||
log.Printf("✗ 心跳失败 [HTTP %d]: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var heartbeatResp HeartbeatResponse
|
||||
if err := json.Unmarshal(body, &heartbeatResp); err != nil {
|
||||
log.Printf("✗ 心跳失败: 解析响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if heartbeatResp.Code != "000000" {
|
||||
log.Printf("✗ 心跳失败 [%s]: %s", heartbeatResp.Code, heartbeatResp.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// 打印完整的响应信息
|
||||
respJSON, _ := json.MarshalIndent(heartbeatResp, "", " ")
|
||||
log.Printf("心跳返回信息:\n%s", string(respJSON))
|
||||
|
||||
// 更新配置更新时间
|
||||
if heartbeatResp.Data.ConfigUpdated && heartbeatResp.Data.LicenseFile != nil {
|
||||
log.Println("✓ 收到配置更新,正在更新许可证文件...")
|
||||
config.LicenseFile = *heartbeatResp.Data.LicenseFile
|
||||
config.ConfigUpdatedAt = time.Now().Format(time.RFC3339)
|
||||
|
||||
// 保存许可证文件到 license_code/LICENSE
|
||||
licenseFilePath := filepath.Join(licenseDir, licenseFileName)
|
||||
if err := saveLicenseFile(licenseFilePath); err != nil {
|
||||
log.Printf("保存许可证文件失败: %v", err)
|
||||
} else {
|
||||
log.Printf("许可证文件已更新到 %s", licenseFilePath)
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
if err := saveConfig(); err != nil {
|
||||
log.Printf("保存配置失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✓ 心跳成功 [%s] - 状态: %s, 配置更新: %v, 间隔: %d秒",
|
||||
time.Now().Format("15:04:05"),
|
||||
heartbeatResp.Data.Status,
|
||||
heartbeatResp.Data.ConfigUpdated,
|
||||
heartbeatResp.Data.HeartbeatInterval)
|
||||
|
||||
// 如果服务器返回了不同的心跳间隔,更新配置
|
||||
if heartbeatResp.Data.HeartbeatInterval > 0 &&
|
||||
heartbeatResp.Data.HeartbeatInterval != config.HeartbeatInterval {
|
||||
log.Printf("更新心跳间隔: %d 秒 -> %d 秒",
|
||||
config.HeartbeatInterval, heartbeatResp.Data.HeartbeatInterval)
|
||||
config.HeartbeatInterval = heartbeatResp.Data.HeartbeatInterval
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig 加载配置
|
||||
func loadConfig() error {
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, &config)
|
||||
}
|
||||
|
||||
// saveConfig 保存配置
|
||||
func saveConfig() error {
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configFile, data, 0644)
|
||||
}
|
||||
|
||||
// checkAndParseLicenseFile 检查并解析许可证文件
|
||||
func checkAndParseLicenseFile(filePath string) (*LicenseFileData, error) {
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("许可证文件不存在: %s", filePath)
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取许可证文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试解密许可证文件
|
||||
decryptedData, err := decryptLicenseFile(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解密许可证文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析JSON
|
||||
var licenseData LicenseFileData
|
||||
if err := json.Unmarshal(decryptedData, &licenseData); err != nil {
|
||||
return nil, fmt.Errorf("解析许可证文件JSON失败: %w", err)
|
||||
}
|
||||
|
||||
// 打印解析后的许可证信息
|
||||
log.Println("========== 解析后的许可证信息 ==========")
|
||||
licenseJSON, _ := json.MarshalIndent(licenseData, "", " ")
|
||||
log.Printf("%s\n", string(licenseJSON))
|
||||
log.Println("========================================")
|
||||
|
||||
return &licenseData, nil
|
||||
}
|
||||
|
||||
// validateLicenseTime 验证许可证时间
|
||||
func validateLicenseTime(licenseData *LicenseFileData) (bool, error) {
|
||||
now := time.Now()
|
||||
|
||||
// 检查开始日期
|
||||
if licenseData.StartDate != nil && *licenseData.StartDate != "" {
|
||||
startDate, err := time.Parse(time.RFC3339, *licenseData.StartDate)
|
||||
if err != nil {
|
||||
// 尝试其他时间格式
|
||||
startDate, err = time.Parse("2006-01-02T15:04:05Z07:00", *licenseData.StartDate)
|
||||
if err != nil {
|
||||
startDate, err = time.Parse("2006-01-02 15:04:05", *licenseData.StartDate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("解析开始日期失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if now.Before(startDate) {
|
||||
return false, fmt.Errorf("许可证尚未生效,生效日期: %s", startDate.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查结束日期
|
||||
if licenseData.EndDate != nil && *licenseData.EndDate != "" {
|
||||
endDate, err := time.Parse(time.RFC3339, *licenseData.EndDate)
|
||||
if err != nil {
|
||||
// 尝试其他时间格式
|
||||
endDate, err = time.Parse("2006-01-02T15:04:05Z07:00", *licenseData.EndDate)
|
||||
if err != nil {
|
||||
endDate, err = time.Parse("2006-01-02 15:04:05", *licenseData.EndDate)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("解析结束日期失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if now.After(endDate) {
|
||||
return false, fmt.Errorf("许可证已过期,过期日期: %s", endDate.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
startDateStr := ""
|
||||
if licenseData.StartDate != nil {
|
||||
startDateStr = *licenseData.StartDate
|
||||
}
|
||||
endDateStr := ""
|
||||
if licenseData.EndDate != nil {
|
||||
endDateStr = *licenseData.EndDate
|
||||
}
|
||||
log.Printf("许可证有效期: %s - %s", startDateStr, endDateStr)
|
||||
} else {
|
||||
return false, fmt.Errorf("许可证文件缺少结束日期")
|
||||
}
|
||||
|
||||
// 检查状态
|
||||
if licenseData.Status == "revoked" {
|
||||
return false, fmt.Errorf("许可证已被撤销")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// LicenseFileWithSignature RSA签名后的许可证文件结构
|
||||
type LicenseFileWithSignature struct {
|
||||
Data string `json:"data"` // 原始数据(JSON字符串)
|
||||
Signature string `json:"signature"` // 数字签名
|
||||
Algorithm string `json:"algorithm"` // 签名算法
|
||||
}
|
||||
|
||||
// decryptLicenseFile 验证RSA签名的许可证文件
|
||||
func decryptLicenseFile(encryptedData []byte) ([]byte, error) {
|
||||
// Base64解码
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(encryptedData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64解码失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析签名后的许可证文件
|
||||
var signedLicense LicenseFileWithSignature
|
||||
if err := json.Unmarshal(decoded, &signedLicense); err != nil {
|
||||
return nil, fmt.Errorf("解析许可证文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查签名算法
|
||||
if signedLicense.Algorithm != "RSA-PSS-SHA256" {
|
||||
return nil, fmt.Errorf("不支持的签名算法: %s", signedLicense.Algorithm)
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
dataBytes := []byte(signedLicense.Data)
|
||||
if err := verifyRSASignature(dataBytes, signedLicense.Signature); err != nil {
|
||||
return nil, fmt.Errorf("签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
// 返回原始数据
|
||||
return dataBytes, nil
|
||||
}
|
||||
|
||||
// saveLicenseFile 保存许可证文件到 license_code/LICENSE
|
||||
func saveLicenseFile(filePath string) error {
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 如果config.LicenseFile不为空,直接保存
|
||||
if config.LicenseFile != "" {
|
||||
// 写入文件(已经是base64编码的加密数据)
|
||||
return os.WriteFile(filePath, []byte(config.LicenseFile), 0644)
|
||||
}
|
||||
|
||||
// 如果LicenseFile为空,但LicenseKey存在,可能需要重新生成
|
||||
// 这里我们假设LicenseFile已经在激活时设置
|
||||
return fmt.Errorf("许可证文件内容为空")
|
||||
}
|
||||
|
||||
// readAuthCodeFromFile 从 license_code/AUTH_CODE 文件读取授权码
|
||||
func readAuthCodeFromFile() (string, error) {
|
||||
authCodePath := filepath.Join(licenseDir, "AUTH_CODE")
|
||||
data, err := os.ReadFile(authCodePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取授权码文件失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// 默认RSA公钥(应该从配置文件或环境变量加载)
|
||||
// 在生产环境中,这个公钥应该编译到程序中,或者从配置文件读取
|
||||
var defaultRSAPublicKeyPEM = `
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyour-public-key-here
|
||||
-----END PUBLIC KEY-----
|
||||
`
|
||||
|
||||
var rsaPublicKey *rsa.PublicKey
|
||||
|
||||
// initRSAPublicKey 初始化RSA公钥(从文件或环境变量加载)
|
||||
func initRSAPublicKey() error {
|
||||
if rsaPublicKey != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pubKeyPEM string
|
||||
var err error
|
||||
|
||||
// 1. 首先尝试从当前目录下的 rsa_public_key.pem 文件加载
|
||||
defaultPubKeyPath := "rsa_public_key.pem"
|
||||
if _, err := os.Stat(defaultPubKeyPath); err == nil {
|
||||
data, err := os.ReadFile(defaultPubKeyPath)
|
||||
if err == nil {
|
||||
pubKeyPEM = string(data)
|
||||
// 获取绝对路径用于日志
|
||||
if absPath, err := filepath.Abs(defaultPubKeyPath); err == nil {
|
||||
log.Printf("从当前目录加载RSA公钥: %s", absPath)
|
||||
} else {
|
||||
log.Printf("从当前目录加载RSA公钥: %s", defaultPubKeyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果当前目录没有,尝试从环境变量获取公钥内容
|
||||
if pubKeyPEM == "" {
|
||||
pubKeyPEM = os.Getenv("LICENSE_RSA_PUBLIC_KEY")
|
||||
if pubKeyPEM != "" {
|
||||
log.Println("从环境变量 LICENSE_RSA_PUBLIC_KEY 加载RSA公钥")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果还是没有,尝试从环境变量指定的文件路径加载
|
||||
if pubKeyPEM == "" {
|
||||
pubKeyPath := os.Getenv("LICENSE_RSA_PUBLIC_KEY_PATH")
|
||||
if pubKeyPath != "" {
|
||||
data, err := os.ReadFile(pubKeyPath)
|
||||
if err == nil {
|
||||
pubKeyPEM = string(data)
|
||||
log.Printf("从环境变量指定的路径加载RSA公钥: %s", pubKeyPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还是没有找到公钥,返回错误
|
||||
if pubKeyPEM == "" {
|
||||
// 获取当前工作目录用于错误提示
|
||||
cwd, _ := os.Getwd()
|
||||
return fmt.Errorf("RSA公钥未配置,请将公钥文件 rsa_public_key.pem 放在当前目录 (%s) 下,或设置 LICENSE_RSA_PUBLIC_KEY 环境变量或 LICENSE_RSA_PUBLIC_KEY_PATH 环境变量", cwd)
|
||||
}
|
||||
|
||||
key, err := loadRSAPublicKeyFromString(pubKeyPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载RSA公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPublicKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRSAPublicKeyFromString 从PEM格式字符串加载RSA公钥
|
||||
func loadRSAPublicKeyFromString(pubKeyStr string) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(pubKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的公钥")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA公钥")
|
||||
}
|
||||
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
// verifyRSASignature 使用RSA公钥验证数字签名
|
||||
func verifyRSASignature(data []byte, signatureBase64 string) error {
|
||||
if rsaPublicKey == nil {
|
||||
if err := initRSAPublicKey(); err != nil {
|
||||
return fmt.Errorf("RSA公钥未初始化: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 解码base64签名
|
||||
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解码签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算数据的SHA256哈希
|
||||
hashed := sha256.Sum256(data)
|
||||
|
||||
// 验证签名
|
||||
err = rsa.VerifyPSS(rsaPublicKey, crypto.SHA256, hashed[:], signature, &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI
|
||||
t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx
|
||||
gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw
|
||||
6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY
|
||||
j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO
|
||||
BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso
|
||||
ZQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"license-manager/pkg/utils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
privateKeyPath = flag.String("private", "../../configs/rsa_private_key.pem", "私钥文件路径")
|
||||
publicKeyPath = flag.String("public", "../../configs/rsa_public_key.pem", "公钥文件路径")
|
||||
keySize = flag.Int("size", 2048, "密钥大小(2048或4096)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
fmt.Printf("正在生成 RSA 密钥对...\n")
|
||||
fmt.Printf("密钥大小: %d 位\n", *keySize)
|
||||
fmt.Printf("私钥路径: %s\n", *privateKeyPath)
|
||||
fmt.Printf("公钥路径: %s\n", *publicKeyPath)
|
||||
|
||||
if err := utils.GenerateRSAKeyPair(*privateKeyPath, *publicKeyPath, *keySize); err != nil {
|
||||
fmt.Printf("生成失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✓ 密钥对生成成功!\n")
|
||||
fmt.Printf("\n请将私钥文件保存在安全的位置(服务器端)\n")
|
||||
fmt.Printf("请将公钥提供给客户端开发人员(可以编译到客户端程序中)\n")
|
||||
}
|
||||
@@ -38,7 +38,12 @@ i18n:
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"]
|
||||
|
||||
license:
|
||||
encryption_key: "license-mgr-aes256-secret-key123" # 32字节许可证文件加密密钥
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
|
||||
@@ -39,7 +39,12 @@ i18n:
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
|
||||
|
||||
license:
|
||||
encryption_key: "your-32-byte-secret-key-here!!" # 32字节许可证文件加密密钥
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
@@ -38,7 +38,12 @@ i18n:
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"]
|
||||
|
||||
license:
|
||||
encryption_key: "please-change-in-prod-32-bytes-key" # 32字节许可证文件加密密钥
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
|
||||
@@ -38,7 +38,12 @@ i18n:
|
||||
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
|
||||
|
||||
license:
|
||||
encryption_key: "license-mgr-aes256-secret-key123" # 32字节许可证文件加密密钥
|
||||
# RSA非对称加密配置
|
||||
rsa:
|
||||
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
|
||||
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
|
||||
key_size: 2048 # 密钥大小:2048或4096,默认2048
|
||||
|
||||
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
|
||||
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
|
||||
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
|
||||
@@ -21,6 +21,8 @@ errors:
|
||||
"200003": "Customer name is required"
|
||||
"200004": "Invalid customer information format"
|
||||
"200005": "Failed to query customer list"
|
||||
"200006": "Customer has authorization codes, cannot delete. Please delete authorizations first"
|
||||
"200007": "Customer is disabled, cannot create authorization"
|
||||
|
||||
# Authorization module (30xxxx)
|
||||
authorization:
|
||||
@@ -72,10 +74,10 @@ enums:
|
||||
expired: "Expired"
|
||||
|
||||
customer_level:
|
||||
"normal": "Normal"
|
||||
"vip": "VIP"
|
||||
"enterprise": "Enterprise"
|
||||
"strategic": "Strategic"
|
||||
"basic": "Basic Customer"
|
||||
"active": "Active Customer"
|
||||
"vip": "VIP Customer"
|
||||
"strategic": "Strategic Partner"
|
||||
|
||||
customer_status:
|
||||
"active": "Active"
|
||||
|
||||
@@ -21,6 +21,8 @@ errors:
|
||||
"200003": "顧客名は必須です"
|
||||
"200004": "無効な顧客情報形式"
|
||||
"200005": "顧客リストの照会に失敗しました"
|
||||
"200006": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください"
|
||||
"200007": "顧客が無効になっています。認可を作成できません"
|
||||
|
||||
# 認可モジュール (30xxxx)
|
||||
authorization:
|
||||
@@ -72,10 +74,10 @@ enums:
|
||||
expired: "期限切れ"
|
||||
|
||||
customer_level:
|
||||
"normal": "一般顧客"
|
||||
"basic": "基本顧客"
|
||||
"active": "アクティブ顧客"
|
||||
"vip": "VIP顧客"
|
||||
"enterprise": "企業顧客"
|
||||
"strategic": "戦略顧客"
|
||||
"strategic": "戦略的パートナー"
|
||||
|
||||
customer_status:
|
||||
"active": "アクティブ"
|
||||
|
||||
@@ -21,6 +21,8 @@ errors:
|
||||
"200003": "客户名称不能为空"
|
||||
"200004": "客户信息格式无效"
|
||||
"200005": "客户列表查询失败"
|
||||
"200006": "客户仍有授权,无法删除,请先删除授权"
|
||||
"200007": "客户已停用,无法创建授权"
|
||||
|
||||
# 授权模块 (30xxxx)
|
||||
authorization:
|
||||
@@ -72,10 +74,10 @@ enums:
|
||||
expired: "已过期"
|
||||
|
||||
customer_level:
|
||||
"normal": "普通客户"
|
||||
"basic": "基础客户"
|
||||
"active": "活跃客户"
|
||||
"vip": "VIP客户"
|
||||
"enterprise": "企业客户"
|
||||
"strategic": "战略客户"
|
||||
"strategic": "战略合作客户"
|
||||
|
||||
customer_status:
|
||||
"active": "激活"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEA5hGz3hws+L2MUW7CaRQIt7Vxk0KAdPBUuNdkJcs2kpgkDmiT
|
||||
/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCxgN/5bKSeLTPXT0VvvSkfB5FUUQAB
|
||||
HFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw6oMDI7MHVRfRZTe3rTM3T0qi3Ed6
|
||||
qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iYj8ZJP7iU2vKYvWIG9TNv0FK/dXQk
|
||||
7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhOBumOLXXBdlwgCVtejBTqDmlQpiJY
|
||||
ADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69ssoZQIDAQABAoIBAAyqfNSFiJRJGyiA
|
||||
iINwbupuOsp4psnJUWC7uu+5uE9pwNJqP3uMXGBo6eYg/vfwSsxuk+mfQCFBINaL
|
||||
5w/thtgpfHnWtj6W1AuxLu2jDepEvfeXoHgG3c7i939h53L3DPWBfA6B+0hS9qHP
|
||||
tJIAojDG9a1JdeXhYc3Iu3v1jd33izEPeX/O+I35dusJxag2+0Ii3DIl13/JeSLR
|
||||
m1i79lpsx6QobVKNt3bz27dxRjDZIXnsalpd5ik/QK8q1iRfGfLHfDwGn2J+X1LT
|
||||
btbW/fipsBc56AJMOGZ/OXpvO2LncZBzW/66Oa+9fvBi4kH9J3+fGpR9X28qMxuo
|
||||
OE86niECgYEA/QFHViamV0SzjCb6g9xIYD9hGMToRiq6O9UqPd7512yfxRF9y0I4
|
||||
HbrJG0k4bASeDTd8e1FvJplwPQn4tkBM02sPlfSFcjOYsDDfHmGaOh79GW/oMdst
|
||||
BfmaXSCKzYc/vR+cePDRqY+2Y8gAGUfKyte2BAOKOahLblPlp54Qn4MCgYEA6Mrq
|
||||
9XMWZagFv8y+oxQKm3i4e7/M6y7p/BadDX5s3jNjrGpkr9eE+vZw/033xuiz9rVR
|
||||
nKVLMeazferXKB1FyD3RU+5lAw3GqaWJDfUWiNyYZMgyOintEMllDTj4YsMhT/w9
|
||||
d5qS5NNYpgKToEtmPydtynn/DG55CWZkkkAc6/cCgYEA847xj5vxcf1vIzK644mC
|
||||
GzWoUWHtgO7NDaBt49XtilbFj78A7FU5FFWmGCBqsoHXbT3HFigU6oG3rOsWhMxt
|
||||
qPFdtawJcyD8klDZ2bN39w+x83TpgGVQeHoLAi+7r5PN5lCyHzMlgygYGwKEJdIZ
|
||||
BSCX1lFaFZtP0kvtv2jI8+UCgYEA1HBxcOLA+q4V6Q2Qn0Pn1i+Zs5y6BZm/gASm
|
||||
+Cx6b2BYQnypO+si+N8zCVl+l2++45JeUuRpNbXB8VQBWyMzjmQThlOUrO/CbwiY
|
||||
hTs3gFhHXhlv9LJUHDCd3i4yRfaqjNK0yVQvX1EbogvR6sNEILf7sn4YaaDbJd/i
|
||||
QbDuc8sCgYEA5I9wdyGUgnqunw/5r4D+uKNRCtms9hTTMql1me8ZOGcF3+KjjSL/
|
||||
zjt+BToLhrjjCHJYByS2P5O1vtXeeyiWslexWWTc1ujOjJlrxLgBCAMW+qNz5fLc
|
||||
CeXO8bo/mT7KS7ABXh7tCNhFsSjNNCUcHyzzKp9S1gzRC6k4AGADx8s=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI
|
||||
t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx
|
||||
gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw
|
||||
6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY
|
||||
j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO
|
||||
BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso
|
||||
ZQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,177 @@
|
||||
# 客户详情接口改造需求
|
||||
|
||||
## 一、接口信息
|
||||
|
||||
**接口路径**: `GET /api/customers/{id}`
|
||||
**接口说明**: 获取客户详细信息,在原有客户基本信息基础上,新增客户授权统计信息
|
||||
|
||||
---
|
||||
|
||||
## 二、需求概述
|
||||
|
||||
在客户详情接口的响应数据中,新增一个 `authorization_stats` 对象,包含该客户的授权码和许可证相关统计数据,用于在客户详情页面展示客户的授权使用情况。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型设计
|
||||
|
||||
### 3.1 响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "000000",
|
||||
"message": "success",
|
||||
"data": {
|
||||
// ... 原有客户基本信息字段 ...
|
||||
|
||||
// 新增:授权统计信息
|
||||
"authorization_stats": {
|
||||
"total_auth_codes": 15, // 总授权码数量
|
||||
"expired_auth_codes": 3, // 已过期授权码数量
|
||||
"expiring_soon_auth_codes": 2, // 30日内即将到期授权码数量
|
||||
"total_licenses": 42, // 总许可证数量
|
||||
"active_licenses": 28, // 已激活许可证数量
|
||||
"inactive_licenses": 14, // 未激活许可证数量
|
||||
"expired_licenses": 8 // 已过期许可证数量
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 统计字段详细说明
|
||||
|
||||
#### 授权码统计(基于 `authorization_codes` 表)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 计算规则 |
|
||||
|--------|------|------|----------|
|
||||
| `total_auth_codes` | int64 | 总授权码数量 | 统计该客户(`customer_id`)的所有授权码总数 |
|
||||
| `expired_auth_codes` | int64 | 已过期授权码数量 | `end_date < NOW()` 的授权码数量(不考虑锁定状态) |
|
||||
| `expiring_soon_auth_codes` | int64 | 30日内即将到期授权码数量 | `end_date >= NOW()` 且 `end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)` 的授权码数量(不考虑锁定状态) |
|
||||
|
||||
#### 许可证统计(基于 `licenses` 表)
|
||||
|
||||
| 字段名 | 类型 | 说明 | 计算规则 |
|
||||
|--------|------|------|----------|
|
||||
| `total_licenses` | int64 | 总许可证数量 | 统计该客户(`customer_id`)的所有许可证总数 |
|
||||
| `active_licenses` | int64 | 已激活许可证数量 | `status = 'active'` 的许可证数量 |
|
||||
| `inactive_licenses` | int64 | 未激活许可证数量 | `total_licenses - active_licenses` |
|
||||
| `expired_licenses` | int64 | 已过期许可证数量 | 关联的授权码已过期(`authorization_code.end_date < NOW()`)的许可证总数 |
|
||||
|
||||
---
|
||||
|
||||
## 四、计算逻辑说明
|
||||
|
||||
### 4.1 授权码状态判断
|
||||
|
||||
- **正常(normal)**: `NOW() >= start_date AND NOW() <= end_date AND is_locked = false`
|
||||
- **已过期(expired)**: `NOW() > end_date`(无论是否锁定)
|
||||
- **已锁定(locked)**: `is_locked = true`(锁定状态不影响有效期统计)
|
||||
|
||||
**注意**:
|
||||
- **过期和即将到期统计不考虑锁定状态**:只要授权码的 `end_date` 满足条件,无论是否锁定都应计入统计
|
||||
- 例如:即使授权码已锁定,只要 `end_date < NOW()`,仍应计入"已过期授权码数量"
|
||||
|
||||
### 4.2 许可证过期判断
|
||||
|
||||
许可证的过期状态**依赖于关联的授权码**:
|
||||
- 许可证本身没有 `end_date` 字段
|
||||
- 如果许可证关联的授权码已过期(`authorization_codes.end_date < NOW()`),则视为该许可证已过期
|
||||
|
||||
### 4.3 边界条件
|
||||
|
||||
1. **空数据情况**: 如果客户没有任何授权码或许可证,所有统计值应返回 `0`
|
||||
2. **软删除**: 只统计未删除的记录(`deleted_at IS NULL`)
|
||||
3. **时间计算**: 使用服务器当前时间(`NOW()`)作为基准进行判断
|
||||
|
||||
---
|
||||
|
||||
## 五、相关数据库表
|
||||
|
||||
### 5.1 涉及表结构
|
||||
|
||||
- **`customers`**: 客户表(主表)
|
||||
- **`authorization_codes`**: 授权码表
|
||||
- `customer_id`: 关联客户ID
|
||||
- `start_date`: 授权开始时间
|
||||
- `end_date`: 授权结束时间
|
||||
- `is_locked`: 是否锁定
|
||||
- **`licenses`**: 许可证表
|
||||
- `customer_id`: 关联客户ID(冗余字段)
|
||||
- `authorization_code_id`: 关联授权码ID(外键)
|
||||
- `status`: 许可证状态(`active`/`inactive`/`revoked`)
|
||||
|
||||
### 5.2 关联关系
|
||||
|
||||
```
|
||||
customers (1) ──< (N) authorization_codes
|
||||
│
|
||||
│ (1)
|
||||
│
|
||||
└──< (N) licenses
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实现建议
|
||||
|
||||
### 6.1 查询优化
|
||||
|
||||
建议使用单次 SQL 查询或有限次查询完成统计,避免 N+1 查询问题:
|
||||
|
||||
1. **授权码统计**: 使用 `COUNT` + `CASE WHEN` 在一条 SQL 中完成
|
||||
2. **许可证统计**:
|
||||
- `total_licenses` 和 `active_licenses`: 单次查询统计
|
||||
- `expired_licenses`: 需要关联 `authorization_codes` 表判断过期状态
|
||||
|
||||
### 6.2 性能考虑
|
||||
|
||||
- 确保相关字段已建立索引(如 `customer_id`, `end_date`, `status`, `is_locked`)
|
||||
- 对于高频访问的接口,可考虑缓存统计结果(需注意数据一致性)
|
||||
|
||||
---
|
||||
|
||||
## 七、示例数据
|
||||
|
||||
假设某客户(ID: `customer-001`)有以下数据:
|
||||
|
||||
**授权码数据**:
|
||||
- 授权码A: `end_date = '2025-12-31'`, `is_locked = false` → 正常
|
||||
- 授权码B: `end_date = '2025-01-25'`, `is_locked = false` → 30日内即将到期
|
||||
- 授权码C: `end_date = '2024-12-31'`, `is_locked = false` → 已过期
|
||||
- 授权码D: `end_date = '2025-12-31'`, `is_locked = true` → 已锁定(正常范围内)
|
||||
- 授权码E: `end_date = '2024-11-30'`, `is_locked = true` → 已锁定且已过期(仍计入过期统计)
|
||||
- 授权码F: `end_date = '2025-01-20'`, `is_locked = true` → 已锁定且即将到期(仍计入即将到期统计)
|
||||
|
||||
**许可证数据**:
|
||||
- 授权码A关联: 10个许可证(8个active, 2个inactive)
|
||||
- 授权码B关联: 5个许可证(4个active, 1个inactive)
|
||||
- 授权码C关联: 3个许可证(已过期授权码下的所有许可证,均为active)
|
||||
- 授权码D关联: 2个许可证(1个active, 1个inactive)
|
||||
- 授权码E关联: 2个许可证(1个active, 1个inactive,因授权码已过期,这2个许可证计入过期许可证)
|
||||
- 授权码F关联: 1个许可证(1个active)
|
||||
|
||||
**统计结果**:
|
||||
```json
|
||||
{
|
||||
"authorization_stats": {
|
||||
"total_auth_codes": 6,
|
||||
"expired_auth_codes": 2, // 授权码C(已过期)+ 授权码E(已锁定但已过期)
|
||||
"expiring_soon_auth_codes": 2, // 授权码B(即将到期)+ 授权码F(已锁定但即将到期)
|
||||
"total_licenses": 23, // 10+5+3+2+2+1 = 23
|
||||
"active_licenses": 18, // 8+4+3+1+1+1 = 18
|
||||
"inactive_licenses": 5, // 23-18 = 5
|
||||
"expired_licenses": 5 // 授权码C的3个 + 授权码E的2个 = 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、验收标准
|
||||
|
||||
1. ✅ 接口响应包含 `authorization_stats` 对象
|
||||
2. ✅ 所有统计字段类型为 `int64`,空数据返回 `0`
|
||||
3. ✅ 已过期和即将到期授权码统计不考虑锁定状态(仅基于 `end_date` 判断)
|
||||
4. ✅ 许可证过期判断基于关联授权码的有效期
|
||||
5. ✅ 计算逻辑准确,边界情况处理正确
|
||||
6. ✅ 查询性能良好,无明显的性能问题
|
||||
@@ -0,0 +1,68 @@
|
||||
# 许可证加密设计文档
|
||||
|
||||
## 加密方案
|
||||
|
||||
使用 **RSA 数字签名**确保许可证文件的完整性和来源认证。
|
||||
|
||||
### 签名算法
|
||||
- **算法**: RSA-PSS
|
||||
- **哈希**: SHA-256
|
||||
- **密钥长度**: 2048 位(推荐)或 4096 位
|
||||
|
||||
## 服务器端实现
|
||||
|
||||
### 密钥管理
|
||||
- 私钥存储在配置文件中:`configs/rsa_private_key.pem`
|
||||
- 配置文件路径:`license.rsa.private_key_path`
|
||||
|
||||
### 签名流程
|
||||
1. 构建许可证数据(JSON)
|
||||
2. 使用 RSA 私钥对数据进行数字签名
|
||||
3. 生成许可证文件结构:
|
||||
```json
|
||||
{
|
||||
"data": "原始JSON数据(字符串)",
|
||||
"signature": "base64编码的签名",
|
||||
"algorithm": "RSA-PSS-SHA256"
|
||||
}
|
||||
```
|
||||
4. Base64 编码整个结构
|
||||
|
||||
### 代码位置
|
||||
- 签名实现:`backend/internal/service/license_service.go::signLicenseFile()`
|
||||
- 密钥工具:`backend/pkg/utils/crypto.go`
|
||||
|
||||
## 客户端实现
|
||||
|
||||
### 公钥加载
|
||||
支持三种方式(按优先级):
|
||||
1. 当前目录的 `rsa_public_key.pem` 文件
|
||||
2. 环境变量 `LICENSE_RSA_PUBLIC_KEY`(PEM 格式字符串)
|
||||
3. 环境变量 `LICENSE_RSA_PUBLIC_KEY_PATH`(文件路径)
|
||||
|
||||
### 验证流程
|
||||
1. Base64 解码许可证文件
|
||||
2. 解析 JSON 结构
|
||||
3. 使用 RSA 公钥验证签名
|
||||
4. 验证通过后解析许可证数据
|
||||
|
||||
### 代码位置
|
||||
- 验证实现:`backend/cmd/client-demo/rsa.go::verifyRSASignature()`
|
||||
- 许可证解析:`backend/cmd/client-demo/main.go::decryptLicenseFile()`
|
||||
|
||||
## 密钥生成
|
||||
|
||||
使用工具生成密钥对:
|
||||
```bash
|
||||
go run backend/cmd/gen-rsa-keys/main.go
|
||||
```
|
||||
|
||||
生成文件:
|
||||
- `rsa_private_key.pem` - 服务器使用(必须保密)
|
||||
- `rsa_public_key.pem` - 客户端使用(可公开)
|
||||
|
||||
## 安全性说明
|
||||
|
||||
1. **防伪造**:只有服务器拥有私钥,无法伪造有效签名
|
||||
2. **防篡改**:任何数据修改都会导致签名验证失败
|
||||
3. **密钥分离**:公钥可公开,私钥仅服务器持有
|
||||
@@ -392,7 +392,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "软删除客户记录",
|
||||
"description": "直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -437,6 +437,12 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "客户仍有授权,无法删除",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"schema": {
|
||||
@@ -2590,9 +2596,9 @@ const docTemplate = `{
|
||||
"description": "使用限制(JSON对象)"
|
||||
},
|
||||
"validity_days": {
|
||||
"description": "有效天数(1-36500天)",
|
||||
"description": "有效天数(1-365000天,365000代表永久有效)",
|
||||
"type": "integer",
|
||||
"maximum": 36500,
|
||||
"maximum": 365000,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
@@ -2795,19 +2801,60 @@ const docTemplate = `{
|
||||
"description": "使用限制"
|
||||
},
|
||||
"validity_days": {
|
||||
"description": "有效天数",
|
||||
"description": "有效天数(1-365000天,365000代表永久有效)",
|
||||
"type": "integer",
|
||||
"maximum": 36500,
|
||||
"maximum": 365000,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.AuthorizationStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_licenses": {
|
||||
"description": "已激活许可证数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"expired_auth_codes": {
|
||||
"description": "已过期授权码数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"expired_licenses": {
|
||||
"description": "已过期许可证数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"expiring_soon_auth_codes": {
|
||||
"description": "30日内即将到期授权码数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"inactive_licenses": {
|
||||
"description": "未激活许可证数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"total_auth_codes": {
|
||||
"description": "总授权码数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"total_licenses": {
|
||||
"description": "总许可证数量",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Customer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"authorization_stats": {
|
||||
"description": "授权统计信息(仅在详情接口返回)",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.AuthorizationStats"
|
||||
}
|
||||
]
|
||||
},
|
||||
"company_size": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2887,13 +2934,7 @@ const docTemplate = `{
|
||||
},
|
||||
"company_size": {
|
||||
"description": "企业规模,可选",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"enterprise"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"contact_person": {
|
||||
"description": "联系人姓名,必填",
|
||||
@@ -2907,13 +2948,7 @@ const docTemplate = `{
|
||||
},
|
||||
"customer_level": {
|
||||
"description": "客户等级,必填",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"vip",
|
||||
"enterprise",
|
||||
"strategic"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"customer_name": {
|
||||
"description": "客户名称,必填",
|
||||
@@ -3082,13 +3117,7 @@ const docTemplate = `{
|
||||
},
|
||||
"company_size": {
|
||||
"description": "企业规模,可选",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"enterprise"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"contact_person": {
|
||||
"description": "联系人姓名,可选",
|
||||
@@ -3102,13 +3131,7 @@ const docTemplate = `{
|
||||
},
|
||||
"customer_level": {
|
||||
"description": "客户等级,可选",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"vip",
|
||||
"enterprise",
|
||||
"strategic"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"customer_name": {
|
||||
"description": "客户名称,可选",
|
||||
@@ -3256,11 +3279,11 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_codes": {
|
||||
"description": "授权码增长率(%)",
|
||||
"description": "授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)",
|
||||
"type": "number"
|
||||
},
|
||||
"licenses": {
|
||||
"description": "许可证增长率(%)",
|
||||
"description": "许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -3713,19 +3736,19 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"abnormal_alerts": {
|
||||
"description": "异常告警数量",
|
||||
"description": "异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)",
|
||||
"type": "integer"
|
||||
},
|
||||
"active_licenses": {
|
||||
"description": "活跃许可证数量",
|
||||
"description": "活跃许可证数量(状态为active的许可证总数)",
|
||||
"type": "integer"
|
||||
},
|
||||
"expiring_soon": {
|
||||
"description": "即将过期数量",
|
||||
"description": "即将过期数量(30天内即将过期的授权码,且未被锁定)",
|
||||
"type": "integer"
|
||||
},
|
||||
"growth_rate": {
|
||||
"description": "增长率",
|
||||
"description": "增长率(同比上月)",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.GrowthRate"
|
||||
@@ -3733,7 +3756,7 @@ const docTemplate = `{
|
||||
]
|
||||
},
|
||||
"total_auth_codes": {
|
||||
"description": "总授权码数量",
|
||||
"description": "总授权码数量(所有授权码的累计总数)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "软删除客户记录",
|
||||
"description": "直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -434,6 +434,12 @@
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "客户仍有授权,无法删除",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"schema": {
|
||||
@@ -2587,9 +2593,9 @@
|
||||
"description": "使用限制(JSON对象)"
|
||||
},
|
||||
"validity_days": {
|
||||
"description": "有效天数(1-36500天)",
|
||||
"description": "有效天数(1-365000天,365000代表永久有效)",
|
||||
"type": "integer",
|
||||
"maximum": 36500,
|
||||
"maximum": 365000,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
@@ -2792,19 +2798,60 @@
|
||||
"description": "使用限制"
|
||||
},
|
||||
"validity_days": {
|
||||
"description": "有效天数",
|
||||
"description": "有效天数(1-365000天,365000代表永久有效)",
|
||||
"type": "integer",
|
||||
"maximum": 36500,
|
||||
"maximum": 365000,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.AuthorizationStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_licenses": {
|
||||
"description": "已激活许可证数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"expired_auth_codes": {
|
||||
"description": "已过期授权码数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"expired_licenses": {
|
||||
"description": "已过期许可证数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"expiring_soon_auth_codes": {
|
||||
"description": "30日内即将到期授权码数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"inactive_licenses": {
|
||||
"description": "未激活许可证数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"total_auth_codes": {
|
||||
"description": "总授权码数量",
|
||||
"type": "integer"
|
||||
},
|
||||
"total_licenses": {
|
||||
"description": "总许可证数量",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.Customer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string"
|
||||
},
|
||||
"authorization_stats": {
|
||||
"description": "授权统计信息(仅在详情接口返回)",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.AuthorizationStats"
|
||||
}
|
||||
]
|
||||
},
|
||||
"company_size": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2884,13 +2931,7 @@
|
||||
},
|
||||
"company_size": {
|
||||
"description": "企业规模,可选",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"enterprise"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"contact_person": {
|
||||
"description": "联系人姓名,必填",
|
||||
@@ -2904,13 +2945,7 @@
|
||||
},
|
||||
"customer_level": {
|
||||
"description": "客户等级,必填",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"vip",
|
||||
"enterprise",
|
||||
"strategic"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"customer_name": {
|
||||
"description": "客户名称,必填",
|
||||
@@ -3079,13 +3114,7 @@
|
||||
},
|
||||
"company_size": {
|
||||
"description": "企业规模,可选",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"small",
|
||||
"medium",
|
||||
"large",
|
||||
"enterprise"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"contact_person": {
|
||||
"description": "联系人姓名,可选",
|
||||
@@ -3099,13 +3128,7 @@
|
||||
},
|
||||
"customer_level": {
|
||||
"description": "客户等级,可选",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"vip",
|
||||
"enterprise",
|
||||
"strategic"
|
||||
]
|
||||
"type": "string"
|
||||
},
|
||||
"customer_name": {
|
||||
"description": "客户名称,可选",
|
||||
@@ -3253,11 +3276,11 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_codes": {
|
||||
"description": "授权码增长率(%)",
|
||||
"description": "授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)",
|
||||
"type": "number"
|
||||
},
|
||||
"licenses": {
|
||||
"description": "许可证增长率(%)",
|
||||
"description": "许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
@@ -3710,19 +3733,19 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"abnormal_alerts": {
|
||||
"description": "异常告警数量",
|
||||
"description": "异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)",
|
||||
"type": "integer"
|
||||
},
|
||||
"active_licenses": {
|
||||
"description": "活跃许可证数量",
|
||||
"description": "活跃许可证数量(状态为active的许可证总数)",
|
||||
"type": "integer"
|
||||
},
|
||||
"expiring_soon": {
|
||||
"description": "即将过期数量",
|
||||
"description": "即将过期数量(30天内即将过期的授权码,且未被锁定)",
|
||||
"type": "integer"
|
||||
},
|
||||
"growth_rate": {
|
||||
"description": "增长率",
|
||||
"description": "增长率(同比上月)",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.GrowthRate"
|
||||
@@ -3730,7 +3753,7 @@
|
||||
]
|
||||
},
|
||||
"total_auth_codes": {
|
||||
"description": "总授权码数量",
|
||||
"description": "总授权码数量(所有授权码的累计总数)",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +218,8 @@ definitions:
|
||||
usage_limits:
|
||||
description: 使用限制(JSON对象)
|
||||
validity_days:
|
||||
description: 有效天数(1-36500天)
|
||||
maximum: 36500
|
||||
description: 有效天数(1-365000天,365000代表永久有效)
|
||||
maximum: 365000
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
@@ -373,17 +373,45 @@ definitions:
|
||||
usage_limits:
|
||||
description: 使用限制
|
||||
validity_days:
|
||||
description: 有效天数
|
||||
maximum: 36500
|
||||
description: 有效天数(1-365000天,365000代表永久有效)
|
||||
maximum: 365000
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- change_type
|
||||
type: object
|
||||
models.AuthorizationStats:
|
||||
properties:
|
||||
active_licenses:
|
||||
description: 已激活许可证数量
|
||||
type: integer
|
||||
expired_auth_codes:
|
||||
description: 已过期授权码数量
|
||||
type: integer
|
||||
expired_licenses:
|
||||
description: 已过期许可证数量
|
||||
type: integer
|
||||
expiring_soon_auth_codes:
|
||||
description: 30日内即将到期授权码数量
|
||||
type: integer
|
||||
inactive_licenses:
|
||||
description: 未激活许可证数量
|
||||
type: integer
|
||||
total_auth_codes:
|
||||
description: 总授权码数量
|
||||
type: integer
|
||||
total_licenses:
|
||||
description: 总许可证数量
|
||||
type: integer
|
||||
type: object
|
||||
models.Customer:
|
||||
properties:
|
||||
address:
|
||||
type: string
|
||||
authorization_stats:
|
||||
allOf:
|
||||
- $ref: '#/definitions/models.AuthorizationStats'
|
||||
description: 授权统计信息(仅在详情接口返回)
|
||||
company_size:
|
||||
type: string
|
||||
company_size_display:
|
||||
@@ -433,11 +461,6 @@ definitions:
|
||||
type: string
|
||||
company_size:
|
||||
description: 企业规模,可选
|
||||
enum:
|
||||
- small
|
||||
- medium
|
||||
- large
|
||||
- enterprise
|
||||
type: string
|
||||
contact_person:
|
||||
description: 联系人姓名,必填
|
||||
@@ -449,11 +472,6 @@ definitions:
|
||||
type: string
|
||||
customer_level:
|
||||
description: 客户等级,必填
|
||||
enum:
|
||||
- normal
|
||||
- vip
|
||||
- enterprise
|
||||
- strategic
|
||||
type: string
|
||||
customer_name:
|
||||
description: 客户名称,必填
|
||||
@@ -580,11 +598,6 @@ definitions:
|
||||
type: string
|
||||
company_size:
|
||||
description: 企业规模,可选
|
||||
enum:
|
||||
- small
|
||||
- medium
|
||||
- large
|
||||
- enterprise
|
||||
type: string
|
||||
contact_person:
|
||||
description: 联系人姓名,可选
|
||||
@@ -596,11 +609,6 @@ definitions:
|
||||
type: string
|
||||
customer_level:
|
||||
description: 客户等级,可选
|
||||
enum:
|
||||
- normal
|
||||
- vip
|
||||
- enterprise
|
||||
- strategic
|
||||
type: string
|
||||
customer_name:
|
||||
description: 客户名称,可选
|
||||
@@ -703,10 +711,10 @@ definitions:
|
||||
models.GrowthRate:
|
||||
properties:
|
||||
auth_codes:
|
||||
description: 授权码增长率(%)
|
||||
description: 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
|
||||
type: number
|
||||
licenses:
|
||||
description: 许可证增长率(%)
|
||||
description: 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
|
||||
type: number
|
||||
type: object
|
||||
models.HealthResponse:
|
||||
@@ -1027,20 +1035,20 @@ definitions:
|
||||
models.StatsOverviewResponse:
|
||||
properties:
|
||||
abnormal_alerts:
|
||||
description: 异常告警数量
|
||||
description: 异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)
|
||||
type: integer
|
||||
active_licenses:
|
||||
description: 活跃许可证数量
|
||||
description: 活跃许可证数量(状态为active的许可证总数)
|
||||
type: integer
|
||||
expiring_soon:
|
||||
description: 即将过期数量
|
||||
description: 即将过期数量(30天内即将过期的授权码,且未被锁定)
|
||||
type: integer
|
||||
growth_rate:
|
||||
allOf:
|
||||
- $ref: '#/definitions/models.GrowthRate'
|
||||
description: 增长率
|
||||
description: 增长率(同比上月)
|
||||
total_auth_codes:
|
||||
description: 总授权码数量
|
||||
description: 总授权码数量(所有授权码的累计总数)
|
||||
type: integer
|
||||
type: object
|
||||
models.SystemInfo:
|
||||
@@ -1253,7 +1261,7 @@ paths:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 软删除客户记录
|
||||
description: 直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权
|
||||
parameters:
|
||||
- description: 客户ID
|
||||
in: path
|
||||
@@ -1279,6 +1287,10 @@ paths:
|
||||
description: 客户不存在
|
||||
schema:
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"409":
|
||||
description: 客户仍有授权,无法删除
|
||||
schema:
|
||||
$ref: '#/definitions/models.ErrorResponse'
|
||||
"500":
|
||||
description: 服务器内部错误
|
||||
schema:
|
||||
|
||||
@@ -29,6 +29,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
@@ -45,6 +46,7 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -52,15 +54,21 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
|
||||
@@ -31,6 +31,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
@@ -55,6 +57,7 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -83,6 +86,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
@@ -105,12 +110,18 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -144,11 +155,17 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
|
||||
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
@@ -177,7 +194,9 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
|
||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -185,6 +204,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -203,6 +224,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -293,7 +293,7 @@ func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
||||
|
||||
// DeleteCustomer 删除客户
|
||||
// @Summary 删除客户
|
||||
// @Description 软删除客户记录
|
||||
// @Description 直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权
|
||||
// @Tags 客户管理
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -303,6 +303,7 @@ func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
|
||||
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
|
||||
// @Failure 401 {object} models.ErrorResponse "未认证"
|
||||
// @Failure 404 {object} models.ErrorResponse "客户不存在"
|
||||
// @Failure 409 {object} models.ErrorResponse "客户仍有授权,无法删除"
|
||||
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
|
||||
// @Router /api/customers/{id} [delete]
|
||||
func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
|
||||
|
||||
@@ -67,12 +67,20 @@ type I18nConfig struct {
|
||||
}
|
||||
|
||||
type LicenseConfig struct {
|
||||
EncryptionKey string `mapstructure:"encryption_key"` // 许可证文件加密密钥
|
||||
// RSA非对称加密配置
|
||||
RSA RSAConfig `mapstructure:"rsa"`
|
||||
|
||||
HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时时间(秒)
|
||||
OfflineTimeout int `mapstructure:"offline_timeout"` // 离线超时时间(分钟)
|
||||
ExpiringDays int `mapstructure:"expiring_days"` // 即将过期天数
|
||||
}
|
||||
|
||||
type RSAConfig struct {
|
||||
PrivateKeyPath string `mapstructure:"private_key_path"` // RSA私钥文件路径
|
||||
PublicKeyPath string `mapstructure:"public_key_path"` // RSA公钥文件路径
|
||||
KeySize int `mapstructure:"key_size"` // 密钥大小(2048或4096),默认2048
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
func Load(configPath string) error {
|
||||
@@ -145,7 +153,10 @@ func setDefaults() {
|
||||
viper.SetDefault("i18n.support_langs", []string{"zh-CN", "en-US", "ja-JP"})
|
||||
|
||||
// License defaults
|
||||
viper.SetDefault("license.encryption_key", "license-manager-secret-key-32b")
|
||||
viper.SetDefault("license.encryption_key", "license-manager-secret-key-32b") // legacy AES key
|
||||
viper.SetDefault("license.rsa.private_key_path", "configs/rsa_private_key.pem")
|
||||
viper.SetDefault("license.rsa.public_key_path", "configs/rsa_public_key.pem")
|
||||
viper.SetDefault("license.rsa.key_size", 2048)
|
||||
viper.SetDefault("license.heartbeat_timeout", 300)
|
||||
viper.SetDefault("license.offline_timeout", 1440)
|
||||
viper.SetDefault("license.expiring_days", 30)
|
||||
|
||||
@@ -111,7 +111,7 @@ type AuthorizationCodeCreateRequest struct {
|
||||
CustomerID string `json:"customer_id" binding:"required"` // 客户ID
|
||||
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
|
||||
ValidityDays int `json:"validity_days" binding:"required,min=1,max=36500"` // 有效天数(1-36500天)
|
||||
ValidityDays int `json:"validity_days" binding:"required,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
|
||||
DeploymentType string `json:"deployment_type" binding:"required,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
|
||||
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
|
||||
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
|
||||
@@ -172,7 +172,7 @@ type AuthorizationCodeListResponse struct {
|
||||
type AuthorizationCodeUpdateRequest struct {
|
||||
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
|
||||
ValidityDays *int `json:"validity_days" binding:"omitempty,min=1,max=36500"` // 有效天数
|
||||
ValidityDays *int `json:"validity_days" binding:"omitempty,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
|
||||
DeploymentType *string `json:"deployment_type" binding:"omitempty,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
|
||||
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
|
||||
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
|
||||
|
||||
@@ -21,7 +21,7 @@ type Customer struct {
|
||||
Address *string `gorm:"type:text" json:"address"`
|
||||
CompanySize *string `gorm:"type:varchar(20)" json:"company_size"`
|
||||
CompanySizeDisplay string `gorm:"-" json:"company_size_display,omitempty"`
|
||||
CustomerLevel string `gorm:"type:varchar(20);not null;default:'normal';index" json:"customer_level"`
|
||||
CustomerLevel string `gorm:"type:varchar(20);not null;default:'basic';index" json:"customer_level"`
|
||||
CustomerLevelDisplay string `gorm:"-" json:"customer_level_display,omitempty"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'active';index" json:"status"`
|
||||
StatusDisplay string `gorm:"-" json:"status_display,omitempty"`
|
||||
@@ -31,6 +31,9 @@ type Customer struct {
|
||||
CreatedBy string `gorm:"type:varchar(36);not null;index" json:"created_by"`
|
||||
UpdatedBy *string `gorm:"type:varchar(36)" json:"updated_by"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// 授权统计信息(仅在详情接口返回)
|
||||
AuthorizationStats *AuthorizationStats `gorm:"-" json:"authorization_stats,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerCodeSequence 客户编码序列模型
|
||||
@@ -81,7 +84,7 @@ type CustomerListRequest struct {
|
||||
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
|
||||
Search string `form:"search" binding:"omitempty,max=100"` // 搜索关键词(支持客户编码、名称、联系人、邮箱)
|
||||
CustomerType string `form:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` // 客户类型筛选
|
||||
CustomerLevel string `form:"customer_level" binding:"omitempty,oneof=normal vip enterprise strategic"` // 客户等级筛选
|
||||
CustomerLevel string `form:"customer_level" binding:"omitempty"` // 客户等级筛选
|
||||
Status string `form:"status" binding:"omitempty,oneof=active disabled"` // 状态筛选
|
||||
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at customer_name customer_code"` // 排序字段,默认created_at
|
||||
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
|
||||
@@ -121,8 +124,8 @@ type CustomerCreateRequest struct {
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选
|
||||
Phone *string `json:"phone" binding:"omitempty,max=20"` // 电话,可选
|
||||
Address *string `json:"address" binding:"omitempty,max=500"` // 地址,可选
|
||||
CompanySize *string `json:"company_size" binding:"omitempty,oneof=small medium large enterprise"` // 企业规模,可选
|
||||
CustomerLevel string `json:"customer_level" binding:"required,oneof=normal vip enterprise strategic"` // 客户等级,必填
|
||||
CompanySize *string `json:"company_size" binding:"omitempty"` // 企业规模,可选
|
||||
CustomerLevel string `json:"customer_level" binding:"required"` // 客户等级,必填
|
||||
Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选
|
||||
}
|
||||
@@ -136,8 +139,8 @@ type CustomerUpdateRequest struct {
|
||||
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选
|
||||
Phone *string `json:"phone" binding:"omitempty,max=20"` // 电话,可选
|
||||
Address *string `json:"address" binding:"omitempty,max=500"` // 地址,可选
|
||||
CompanySize *string `json:"company_size" binding:"omitempty,oneof=small medium large enterprise"` // 企业规模,可选
|
||||
CustomerLevel *string `json:"customer_level" binding:"omitempty,oneof=normal vip enterprise strategic"` // 客户等级,可选
|
||||
CompanySize *string `json:"company_size" binding:"omitempty"` // 企业规模,可选
|
||||
CustomerLevel *string `json:"customer_level" binding:"omitempty"` // 客户等级,可选
|
||||
Status *string `json:"status" binding:"omitempty,oneof=active disabled"` // 状态,可选
|
||||
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选
|
||||
}
|
||||
@@ -146,3 +149,14 @@ type CustomerUpdateRequest struct {
|
||||
type CustomerStatusUpdateRequest struct {
|
||||
Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填
|
||||
}
|
||||
|
||||
// AuthorizationStats 授权统计信息
|
||||
type AuthorizationStats struct {
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量
|
||||
ExpiredAuthCodes int64 `json:"expired_auth_codes"` // 已过期授权码数量
|
||||
ExpiringSoonAuthCodes int64 `json:"expiring_soon_auth_codes"` // 30日内即将到期授权码数量
|
||||
TotalLicenses int64 `json:"total_licenses"` // 总许可证数量
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 已激活许可证数量
|
||||
InactiveLicenses int64 `json:"inactive_licenses"` // 未激活许可证数量
|
||||
ExpiredLicenses int64 `json:"expired_licenses"` // 已过期许可证数量
|
||||
}
|
||||
@@ -171,15 +171,15 @@ type HeartbeatResponse struct {
|
||||
|
||||
// StatsOverviewResponse 授权概览统计响应结构
|
||||
type StatsOverviewResponse struct {
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证数量
|
||||
ExpiringSoon int64 `json:"expiring_soon"` // 即将过期数量
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警数量
|
||||
GrowthRate GrowthRate `json:"growth_rate"` // 增长率
|
||||
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量(所有授权码的累计总数)
|
||||
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证数量(状态为active的许可证总数)
|
||||
ExpiringSoon int64 `json:"expiring_soon"` // 即将过期数量(30天内即将过期的授权码,且未被锁定)
|
||||
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)
|
||||
GrowthRate GrowthRate `json:"growth_rate"` // 增长率(同比上月)
|
||||
}
|
||||
|
||||
// GrowthRate 增长率结构
|
||||
// GrowthRate 增长率结构(同比上月:当前值相比一个月前同一时刻值的增长率)
|
||||
type GrowthRate struct {
|
||||
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)
|
||||
Licenses float64 `json:"licenses"` // 许可证增长率(%)
|
||||
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
|
||||
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
|
||||
}
|
||||
@@ -189,9 +189,9 @@ func (r *customerRepository) UpdateCustomer(ctx context.Context, customer *model
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCustomer 删除客户
|
||||
// DeleteCustomer 删除客户(物理删除)
|
||||
func (r *customerRepository) DeleteCustomer(ctx context.Context, id string) error {
|
||||
result := r.db.Where("id = ?", id).Delete(&models.Customer{})
|
||||
result := r.db.Unscoped().Where("id = ?", id).Delete(&models.Customer{})
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete customer: %w", result.Error)
|
||||
}
|
||||
@@ -201,6 +201,26 @@ func (r *customerRepository) DeleteCustomer(ctx context.Context, id string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckCustomerHasAuthorizationCodes 检查客户是否有关联的授权码
|
||||
func (r *customerRepository) CheckCustomerHasAuthorizationCodes(ctx context.Context, customerID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.AuthorizationCode{}).Where("customer_id = ?", customerID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check authorization codes: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CheckCustomerHasLicenses 检查客户是否有关联的许可证
|
||||
func (r *customerRepository) CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.License{}).Where("customer_id = ?", customerID).Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check licenses: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetCustomerCount 获取客户总数(用于统计)
|
||||
func (r *customerRepository) GetCustomerCount(ctx context.Context, filters map[string]interface{}) (int64, error) {
|
||||
var count int64
|
||||
@@ -217,3 +237,71 @@ func (r *customerRepository) GetCustomerCount(ctx context.Context, filters map[s
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetCustomerAuthorizationStats 获取客户授权统计信息
|
||||
func (r *customerRepository) GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error) {
|
||||
stats := &models.AuthorizationStats{}
|
||||
|
||||
// 1. 授权码统计
|
||||
// 总授权码数量
|
||||
var totalAuthCodes int64
|
||||
if err := r.db.Model(&models.AuthorizationCode{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Count(&totalAuthCodes).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count total auth codes: %w", err)
|
||||
}
|
||||
stats.TotalAuthCodes = totalAuthCodes
|
||||
|
||||
// 已过期授权码数量(end_date < NOW(),不考虑锁定状态)
|
||||
var expiredAuthCodes int64
|
||||
if err := r.db.Model(&models.AuthorizationCode{}).
|
||||
Where("customer_id = ? AND end_date < NOW()", customerID).
|
||||
Count(&expiredAuthCodes).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count expired auth codes: %w", err)
|
||||
}
|
||||
stats.ExpiredAuthCodes = expiredAuthCodes
|
||||
|
||||
// 30日内即将到期授权码数量(end_date >= NOW() AND end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY),不考虑锁定状态)
|
||||
var expiringSoonAuthCodes int64
|
||||
if err := r.db.Model(&models.AuthorizationCode{}).
|
||||
Where("customer_id = ? AND end_date >= NOW() AND end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)", customerID).
|
||||
Count(&expiringSoonAuthCodes).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count expiring soon auth codes: %w", err)
|
||||
}
|
||||
stats.ExpiringSoonAuthCodes = expiringSoonAuthCodes
|
||||
|
||||
// 2. 许可证统计
|
||||
// 总许可证数量
|
||||
var totalLicenses int64
|
||||
if err := r.db.Model(&models.License{}).
|
||||
Where("customer_id = ? AND deleted_at IS NULL", customerID).
|
||||
Count(&totalLicenses).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count total licenses: %w", err)
|
||||
}
|
||||
stats.TotalLicenses = totalLicenses
|
||||
|
||||
// 已激活许可证数量(status = 'active')
|
||||
var activeLicenses int64
|
||||
if err := r.db.Model(&models.License{}).
|
||||
Where("customer_id = ? AND status = 'active' AND deleted_at IS NULL", customerID).
|
||||
Count(&activeLicenses).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count active licenses: %w", err)
|
||||
}
|
||||
stats.ActiveLicenses = activeLicenses
|
||||
|
||||
// 未激活许可证数量(total - active)
|
||||
stats.InactiveLicenses = totalLicenses - activeLicenses
|
||||
|
||||
// 已过期许可证数量(关联的授权码已过期)
|
||||
// 需要通过JOIN authorization_codes表来判断
|
||||
var expiredLicenses int64
|
||||
if err := r.db.Model(&models.License{}).
|
||||
Joins("INNER JOIN authorization_codes ON licenses.authorization_code_id = authorization_codes.id").
|
||||
Where("licenses.customer_id = ? AND authorization_codes.end_date < NOW() AND licenses.deleted_at IS NULL", customerID).
|
||||
Count(&expiredLicenses).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count expired licenses: %w", err)
|
||||
}
|
||||
stats.ExpiredLicenses = expiredLicenses
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -20,11 +20,20 @@ type CustomerRepository interface {
|
||||
// UpdateCustomer 更新客户信息
|
||||
UpdateCustomer(ctx context.Context, customer *models.Customer) error
|
||||
|
||||
// DeleteCustomer 删除客户
|
||||
// DeleteCustomer 删除客户(物理删除)
|
||||
DeleteCustomer(ctx context.Context, id string) error
|
||||
|
||||
// GetCustomerCount 获取客户总数(用于统计)
|
||||
GetCustomerCount(ctx context.Context, filters map[string]interface{}) (int64, error)
|
||||
|
||||
// CheckCustomerHasAuthorizationCodes 检查客户是否有关联的授权码
|
||||
CheckCustomerHasAuthorizationCodes(ctx context.Context, customerID string) (bool, error)
|
||||
|
||||
// CheckCustomerHasLicenses 检查客户是否有关联的许可证
|
||||
CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error)
|
||||
|
||||
// GetCustomerAuthorizationStats 获取客户授权统计信息
|
||||
GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error)
|
||||
}
|
||||
|
||||
// UserRepository 用户数据访问接口
|
||||
|
||||
@@ -44,13 +44,18 @@ func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context,
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证客户是否存在
|
||||
exists, err := s.authCodeRepo.CheckCustomerExists(ctx, req.CustomerID)
|
||||
// 验证客户是否存在并检查状态
|
||||
customer, err := s.customerRepo.GetCustomerByID(ctx, req.CustomerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return nil, i18n.NewI18nError("200001", lang) // 客户不存在
|
||||
}
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if !exists {
|
||||
return nil, i18n.NewI18nError("200001", lang) // 客户不存在
|
||||
|
||||
// 检查客户状态:如果客户状态为停用(disabled),不允许创建授权
|
||||
if customer.Status == "disabled" {
|
||||
return nil, i18n.NewI18nError("200007", lang) // 客户已停用,无法创建授权
|
||||
}
|
||||
|
||||
// 业务逻辑:计算开始时间和结束时间
|
||||
@@ -548,6 +553,8 @@ func (s *authorizationCodeService) fillAuthCodeDisplayFields(item *models.Author
|
||||
}
|
||||
|
||||
// generateAuthorizationCode 生成授权码
|
||||
// 格式: LIC-{4位客户代码}-{12位随机}-{4位校验码}
|
||||
// 安全性:12位随机字符(62字符集)提供约 3.23 × 10^21 种可能组合
|
||||
func (s *authorizationCodeService) generateAuthorizationCode(customerID string) (string, error) {
|
||||
// 获取客户编码的前4位作为前缀
|
||||
customerCode := "COMP" // 默认前缀
|
||||
@@ -555,8 +562,8 @@ func (s *authorizationCodeService) generateAuthorizationCode(customerID string)
|
||||
customerCode = strings.ToUpper(customerID[:4])
|
||||
}
|
||||
|
||||
// 生成8位随机字符串
|
||||
randomStr, err := s.generateRandomString(8)
|
||||
// 生成12位随机字符串(从8位增加到12位以提升安全性)
|
||||
randomStr, err := s.generateRandomString(12)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -569,8 +576,10 @@ func (s *authorizationCodeService) generateAuthorizationCode(customerID string)
|
||||
}
|
||||
|
||||
// generateRandomString 生成随机字符串
|
||||
// 使用62字符集(A-Z, a-z, 0-9)提供更高的熵值
|
||||
func (s *authorizationCodeService) generateRandomString(length int) (string, error) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
// 扩展字符集到62字符(A-Z, a-z, 0-9)以提升安全性
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
@@ -582,12 +591,71 @@ func (s *authorizationCodeService) generateRandomString(length int) (string, err
|
||||
}
|
||||
|
||||
// generateChecksum 生成校验码
|
||||
// 基于输入字符串的ASCII值计算,用于格式验证
|
||||
func (s *authorizationCodeService) generateChecksum(input string) string {
|
||||
sum := 0
|
||||
for _, char := range input {
|
||||
sum += int(char)
|
||||
}
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
// 使用与随机字符串相同的62字符集以保持一致性
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
checksum := ""
|
||||
for i := 0; i < 4; i++ {
|
||||
checksum += string(chars[sum%len(chars)])
|
||||
sum = sum / len(chars)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
// ValidateAuthorizationCodeFormat 验证授权码格式和校验和
|
||||
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
|
||||
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
|
||||
func (s *authorizationCodeService) ValidateAuthorizationCodeFormat(code string) bool {
|
||||
// 解析格式
|
||||
parts := strings.Split(code, "-")
|
||||
if len(parts) != 4 || parts[0] != "LIC" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证各部分长度(兼容旧格式8位和新格式12位)
|
||||
customerCodeLen := len(parts[1])
|
||||
randomLen := len(parts[2])
|
||||
checksumLen := len(parts[3])
|
||||
|
||||
// 客户代码必须为4位,校验和必须为4位
|
||||
if customerCodeLen != 4 || checksumLen != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 随机部分:兼容8位(旧格式)和12位(新格式)
|
||||
if randomLen != 8 && randomLen != 12 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据随机部分长度选择字符集验证校验和
|
||||
// 8位:使用36字符集(旧格式)
|
||||
// 12位:使用62字符集(新格式)
|
||||
expectedChecksum := s.generateChecksumWithCharset(parts[1]+parts[2], randomLen == 12)
|
||||
return expectedChecksum == parts[3]
|
||||
}
|
||||
|
||||
// generateChecksumWithCharset 生成校验码(支持指定字符集)
|
||||
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
|
||||
func (s *authorizationCodeService) generateChecksumWithCharset(input string, useExtendedCharset bool) string {
|
||||
sum := 0
|
||||
for _, char := range input {
|
||||
sum += int(char)
|
||||
}
|
||||
|
||||
var chars string
|
||||
if useExtendedCharset {
|
||||
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
} else {
|
||||
// 使用36字符集(A-Z, 0-9)用于旧格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
|
||||
checksum := ""
|
||||
for i := 0; i < 4; i++ {
|
||||
checksum += string(chars[sum%len(chars)])
|
||||
|
||||
@@ -72,6 +72,16 @@ func (s *customerService) GetCustomer(ctx context.Context, id string) (*models.C
|
||||
return nil, i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 获取授权统计信息
|
||||
stats, err := s.customerRepo.GetCustomerAuthorizationStats(ctx, id)
|
||||
if err != nil {
|
||||
// 统计信息获取失败不影响主流程,记录错误但继续执行
|
||||
// 如果获取失败,stats 为 nil,客户详情仍然可以返回,只是没有统计信息
|
||||
// 根据需求文档,空数据时应该返回0,所以这里创建一个空的统计对象
|
||||
stats = &models.AuthorizationStats{}
|
||||
}
|
||||
customer.AuthorizationStats = stats
|
||||
|
||||
// 填充多语言显示字段
|
||||
s.fillCustomerDisplayFields(customer, lang)
|
||||
|
||||
@@ -206,7 +216,7 @@ func (s *customerService) UpdateCustomer(ctx context.Context, id string, req *mo
|
||||
return existingCustomer, nil
|
||||
}
|
||||
|
||||
// DeleteCustomer 删除客户
|
||||
// DeleteCustomer 删除客户(物理删除,删除前检查是否有授权)
|
||||
func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
|
||||
@@ -215,7 +225,34 @@ func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
|
||||
return i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行数据删除
|
||||
// 检查客户是否存在
|
||||
_, err := s.customerRepo.GetCustomerByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return i18n.NewI18nError("200001", lang)
|
||||
}
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
|
||||
// 检查是否有关联的授权码
|
||||
hasAuthCodes, err := s.customerRepo.CheckCustomerHasAuthorizationCodes(ctx, id)
|
||||
if err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if hasAuthCodes {
|
||||
return i18n.NewI18nError("200006", lang)
|
||||
}
|
||||
|
||||
// 检查是否有关联的许可证
|
||||
hasLicenses, err := s.customerRepo.CheckCustomerHasLicenses(ctx, id)
|
||||
if err != nil {
|
||||
return i18n.NewI18nError("900004", lang, err.Error())
|
||||
}
|
||||
if hasLicenses {
|
||||
return i18n.NewI18nError("200006", lang)
|
||||
}
|
||||
|
||||
// 委托给Repository层进行物理删除
|
||||
if err := s.customerRepo.DeleteCustomer(ctx, id); err != nil {
|
||||
if errors.Is(err, repository.ErrCustomerNotFound) {
|
||||
return i18n.NewI18nError("200001", lang)
|
||||
|
||||
@@ -2,8 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
@@ -20,12 +18,14 @@ import (
|
||||
"license-manager/internal/repository"
|
||||
pkgcontext "license-manager/pkg/context"
|
||||
"license-manager/pkg/i18n"
|
||||
"license-manager/pkg/utils"
|
||||
)
|
||||
|
||||
type licenseService struct {
|
||||
licenseRepo repository.LicenseRepository
|
||||
db *gorm.DB
|
||||
logger *logrus.Logger
|
||||
rsaPrivateKey *utils.RSAPrivateKey // RSA私钥(缓存)
|
||||
}
|
||||
|
||||
// NewLicenseService 创建许可证服务实例
|
||||
@@ -327,7 +327,6 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
|
||||
licenseFileData["start_date"] = license.AuthorizationCode.StartDate
|
||||
licenseFileData["end_date"] = license.AuthorizationCode.EndDate
|
||||
licenseFileData["deployment_type"] = license.AuthorizationCode.DeploymentType
|
||||
licenseFileData["encryption_type"] = license.AuthorizationCode.EncryptionType
|
||||
licenseFileData["max_activations"] = license.AuthorizationCode.MaxActivations
|
||||
|
||||
// 包含功能配置
|
||||
@@ -361,8 +360,8 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]
|
||||
return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
|
||||
}
|
||||
|
||||
// 加密许可证文件
|
||||
encryptedData, err := s.encryptLicenseFile(licenseJSON)
|
||||
// 使用RSA数字签名
|
||||
encryptedData, err := s.signLicenseFile(licenseJSON)
|
||||
if err != nil {
|
||||
return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
|
||||
}
|
||||
@@ -386,6 +385,64 @@ func (s *licenseService) generateLicenseKey() (string, error) {
|
||||
return fmt.Sprintf("LIC-DEVICE-%s", hex), nil
|
||||
}
|
||||
|
||||
// validateAuthorizationCodeFormat 验证授权码格式和校验和
|
||||
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
|
||||
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
|
||||
func validateAuthorizationCodeFormat(code string) bool {
|
||||
// 解析格式
|
||||
parts := strings.Split(code, "-")
|
||||
if len(parts) != 4 || parts[0] != "LIC" {
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证各部分长度(兼容旧格式8位和新格式12位)
|
||||
customerCodeLen := len(parts[1])
|
||||
randomLen := len(parts[2])
|
||||
checksumLen := len(parts[3])
|
||||
|
||||
// 客户代码必须为4位,校验和必须为4位
|
||||
if customerCodeLen != 4 || checksumLen != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 随机部分:兼容8位(旧格式)和12位(新格式)
|
||||
if randomLen != 8 && randomLen != 12 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 根据随机部分长度选择字符集验证校验和
|
||||
// 8位:使用36字符集(旧格式)
|
||||
// 12位:使用62字符集(新格式)
|
||||
expectedChecksum := generateChecksumForCode(parts[1]+parts[2], randomLen == 12)
|
||||
return expectedChecksum == parts[3]
|
||||
}
|
||||
|
||||
// generateChecksumForCode 生成校验码(用于验证)
|
||||
// 基于输入字符串的ASCII值计算
|
||||
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
|
||||
func generateChecksumForCode(input string, useExtendedCharset bool) string {
|
||||
sum := 0
|
||||
for _, char := range input {
|
||||
sum += int(char)
|
||||
}
|
||||
|
||||
var chars string
|
||||
if useExtendedCharset {
|
||||
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
} else {
|
||||
// 使用36字符集(A-Z, 0-9)用于旧格式
|
||||
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
}
|
||||
|
||||
checksum := ""
|
||||
for i := 0; i < 4; i++ {
|
||||
checksum += string(chars[sum%len(chars)])
|
||||
sum = sum / len(chars)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
// ActivateLicense 激活许可证
|
||||
func (s *licenseService) ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error) {
|
||||
lang := pkgcontext.GetLanguageFromContext(ctx)
|
||||
@@ -395,6 +452,11 @@ func (s *licenseService) ActivateLicense(ctx context.Context, req *models.Activa
|
||||
return nil, i18n.NewI18nError("900001", lang)
|
||||
}
|
||||
|
||||
// 验证授权码格式(在查询数据库前先验证格式,避免无效查询)
|
||||
if !validateAuthorizationCodeFormat(req.AuthorizationCode) {
|
||||
return nil, i18n.NewI18nError("300001", lang) // 授权码格式无效
|
||||
}
|
||||
|
||||
// 获取授权码信息
|
||||
authCode, err := s.licenseRepo.GetAuthorizationCodeByCode(ctx, req.AuthorizationCode)
|
||||
if err != nil {
|
||||
@@ -601,7 +663,6 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut
|
||||
licenseFileData["start_date"] = authCode.StartDate
|
||||
licenseFileData["end_date"] = authCode.EndDate
|
||||
licenseFileData["deployment_type"] = authCode.DeploymentType
|
||||
licenseFileData["encryption_type"] = authCode.EncryptionType
|
||||
licenseFileData["max_activations"] = authCode.MaxActivations
|
||||
|
||||
// 包含功能配置等
|
||||
@@ -627,13 +688,14 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化并加密
|
||||
// 序列化
|
||||
licenseJSON, err := json.Marshal(licenseFileData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encryptedData, err := s.encryptLicenseFile(licenseJSON)
|
||||
// 使用RSA数字签名
|
||||
encryptedData, err := s.signLicenseFile(licenseJSON)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -641,41 +703,60 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut
|
||||
return string(encryptedData), nil
|
||||
}
|
||||
|
||||
// encryptLicenseFile 加密许可证文件
|
||||
func (s *licenseService) encryptLicenseFile(data []byte) ([]byte, error) {
|
||||
// 从配置文件获取加密密钥
|
||||
// loadRSAPrivateKey 加载RSA私钥(懒加载)
|
||||
func (s *licenseService) loadRSAPrivateKey() (*utils.RSAPrivateKey, error) {
|
||||
if s.rsaPrivateKey != nil {
|
||||
return s.rsaPrivateKey, nil
|
||||
}
|
||||
|
||||
cfg := config.GetConfig()
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("configuration not initialized")
|
||||
}
|
||||
|
||||
key := []byte(cfg.License.EncryptionKey)
|
||||
if len(key) != 16 && len(key) != 24 && len(key) != 32 {
|
||||
return nil, fmt.Errorf("invalid encryption key length: %d bytes, must be 16, 24, or 32 bytes", len(key))
|
||||
privateKeyPath := cfg.License.RSA.PrivateKeyPath
|
||||
if privateKeyPath == "" {
|
||||
return nil, fmt.Errorf("RSA private key path not configured")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
key, err := utils.LoadRSAPrivateKeyFromFile(privateKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
|
||||
return nil, fmt.Errorf("加载RSA私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 使用GCM模式进行加密
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
s.rsaPrivateKey = key
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// signLicenseFile 使用RSA数字签名许可证文件
|
||||
func (s *licenseService) signLicenseFile(data []byte) ([]byte, error) {
|
||||
// 加载RSA私钥
|
||||
privateKey, err := s.loadRSAPrivateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("加载RSA私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 生成随机nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
// 对数据进行数字签名
|
||||
signature, err := privateKey.SignData(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 加密数据
|
||||
ciphertext := gcm.Seal(nonce, nonce, data, nil)
|
||||
// 构建签名后的许可证文件结构
|
||||
licenseWithSignature := map[string]interface{}{
|
||||
"data": string(data), // 原始数据(JSON字符串)
|
||||
"signature": signature, // 数字签名
|
||||
"algorithm": "RSA-PSS-SHA256",
|
||||
}
|
||||
|
||||
// 序列化为JSON
|
||||
licenseJSON, err := json.Marshal(licenseWithSignature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化失败: %w", err)
|
||||
}
|
||||
|
||||
// 使用base64编码
|
||||
encoded := base64.StdEncoding.EncodeToString(ciphertext)
|
||||
encoded := base64.StdEncoding.EncodeToString(licenseJSON)
|
||||
|
||||
return []byte(encoded), nil
|
||||
}
|
||||
@@ -722,32 +803,32 @@ func (s *licenseService) GetStatsOverview(ctx context.Context) (*models.StatsOve
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 计算上月的授权码和许可证数量(用于计算增长率)
|
||||
var lastMonthAuthCodes, lastMonthLicenses int64
|
||||
// 5. 计算同比上月的授权码和许可证数量(用于计算增长率)
|
||||
var lastMonthAuthCodes, lastMonthActiveLicenses int64
|
||||
|
||||
// 上月授权码数量
|
||||
// 上月同期授权码总数(一个月前同一时刻的累计总数)
|
||||
if err := tx.Model(&models.AuthorizationCode{}).
|
||||
Where("created_at <= ?", lastMonth).
|
||||
Count(&lastMonthAuthCodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 上月许可证数量
|
||||
// 上月同期活跃许可证总数(一个月前同一时刻的状态为active的许可证数)
|
||||
if err := tx.Model(&models.License{}).
|
||||
Where("created_at <= ?", lastMonth).
|
||||
Count(&lastMonthLicenses).Error; err != nil {
|
||||
Where("status = ? AND created_at <= ?", "active", lastMonth).
|
||||
Count(&lastMonthActiveLicenses).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 计算增长率
|
||||
// 计算增长率(同比上月:当前总数相比一个月前总数的增长率)
|
||||
if lastMonthAuthCodes > 0 {
|
||||
stats.GrowthRate.AuthCodes = float64(stats.TotalAuthCodes-lastMonthAuthCodes) / float64(lastMonthAuthCodes) * 100
|
||||
} else {
|
||||
stats.GrowthRate.AuthCodes = 0
|
||||
}
|
||||
|
||||
if lastMonthLicenses > 0 {
|
||||
stats.GrowthRate.Licenses = float64(stats.ActiveLicenses-lastMonthLicenses) / float64(lastMonthLicenses) * 100
|
||||
if lastMonthActiveLicenses > 0 {
|
||||
stats.GrowthRate.Licenses = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
|
||||
} else {
|
||||
stats.GrowthRate.Licenses = 0
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ CREATE TABLE customers (
|
||||
company_size VARCHAR(20) COMMENT '企业规模: small, medium, large, enterprise',
|
||||
|
||||
-- 客户分级
|
||||
customer_level VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT '客户等级: normal, vip, enterprise, strategic',
|
||||
customer_level VARCHAR(20) NOT NULL DEFAULT 'basic' COMMENT '客户等级: basic, active, vip, strategic',
|
||||
|
||||
-- 状态管理
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active, disabled',
|
||||
|
||||
@@ -108,7 +108,7 @@ func getHTTPStatusByCode(code string) int {
|
||||
return StatusForbidden
|
||||
case "900002", "200001", "300001": // 资源不存在
|
||||
return StatusNotFound
|
||||
case "900003", "200002", "300004": // 资源冲突
|
||||
case "900003", "200002", "200006", "300004": // 资源冲突
|
||||
return StatusConflict
|
||||
case "900004": // 服务器内部错误
|
||||
return StatusInternalServerError
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -15,3 +26,170 @@ func CheckPasswordHash(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// RSAPrivateKey RSA私钥结构
|
||||
type RSAPrivateKey struct {
|
||||
*rsa.PrivateKey
|
||||
}
|
||||
|
||||
// RSAPublicKey RSA公钥结构
|
||||
type RSAPublicKey struct {
|
||||
*rsa.PublicKey
|
||||
}
|
||||
|
||||
// LoadRSAPrivateKeyFromFile 从文件加载RSA私钥
|
||||
func LoadRSAPrivateKeyFromFile(filePath string) (*RSAPrivateKey, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的私钥")
|
||||
}
|
||||
|
||||
var key *rsa.PrivateKey
|
||||
var errParse error
|
||||
|
||||
// 尝试PKCS1格式
|
||||
key, errParse = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if errParse != nil {
|
||||
// 尝试PKCS8格式
|
||||
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析私钥失败: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
key, ok = parsedKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA私钥")
|
||||
}
|
||||
}
|
||||
|
||||
return &RSAPrivateKey{PrivateKey: key}, nil
|
||||
}
|
||||
|
||||
// LoadRSAPublicKeyFromFile 从文件加载RSA公钥
|
||||
func LoadRSAPublicKeyFromFile(filePath string) (*RSAPublicKey, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取公钥文件失败: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的公钥")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA公钥")
|
||||
}
|
||||
|
||||
return &RSAPublicKey{PublicKey: rsaPub}, nil
|
||||
}
|
||||
|
||||
// LoadRSAPublicKeyFromString 从字符串加载RSA公钥(支持PEM格式字符串)
|
||||
func LoadRSAPublicKeyFromString(pubKeyStr string) (*RSAPublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(pubKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("无法解析PEM格式的公钥")
|
||||
}
|
||||
|
||||
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析公钥失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errors.New("不是有效的RSA公钥")
|
||||
}
|
||||
|
||||
return &RSAPublicKey{PublicKey: rsaPub}, nil
|
||||
}
|
||||
|
||||
// SignData 使用RSA私钥对数据进行数字签名
|
||||
func (key *RSAPrivateKey) SignData(data []byte) (string, error) {
|
||||
// 计算数据的SHA256哈希
|
||||
hashed := sha256.Sum256(data)
|
||||
|
||||
// 使用PSS模式进行签名(更安全)
|
||||
signature, err := rsa.SignPSS(rand.Reader, key.PrivateKey, crypto.SHA256, hashed[:], &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 返回base64编码的签名
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// VerifySignature 使用RSA公钥验证数字签名
|
||||
func (key *RSAPublicKey) VerifySignature(data []byte, signatureBase64 string) error {
|
||||
// 解码base64签名
|
||||
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解码签名失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算数据的SHA256哈希
|
||||
hashed := sha256.Sum256(data)
|
||||
|
||||
// 验证签名
|
||||
err = rsa.VerifyPSS(key.PublicKey, crypto.SHA256, hashed[:], signature, &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateRSAKeyPair 生成RSA密钥对并保存到文件
|
||||
func GenerateRSAKeyPair(privateKeyPath, publicKeyPath string, keySize int) error {
|
||||
if keySize < 2048 {
|
||||
keySize = 2048 // 最小2048位
|
||||
}
|
||||
|
||||
// 生成密钥对
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成RSA密钥对失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存私钥(PKCS1格式)
|
||||
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
})
|
||||
if err := os.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil {
|
||||
return fmt.Errorf("保存私钥失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存公钥
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化公钥失败: %w", err)
|
||||
}
|
||||
|
||||
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
})
|
||||
if err := os.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil {
|
||||
return fmt.Errorf("保存公钥失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export interface CustomerQueryRequest {
|
||||
search?: string;
|
||||
customer_name?: string;
|
||||
customer_type?: 'individual' | 'enterprise' | 'government' | 'education';
|
||||
customer_level?: 'normal' | 'vip' | 'enterprise' | 'strategic';
|
||||
customer_level?: string;
|
||||
status?: 'active' | 'disabled';
|
||||
sort?: 'created_at' | 'updated_at' | 'customer_name' | 'customer_code';
|
||||
order?: 'asc' | 'desc';
|
||||
@@ -59,9 +59,9 @@ export interface CustomerCreateRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
customer_level: 'normal' | 'vip' | 'enterprise' | 'strategic';
|
||||
customer_level: string;
|
||||
status: 'active' | 'disabled';
|
||||
company_size?: 'small' | 'medium' | 'large' | 'enterprise';
|
||||
company_size?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ export interface CustomerUpdateRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
customer_level?: 'normal' | 'vip' | 'enterprise' | 'strategic';
|
||||
customer_level?: string;
|
||||
status?: 'active' | 'disabled';
|
||||
company_size?: 'small' | 'medium' | 'large' | 'enterprise';
|
||||
company_size?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user