From e91775ea76d611f73608f9c181757877dc25c5c4 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Fri, 16 Jan 2026 21:21:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E7=A0=81=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=94=9F=E6=88=90=E5=8C=85=E5=90=AB=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E9=85=8D=E7=BD=AE=E7=AD=BE=E5=90=8D=E7=9A=84?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E6=BF=80=E6=B4=BB=E7=A0=81=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=8E=88=E6=9D=83=E7=A0=81=E7=AE=A1=E7=90=86=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docs/backend_design.md | 2 +- .../2026.1.16-授权码生成规范再变更.md | 191 ++++++++ .../api/handlers/cu_authorization_handler.go | 64 +++ backend/internal/api/routes/router.go | 1 + backend/internal/models/auth.go | 10 + .../service/authorization_code_service.go | 236 +++++----- backend/internal/service/cu_order_service.go | 53 ++- backend/internal/service/interfaces.go | 3 + backend/internal/service/license_service.go | 70 +-- backend/pkg/utils/authorization_code.go | 51 +++ backend/pkg/utils/license_codec.go | 409 ------------------ 11 files changed, 468 insertions(+), 622 deletions(-) create mode 100644 backend/docs/modules_design/2026.1.16-授权码生成规范再变更.md create mode 100644 backend/pkg/utils/authorization_code.go delete mode 100644 backend/pkg/utils/license_codec.go diff --git a/backend/docs/backend_design.md b/backend/docs/backend_design.md index 0fa776e..4972b39 100644 --- a/backend/docs/backend_design.md +++ b/backend/docs/backend_design.md @@ -107,7 +107,7 @@ backend/ │ │ ├── crypto.go # 加密工具 │ │ ├── cu_jwt.go # C端用户JWT工具 │ │ ├── jwt.go # JWT工具 -│ │ └── license_codec.go # 授权码编解码 +│ │ └── authorization_code.go # 授权码生成 │ └── logger/ # 日志封装 │ └── logger.go ├── configs/ diff --git a/backend/docs/modules_design/2026.1.16-授权码生成规范再变更.md b/backend/docs/modules_design/2026.1.16-授权码生成规范再变更.md new file mode 100644 index 0000000..181de4b --- /dev/null +++ b/backend/docs/modules_design/2026.1.16-授权码生成规范再变更.md @@ -0,0 +1,191 @@ +# 授权码生成规范再变更(2026-01-16) +## 1. 背景与目标 +授权码(Authorization Code)当前用于“在线激活”:客户端携带授权码请求服务端,服务端签发许可证文件。 + +当客户环境无网络时,在线激活不可用,因此需要一段可由客户端内置 RSA 公钥解析/验签的字符串,携带授权的配置(到期时间、功能配置、使用限制等),客户端在离线场景可据此完成自校验与功能开关。 + +本次“再变更”的核心原则: +- 不改动现有授权码本体格式与核心激活流程 +- 仅在“复制/导出”时提供“产品激活码(Product Activation Code)”扩展串 + +## 2. 当前代码实现(现状梳理) +### 2.1 授权码基础格式(旧规则,核心流程依赖) +当前核心流程依赖的授权码格式为 4 段: + +``` +LIC-{客户ID前4位}-{12位随机}-{4位校验码} +``` + +示例:`LIC-F44D-GvBzMfEGbxMP-LVAA` + +对应实现: +- 生成:`backend/pkg/utils/authorization_code.go` 的 `GenerateLegacyAuthorizationCode()`(由 `backend/internal/service/authorization_code_service.go` 的 `generateAuthorizationCode()` 调用) +- 使用:核心流程以数据库匹配为准(`licenseRepo.GetAuthorizationCodeByCode`),不做额外格式校验 + +### 2.2 历史“自包含授权码”方案(已移除) +历史上曾存在一套“自包含授权码”的生成/解析方案(已删除,不再使用): +- 曾实现:`backend/pkg/utils/license_codec.go`(已删除,原 `EncodeLicenseData()/DecodeLicenseData()`) +- 格式:`LIC-{2位随机}-{Base64数据}`(3 段示例:`LIC-B5-xxxx...`) +- 特征:将到期时间/配置等编码进 `{Base64数据}`,并做签名校验 + +该格式与 2.1 的旧规则(4 段)以及现有激活流程不一致,已按本次“再变更”回退/统一到 2.1。 + +受影响的核心流程(已改回旧规则生成): +- `POST /api/v1/authorization-codes`(管理端创建授权码) +- `POST /api/cu/orders`(C 端下单创建授权码) + +### 2.3 授权码下载包(保持不变) +现有下载接口会导出一个 zip,包含: +- `authorization_code.txt`:仅包含授权码字符串(`authCode.Code`) +- `rsa_public_key.pem`:RSA 公钥(从配置 `cfg.License.RSA.PublicKeyPath` 读取) + +对应实现: +- 路由:`GET /api/v1/authorization-codes/:id/download` +- Service:`backend/internal/service/authorization_code_service.go` 的 `GenerateAuthorizationFile()` +- Handler:`backend/internal/api/handlers/authorization_code_handler.go` 的 `DownloadAuthorizationFile()` + +### 2.4 “公钥可解析”的签名封装(可复用) +代码中已存在一套「RSA 私钥签名 + Base64 封装」实现,客户端可用公钥验签后得到原始 JSON: +- 服务端签名封装:`backend/internal/service/license_service.go` 的 `signLicenseFile()` +- 签名算法:`RSA-PSS-SHA256`(`backend/pkg/utils/crypto.go` 的 `RSAPrivateKey.SignData()` / `RSAPublicKey.VerifySignature()`) +- 客户端验签示例:`backend/cmd/client-demo/main.go` 的 `decryptLicenseFile()` + +说明:这里的“加密字符串”在现有体系中更贴近“签名封装字符串”(提供防篡改与来源可信验证,不提供保密性)。 + +## 3. 本次再变更:产品激活码(Product Activation Code) +### 3.1 输出格式 +在需要离线激活/离线解析配置的场景,对外展示/复制的字符串为“产品激活码”: + +``` +{授权码}&{配置签名封装串} +``` + +示例:`LIC-F44D-GvBzMfEGbxMP-LVAA&{payload}` + +其中: +- `{授权码}`:保持现有 4 段格式不变(用于在线激活、数据库查找、订单绑定等核心流程) +- `{payload}`:可由客户端内置 RSA 公钥验签并解析出 JSON 的字符串 + +### 3.2 payload(配置签名封装串)业务 JSON +payload 解析后的业务 JSON(即“配置 JSON”)建议至少包含: + +```json +{ + "authorization_code": "LIC-F44D-GvBzMfEGbxMP-LVAA", + "start_date": "2026-01-16T00:00:00+08:00", + "end_date": "2026-12-31T23:59:59+08:00", + "deployment_type": "standalone", + "max_activations": 100, + "feature_config": {}, + "usage_limits": {}, + "custom_parameters": {}, + "generated_at": "2026-01-16T12:00:00+08:00", + "ver": 1 +} +``` + +字段来源: +- `authorization_code/start_date/end_date/deployment_type/max_activations/feature_config/usage_limits/custom_parameters`:来自 `authorization_codes` 表记录 +- `generated_at`:服务端生成时间 + +### 3.3 payload 生成规则(服务端) +复用许可证文件的签名封装方式(`license_service.signLicenseFile()` 的数据结构与算法): + +1. 构造“配置 JSON”(见 3.2),序列化为 `data`(JSON 字符串) +2. 使用 RSA 私钥对 `data` 进行 PSS+SHA256 签名得到 `signature`(Base64) +3. 组装签名封装对象: + +```json +{ + "data": "{...配置JSON字符串...}", + "signature": "{base64_signature}", + "algorithm": "RSA-PSS-SHA256" +} +``` + +4. 将封装对象再序列化为 JSON,并 Base64 编码得到 `{payload}` + +说明: +- 私钥路径来自 `cfg.License.RSA.PrivateKeyPath` +- 公钥路径来自 `cfg.License.RSA.PublicKeyPath`(客户端内置同一对公钥) +- Base64 编码沿用 `base64.StdEncoding`(与现有 license file 一致) + +### 3.4 客户端解析规则(产品端) +1. 读取输入字符串,按 `&` 分割: + - 只有 1 段:视为仅“在线激活授权码” + - 至少 2 段:`base_code` 与 `payload`(取第 1 段为授权码本体) +2. 对 `payload` Base64 解码得到封装 JSON +3. 解析封装 JSON,校验 `algorithm == "RSA-PSS-SHA256"` +4. 用内置 RSA 公钥对 `data` 与 `signature` 验签 +5. 验签通过后解析 `data` 得到配置 JSON,并自行校验: + - 到期时间、功能配置、使用限制等 + +补充(在线激活): +- `/api/v1/activate` 的 `authorization_code` 参数只使用 `{授权码}` 本体,不依赖 `{payload}` +- 客户端若传入完整 `product_activation_code`(`{授权码}&{payload}`),服务端会自动截取 `&` 前的 `{授权码}` 完成激活 + +## 4. 接口变更清单(需求说明) +### 4.1 现有接口需要调整的内容 +#### 4.1.1 管理端:创建授权码 +- 接口:`POST /api/v1/authorization-codes` +- 变更点: + - `code` 生成规则统一为 2.1 的 4 段旧规则:`LIC-{客户ID前4位}-{12位随机}-{4位校验码}` + - `feature_config/usage_limits/custom_parameters/start_date/end_date/...` 等仍按现有结构字段写入数据库(供 license file 与 payload 复用) +- 返回:响应中的 `code` 仅返回“旧规则授权码本体”(不包含 `&payload`) + +#### 4.1.2 C 端:下单创建授权码 +- 接口:`POST /api/cu/orders` +- 变更点: + - 订单关联的授权码字符串(`order.authorization_code`)生成规则统一为 2.1 的旧规则 + - 同步把订单对应配置写入 `authorization_codes` 表的结构化字段(不再依赖“授权码本体自包含”) +- 返回:仍返回 `authorization_code`(旧规则授权码本体,不包含 `&payload`) +- 备注:`POST /api/cu/authorization-codes/:codeId/share` 仍可沿用旧规则生成(如后续需要统一,可复用同一生成器) + +#### 4.1.3 客户端:激活 +- 接口:`POST /api/v1/activate` +- 变更点: + - 激活不需要配置 payload;仅以 `{授权码}` 做数据库匹配,不做额外格式校验 + - 兼容传入完整 `product_activation_code`:服务端自动截取 `&` 前的 `{授权码}` + +### 4.2 新增接口(仅客户端使用):获取产品激活码 +为客户端提供一个“获取产品激活码”的接口,返回 `{授权码}&{payload}`。管理端不提供该接口(当前仅 C 端“复制/导出”场景需要)。 + +#### 4.2.1 建议接口形态(C 端) + +``` +POST /api/cu/authorization-codes/product-activation-code +``` + +请求: + +```json +{ + "authorization_code": "LIC-F44D-GvBzMfEGbxMP-LVAA" +} +``` + +返回: + +```json +{ + "code": "000000", + "message": "success", + "data": { + "product_activation_code": "LIC-F44D-GvBzMfEGbxMP-LVAA&{payload}" + } +} +``` + +约束与校验(建议): +- 授权码存在,且归属当前登录客户(CustomerAuth) +- 授权码未锁定,且在有效期内 + +## 5. 兼容性与验收标准 +### 5.1 兼容性 +- 原授权码本体格式不变,在线激活核心流程不受影响 +- 旧授权码仍可用;仅新增“产品激活码”扩展串用于离线解析 + +### 5.2 验收标准 +- `product_activation_code` 中的 `{payload}` 能被客户端仅凭公钥验签并解析出配置 JSON +- 客户端可在离线场景下依据配置 JSON 完成自校验与功能开关 +- 不影响 `/api/v1/activate`、授权码管理、订单等核心流程 diff --git a/backend/internal/api/handlers/cu_authorization_handler.go b/backend/internal/api/handlers/cu_authorization_handler.go index 4fe188b..ec74a05 100644 --- a/backend/internal/api/handlers/cu_authorization_handler.go +++ b/backend/internal/api/handlers/cu_authorization_handler.go @@ -101,3 +101,67 @@ func (h *CuAuthorizationHandler) ShareAuthorizationCode(c *gin.Context) { Data: data, }) } + +// GetProductActivationCode 获取产品激活码 +// @Summary 获取产品激活码 +// @Description 获取产品激活码:{授权码}&{payload}(payload 为 RSA-PSS-SHA256 签名封装串) +// @Tags 用户端授权码管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body models.ProductActivationCodeRequest true "获取产品激活码请求" +// @Success 200 {object} models.APIResponse{data=models.ProductActivationCodeResponse} "获取成功" +// @Failure 400 {object} models.ErrorResponse "请求参数无效" +// @Failure 401 {object} models.ErrorResponse "未认证" +// @Failure 403 {object} models.ErrorResponse "授权码已被锁定" +// @Failure 404 {object} models.ErrorResponse "授权码不存在或已过期" +// @Failure 500 {object} models.ErrorResponse "服务器内部错误" +// @Router /api/cu/authorization-codes/product-activation-code [post] +func (h *CuAuthorizationHandler) GetProductActivationCode(c *gin.Context) { + claims := c.MustGet("cu_user").(*utils.CuClaims) + customerID := claims.CustomerID + + var req models.ProductActivationCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message + ": " + err.Error(), + Timestamp: getCurrentTimestamp(), + }) + return + } + + ctx := middleware.WithLanguage(c.Request.Context(), c) + data, err := h.authCodeService.GetProductActivationCode(ctx, customerID, &req) + if err != nil { + // err已经是i18n.I18nError,直接使用 + i18nErr, ok := err.(*i18n.I18nError) + if ok { + c.JSON(i18nErr.HttpCode, models.ErrorResponse{ + Code: i18nErr.Code, + Message: i18nErr.Message, + Timestamp: getCurrentTimestamp(), + }) + return + } + // 如果不是i18n错误,使用通用错误 + lang := middleware.GetLanguage(c) + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error()) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: getCurrentTimestamp(), + }) + return + } + + lang := middleware.GetLanguage(c) + successMessage := i18n.GetErrorMessage("000000", lang) + c.JSON(http.StatusOK, models.APIResponse{ + Code: "000000", + Message: successMessage, + Data: data, + }) +} diff --git a/backend/internal/api/routes/router.go b/backend/internal/api/routes/router.go index 510ec94..e543d29 100644 --- a/backend/internal/api/routes/router.go +++ b/backend/internal/api/routes/router.go @@ -225,6 +225,7 @@ func SetupRouter() *gin.Engine { // 授权码管理 cuAuth.POST("/authorization-codes/:codeId/share", cuAuthorizationHandler.ShareAuthorizationCode) + cuAuth.POST("/authorization-codes/product-activation-code", cuAuthorizationHandler.GetProductActivationCode) // 设备管理 cuAuth.GET("/devices", cuDeviceHandler.GetDevices) diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index 58bdf52..680d4d6 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -280,3 +280,13 @@ type AuthorizationCodeShareResponseItem struct { EndDate string `json:"end_date"` // 结束时间 MaxActivations int `json:"max_activations"` // 最大激活次数 } + +// ProductActivationCodeRequest 获取产品激活码请求结构 +type ProductActivationCodeRequest struct { + AuthorizationCode string `json:"authorization_code" binding:"required"` // 授权码(旧规则格式) +} + +// ProductActivationCodeResponse 获取产品激活码响应结构 +type ProductActivationCodeResponse struct { + ProductActivationCode string `json:"product_activation_code"` // 产品激活码:{授权码}&{payload} +} diff --git a/backend/internal/service/authorization_code_service.go b/backend/internal/service/authorization_code_service.go index c5047ad..4da6212 100644 --- a/backend/internal/service/authorization_code_service.go +++ b/backend/internal/service/authorization_code_service.go @@ -2,7 +2,7 @@ package service import ( "context" - "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -77,35 +77,8 @@ func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context, return nil, i18n.NewI18nError("100004", lang) // 缺少认证信息 } - // 生成自包含授权码 - var featureConfigMap, usageLimitsMap, customParametersMap map[string]interface{} - - if req.FeatureConfig != nil { - if fc, ok := req.FeatureConfig.(map[string]interface{}); ok { - featureConfigMap = fc - } - } - if req.UsageLimits != nil { - if ul, ok := req.UsageLimits.(map[string]interface{}); ok { - usageLimitsMap = ul - } - } - if req.CustomParameters != nil { - if cp, ok := req.CustomParameters.(map[string]interface{}); ok { - customParametersMap = cp - } - } - - licenseConfig := &utils.LicenseConfig{ - EndDate: endDate, - FeatureConfig: featureConfigMap, - UsageLimits: usageLimitsMap, - CustomParameters: customParametersMap, - } - - // 从配置获取RSA私钥路径 - privateKeyPath := config.GetConfig().License.RSA.PrivateKeyPath - authCode, err := utils.EncodeLicenseData(licenseConfig, privateKeyPath) + // 授权码生成规则回退/统一为旧规则(不再自包含配置) + authCode, err := s.generateAuthorizationCode(req.CustomerID) if err != nil { return nil, i18n.NewI18nError("900004", lang, err.Error()) } @@ -292,6 +265,102 @@ func (s *authorizationCodeService) GenerateAuthorizationFile(ctx context.Context return []byte(authCode.Code), fileName, authCode.Code, nil } +// GetProductActivationCode 获取产品激活码:{授权码}&{payload} +func (s *authorizationCodeService) GetProductActivationCode(ctx context.Context, customerID string, req *models.ProductActivationCodeRequest) (*models.ProductActivationCodeResponse, error) { + lang := pkgcontext.GetLanguageFromContext(ctx) + + if customerID == "" || req == nil || strings.TrimSpace(req.AuthorizationCode) == "" { + return nil, i18n.NewI18nError("900001", lang) + } + + code := strings.TrimSpace(req.AuthorizationCode) + if idx := strings.Index(code, "&"); idx > 0 { + code = strings.TrimSpace(code[:idx]) + } + if code == "" { + return nil, i18n.NewI18nError("900001", lang) + } + + authCode, err := s.licenseRepo.GetAuthorizationCodeByCode(ctx, code) + if err != nil { + if errors.Is(err, repository.ErrAuthorizationCodeNotFound) { + return nil, i18n.NewI18nError("300001", lang) // 授权码不存在 + } + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // 校验归属 + if authCode.CustomerID != customerID { + return nil, i18n.NewI18nError("100005", lang) // 权限不足 + } + + // 校验状态与有效期 + now := time.Now() + if authCode.IsLocked { + return nil, i18n.NewI18nError("300003", lang) // 授权码已被锁定 + } + if now.Before(authCode.StartDate) || now.After(authCode.EndDate) { + return nil, i18n.NewI18nError("300001", lang) // 授权码未生效或已过期 + } + + // 构造配置 JSON(客户端离线解析/自校验) + payloadData := map[string]interface{}{ + "ver": 1, + "authorization_code": authCode.Code, + "start_date": authCode.StartDate, + "end_date": authCode.EndDate, + "deployment_type": authCode.DeploymentType, + "max_activations": authCode.MaxActivations, + "generated_at": time.Now().Format(time.RFC3339), + } + + if featureConfig := parseJSONField(authCode.FeatureConfig); len(featureConfig) > 0 { + payloadData["feature_config"] = featureConfig + } + if usageLimits := parseJSONField(authCode.UsageLimits); len(usageLimits) > 0 { + payloadData["usage_limits"] = usageLimits + } + if customParameters := parseJSONField(authCode.CustomParameters); len(customParameters) > 0 { + payloadData["custom_parameters"] = customParameters + } + + payloadDataBytes, err := json.Marshal(payloadData) + if err != nil { + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + // RSA 私钥签名封装(与 license_service.signLicenseFile 相同结构) + cfg := config.GetConfig() + if cfg == nil || cfg.License.RSA.PrivateKeyPath == "" { + return nil, i18n.NewI18nError("900004", lang, "RSA private key path not configured") + } + + privateKey, err := utils.LoadRSAPrivateKeyFromFile(cfg.License.RSA.PrivateKeyPath) + if err != nil { + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + signature, err := privateKey.SignData(payloadDataBytes) + if err != nil { + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + signedPayload := map[string]interface{}{ + "data": string(payloadDataBytes), + "signature": signature, + "algorithm": "RSA-PSS-SHA256", + } + + signedPayloadBytes, err := json.Marshal(signedPayload) + if err != nil { + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + + payload := base64.StdEncoding.EncodeToString(signedPayloadBytes) + productActivationCode := fmt.Sprintf("%s&%s", authCode.Code, payload) + return &models.ProductActivationCodeResponse{ProductActivationCode: productActivationCode}, nil +} + // fillAuthorizationCodeDisplayFields 填充完整授权码模型的多语言显示字段 func (s *authorizationCodeService) fillAuthorizationCodeDisplayFields(authCode *models.AuthorizationCode, lang string) { // 计算状态 @@ -638,112 +707,7 @@ func (s *authorizationCodeService) fillAuthCodeDisplayFields(item *models.Author // 格式: LIC-{4位客户代码}-{12位随机}-{4位校验码} // 安全性:12位随机字符(62字符集)提供约 3.23 × 10^21 种可能组合 func (s *authorizationCodeService) generateAuthorizationCode(customerID string) (string, error) { - // 获取客户编码的前4位作为前缀 - customerCode := "COMP" // 默认前缀 - if len(customerID) >= 4 { - customerCode = strings.ToUpper(customerID[:4]) - } - - // 生成12位随机字符串(从8位增加到12位以提升安全性) - randomStr, err := s.generateRandomString(12) - if err != nil { - return "", err - } - - // 生成4位校验码 - checksum := s.generateChecksum(customerCode + randomStr) - - // 格式: LIC-{customer_code}-{random}-{checksum} - return fmt.Sprintf("LIC-%s-%s-%s", customerCode, randomStr, checksum), nil -} - -// generateRandomString 生成随机字符串 -// 使用62字符集(A-Z, a-z, 0-9)提供更高的熵值 -func (s *authorizationCodeService) generateRandomString(length int) (string, error) { - // 扩展字符集到62字符(A-Z, a-z, 0-9)以提升安全性 - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - for i, b := range bytes { - bytes[i] = chars[b%byte(len(chars))] - } - return string(bytes), nil -} - -// generateChecksum 生成校验码 -// 基于输入字符串的ASCII值计算,用于格式验证 -func (s *authorizationCodeService) generateChecksum(input string) string { - sum := 0 - for _, char := range input { - sum += int(char) - } - // 使用与随机字符串相同的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)]) - sum = sum / len(chars) - } - return checksum + return utils.GenerateLegacyAuthorizationCode(customerID) } // GetAuthorizationChangeList 查询授权变更历史列表 diff --git a/backend/internal/service/cu_order_service.go b/backend/internal/service/cu_order_service.go index 17d818f..e36f363 100644 --- a/backend/internal/service/cu_order_service.go +++ b/backend/internal/service/cu_order_service.go @@ -2,8 +2,8 @@ package service import ( "context" + "encoding/json" "fmt" - "license-manager/internal/config" "license-manager/internal/models" "license-manager/internal/repository" pkgcontext "license-manager/pkg/context" @@ -250,7 +250,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s expiredAt = nil } - // 生成自包含授权码 + // 构建授权配置(用于后续生成产品激活码 payload) var featureConfigMap, usageLimitsMap map[string]interface{} // 根据套餐类型设置配置信息 @@ -260,20 +260,14 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s } } - licenseConfig := &utils.LicenseConfig{ - EndDate: endDate, - FeatureConfig: featureConfigMap, - UsageLimits: usageLimitsMap, - CustomParameters: map[string]interface{}{ - "package_id": req.PackageID, - "package_name": pkg.Name, - "license_count": req.LicenseCount, - }, + customParametersMap := map[string]interface{}{ + "package_id": req.PackageID, + "package_name": pkg.Name, + "license_count": req.LicenseCount, } - // 从配置获取RSA私钥路径 - privateKeyPath := config.GetConfig().License.RSA.PrivateKeyPath - authCode, err := utils.EncodeLicenseData(licenseConfig, privateKeyPath) + // 授权码生成规则回退/统一为旧规则(不再自包含配置) + authCode, err := utils.GenerateLegacyAuthorizationCode(customerID) if err != nil { tx.Rollback() return nil, i18n.NewI18nError("900004", lang, err.Error()) @@ -282,6 +276,33 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s // 构建订单描述 description := fmt.Sprintf("%s - %d个许可", pkg.Name, req.LicenseCount) + // 处理JSON字段(用于数据库存储与后续生成产品激活码 payload) + var featureConfig, usageLimits, customParameters models.JSON + if featureConfigMap != nil { + b, err := json.Marshal(featureConfigMap) + if err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + featureConfig = models.JSON(b) + } + if usageLimitsMap != nil { + b, err := json.Marshal(usageLimitsMap) + if err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + usageLimits = models.JSON(b) + } + if customParametersMap != nil { + b, err := json.Marshal(customParametersMap) + if err != nil { + tx.Rollback() + return nil, i18n.NewI18nError("900004", lang, err.Error()) + } + customParameters = models.JSON(b) + } + // 构建授权码实体(使用生成的授权码) authCodeEntity := &models.AuthorizationCode{ ID: uuid.New().String(), // 生成新的UUID作为主键 @@ -295,7 +316,9 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s EncryptionType: &[]string{"standard"}[0], MaxActivations: req.LicenseCount, IsLocked: false, - // FeatureConfig 和 UsageLimits 已在授权码字符串中,无需重复存储 + FeatureConfig: featureConfig, + UsageLimits: usageLimits, + CustomParameters: customParameters, } // 创建授权码 diff --git a/backend/internal/service/interfaces.go b/backend/internal/service/interfaces.go index 8aeb781..5a34901 100644 --- a/backend/internal/service/interfaces.go +++ b/backend/internal/service/interfaces.go @@ -47,6 +47,9 @@ type AuthorizationCodeService interface { // 用户端分享功能 ShareAuthorizationCode(ctx context.Context, authCodeID, userID string, req *models.AuthorizationCodeShareRequest) (*models.AuthorizationCodeShareResponse, error) + + // 用户端获取产品激活码 + GetProductActivationCode(ctx context.Context, customerID string, req *models.ProductActivationCodeRequest) (*models.ProductActivationCodeResponse, error) } // LicenseService 许可证服务接口 diff --git a/backend/internal/service/license_service.go b/backend/internal/service/license_service.go index f1b2509..3484ae9 100644 --- a/backend/internal/service/license_service.go +++ b/backend/internal/service/license_service.go @@ -378,64 +378,6 @@ 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) @@ -445,9 +387,15 @@ 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) // 授权码格式无效 + // 兼容“产品激活码”形式:{授权码}&{payload} + // 激活流程只需要授权码本体,不依赖payload。 + req.AuthorizationCode = strings.TrimSpace(req.AuthorizationCode) + if idx := strings.Index(req.AuthorizationCode, "&"); idx > 0 { + req.AuthorizationCode = strings.TrimSpace(req.AuthorizationCode[:idx]) + } + + if req.AuthorizationCode == "" { + return nil, i18n.NewI18nError("900001", lang) } // 获取授权码信息 diff --git a/backend/pkg/utils/authorization_code.go b/backend/pkg/utils/authorization_code.go new file mode 100644 index 0000000..c7cb653 --- /dev/null +++ b/backend/pkg/utils/authorization_code.go @@ -0,0 +1,51 @@ +package utils + +import ( + "crypto/rand" + "fmt" + "strings" +) + +// GenerateLegacyAuthorizationCode 生成旧规则授权码: +// LIC-{客户ID前4位}-{12位随机}-{4位校验码} +func GenerateLegacyAuthorizationCode(customerID string) (string, error) { + customerCode := "COMP" + if len(customerID) >= 4 { + customerCode = strings.ToUpper(customerID[:4]) + } + + randomStr, err := generateSecureRandomString(12) + if err != nil { + return "", err + } + + checksum := generateChecksum62(customerCode + randomStr) + return fmt.Sprintf("LIC-%s-%s-%s", customerCode, randomStr, checksum), nil +} + +func generateSecureRandomString(length int) (string, error) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + for i, b := range bytes { + bytes[i] = chars[b%byte(len(chars))] + } + return string(bytes), nil +} + +func generateChecksum62(input string) string { + sum := 0 + for _, char := range input { + sum += int(char) + } + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + checksum := "" + for i := 0; i < 4; i++ { + checksum += string(chars[sum%len(chars)]) + sum = sum / len(chars) + } + return checksum +} + diff --git a/backend/pkg/utils/license_codec.go b/backend/pkg/utils/license_codec.go deleted file mode 100644 index f505f48..0000000 --- a/backend/pkg/utils/license_codec.go +++ /dev/null @@ -1,409 +0,0 @@ -package utils - -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "os" - "strings" - "time" -) - -// LicenseConfig 自包含授权码配置结构(极简字段名) -type LicenseConfig struct { - EndDate time.Time `json:"e"` // 到期时间 - FeatureConfig map[string]interface{} `json:"f,omitempty"` // 功能配置 - UsageLimits map[string]interface{} `json:"l,omitempty"` // 使用限制 - CustomParameters map[string]interface{} `json:"p,omitempty"` // 自定义参数 -} - -// EncodeLicenseData 将配置数据编码为自包含授权码(极简模式) -func EncodeLicenseData(config *LicenseConfig, privateKeyPath string) (string, error) { - if config == nil { - return "", errors.New("config cannot be nil") - } - - // 使用极简紧凑序列化 - configBytes, err := ultraCompactMarshal(config) - if err != nil { - return "", fmt.Errorf("failed to marshal config: %w", err) - } - - // 使用HMAC-SHA256替代RSA签名(短签名模式) - // 从私钥文件中提取密钥作为HMAC密钥(简化处理) - hmacKey, err := getHMACKeyFromRSAKey(privateKeyPath) - if err != nil { - return "", fmt.Errorf("failed to get HMAC key: %w", err) - } - - signature, err := signWithHMAC(configBytes, hmacKey) - if err != nil { - return "", fmt.Errorf("failed to sign data: %w", err) - } - - // 极简格式:[版本(4位)][时间戳(32位)][配置数据][HMAC签名(32字节)] - version := uint8(3) // 版本3(极简模式) - timestamp := uint32(config.EndDate.Unix()) - - // 打包头部:版本(4位) + 时间戳(28位) 到5字节 - header := make([]byte, 5) - header[0] = (version << 4) | byte(timestamp>>28) - header[1] = byte(timestamp >> 20) - header[2] = byte(timestamp >> 12) - header[3] = byte(timestamp >> 4) - header[4] = byte(timestamp << 4) // 最后4位保留给标志位 - - // 设置字段存在标志 - flagBits := uint8(0) - if config.FeatureConfig != nil { - flagBits |= 1 << 0 - } - if config.UsageLimits != nil { - flagBits |= 1 << 1 - } - if config.CustomParameters != nil { - flagBits |= 1 << 2 - } - header[4] |= flagBits - - // 构建数据:[头部(5字节)][配置数据][签名(32字节)] - data := append(header, configBytes...) - data = append(data, signature...) - - // 使用URL安全的Base64编码 - encodedData := base64.RawURLEncoding.EncodeToString(data) - - // 生成随机码(2位) - randomStr, err := generateRandomString(2) - if err != nil { - return "", fmt.Errorf("failed to generate random string: %w", err) - } - - // 最终格式:LIC-{2位随机}-{数据} - return fmt.Sprintf("LIC-%s-%s", randomStr, encodedData), nil -} - -// ultraCompactMarshal 极简紧凑序列化(无长度前缀) -func ultraCompactMarshal(config *LicenseConfig) ([]byte, error) { - var buf []byte - - // 直接序列化存在的字段,不使用长度前缀 - // 依赖解码时知道字段顺序和格式 - - if config.FeatureConfig != nil { - mapBytes, err := json.Marshal(config.FeatureConfig) - if err != nil { - return nil, err - } - buf = append(buf, mapBytes...) - buf = append(buf, 0) // 字段分隔符 - } - - if config.UsageLimits != nil { - mapBytes, err := json.Marshal(config.UsageLimits) - if err != nil { - return nil, err - } - buf = append(buf, mapBytes...) - buf = append(buf, 0) // 字段分隔符 - } - - if config.CustomParameters != nil { - mapBytes, err := json.Marshal(config.CustomParameters) - if err != nil { - return nil, err - } - buf = append(buf, mapBytes...) - buf = append(buf, 0) // 字段分隔符 - } - - return buf, nil -} - -// getHMACKeyFromRSAKey 从RSA密钥文件提取HMAC密钥 -func getHMACKeyFromRSAKey(keyPath string) ([]byte, error) { - // 首先尝试作为私钥加载 - if privateKey, err := getLicensePrivateKey(keyPath); err == nil { - // 使用私钥的模数作为HMAC密钥 - key := privateKey.N.Bytes() - // 截取前32字节作为HMAC-SHA256密钥 - if len(key) > 32 { - key = key[:32] - } else if len(key) < 32 { - // 补齐到32字节 - padding := make([]byte, 32-len(key)) - key = append(key, padding...) - } - return key, nil - } - - // 如果私钥加载失败,尝试作为公钥加载 - if publicKey, err := getLicensePublicKey(keyPath); err == nil { - // 使用公钥的模数作为HMAC密钥 - key := publicKey.N.Bytes() - // 截取前32字节作为HMAC-SHA256密钥 - if len(key) > 32 { - key = key[:32] - } else if len(key) < 32 { - // 补齐到32字节 - padding := make([]byte, 32-len(key)) - key = append(key, padding...) - } - return key, nil - } - - return nil, errors.New("failed to load RSA key for HMAC") -} - -// getLicensePublicKey 获取RSA公钥(复用crypto.go的函数) -func getLicensePublicKey(publicKeyPath string) (*RSAPublicKey, error) { - // 检查文件是否存在 - if _, err := os.Stat(publicKeyPath); os.IsNotExist(err) { - return nil, fmt.Errorf("RSA公钥文件不存在: %s", publicKeyPath) - } - - // 加载公钥 - publicKey, err := LoadRSAPublicKeyFromFile(publicKeyPath) - if err != nil { - return nil, fmt.Errorf("RSA公钥文件损坏或格式错误: %w", err) - } - - return publicKey, nil -} - -// signWithHMAC 使用HMAC-SHA256签名 -func signWithHMAC(data []byte, key []byte) ([]byte, error) { - h := hmac.New(sha256.New, key) - h.Write(data) - return h.Sum(nil), nil -} - -// ultraCompactUnmarshal 从极简格式反序列化配置 -func ultraCompactUnmarshal(data []byte, flags uint8) (*LicenseConfig, error) { - config := &LicenseConfig{} - offset := 0 - - // 解析f字段(如果存在) - if flags&1 != 0 { - end := findFieldEnd(data, offset) - if end == -1 { - return nil, errors.New("invalid feature config format") - } - var m map[string]interface{} - if err := json.Unmarshal(data[offset:end], &m); err != nil { - return nil, fmt.Errorf("invalid feature config: %w", err) - } - config.FeatureConfig = m - offset = end + 1 - } - - // 解析l字段(如果存在) - if flags&(1<<1) != 0 { - end := findFieldEnd(data, offset) - if end == -1 { - return nil, errors.New("invalid usage limits format") - } - var m map[string]interface{} - if err := json.Unmarshal(data[offset:end], &m); err != nil { - return nil, fmt.Errorf("invalid usage limits: %w", err) - } - config.UsageLimits = m - offset = end + 1 - } - - // 解析p字段(如果存在) - if flags&(1<<2) != 0 { - if offset >= len(data) { - return nil, errors.New("missing custom parameters") - } - // 最后一个字段到数据结尾 - var m map[string]interface{} - if err := json.Unmarshal(data[offset:], &m); err != nil { - return nil, fmt.Errorf("invalid custom parameters: %w", err) - } - config.CustomParameters = m - } - - return config, nil -} - -// findFieldEnd 查找字段结束位置(以null字节分隔) -func findFieldEnd(data []byte, start int) int { - for i := start; i < len(data); i++ { - if data[i] == 0 { - return i - } - } - return -1 -} - -// DecodeLicenseData 从授权码解析配置数据 -func DecodeLicenseData(code string, publicKeyPath string) (*LicenseConfig, error) { - if !strings.HasPrefix(code, "LIC-") { - return nil, errors.New("invalid license format") - } - - // 解析授权码格式:LIC-{2位随机码}-{数据} - parts := strings.Split(code, "-") - if len(parts) != 3 || parts[0] != "LIC" { - return nil, errors.New("invalid license format") - } - - randomStr := parts[1] - encodedData := parts[2] - - // 验证随机码长度 - if len(randomStr) != 2 { - return nil, errors.New("invalid random string length") - } - - // 使用URL安全的Base64解码 - dataBytes, err := base64.RawURLEncoding.DecodeString(encodedData) - if err != nil { - return nil, fmt.Errorf("failed to decode base64: %w", err) - } - - // 解析极简格式:[头部(5字节)][配置数据][签名(32字节)] - if len(dataBytes) < 5+32 { // 头部 + 最少签名 - return nil, errors.New("invalid data format") - } - - // 解析头部 - header := dataBytes[:5] - version := header[0] >> 4 - timestamp := uint32(header[0]&0x0F)<<28 | uint32(header[1])<<20 | uint32(header[2])<<12 | uint32(header[3])<<4 | uint32(header[4]>>4) - flags := header[4] & 0x0F - - if version != 3 { - return nil, errors.New("unsupported version") - } - - config := &LicenseConfig{ - EndDate: time.Unix(int64(timestamp), 0), - } - - // 分离配置数据和签名 - configDataEnd := len(dataBytes) - 32 - configBytes := dataBytes[5:configDataEnd] - signature := dataBytes[configDataEnd:] - - // 获取HMAC密钥用于验证 - hmacKey, err := getHMACKeyFromRSAKey(publicKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to get HMAC key: %w", err) - } - - // 验证HMAC签名 - if !verifyHMAC(configBytes, signature, hmacKey) { - return nil, errors.New("signature verification failed") - } - - // 从极简格式反序列化配置 - fullConfig, err := ultraCompactUnmarshal(configBytes, flags) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) - } - - // 合并时间戳 - fullConfig.EndDate = config.EndDate - return fullConfig, nil -} - -// verifyHMAC 验证HMAC签名 -func verifyHMAC(data []byte, signature []byte, key []byte) bool { - h := hmac.New(sha256.New, key) - h.Write(data) - expectedMAC := h.Sum(nil) - return hmac.Equal(signature, expectedMAC) -} - -// SignLicenseData 使用RSA私钥对数据进行签名 -func SignLicenseData(data []byte, privateKeyPath string) ([]byte, error) { - // 获取RSA私钥 - privateKey, err := getLicensePrivateKey(privateKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to get private key: %w", err) - } - - // 使用现有的RSA签名方法(PSS模式,与许可证保持一致) - signature, err := privateKey.SignData(data) - if err != nil { - return nil, fmt.Errorf("failed to sign data: %w", err) - } - - // 解码Base64签名 - signatureBytes, err := base64.StdEncoding.DecodeString(signature) - if err != nil { - return nil, fmt.Errorf("failed to decode signature: %w", err) - } - - return signatureBytes, nil -} - -// VerifyLicenseSignature 使用RSA公钥验证签名 -func VerifyLicenseSignature(data []byte, signature []byte, publicKeyPath string) error { - // 获取RSA公钥 - publicKey, err := getLicensePublicKey(publicKeyPath) - if err != nil { - return fmt.Errorf("failed to get public key: %w", err) - } - - // 编码签名为Base64(与SignData方法匹配) - signatureBase64 := base64.StdEncoding.EncodeToString(signature) - - // 使用现有的RSA验证方法 - err = publicKey.VerifySignature(data, signatureBase64) - if err != nil { - return fmt.Errorf("signature verification failed: %w", err) - } - - return nil -} - -// getLicensePrivateKey 获取许可证RSA私钥 -func getLicensePrivateKey(privateKeyPath string) (*RSAPrivateKey, error) { - // 检查文件是否存在 - if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) { - return nil, fmt.Errorf("RSA私钥文件不存在,请先生成RSA密钥对: %s", privateKeyPath) - } - - // 加载私钥 - privateKey, err := LoadRSAPrivateKeyFromFile(privateKeyPath) - if err != nil { - return nil, fmt.Errorf("RSA私钥文件损坏或格式错误: %w", err) - } - - return privateKey, nil -} - -// generateRandomString 生成随机字符串 -func generateRandomString(length int) (string, error) { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - for i, b := range bytes { - bytes[i] = chars[b%byte(len(chars))] - } - return string(bytes), nil -} - -// generateChecksum 生成校验码 -func generateChecksum(input string) string { - sum := 0 - for _, char := range input { - sum += int(char) - } - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - checksum := "" - for i := 0; i < 4; i++ { - checksum += string(chars[sum%len(chars)]) - sum = sum / len(chars) - } - return checksum -}