This commit is contained in:
13895237362 committed 2025-11-07 15:33:18 +08:00
commit 49e5952e1d
38 files changed
+2069 -200

No files matched your search

+121
View File
@@ -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
+682
View File
@@ -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
}
+134
View File
@@ -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-----
+30
View File
@@ -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")
}
+6 -1
View File
@@ -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天内到期算即将过期
+6 -1
View File
@@ -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天内到期算即将过期
+6 -1
View File
@@ -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天内到期算即将过期
+6 -1
View File
@@ -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天内到期算即将过期
+6 -4
View File
@@ -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"
+5 -3
View File
@@ -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": "アクティブ"
+5 -3
View File
@@ -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": "激活"
+27
View File
@@ -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-----
+9
View File
@@ -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. ✅ 查询性能良好,无明显的性能问题
+68
View File
@@ -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. **密钥分离**:公钥可公开,私钥仅服务器持有
+63 -40
View File
@@ -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"
}
}
+63 -40
View File
@@ -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"
}
}
+44 -32
View File
@@ -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:
+8
View File
@@ -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
+22
View File
@@ -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) {
+13 -2
View File
@@ -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)
+2 -2
View File
@@ -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"` // 软件版本
+20 -6
View File
@@ -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"` // 已过期许可证数量
}
+8 -8
View File
@@ -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
}
+10 -1
View File
@@ -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)])
+39 -2
View File
@@ -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)
+116 -35
View File
@@ -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',
+1 -1
View File
@@ -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
+178
View File
@@ -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
}
+5 -5
View File
@@ -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;
}