From e54dbcb1aee8560073b494fc5bddabaa57b5d242 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 14:11:38 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E5=88=A0=E9=99=A4=E5=89=8D=E7=9A=84=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E8=AE=B8=E5=8F=AF=E8=AF=81=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/configs/i18n/errors/en-US.yaml | 9 ++-- backend/configs/i18n/errors/ja-JP.yaml | 7 +-- backend/configs/i18n/errors/zh-CN.yaml | 7 +-- .../internal/api/handlers/customer_handler.go | 3 +- .../repository/customer_repository.go | 24 ++++++++++- backend/internal/repository/interfaces.go | 8 +++- backend/internal/service/customer_service.go | 43 +++++++++++++++---- backend/pkg/i18n/errors.go | 2 +- 8 files changed, 80 insertions(+), 23 deletions(-) diff --git a/backend/configs/i18n/errors/en-US.yaml b/backend/configs/i18n/errors/en-US.yaml index 9c5e27b..1832c86 100644 --- a/backend/configs/i18n/errors/en-US.yaml +++ b/backend/configs/i18n/errors/en-US.yaml @@ -21,6 +21,7 @@ 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" # Authorization module (30xxxx) authorization: @@ -72,10 +73,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" diff --git a/backend/configs/i18n/errors/ja-JP.yaml b/backend/configs/i18n/errors/ja-JP.yaml index 155d718..e9ae7ac 100644 --- a/backend/configs/i18n/errors/ja-JP.yaml +++ b/backend/configs/i18n/errors/ja-JP.yaml @@ -21,6 +21,7 @@ errors: "200003": "顧客名は必須です" "200004": "無効な顧客情報形式" "200005": "顧客リストの照会に失敗しました" + "200006": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください" # 認可モジュール (30xxxx) authorization: @@ -72,10 +73,10 @@ enums: expired: "期限切れ" customer_level: - "normal": "一般顧客" + "basic": "基本顧客" + "active": "アクティブ顧客" "vip": "VIP顧客" - "enterprise": "企業顧客" - "strategic": "戦略顧客" + "strategic": "戦略的パートナー" customer_status: "active": "アクティブ" diff --git a/backend/configs/i18n/errors/zh-CN.yaml b/backend/configs/i18n/errors/zh-CN.yaml index c3bda6d..69628ae 100644 --- a/backend/configs/i18n/errors/zh-CN.yaml +++ b/backend/configs/i18n/errors/zh-CN.yaml @@ -21,6 +21,7 @@ errors: "200003": "客户名称不能为空" "200004": "客户信息格式无效" "200005": "客户列表查询失败" + "200006": "客户仍有授权,无法删除,请先删除授权" # 授权模块 (30xxxx) authorization: @@ -72,10 +73,10 @@ enums: expired: "已过期" customer_level: - "normal": "普通客户" + "basic": "基础客户" + "active": "活跃客户" "vip": "VIP客户" - "enterprise": "企业客户" - "strategic": "战略客户" + "strategic": "战略合作客户" customer_status: "active": "激活" diff --git a/backend/internal/api/handlers/customer_handler.go b/backend/internal/api/handlers/customer_handler.go index cf53076..51bdad7 100644 --- a/backend/internal/api/handlers/customer_handler.go +++ b/backend/internal/api/handlers/customer_handler.go @@ -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) { diff --git a/backend/internal/repository/customer_repository.go b/backend/internal/repository/customer_repository.go index ab8bbbc..d835d7a 100644 --- a/backend/internal/repository/customer_repository.go +++ b/backend/internal/repository/customer_repository.go @@ -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 diff --git a/backend/internal/repository/interfaces.go b/backend/internal/repository/interfaces.go index 909430a..88eb6e8 100644 --- a/backend/internal/repository/interfaces.go +++ b/backend/internal/repository/interfaces.go @@ -20,11 +20,17 @@ 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) } // UserRepository 用户数据访问接口 diff --git a/backend/internal/service/customer_service.go b/backend/internal/service/customer_service.go index 9c44e9a..06d3624 100644 --- a/backend/internal/service/customer_service.go +++ b/backend/internal/service/customer_service.go @@ -88,7 +88,7 @@ func (s *customerService) fillDisplayFields(item *models.CustomerListItem, lang // CreateCustomer 创建客户 func (s *customerService) CreateCustomer(ctx context.Context, req *models.CustomerCreateRequest) (*models.Customer, error) { lang := pkgcontext.GetLanguageFromContext(ctx) - + // 业务逻辑:参数验证 if req == nil { return nil, i18n.NewI18nError("900001", lang) @@ -122,7 +122,7 @@ func (s *customerService) CreateCustomer(ctx context.Context, req *models.Custom if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { return nil, i18n.NewI18nError("200002", lang) // 客户已存在 } - + // 数据库相关错误 return nil, i18n.NewI18nError("900004", lang, err.Error()) } @@ -136,7 +136,7 @@ func (s *customerService) CreateCustomer(ctx context.Context, req *models.Custom // UpdateCustomer 更新客户信息 func (s *customerService) UpdateCustomer(ctx context.Context, id string, req *models.CustomerUpdateRequest) (*models.Customer, error) { lang := pkgcontext.GetLanguageFromContext(ctx) - + // 业务逻辑:参数验证 if id == "" { return nil, i18n.NewI18nError("900001", lang) @@ -191,7 +191,7 @@ func (s *customerService) UpdateCustomer(ctx context.Context, id string, req *mo if req.Description != nil { existingCustomer.Description = req.Description } - + // 设置更新者 existingCustomer.UpdatedBy = ¤tUserID @@ -206,16 +206,43 @@ 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) - + // 业务逻辑:参数验证 if id == "" { 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) @@ -229,7 +256,7 @@ func (s *customerService) DeleteCustomer(ctx context.Context, id string) error { // UpdateCustomerStatus 更新客户状态 func (s *customerService) UpdateCustomerStatus(ctx context.Context, id string, req *models.CustomerStatusUpdateRequest) (*models.Customer, error) { lang := pkgcontext.GetLanguageFromContext(ctx) - + // 业务逻辑:参数验证 if id == "" { return nil, i18n.NewI18nError("900001", lang) diff --git a/backend/pkg/i18n/errors.go b/backend/pkg/i18n/errors.go index 000a428..be5f1d7 100644 --- a/backend/pkg/i18n/errors.go +++ b/backend/pkg/i18n/errors.go @@ -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 From caa4506897685252af26506701502f02f12fb11a Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 16:57:39 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0RSA=E9=9D=9E?= =?UTF-8?q?=E5=AF=B9=E7=A7=B0=E5=8A=A0=E5=AF=86=E6=94=AF=E6=8C=81=E4=BB=A5?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E8=AE=B8=E5=8F=AF=E8=AF=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E9=85=8D=E7=BD=AE=E5=92=8C=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/client-demo/README.md | 121 ++++ backend/cmd/client-demo/client_config.json | 9 + .../cmd/client-demo/license_code/AUTH_CODE | 1 + backend/cmd/client-demo/license_code/LICENSE | 1 + backend/cmd/client-demo/main.go | 682 ++++++++++++++++++ backend/cmd/client-demo/rsa.go | 134 ++++ backend/cmd/client-demo/rsa_public_key.pem | 9 + backend/cmd/gen-rsa-keys/main.go | 30 + backend/configs/config.dev.yaml | 7 +- backend/configs/config.example.yaml | 7 +- backend/configs/config.prod.yaml | 7 +- backend/configs/config.yaml | 7 +- backend/configs/rsa_private_key.pem | 27 + backend/configs/rsa_public_key.pem | 9 + backend/docs/encryption_design.md | 68 ++ backend/go.mod | 8 + backend/go.sum | 22 + backend/internal/config/config.go | 21 +- backend/internal/service/license_service.go | 84 ++- backend/pkg/utils/crypto.go | 180 ++++- 20 files changed, 1391 insertions(+), 43 deletions(-) create mode 100644 backend/cmd/client-demo/README.md create mode 100644 backend/cmd/client-demo/client_config.json create mode 100644 backend/cmd/client-demo/license_code/AUTH_CODE create mode 100644 backend/cmd/client-demo/license_code/LICENSE create mode 100644 backend/cmd/client-demo/main.go create mode 100644 backend/cmd/client-demo/rsa.go create mode 100644 backend/cmd/client-demo/rsa_public_key.pem create mode 100644 backend/cmd/gen-rsa-keys/main.go create mode 100644 backend/configs/rsa_private_key.pem create mode 100644 backend/configs/rsa_public_key.pem create mode 100644 backend/docs/encryption_design.md diff --git a/backend/cmd/client-demo/README.md b/backend/cmd/client-demo/README.md new file mode 100644 index 0000000..ccb3f31 --- /dev/null +++ b/backend/cmd/client-demo/README.md @@ -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` 文件存在且包含有效的授权码 +- 查看服务器日志了解详细错误 + +**心跳失败** +- 检查网络连接 +- 确认许可证密钥和硬件指纹是否正确 + +**硬件指纹变更** +- 更换硬件需重新激活 +- 联系管理员释放旧的激活记录 diff --git a/backend/cmd/client-demo/client_config.json b/backend/cmd/client-demo/client_config.json new file mode 100644 index 0000000..4828abc --- /dev/null +++ b/backend/cmd/client-demo/client_config.json @@ -0,0 +1,9 @@ +{ + "server_url": "http://localhost:18888", + "authorization_code": "LIC-5359-QKHSAFPI-VWAA", + "software_version": "1.0.0", + "heartbeat_interval": 300, + "license_key": "LIC-DEVICE-72921EB189E53806B59FF0AE", + "config_updated_at": "2025-11-01T16:31:54+08:00", + "license_file": "eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTY6MTc6MDAuMjE2KzA4OjAwXCIsXCJhdXRob3JpemF0aW9uX2NvZGVcIjpcIkxJQy01MzU5LVFLSFNBRlBJLVZXQUFcIixcImF1dGhvcml6YXRpb25fY29kZV9pZFwiOlwiYmVmODliM2MtNDU1NS00ZjE5LTllMGUtMzM0MDI0MDNhZWFkXCIsXCJjb25maWdfdXBkYXRlZF9hdFwiOlwiMjAyNS0xMS0wMVQxNjoyNzoxOS43NzcrMDg6MDBcIixcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIyMDI1LTEyLTEzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE2OjMxOjU0KzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTcyOTIxRUIxODlFNTM4MDZCNTlGRjBBRVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJFc1ZMQlNSWE1NN0NteTRDeW5EQ0ZGbkw0ZjByTGlpekk1SDZnQ1V5bDFTZGdHR0JZTzdHNStKdTE2T1Jkb0wxSkJRcW5zaHU5RXh1SDVuY1VXM2ZIZFlQTElQR1VBTVhqMGZwS2tPNENCTyt2VjdlSEI2ZDhUbUlkUnE2MU1vdisvM0k4c3Btb1JUNjljbEVuZWFHWE5LbnZOdm5BQXArVWhHamtWK1phZU8wRXlCM2xDcEJkdWJUWkFiUVFic0pSZHY2ZUszSUFGUkdJVnYzWkJhOCtuMnRWQ3RPRXVhVmF1ZHNhRW5ucTZIQWdXdmdUSzViQ2dHZFJkWUJjdG9vVEpiYWtacE9nSWQvVGRmbThGN0krWENsbVl5bWdmYVdUcldKMXd0SzVpSndtV003bTFyTytvZVB3NjBMNjd4ejVmTzRaZjFPUVF2MVNxekhBV25SVFE9PSJ9" +} \ No newline at end of file diff --git a/backend/cmd/client-demo/license_code/AUTH_CODE b/backend/cmd/client-demo/license_code/AUTH_CODE new file mode 100644 index 0000000..f7de605 --- /dev/null +++ b/backend/cmd/client-demo/license_code/AUTH_CODE @@ -0,0 +1 @@ +LIC-5359-QKHSAFPI-VWAA \ No newline at end of file diff --git a/backend/cmd/client-demo/license_code/LICENSE b/backend/cmd/client-demo/license_code/LICENSE new file mode 100644 index 0000000..9eb9224 --- /dev/null +++ b/backend/cmd/client-demo/license_code/LICENSE @@ -0,0 +1 @@ +eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTY6MTc6MDAuMjE2KzA4OjAwXCIsXCJhdXRob3JpemF0aW9uX2NvZGVcIjpcIkxJQy01MzU5LVFLSFNBRlBJLVZXQUFcIixcImF1dGhvcml6YXRpb25fY29kZV9pZFwiOlwiYmVmODliM2MtNDU1NS00ZjE5LTllMGUtMzM0MDI0MDNhZWFkXCIsXCJjb25maWdfdXBkYXRlZF9hdFwiOlwiMjAyNS0xMS0wMVQxNjoyNzoxOS43NzcrMDg6MDBcIixcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIyMDI1LTEyLTEzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE2OjMxOjU0KzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTcyOTIxRUIxODlFNTM4MDZCNTlGRjBBRVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJFc1ZMQlNSWE1NN0NteTRDeW5EQ0ZGbkw0ZjByTGlpekk1SDZnQ1V5bDFTZGdHR0JZTzdHNStKdTE2T1Jkb0wxSkJRcW5zaHU5RXh1SDVuY1VXM2ZIZFlQTElQR1VBTVhqMGZwS2tPNENCTyt2VjdlSEI2ZDhUbUlkUnE2MU1vdisvM0k4c3Btb1JUNjljbEVuZWFHWE5LbnZOdm5BQXArVWhHamtWK1phZU8wRXlCM2xDcEJkdWJUWkFiUVFic0pSZHY2ZUszSUFGUkdJVnYzWkJhOCtuMnRWQ3RPRXVhVmF1ZHNhRW5ucTZIQWdXdmdUSzViQ2dHZFJkWUJjdG9vVEpiYWtacE9nSWQvVGRmbThGN0krWENsbVl5bWdmYVdUcldKMXd0SzVpSndtV003bTFyTytvZVB3NjBMNjd4ejVmTzRaZjFPUVF2MVNxekhBV25SVFE9PSJ9 \ No newline at end of file diff --git a/backend/cmd/client-demo/main.go b/backend/cmd/client-demo/main.go new file mode 100644 index 0000000..e500aba --- /dev/null +++ b/backend/cmd/client-demo/main.go @@ -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 +} diff --git a/backend/cmd/client-demo/rsa.go b/backend/cmd/client-demo/rsa.go new file mode 100644 index 0000000..243bd22 --- /dev/null +++ b/backend/cmd/client-demo/rsa.go @@ -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 +} diff --git a/backend/cmd/client-demo/rsa_public_key.pem b/backend/cmd/client-demo/rsa_public_key.pem new file mode 100644 index 0000000..8455471 --- /dev/null +++ b/backend/cmd/client-demo/rsa_public_key.pem @@ -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----- diff --git a/backend/cmd/gen-rsa-keys/main.go b/backend/cmd/gen-rsa-keys/main.go new file mode 100644 index 0000000..987f083 --- /dev/null +++ b/backend/cmd/gen-rsa-keys/main.go @@ -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") +} diff --git a/backend/configs/config.dev.yaml b/backend/configs/config.dev.yaml index d0727f0..3e6e9ee 100644 --- a/backend/configs/config.dev.yaml +++ b/backend/configs/config.dev.yaml @@ -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天内到期算即将过期 diff --git a/backend/configs/config.example.yaml b/backend/configs/config.example.yaml index 2ea7416..7b7b568 100644 --- a/backend/configs/config.example.yaml +++ b/backend/configs/config.example.yaml @@ -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天内到期算即将过期 \ No newline at end of file diff --git a/backend/configs/config.prod.yaml b/backend/configs/config.prod.yaml index 8cd3ff0..76e80db 100644 --- a/backend/configs/config.prod.yaml +++ b/backend/configs/config.prod.yaml @@ -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天内到期算即将过期 diff --git a/backend/configs/config.yaml b/backend/configs/config.yaml index 5c25f0a..e855879 100644 --- a/backend/configs/config.yaml +++ b/backend/configs/config.yaml @@ -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天内到期算即将过期 \ No newline at end of file diff --git a/backend/configs/rsa_private_key.pem b/backend/configs/rsa_private_key.pem new file mode 100644 index 0000000..4c76cef --- /dev/null +++ b/backend/configs/rsa_private_key.pem @@ -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----- diff --git a/backend/configs/rsa_public_key.pem b/backend/configs/rsa_public_key.pem new file mode 100644 index 0000000..8455471 --- /dev/null +++ b/backend/configs/rsa_public_key.pem @@ -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----- diff --git a/backend/docs/encryption_design.md b/backend/docs/encryption_design.md new file mode 100644 index 0000000..6e153b0 --- /dev/null +++ b/backend/docs/encryption_design.md @@ -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. **密钥分离**:公钥可公开,私钥仅服务器持有 diff --git a/backend/go.mod b/backend/go.mod index 5a89008..3b88b3c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 75c2bcd..b2c6324 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 8070d2f..ff29383 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -67,10 +67,18 @@ type I18nConfig struct { } type LicenseConfig struct { - EncryptionKey string `mapstructure:"encryption_key"` // 许可证文件加密密钥 - HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时时间(秒) - OfflineTimeout int `mapstructure:"offline_timeout"` // 离线超时时间(分钟) - ExpiringDays int `mapstructure:"expiring_days"` // 即将过期天数 + // 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 @@ -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) diff --git a/backend/internal/service/license_service.go b/backend/internal/service/license_service.go index bc9f4e8..0edc6e0 100644 --- a/backend/internal/service/license_service.go +++ b/backend/internal/service/license_service.go @@ -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 + 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) // 许可证文件生成失败 } @@ -601,7 +600,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 +625,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,42 +640,61 @@ 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) + } + + // 构建签名后的许可证文件结构 + 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) } - // 加密数据 - ciphertext := gcm.Seal(nonce, nonce, data, nil) - // 使用base64编码 - encoded := base64.StdEncoding.EncodeToString(ciphertext) - + encoded := base64.StdEncoding.EncodeToString(licenseJSON) + return []byte(encoded), nil } diff --git a/backend/pkg/utils/crypto.go b/backend/pkg/utils/crypto.go index 6588316..af23893 100644 --- a/backend/pkg/utils/crypto.go +++ b/backend/pkg/utils/crypto.go @@ -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" ) @@ -14,4 +25,171 @@ func HashPassword(password string) (string, error) { func CheckPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil -} \ No newline at end of file +} + +// 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 +} From 92765441e3637e735e68a323d27178649faf8f81 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 17:12:35 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=A0=81=E7=94=9F=E6=88=90=E5=92=8C=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E6=96=B0=E6=97=A7?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=85=BC=E5=AE=B9=EF=BC=8C=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/authorization_code_service.go | 71 +++++++++++++++++-- backend/internal/service/license_service.go | 63 ++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/authorization_code_service.go b/backend/internal/service/authorization_code_service.go index 1a28a4c..8afe0f3 100644 --- a/backend/internal/service/authorization_code_service.go +++ b/backend/internal/service/authorization_code_service.go @@ -548,6 +548,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 +557,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 +571,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 +586,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)]) diff --git a/backend/internal/service/license_service.go b/backend/internal/service/license_service.go index 0edc6e0..8762db4 100644 --- a/backend/internal/service/license_service.go +++ b/backend/internal/service/license_service.go @@ -385,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) @@ -394,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 { From 478ca96d90db2711292176126ebe0d5b3617f18d Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 17:20:21 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=A0=81=E6=9C=89=E6=95=88=E5=A4=A9=E6=95=B0=E9=99=90=E5=88=B6?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9C=80=E5=A4=A7=E6=9C=89=E6=95=88?= =?UTF-8?q?=E5=A4=A9=E6=95=B0=E4=B8=BA365000=E5=A4=A9=EF=BC=8C=E7=A1=AE?= =?UTF-8?q?=E4=BF=9D=E6=9B=B4=E7=81=B5=E6=B4=BB=E7=9A=84=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/models/auth.go | 64 ++++++++++++++++----------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index 309a3fe..ddd9ac8 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -35,36 +35,36 @@ type UserInfo struct { // AuthorizationCode 授权码模型 type AuthorizationCode struct { - ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 授权码ID - Code string `gorm:"type:varchar(100);uniqueIndex;not null" json:"code"` // 授权码 - CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"` // 客户ID - CustomerName string `gorm:"-" json:"customer_name,omitempty"` // 客户名称 - CustomerNameDisplay string `gorm:"-" json:"customer_name_display,omitempty"` // 客户名称显示(多语言) - CreatedBy string `gorm:"type:varchar(36);not null" json:"created_by"` // 创建人ID - SoftwareID *string `gorm:"type:varchar(50)" json:"software_id"` // 软件ID - Description *string `gorm:"type:text" json:"description"` // 描述 - StartDate time.Time `gorm:"type:datetime(3);not null" json:"start_date"` // 生效日期 - EndDate time.Time `gorm:"type:datetime(3);not null" json:"end_date"` // 失效日期 - DeploymentType string `gorm:"type:varchar(20);not null;default:'standalone'" json:"deployment_type"` // 部署类型:standalone/cloud/hybrid - DeploymentTypeDisplay string `gorm:"-" json:"deployment_type_display,omitempty"` // 部署类型显示(多语言) - EncryptionType *string `gorm:"type:varchar(20);default:'standard'" json:"encryption_type"` // 加密类型:standard/advanced - EncryptionTypeDisplay string `gorm:"-" json:"encryption_type_display,omitempty"` // 加密类型显示(多语言) - SoftwareVersion *string `gorm:"type:varchar(50)" json:"software_version"` // 软件版本 - MaxActivations int `gorm:"not null;default:1" json:"max_activations"` // 最大激活次数 - CurrentActivations int `gorm:"-" json:"current_activations,omitempty"` // 当前激活次数 - IsLocked bool `gorm:"not null;default:false" json:"is_locked"` // 是否锁定 - LockReason *string `gorm:"type:text" json:"lock_reason"` // 锁定原因 - LockedAt *time.Time `gorm:"type:datetime(3)" json:"locked_at"` // 锁定时间 - LockedBy *string `gorm:"type:varchar(36)" json:"locked_by"` // 锁定人ID - FeatureConfig JSON `gorm:"type:json" json:"feature_config" swaggertype:"object"` // 功能配置(JSON对象) - UsageLimits JSON `gorm:"type:json" json:"usage_limits" swaggertype:"object"` // 使用限制(JSON对象) - CustomParameters JSON `gorm:"type:json" json:"custom_parameters" swaggertype:"object"` // 自定义参数(JSON对象) - Status string `gorm:"-" json:"status,omitempty"` // 状态:normal/locked/expired - StatusDisplay string `gorm:"-" json:"status_display,omitempty"` // 状态显示(多语言) - CreatedAt time.Time `gorm:"type:datetime(3);not null" json:"created_at"` // 创建时间 - UpdatedAt time.Time `gorm:"type:datetime(3);not null" json:"updated_at"` // 更新时间 - CustomerInfo *CustomerInfoForAuthCode `gorm:"-" json:"customer_info,omitempty"` // 客户信息(仅在详情接口返回) - ActivatedLicensesCount int64 `gorm:"-" json:"activated_licenses_count,omitempty"` // 该授权码下已激活的许可证数量 + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 授权码ID + Code string `gorm:"type:varchar(100);uniqueIndex;not null" json:"code"` // 授权码 + CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"` // 客户ID + CustomerName string `gorm:"-" json:"customer_name,omitempty"` // 客户名称 + CustomerNameDisplay string `gorm:"-" json:"customer_name_display,omitempty"` // 客户名称显示(多语言) + CreatedBy string `gorm:"type:varchar(36);not null" json:"created_by"` // 创建人ID + SoftwareID *string `gorm:"type:varchar(50)" json:"software_id"` // 软件ID + Description *string `gorm:"type:text" json:"description"` // 描述 + StartDate time.Time `gorm:"type:datetime(3);not null" json:"start_date"` // 生效日期 + EndDate time.Time `gorm:"type:datetime(3);not null" json:"end_date"` // 失效日期 + DeploymentType string `gorm:"type:varchar(20);not null;default:'standalone'" json:"deployment_type"` // 部署类型:standalone/cloud/hybrid + DeploymentTypeDisplay string `gorm:"-" json:"deployment_type_display,omitempty"` // 部署类型显示(多语言) + EncryptionType *string `gorm:"type:varchar(20);default:'standard'" json:"encryption_type"` // 加密类型:standard/advanced + EncryptionTypeDisplay string `gorm:"-" json:"encryption_type_display,omitempty"` // 加密类型显示(多语言) + SoftwareVersion *string `gorm:"type:varchar(50)" json:"software_version"` // 软件版本 + MaxActivations int `gorm:"not null;default:1" json:"max_activations"` // 最大激活次数 + CurrentActivations int `gorm:"-" json:"current_activations,omitempty"` // 当前激活次数 + IsLocked bool `gorm:"not null;default:false" json:"is_locked"` // 是否锁定 + LockReason *string `gorm:"type:text" json:"lock_reason"` // 锁定原因 + LockedAt *time.Time `gorm:"type:datetime(3)" json:"locked_at"` // 锁定时间 + LockedBy *string `gorm:"type:varchar(36)" json:"locked_by"` // 锁定人ID + FeatureConfig JSON `gorm:"type:json" json:"feature_config" swaggertype:"object"` // 功能配置(JSON对象) + UsageLimits JSON `gorm:"type:json" json:"usage_limits" swaggertype:"object"` // 使用限制(JSON对象) + CustomParameters JSON `gorm:"type:json" json:"custom_parameters" swaggertype:"object"` // 自定义参数(JSON对象) + Status string `gorm:"-" json:"status,omitempty"` // 状态:normal/locked/expired + StatusDisplay string `gorm:"-" json:"status_display,omitempty"` // 状态显示(多语言) + CreatedAt time.Time `gorm:"type:datetime(3);not null" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"type:datetime(3);not null" json:"updated_at"` // 更新时间 + CustomerInfo *CustomerInfoForAuthCode `gorm:"-" json:"customer_info,omitempty"` // 客户信息(仅在详情接口返回) + ActivatedLicensesCount int64 `gorm:"-" json:"activated_licenses_count,omitempty"` // 该授权码下已激活的许可证数量 } // CustomerInfoForAuthCode 授权码详情中的客户信息结构 @@ -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"` // 软件版本 From 1d62dc4e95cf26c9045f0694d74cede6f60d5609 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 17:31:29 +0800 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=96=B0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AD=89=E7=BA=A7=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=9F=BA=E6=9C=AC=E5=92=8C=E6=B4=BB=E8=B7=83?= =?UTF-8?q?=E7=AD=89=E7=BA=A7=EF=BC=8C=E8=B0=83=E6=95=B4=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E8=AF=B7=E6=B1=82=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/swagger/swagger.yaml | 8 +-- backend/internal/models/customer.go | 60 +++++++++---------- .../migrations/001_create_customers_table.sql | 2 +- frontend/src/api/customer.ts | 6 +- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index b7d2b4e..999de0c 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -450,9 +450,9 @@ definitions: customer_level: description: 客户等级,必填 enum: - - normal + - basic + - active - vip - - enterprise - strategic type: string customer_name: @@ -597,9 +597,9 @@ definitions: customer_level: description: 客户等级,可选 enum: - - normal + - basic + - active - vip - - enterprise - strategic type: string customer_name: diff --git a/backend/internal/models/customer.go b/backend/internal/models/customer.go index f82ab67..cabe0eb 100644 --- a/backend/internal/models/customer.go +++ b/backend/internal/models/customer.go @@ -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"` @@ -51,7 +51,7 @@ func (c *Customer) BeforeCreate(tx *gorm.DB) error { if c.ID == "" { c.ID = uuid.New().String() } - + // 设置时间戳 now := time.Now() if c.CreatedAt.IsZero() { @@ -77,14 +77,14 @@ func (CustomerCodeSequence) TableName() string { // CustomerListRequest 客户列表查询请求结构 type CustomerListRequest struct { - Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1 - 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"` // 客户等级筛选 - 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 + Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1 + 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=basic active vip strategic"` // 客户等级筛选 + 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 } // CustomerListItem 客户列表项结构(用于列表展示,包含主要字段) @@ -114,32 +114,32 @@ type CustomerListResponse struct { // CustomerCreateRequest 创建客户请求结构 type CustomerCreateRequest struct { - CustomerName string `json:"customer_name" binding:"required,max=200"` // 客户名称,必填 + CustomerName string `json:"customer_name" binding:"required,max=200"` // 客户名称,必填 CustomerType string `json:"customer_type" binding:"required,oneof=individual enterprise government education"` // 客户类型,必填 - ContactPerson string `json:"contact_person" binding:"required,max=100"` // 联系人姓名,必填 - ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选 - 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"` // 客户等级,必填 - Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填 - Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选 + ContactPerson string `json:"contact_person" binding:"required,max=100"` // 联系人姓名,必填 + ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选 + 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=basic active vip strategic"` // 客户等级,必填 + Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填 + Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选 } // CustomerUpdateRequest 更新客户请求结构(所有字段都是可选的) type CustomerUpdateRequest struct { - CustomerName *string `json:"customer_name" binding:"omitempty,max=200"` // 客户名称,可选 + CustomerName *string `json:"customer_name" binding:"omitempty,max=200"` // 客户名称,可选 CustomerType *string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` // 客户类型,可选 - ContactPerson *string `json:"contact_person" binding:"omitempty,max=100"` // 联系人姓名,可选 - ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选 - 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"` // 客户等级,可选 - Status *string `json:"status" binding:"omitempty,oneof=active disabled"` // 状态,可选 - Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选 + ContactPerson *string `json:"contact_person" binding:"omitempty,max=100"` // 联系人姓名,可选 + ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选 + 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=basic active vip strategic"` // 客户等级,可选 + Status *string `json:"status" binding:"omitempty,oneof=active disabled"` // 状态,可选 + Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选 } // CustomerStatusUpdateRequest 客户状态更新请求结构 diff --git a/backend/migrations/001_create_customers_table.sql b/backend/migrations/001_create_customers_table.sql index 99c14cf..dc307c7 100644 --- a/backend/migrations/001_create_customers_table.sql +++ b/backend/migrations/001_create_customers_table.sql @@ -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', diff --git a/frontend/src/api/customer.ts b/frontend/src/api/customer.ts index 9ec9e2b..6f487ad 100644 --- a/frontend/src/api/customer.ts +++ b/frontend/src/api/customer.ts @@ -32,7 +32,7 @@ export interface CustomerQueryRequest { search?: string; customer_name?: string; customer_type?: 'individual' | 'enterprise' | 'government' | 'education'; - customer_level?: 'normal' | 'vip' | 'enterprise' | 'strategic'; + customer_level?: 'basic' | 'active' | 'vip' | 'strategic'; status?: 'active' | 'disabled'; sort?: 'created_at' | 'updated_at' | 'customer_name' | 'customer_code'; order?: 'asc' | 'desc'; @@ -47,7 +47,7 @@ export interface CustomerCreateRequest { email?: string; phone?: string; address?: string; - customer_level: 'normal' | 'vip' | 'enterprise' | 'strategic'; + customer_level: 'basic' | 'active' | 'vip' | 'strategic'; status: 'active' | 'disabled'; company_size?: 'small' | 'medium' | 'large' | 'enterprise'; description?: string; @@ -62,7 +62,7 @@ export interface CustomerUpdateRequest { email?: string; phone?: string; address?: string; - customer_level?: 'normal' | 'vip' | 'enterprise' | 'strategic'; + customer_level?: 'basic' | 'active' | 'vip' | 'strategic'; status?: 'active' | 'disabled'; company_size?: 'small' | 'medium' | 'large' | 'enterprise'; description?: string; From ac8066e0a5b7a8d9da3cea7ded0b096a5dcf7711 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 17:35:26 +0800 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AD=89=E7=BA=A7=E5=92=8C=E4=BC=81=E4=B8=9A=E8=A7=84?= =?UTF-8?q?=E6=A8=A1=E7=9A=84=E6=9E=9A=E4=B8=BE=E9=99=90=E5=88=B6=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3=E5=92=8C?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=BB=93=E6=9E=84=E4=BB=A5=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/swagger/swagger.yaml | 20 -------------------- backend/internal/models/customer.go | 10 +++++----- frontend/src/api/customer.ts | 10 +++++----- 3 files changed, 10 insertions(+), 30 deletions(-) diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index 999de0c..d4635ad 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -433,11 +433,6 @@ definitions: type: string company_size: description: 企业规模,可选 - enum: - - small - - medium - - large - - enterprise type: string contact_person: description: 联系人姓名,必填 @@ -449,11 +444,6 @@ definitions: type: string customer_level: description: 客户等级,必填 - enum: - - basic - - active - - vip - - strategic type: string customer_name: description: 客户名称,必填 @@ -580,11 +570,6 @@ definitions: type: string company_size: description: 企业规模,可选 - enum: - - small - - medium - - large - - enterprise type: string contact_person: description: 联系人姓名,可选 @@ -596,11 +581,6 @@ definitions: type: string customer_level: description: 客户等级,可选 - enum: - - basic - - active - - vip - - strategic type: string customer_name: description: 客户名称,可选 diff --git a/backend/internal/models/customer.go b/backend/internal/models/customer.go index cabe0eb..daaf4fe 100644 --- a/backend/internal/models/customer.go +++ b/backend/internal/models/customer.go @@ -81,7 +81,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=basic active vip 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 +121,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=basic active vip 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 +136,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=basic active vip 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"` // 描述,可选 } diff --git a/frontend/src/api/customer.ts b/frontend/src/api/customer.ts index 6f487ad..4c63cf0 100644 --- a/frontend/src/api/customer.ts +++ b/frontend/src/api/customer.ts @@ -32,7 +32,7 @@ export interface CustomerQueryRequest { search?: string; customer_name?: string; customer_type?: 'individual' | 'enterprise' | 'government' | 'education'; - customer_level?: 'basic' | 'active' | 'vip' | 'strategic'; + customer_level?: string; status?: 'active' | 'disabled'; sort?: 'created_at' | 'updated_at' | 'customer_name' | 'customer_code'; order?: 'asc' | 'desc'; @@ -47,9 +47,9 @@ export interface CustomerCreateRequest { email?: string; phone?: string; address?: string; - customer_level: 'basic' | 'active' | 'vip' | 'strategic'; + customer_level: string; status: 'active' | 'disabled'; - company_size?: 'small' | 'medium' | 'large' | 'enterprise'; + company_size?: string; description?: string; } @@ -62,9 +62,9 @@ export interface CustomerUpdateRequest { email?: string; phone?: string; address?: string; - customer_level?: 'basic' | 'active' | 'vip' | 'strategic'; + customer_level?: string; status?: 'active' | 'disabled'; - company_size?: 'small' | 'medium' | 'large' | 'enterprise'; + company_size?: string; description?: string; } From 10000681cfc2d63a8223ba6fc40172d0adabcedf Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 17:55:38 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E5=81=9C=E7=94=A8=E7=8A=B6=E6=80=81=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8F=90=E7=A4=BA=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/client-demo/client_config.json | 8 ++++---- backend/cmd/client-demo/license_code/AUTH_CODE | 2 +- backend/cmd/client-demo/license_code/LICENSE | 2 +- backend/configs/i18n/errors/en-US.yaml | 1 + backend/configs/i18n/errors/ja-JP.yaml | 1 + backend/configs/i18n/errors/zh-CN.yaml | 1 + .../internal/service/authorization_code_service.go | 13 +++++++++---- 7 files changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/cmd/client-demo/client_config.json b/backend/cmd/client-demo/client_config.json index 4828abc..f77e08c 100644 --- a/backend/cmd/client-demo/client_config.json +++ b/backend/cmd/client-demo/client_config.json @@ -1,9 +1,9 @@ { "server_url": "http://localhost:18888", - "authorization_code": "LIC-5359-QKHSAFPI-VWAA", + "authorization_code": "LIC-B597-EFr0tbfAooQF-uUAA", "software_version": "1.0.0", "heartbeat_interval": 300, - "license_key": "LIC-DEVICE-72921EB189E53806B59FF0AE", - "config_updated_at": "2025-11-01T16:31:54+08:00", - "license_file": "eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTY6MTc6MDAuMjE2KzA4OjAwXCIsXCJhdXRob3JpemF0aW9uX2NvZGVcIjpcIkxJQy01MzU5LVFLSFNBRlBJLVZXQUFcIixcImF1dGhvcml6YXRpb25fY29kZV9pZFwiOlwiYmVmODliM2MtNDU1NS00ZjE5LTllMGUtMzM0MDI0MDNhZWFkXCIsXCJjb25maWdfdXBkYXRlZF9hdFwiOlwiMjAyNS0xMS0wMVQxNjoyNzoxOS43NzcrMDg6MDBcIixcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIyMDI1LTEyLTEzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE2OjMxOjU0KzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTcyOTIxRUIxODlFNTM4MDZCNTlGRjBBRVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJFc1ZMQlNSWE1NN0NteTRDeW5EQ0ZGbkw0ZjByTGlpekk1SDZnQ1V5bDFTZGdHR0JZTzdHNStKdTE2T1Jkb0wxSkJRcW5zaHU5RXh1SDVuY1VXM2ZIZFlQTElQR1VBTVhqMGZwS2tPNENCTyt2VjdlSEI2ZDhUbUlkUnE2MU1vdisvM0k4c3Btb1JUNjljbEVuZWFHWE5LbnZOdm5BQXArVWhHamtWK1phZU8wRXlCM2xDcEJkdWJUWkFiUVFic0pSZHY2ZUszSUFGUkdJVnYzWkJhOCtuMnRWQ3RPRXVhVmF1ZHNhRW5ucTZIQWdXdmdUSzViQ2dHZFJkWUJjdG9vVEpiYWtacE9nSWQvVGRmbThGN0krWENsbVl5bWdmYVdUcldKMXd0SzVpSndtV003bTFyTytvZVB3NjBMNjd4ejVmTzRaZjFPUVF2MVNxekhBV25SVFE9PSJ9" + "license_key": "LIC-DEVICE-68DF8AFEF41CA26F248D7D55", + "config_updated_at": "2025-11-01T17:54:30+08:00", + "license_file": "eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9" } \ No newline at end of file diff --git a/backend/cmd/client-demo/license_code/AUTH_CODE b/backend/cmd/client-demo/license_code/AUTH_CODE index f7de605..80deed3 100644 --- a/backend/cmd/client-demo/license_code/AUTH_CODE +++ b/backend/cmd/client-demo/license_code/AUTH_CODE @@ -1 +1 @@ -LIC-5359-QKHSAFPI-VWAA \ No newline at end of file +LIC-B597-EFr0tbfAooQF-uUAA \ No newline at end of file diff --git a/backend/cmd/client-demo/license_code/LICENSE b/backend/cmd/client-demo/license_code/LICENSE index 9eb9224..6081458 100644 --- a/backend/cmd/client-demo/license_code/LICENSE +++ b/backend/cmd/client-demo/license_code/LICENSE @@ -1 +1 @@ -eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTY6MTc6MDAuMjE2KzA4OjAwXCIsXCJhdXRob3JpemF0aW9uX2NvZGVcIjpcIkxJQy01MzU5LVFLSFNBRlBJLVZXQUFcIixcImF1dGhvcml6YXRpb25fY29kZV9pZFwiOlwiYmVmODliM2MtNDU1NS00ZjE5LTllMGUtMzM0MDI0MDNhZWFkXCIsXCJjb25maWdfdXBkYXRlZF9hdFwiOlwiMjAyNS0xMS0wMVQxNjoyNzoxOS43NzcrMDg6MDBcIixcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIyMDI1LTEyLTEzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE2OjMxOjU0KzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTcyOTIxRUIxODlFNTM4MDZCNTlGRjBBRVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJFc1ZMQlNSWE1NN0NteTRDeW5EQ0ZGbkw0ZjByTGlpekk1SDZnQ1V5bDFTZGdHR0JZTzdHNStKdTE2T1Jkb0wxSkJRcW5zaHU5RXh1SDVuY1VXM2ZIZFlQTElQR1VBTVhqMGZwS2tPNENCTyt2VjdlSEI2ZDhUbUlkUnE2MU1vdisvM0k4c3Btb1JUNjljbEVuZWFHWE5LbnZOdm5BQXArVWhHamtWK1phZU8wRXlCM2xDcEJkdWJUWkFiUVFic0pSZHY2ZUszSUFGUkdJVnYzWkJhOCtuMnRWQ3RPRXVhVmF1ZHNhRW5ucTZIQWdXdmdUSzViQ2dHZFJkWUJjdG9vVEpiYWtacE9nSWQvVGRmbThGN0krWENsbVl5bWdmYVdUcldKMXd0SzVpSndtV003bTFyTytvZVB3NjBMNjd4ejVmTzRaZjFPUVF2MVNxekhBV25SVFE9PSJ9 \ No newline at end of file +eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9 \ No newline at end of file diff --git a/backend/configs/i18n/errors/en-US.yaml b/backend/configs/i18n/errors/en-US.yaml index 1832c86..3b250b7 100644 --- a/backend/configs/i18n/errors/en-US.yaml +++ b/backend/configs/i18n/errors/en-US.yaml @@ -22,6 +22,7 @@ errors: "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: diff --git a/backend/configs/i18n/errors/ja-JP.yaml b/backend/configs/i18n/errors/ja-JP.yaml index e9ae7ac..3e2e82f 100644 --- a/backend/configs/i18n/errors/ja-JP.yaml +++ b/backend/configs/i18n/errors/ja-JP.yaml @@ -22,6 +22,7 @@ errors: "200004": "無効な顧客情報形式" "200005": "顧客リストの照会に失敗しました" "200006": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください" + "200007": "顧客が無効になっています。認可を作成できません" # 認可モジュール (30xxxx) authorization: diff --git a/backend/configs/i18n/errors/zh-CN.yaml b/backend/configs/i18n/errors/zh-CN.yaml index 69628ae..6255810 100644 --- a/backend/configs/i18n/errors/zh-CN.yaml +++ b/backend/configs/i18n/errors/zh-CN.yaml @@ -22,6 +22,7 @@ errors: "200004": "客户信息格式无效" "200005": "客户列表查询失败" "200006": "客户仍有授权,无法删除,请先删除授权" + "200007": "客户已停用,无法创建授权" # 授权模块 (30xxxx) authorization: diff --git a/backend/internal/service/authorization_code_service.go b/backend/internal/service/authorization_code_service.go index 8afe0f3..cb9a806 100644 --- a/backend/internal/service/authorization_code_service.go +++ b/backend/internal/service/authorization_code_service.go @@ -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) // 客户已停用,无法创建授权 } // 业务逻辑:计算开始时间和结束时间 From a7b28022c8114756545ea6f8fd771d51b9b41d3c Mon Sep 17 00:00:00 2001 From: wood911010 Date: Sat, 1 Nov 2025 21:46:38 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=AF=A6=E6=83=85=E6=8E=A5=E5=8F=A3=E7=9A=84=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=BB=9F=E8=AE=A1=E4=BF=A1=E6=81=AF=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9B=B8=E5=85=B3=E6=96=87=E6=A1=A3=E5=92=8C=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=A8=A1=E5=9E=8B=E4=BB=A5=E6=94=AF=E6=8C=81=E6=96=B0?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/demand/2025.11.1-客户详情接口改造.md | 177 ++++++++++++++++++ backend/docs/swagger/docs.go | 103 ++++++---- backend/docs/swagger/swagger.json | 103 ++++++---- backend/docs/swagger/swagger.yaml | 56 ++++-- backend/internal/models/customer.go | 14 ++ backend/internal/models/license.go | 16 +- .../repository/customer_repository.go | 68 +++++++ backend/internal/repository/interfaces.go | 3 + backend/internal/service/customer_service.go | 10 + backend/internal/service/license_service.go | 18 +- 10 files changed, 459 insertions(+), 109 deletions(-) create mode 100644 backend/docs/demand/2025.11.1-客户详情接口改造.md diff --git a/backend/docs/demand/2025.11.1-客户详情接口改造.md b/backend/docs/demand/2025.11.1-客户详情接口改造.md new file mode 100644 index 0000000..e6a8690 --- /dev/null +++ b/backend/docs/demand/2025.11.1-客户详情接口改造.md @@ -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. ✅ 查询性能良好,无明显的性能问题 diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index 6b6be39..2f62fad 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -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" } } diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index 57b571a..67319c0 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -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" } } diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index d4635ad..6ead621 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -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: @@ -683,10 +711,10 @@ definitions: models.GrowthRate: properties: auth_codes: - description: 授权码增长率(%) + description: 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率) type: number licenses: - description: 许可证增长率(%) + description: 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率) type: number type: object models.HealthResponse: @@ -1007,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: @@ -1233,7 +1261,7 @@ paths: delete: consumes: - application/json - description: 软删除客户记录 + description: 直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权 parameters: - description: 客户ID in: path @@ -1259,6 +1287,10 @@ paths: description: 客户不存在 schema: $ref: '#/definitions/models.ErrorResponse' + "409": + description: 客户仍有授权,无法删除 + schema: + $ref: '#/definitions/models.ErrorResponse' "500": description: 服务器内部错误 schema: diff --git a/backend/internal/models/customer.go b/backend/internal/models/customer.go index daaf4fe..1b72c63 100644 --- a/backend/internal/models/customer.go +++ b/backend/internal/models/customer.go @@ -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 客户编码序列模型 @@ -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"` // 已过期许可证数量 +} diff --git a/backend/internal/models/license.go b/backend/internal/models/license.go index a01d0b6..a5a35fd 100644 --- a/backend/internal/models/license.go +++ b/backend/internal/models/license.go @@ -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"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率) } diff --git a/backend/internal/repository/customer_repository.go b/backend/internal/repository/customer_repository.go index d835d7a..e3b568f 100644 --- a/backend/internal/repository/customer_repository.go +++ b/backend/internal/repository/customer_repository.go @@ -236,4 +236,72 @@ 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 } \ No newline at end of file diff --git a/backend/internal/repository/interfaces.go b/backend/internal/repository/interfaces.go index 88eb6e8..d7bb559 100644 --- a/backend/internal/repository/interfaces.go +++ b/backend/internal/repository/interfaces.go @@ -31,6 +31,9 @@ type CustomerRepository interface { // CheckCustomerHasLicenses 检查客户是否有关联的许可证 CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error) + + // GetCustomerAuthorizationStats 获取客户授权统计信息 + GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error) } // UserRepository 用户数据访问接口 diff --git a/backend/internal/service/customer_service.go b/backend/internal/service/customer_service.go index 06d3624..7d3288f 100644 --- a/backend/internal/service/customer_service.go +++ b/backend/internal/service/customer_service.go @@ -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) diff --git a/backend/internal/service/license_service.go b/backend/internal/service/license_service.go index 8762db4..4e8c37a 100644 --- a/backend/internal/service/license_service.go +++ b/backend/internal/service/license_service.go @@ -803,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 }