From 994a5de632b24c245137da4d869da1a16a8429a9 Mon Sep 17 00:00:00 2001 From: wood911010 Date: Tue, 25 Nov 2025 23:10:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=96=87=E4=BB=B6=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=8B=E8=BD=BD=E5=8C=85=E5=90=AB=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E7=A0=81=E5=92=8CRSA=E5=85=AC=E9=92=A5=E7=9A=84?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E5=8C=85=EF=BC=8C=E5=B9=B6=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=9B=BD=E9=99=85=E5=8C=96=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy_frontend.git.yml | 30 ++-- backend/configs/i18n/errors/en-US.yaml | 1 - backend/configs/i18n/errors/ja-JP.yaml | 1 - backend/configs/i18n/errors/zh-CN.yaml | 1 - backend/docs/swagger/docs.go | 76 ++++++++- backend/docs/swagger/swagger.json | 71 ++++++++- backend/docs/swagger/swagger.yaml | 47 +++++- .../handlers/authorization_code_handler.go | 149 ++++++++++++++++++ .../internal/api/handlers/license_handler.go | 105 +++++++++++- backend/internal/api/routes/router.go | 15 +- .../service/authorization_code_service.go | 28 ++++ backend/internal/service/interfaces.go | 9 +- backend/internal/service/license_service.go | 62 ++++---- frontend/.vscode/settings.json | 28 ++++ frontend/src/api/license.ts | 9 ++ .../components/charts/LicenseTrendChart.vue | 30 +++- .../components/common/layout/NavContent.vue | 46 ++++++ frontend/src/i18n/locales/en.json | 13 +- frontend/src/i18n/locales/ja.json | 13 +- frontend/src/i18n/locales/zh.json | 13 +- .../LicensesView/components/LicenseInfo.vue | 2 +- .../src/views/Licenses/LicensesView/index.vue | 60 ++++++- 22 files changed, 716 insertions(+), 93 deletions(-) create mode 100644 frontend/.vscode/settings.json diff --git a/.github/workflows/deploy_frontend.git.yml b/.github/workflows/deploy_frontend.git.yml index 6b964a0..7e38691 100644 --- a/.github/workflows/deploy_frontend.git.yml +++ b/.github/workflows/deploy_frontend.git.yml @@ -28,17 +28,15 @@ jobs: cache: 'npm' cache-dependency-path: frontend/package-lock.json - # 缓存node_modules和构建输出 - - name: Cache dependencies and build + # 缓存node_modules(不缓存构建输出,确保每次都是最新构建) + - name: Cache dependencies uses: actions/cache@v4 with: path: | frontend/node_modules - frontend/production - key: ${{ runner.os }}-frontend-${{ hashFiles('frontend/package-lock.json', 'frontend/src/**/*', 'frontend/vite.config.ts') }} + key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/package-lock.json') }} restore-keys: | - ${{ runner.os }}-frontend-${{ hashFiles('frontend/package-lock.json') }} - ${{ runner.os }}-frontend- + ${{ runner.os }}-frontend-deps- # 安装依赖 - name: Install dependencies @@ -46,23 +44,33 @@ jobs: cd frontend npm ci - # 并行执行类型检查和构建 + # 清理旧的构建目录(确保使用最新代码构建) + - name: Clean old build artifacts + run: | + cd frontend + rm -rf production dist + echo "Cleaned old build directories" + + # 执行类型检查和构建 - name: Type check and build run: | cd frontend # 设置构建环境变量,确保与本地构建一致 export VITE_OUTDIR=production export VITE_ADDRESS_BASE_URL=/production/ - # 并行执行类型检查和构建以节省时间 - npm run type-check & - npm run build:prod & - wait + # 先执行类型检查 + npm run type-check + # 然后执行构建 + npm run build:prod # 检查构建结果 echo "Build completed, checking production directory:" ls -la production/ # 验证构建产物中的 base 路径 echo "Verifying base path in index.html:" grep -o 'href="/production/' production/index.html | head -1 || echo "Warning: base path not found" + # 显示构建产物的时间戳,确认是新构建的 + echo "Build timestamp:" + stat production/index.html 2>/dev/null || ls -la production/index.html # 准备临时部署目录 - name: Prepare temp directory diff --git a/backend/configs/i18n/errors/en-US.yaml b/backend/configs/i18n/errors/en-US.yaml index 3b250b7..87df94e 100644 --- a/backend/configs/i18n/errors/en-US.yaml +++ b/backend/configs/i18n/errors/en-US.yaml @@ -96,7 +96,6 @@ enums: encryption_type: "standard": "Standard" - "advanced": "Advanced" authorization_status: "normal": "Normal" diff --git a/backend/configs/i18n/errors/ja-JP.yaml b/backend/configs/i18n/errors/ja-JP.yaml index 3e2e82f..6968d65 100644 --- a/backend/configs/i18n/errors/ja-JP.yaml +++ b/backend/configs/i18n/errors/ja-JP.yaml @@ -96,7 +96,6 @@ enums: encryption_type: "standard": "標準暗号化" - "advanced": "高度暗号化" authorization_status: "normal": "正常" diff --git a/backend/configs/i18n/errors/zh-CN.yaml b/backend/configs/i18n/errors/zh-CN.yaml index 6255810..7fce552 100644 --- a/backend/configs/i18n/errors/zh-CN.yaml +++ b/backend/configs/i18n/errors/zh-CN.yaml @@ -96,7 +96,6 @@ enums: encryption_type: "standard": "标准加密" - "advanced": "高级加密" authorization_status: "normal": "正常" diff --git a/backend/docs/swagger/docs.go b/backend/docs/swagger/docs.go index 2f62fad..8c8705e 100644 --- a/backend/docs/swagger/docs.go +++ b/backend/docs/swagger/docs.go @@ -1,4 +1,5 @@ -// Package swagger Code generated by swaggo/swag. DO NOT EDIT +// Code generated by swaggo/swag. DO NOT EDIT. + package swagger import "github.com/swaggo/swag" @@ -1363,6 +1364,73 @@ const docTemplate = `{ } } }, + "/api/v1/authorization-codes/{id}/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "下载授权码文件,包含授权码和RSA公钥,供客户端使用", + "consumes": [ + "application/json" + ], + "produces": [ + "application/zip" + ], + "tags": [ + "授权码管理" + ], + "summary": "下载授权文件", + "parameters": [ + { + "type": "string", + "description": "授权码ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "包含授权码文件和RSA公钥的压缩包", + "schema": { + "type": "file" + } + }, + "400": { + "description": "请求参数无效", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "授权码不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "409": { + "description": "授权码不可用", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/authorization-codes/{id}/lock": { "put": { "security": [ @@ -1984,7 +2052,7 @@ const docTemplate = `{ "application/json" ], "produces": [ - "application/octet-stream" + "application/zip" ], "tags": [ "许可证管理" @@ -2001,7 +2069,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "许可证文件", + "description": "包含许可证文件和RSA公钥的压缩包", "schema": { "type": "file" } @@ -3865,8 +3933,6 @@ var SwaggerInfo = &swag.Spec{ Description: "软件授权管理平台API文档", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", } func init() { diff --git a/backend/docs/swagger/swagger.json b/backend/docs/swagger/swagger.json index 67319c0..b97fcc2 100644 --- a/backend/docs/swagger/swagger.json +++ b/backend/docs/swagger/swagger.json @@ -1360,6 +1360,73 @@ } } }, + "/api/v1/authorization-codes/{id}/download": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "下载授权码文件,包含授权码和RSA公钥,供客户端使用", + "consumes": [ + "application/json" + ], + "produces": [ + "application/zip" + ], + "tags": [ + "授权码管理" + ], + "summary": "下载授权文件", + "parameters": [ + { + "type": "string", + "description": "授权码ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "包含授权码文件和RSA公钥的压缩包", + "schema": { + "type": "file" + } + }, + "400": { + "description": "请求参数无效", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "401": { + "description": "未认证", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "404": { + "description": "授权码不存在", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "409": { + "description": "授权码不可用", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + }, + "500": { + "description": "服务器内部错误", + "schema": { + "$ref": "#/definitions/models.ErrorResponse" + } + } + } + } + }, "/api/v1/authorization-codes/{id}/lock": { "put": { "security": [ @@ -1981,7 +2048,7 @@ "application/json" ], "produces": [ - "application/octet-stream" + "application/zip" ], "tags": [ "许可证管理" @@ -1998,7 +2065,7 @@ ], "responses": { "200": { - "description": "许可证文件", + "description": "包含许可证文件和RSA公钥的压缩包", "schema": { "type": "file" } diff --git a/backend/docs/swagger/swagger.yaml b/backend/docs/swagger/swagger.yaml index 6ead621..636c8e4 100644 --- a/backend/docs/swagger/swagger.yaml +++ b/backend/docs/swagger/swagger.yaml @@ -1959,6 +1959,49 @@ paths: summary: 查询授权变更历史列表 tags: - 授权码管理 + /api/v1/authorization-codes/{id}/download: + get: + consumes: + - application/json + description: 下载授权码文件,包含授权码和RSA公钥,供客户端使用 + parameters: + - description: 授权码ID + in: path + name: id + required: true + type: string + produces: + - application/zip + responses: + "200": + description: 包含授权码文件和RSA公钥的压缩包 + schema: + type: file + "400": + description: 请求参数无效 + schema: + $ref: '#/definitions/models.ErrorResponse' + "401": + description: 未认证 + schema: + $ref: '#/definitions/models.ErrorResponse' + "404": + description: 授权码不存在 + schema: + $ref: '#/definitions/models.ErrorResponse' + "409": + description: 授权码不可用 + schema: + $ref: '#/definitions/models.ErrorResponse' + "500": + description: 服务器内部错误 + schema: + $ref: '#/definitions/models.ErrorResponse' + security: + - BearerAuth: [] + summary: 下载授权文件 + tags: + - 授权码管理 /api/v1/authorization-codes/{id}/lock: put: consumes: @@ -2349,10 +2392,10 @@ paths: required: true type: string produces: - - application/octet-stream + - application/zip responses: "200": - description: 许可证文件 + description: 包含许可证文件和RSA公钥的压缩包 schema: type: file "400": diff --git a/backend/internal/api/handlers/authorization_code_handler.go b/backend/internal/api/handlers/authorization_code_handler.go index 3a10f9f..ef58994 100644 --- a/backend/internal/api/handlers/authorization_code_handler.go +++ b/backend/internal/api/handlers/authorization_code_handler.go @@ -1,11 +1,18 @@ package handlers import ( + "archive/zip" + "bytes" "errors" + "fmt" "net/http" + "os" + "path/filepath" + "strings" "time" "license-manager/internal/api/middleware" + "license-manager/internal/config" "license-manager/internal/models" "license-manager/internal/service" "license-manager/pkg/i18n" @@ -211,6 +218,148 @@ func (h *AuthorizationCodeHandler) GetAuthorizationCode(c *gin.Context) { }) } +// DownloadAuthorizationFile 下载授权文件 +// @Summary 下载授权文件 +// @Description 下载授权码文件,包含授权码和RSA公钥,供客户端使用 +// @Tags 授权码管理 +// @Accept json +// @Produce application/zip +// @Security BearerAuth +// @Param id path string true "授权码ID" +// @Success 200 {file} binary "包含授权码文件和RSA公钥的压缩包" +// @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/v1/authorization-codes/{id}/download [get] +func (h *AuthorizationCodeHandler) DownloadAuthorizationFile(c *gin.Context) { + lang := middleware.GetLanguage(c) + id := c.Param("id") + if id == "" { + status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + ctx := middleware.WithLanguage(c.Request.Context(), c) + + fileData, fileName, authCode, err := h.authCodeService.GenerateAuthorizationFile(ctx, id) + if err != nil { + var i18nErr *i18n.I18nError + if errors.As(err, &i18nErr) { + c.JSON(i18nErr.HttpCode, models.ErrorResponse{ + Code: i18nErr.Code, + Message: i18nErr.Message, + Timestamp: time.Now().Format(time.RFC3339), + }) + } else { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + } + return + } + + cfg := config.GetConfig() + if cfg == nil || cfg.License.RSA.PublicKeyPath == "" { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + publicKeyPath := filepath.Clean(cfg.License.RSA.PublicKeyPath) + publicKeyData, err := os.ReadFile(publicKeyPath) + if err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + var buffer bytes.Buffer + zipWriter := zip.NewWriter(&buffer) + + const zipAuthFileName = "authorization_code.txt" + authFileWriter, err := zipWriter.Create(zipAuthFileName) + if err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + if _, err := authFileWriter.Write(fileData); err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + publicKeyWriter, err := zipWriter.Create("rsa_public_key.pem") + if err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + if _, err := publicKeyWriter.Write(publicKeyData); err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + if err := zipWriter.Close(); err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + packageFileName := fmt.Sprintf("authorization_package_%s.zip", strings.TrimSpace(authCode)) + if strings.TrimSpace(authCode) == "" { + baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + packageFileName = fmt.Sprintf("%s_package.zip", baseName) + } + + c.Header("Content-Description", "File Transfer") + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", packageFileName)) + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Cache-Control", "must-revalidate") + c.Header("Pragma", "public") + c.Data(http.StatusOK, "application/zip", buffer.Bytes()) +} + // UpdateAuthorizationCode 更新授权码 // @Summary 更新授权码 // @Description 更新授权码信息,所有字段都是可选的,需要指定变更类型和原因 diff --git a/backend/internal/api/handlers/license_handler.go b/backend/internal/api/handlers/license_handler.go index a587cc6..29dc2e0 100644 --- a/backend/internal/api/handlers/license_handler.go +++ b/backend/internal/api/handlers/license_handler.go @@ -1,12 +1,18 @@ package handlers import ( + "archive/zip" + "bytes" "errors" "fmt" "net/http" + "os" + "path/filepath" + "strings" "time" "license-manager/internal/api/middleware" + "license-manager/internal/config" "license-manager/internal/models" "license-manager/internal/service" "license-manager/pkg/i18n" @@ -298,10 +304,10 @@ func (h *LicenseHandler) RevokeLicense(c *gin.Context) { // @Description 下载加密的许可证文件,用于客户端软件激活 // @Tags 许可证管理 // @Accept json -// @Produce application/octet-stream +// @Produce application/zip // @Security BearerAuth // @Param id path string true "许可证ID" -// @Success 200 {file} binary "许可证文件" +// @Success 200 {file} binary "包含许可证文件和RSA公钥的压缩包" // @Failure 400 {object} models.ErrorResponse "请求参数无效" // @Failure 401 {object} models.ErrorResponse "未认证" // @Failure 404 {object} models.ErrorResponse "许可证不存在" @@ -309,9 +315,9 @@ func (h *LicenseHandler) RevokeLicense(c *gin.Context) { // @Failure 500 {object} models.ErrorResponse "服务器内部错误" // @Router /api/v1/licenses/{id}/download [get] func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) { + lang := middleware.GetLanguage(c) id := c.Param("id") if id == "" { - lang := middleware.GetLanguage(c) status, errCode, message := i18n.NewI18nErrorResponse("900001", lang) c.JSON(status, models.ErrorResponse{ Code: errCode, @@ -324,7 +330,7 @@ func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) { // 设置语言到Context中 ctx := middleware.WithLanguage(c.Request.Context(), c) - fileData, fileName, err := h.licenseService.GenerateLicenseFile(ctx, id) + fileData, fileName, licenseKey, err := h.licenseService.GenerateLicenseFile(ctx, id) if err != nil { // 错误已经在Service层完全包装好了,直接使用 var i18nErr *i18n.I18nError @@ -347,16 +353,99 @@ func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) { return } + cfg := config.GetConfig() + if cfg == nil || cfg.License.RSA.PublicKeyPath == "" { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + publicKeyPath := filepath.Clean(cfg.License.RSA.PublicKeyPath) + publicKeyData, err := os.ReadFile(publicKeyPath) + if err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + var buffer bytes.Buffer + zipWriter := zip.NewWriter(&buffer) + + const zipLicenseFileName = "license.lic" + licenseFileWriter, err := zipWriter.Create(zipLicenseFileName) + if err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + if _, err := licenseFileWriter.Write(fileData); err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + publicKeyWriter, err := zipWriter.Create("rsa_public_key.pem") + if err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + if _, err := publicKeyWriter.Write(publicKeyData); err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + if err := zipWriter.Close(); err != nil { + status, errCode, message := i18n.NewI18nErrorResponse("900004", lang) + c.JSON(status, models.ErrorResponse{ + Code: errCode, + Message: message, + Timestamp: time.Now().Format(time.RFC3339), + }) + return + } + + packageFileName := fmt.Sprintf("license_package_%s.zip", strings.TrimSpace(licenseKey)) + if strings.TrimSpace(licenseKey) == "" { + baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) + packageFileName = fmt.Sprintf("%s_package.zip", baseName) + } + // 设置响应头,指示这是一个文件下载 c.Header("Content-Description", "File Transfer") - c.Header("Content-Type", "application/octet-stream") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", packageFileName)) c.Header("Content-Transfer-Encoding", "binary") c.Header("Cache-Control", "must-revalidate") c.Header("Pragma", "public") // 返回文件内容 - c.Data(http.StatusOK, "application/octet-stream", fileData) + c.Data(http.StatusOK, "application/zip", buffer.Bytes()) } // ActivateLicense 激活许可证 @@ -533,4 +622,4 @@ func (h *LicenseHandler) GetStatsOverview(c *gin.Context) { Message: successMessage, Data: data, }) -} \ No newline at end of file +} diff --git a/backend/internal/api/routes/router.go b/backend/internal/api/routes/router.go index cf91f38..778ce3d 100644 --- a/backend/internal/api/routes/router.go +++ b/backend/internal/api/routes/router.go @@ -76,7 +76,7 @@ func SetupRouter() *gin.Engine { public := api.Group("") { public.POST("/v1/login", authHandler.Login) - + // 许可证激活接口(客户端软件使用) public.POST("/v1/activate", licenseHandler.ActivateLicense) public.POST("/v1/heartbeat", licenseHandler.Heartbeat) @@ -89,7 +89,7 @@ func SetupRouter() *gin.Engine { // 认证相关 auth.POST("/v1/logout", authHandler.Logout) auth.POST("/v1/auth/refresh", authHandler.RefreshToken) - + // 客户管理 auth.GET("/customers", customerHandler.GetCustomerList) auth.GET("/customers/:id", customerHandler.GetCustomer) @@ -97,30 +97,31 @@ func SetupRouter() *gin.Engine { auth.PUT("/customers/:id", customerHandler.UpdateCustomer) auth.DELETE("/customers/:id", customerHandler.DeleteCustomer) auth.PATCH("/customers/:id/status", customerHandler.UpdateCustomerStatus) - + // 枚举管理 auth.GET("/enums", enumHandler.GetAllEnums) auth.GET("/enums/:type", enumHandler.GetEnumsByType) - + // 授权码管理 auth.GET("/v1/authorization-codes", authCodeHandler.GetAuthorizationCodeList) auth.POST("/v1/authorization-codes", authCodeHandler.CreateAuthorizationCode) auth.GET("/v1/authorization-codes/:id", authCodeHandler.GetAuthorizationCode) + auth.GET("/v1/authorization-codes/:id/download", authCodeHandler.DownloadAuthorizationFile) auth.PUT("/v1/authorization-codes/:id", authCodeHandler.UpdateAuthorizationCode) auth.PUT("/v1/authorization-codes/:id/lock", authCodeHandler.LockUnlockAuthorizationCode) auth.DELETE("/v1/authorization-codes/:id", authCodeHandler.DeleteAuthorizationCode) auth.GET("/v1/authorization-codes/:id/changes", authCodeHandler.GetAuthorizationChangeList) - + // 许可证管理 auth.GET("/v1/licenses", licenseHandler.GetLicenseList) auth.GET("/v1/licenses/:id", licenseHandler.GetLicense) auth.POST("/v1/licenses", licenseHandler.CreateLicense) auth.PUT("/v1/licenses/:id/revoke", licenseHandler.RevokeLicense) auth.GET("/v1/licenses/:id/download", licenseHandler.DownloadLicenseFile) - + // 统计分析 auth.GET("/v1/stats/overview", licenseHandler.GetStatsOverview) - + // 仪表盘接口 auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend) auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations) diff --git a/backend/internal/service/authorization_code_service.go b/backend/internal/service/authorization_code_service.go index cb9a806..9f150c9 100644 --- a/backend/internal/service/authorization_code_service.go +++ b/backend/internal/service/authorization_code_service.go @@ -228,6 +228,34 @@ func (s *authorizationCodeService) GetAuthorizationCode(ctx context.Context, id return authCode, nil } +// GenerateAuthorizationFile 生成授权码文件内容 +func (s *authorizationCodeService) GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error) { + lang := pkgcontext.GetLanguageFromContext(ctx) + + if id == "" { + return nil, "", "", i18n.NewI18nError("900001", lang) + } + + authCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrAuthorizationCodeNotFound) { + return nil, "", "", i18n.NewI18nError("300001", lang) // 授权码不存在 + } + return nil, "", "", i18n.NewI18nError("900004", lang, err.Error()) + } + + 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) // 授权码未生效或已过期 + } + + fileName := fmt.Sprintf("authorization_%s.txt", authCode.Code) + return []byte(authCode.Code), fileName, authCode.Code, nil +} + // fillAuthorizationCodeDisplayFields 填充完整授权码模型的多语言显示字段 func (s *authorizationCodeService) fillAuthorizationCodeDisplayFields(authCode *models.AuthorizationCode, lang string) { // 计算状态 diff --git a/backend/internal/service/interfaces.go b/backend/internal/service/interfaces.go index 5d029c3..4a92d00 100644 --- a/backend/internal/service/interfaces.go +++ b/backend/internal/service/interfaces.go @@ -43,6 +43,7 @@ type AuthorizationCodeService interface { LockUnlockAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeLockRequest) (*models.AuthorizationCode, error) DeleteAuthorizationCode(ctx context.Context, id string) error GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error) + GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error) } // LicenseService 许可证服务接口 @@ -51,12 +52,12 @@ type LicenseService interface { GetLicense(ctx context.Context, id string) (*models.LicenseDetailResponse, error) CreateLicense(ctx context.Context, req *models.LicenseCreateRequest) (*models.License, error) RevokeLicense(ctx context.Context, id string, req *models.LicenseRevokeRequest) (*models.License, error) - GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, error) - + GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, string, error) + // 客户端激活和心跳接口 ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error) Heartbeat(ctx context.Context, req *models.HeartbeatRequest, clientIP string) (*models.HeartbeatResponse, error) - + // 统计接口 GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error) } @@ -65,7 +66,7 @@ type LicenseService interface { type DashboardService interface { // 获取授权趋势数据 GetAuthorizationTrend(ctx context.Context, req *models.DashboardAuthorizationTrendRequest) (*models.DashboardAuthorizationTrendResponse, error) - + // 获取最近授权列表 GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error) } diff --git a/backend/internal/service/license_service.go b/backend/internal/service/license_service.go index 6119cdf..3f7cc9d 100644 --- a/backend/internal/service/license_service.go +++ b/backend/internal/service/license_service.go @@ -251,14 +251,14 @@ func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *mode // 更新许可证状态为撤销 existingLicense.Status = "revoked" - + // 如果提供了撤销原因,保存到使用数据中 if req.Reason != "" { revokeData := map[string]interface{}{ - "revoked_at": time.Now().Format(time.RFC3339), - "revoke_reason": req.Reason, + "revoked_at": time.Now().Format(time.RFC3339), + "revoke_reason": req.Reason, } - + // 保留原有的使用数据 if len(existingLicense.UsageData) > 0 { var existingUsageData map[string]interface{} @@ -268,7 +268,7 @@ func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *mode } } } - + usageDataBytes, err := json.Marshal(revokeData) if err == nil { existingLicense.UsageData = models.JSON(usageDataBytes) @@ -284,14 +284,14 @@ func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *mode } // GenerateLicenseFile 生成许可证文件 -func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, error) { +func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, string, error) { lang := pkgcontext.GetLanguageFromContext(ctx) s.logger.Infof("[GenerateLicenseFile] 开始生成许可证文件,license_id: %s", id) // 业务逻辑:参数验证 if id == "" { s.logger.Error("[GenerateLicenseFile] 错误:许可证ID为空") - return nil, "", i18n.NewI18nError("900001", lang) + return nil, "", "", i18n.NewI18nError("900001", lang) } // 获取许可证信息 @@ -300,9 +300,9 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] if err != nil { s.logger.Errorf("[GenerateLicenseFile] 查询许可证失败,license_id: %s, error: %v", id, err) if errors.Is(err, repository.ErrLicenseNotFound) { - return nil, "", i18n.NewI18nError("300006", lang) // 许可证不存在 + return nil, "", "", i18n.NewI18nError("300006", lang) // 许可证不存在 } - return nil, "", i18n.NewI18nError("900004", lang, err.Error()) + return nil, "", "", i18n.NewI18nError("900004", lang, err.Error()) } s.logger.Infof("[GenerateLicenseFile] 许可证信息查询成功,license_key: %s, status: %s", license.LicenseKey, license.Status) @@ -310,7 +310,7 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] // 检查许可证状态 if license.Status == "revoked" { s.logger.Warnf("[GenerateLicenseFile] 许可证已被撤销,license_id: %s", id) - return nil, "", i18n.NewI18nError("300007", lang) // 许可证已被撤销 + return nil, "", "", i18n.NewI18nError("300007", lang) // 许可证已被撤销 } // 构建许可证文件内容 @@ -318,10 +318,10 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] "license_key": license.LicenseKey, "authorization_code_id": license.AuthorizationCodeID, "hardware_fingerprint": license.HardwareFingerprint, - "status": license.Status, - "activated_at": license.ActivatedAt, - "config_updated_at": license.ConfigUpdatedAt, - "generated_at": time.Now().Format(time.RFC3339), + "status": license.Status, + "activated_at": license.ActivatedAt, + "config_updated_at": license.ConfigUpdatedAt, + "generated_at": time.Now().Format(time.RFC3339), } // 包含授权码信息 @@ -331,7 +331,7 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] licenseFileData["end_date"] = license.AuthorizationCode.EndDate licenseFileData["deployment_type"] = license.AuthorizationCode.DeploymentType licenseFileData["max_activations"] = license.AuthorizationCode.MaxActivations - + // 包含功能配置 if len(license.AuthorizationCode.FeatureConfig) > 0 { var featureConfig map[string]interface{} @@ -339,7 +339,7 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] licenseFileData["feature_config"] = featureConfig } } - + // 包含使用限制 if len(license.AuthorizationCode.UsageLimits) > 0 { var usageLimits map[string]interface{} @@ -347,7 +347,7 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] licenseFileData["usage_limits"] = usageLimits } } - + // 包含自定义参数 if len(license.AuthorizationCode.CustomParameters) > 0 { var customParameters map[string]interface{} @@ -360,19 +360,19 @@ func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([] // 序列化许可证数据 licenseJSON, err := json.Marshal(licenseFileData) if err != nil { - return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败 + return nil, "", "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败 } // 使用RSA数字签名 encryptedData, err := s.signLicenseFile(licenseJSON) if err != nil { - return nil, "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败 + return nil, "", "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败 } // 生成文件名 fileName := fmt.Sprintf("license_%s.lic", license.LicenseKey) - return encryptedData, fileName, nil + return encryptedData, fileName, license.LicenseKey, nil } // generateLicenseKey 生成许可证密钥 @@ -489,9 +489,9 @@ func (s *licenseService) ActivateLicense(ctx context.Context, req *models.Activa // 检查是否已存在相同硬件指纹的许可证 var existingLicense models.License - err = tx.Where("authorization_code_id = ? AND hardware_fingerprint = ?", + err = tx.Where("authorization_code_id = ? AND hardware_fingerprint = ?", authCode.ID, req.HardwareFingerprint).First(&existingLicense).Error - + if err == nil { // 已存在,直接激活 existingLicense.Status = "active" @@ -500,7 +500,7 @@ func (s *licenseService) ActivateLicense(ctx context.Context, req *models.Activa existingLicense.ActivatedAt = &now existingLicense.LastHeartbeat = &now existingLicense.LastOnlineIP = &clientIP - + if err := tx.Save(&existingLicense).Error; err != nil { return err } @@ -655,10 +655,10 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut "license_key": license.LicenseKey, "authorization_code_id": license.AuthorizationCodeID, "hardware_fingerprint": license.HardwareFingerprint, - "status": license.Status, - "activated_at": license.ActivatedAt, - "config_updated_at": license.ConfigUpdatedAt, - "generated_at": time.Now().Format(time.RFC3339), + "status": license.Status, + "activated_at": license.ActivatedAt, + "config_updated_at": license.ConfigUpdatedAt, + "generated_at": time.Now().Format(time.RFC3339), } if authCode != nil { @@ -667,7 +667,7 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut licenseFileData["end_date"] = authCode.EndDate licenseFileData["deployment_type"] = authCode.DeploymentType licenseFileData["max_activations"] = authCode.MaxActivations - + // 包含功能配置等 if len(authCode.FeatureConfig) > 0 { var featureConfig map[string]interface{} @@ -675,14 +675,14 @@ func (s *licenseService) generateLicenseFileContent(license *models.License, aut licenseFileData["feature_config"] = featureConfig } } - + if len(authCode.UsageLimits) > 0 { var usageLimits map[string]interface{} if err := json.Unmarshal(authCode.UsageLimits, &usageLimits); err == nil { licenseFileData["usage_limits"] = usageLimits } } - + if len(authCode.CustomParameters) > 0 { var customParameters map[string]interface{} if err := json.Unmarshal(authCode.CustomParameters, &customParameters); err == nil { @@ -748,7 +748,7 @@ func (s *licenseService) signLicenseFile(data []byte) ([]byte, error) { // 构建签名后的许可证文件结构 licenseWithSignature := map[string]interface{}{ "data": string(data), // 原始数据(JSON字符串) - "signature": signature, // 数字签名 + "signature": signature, // 数字签名 "algorithm": "RSA-PSS-SHA256", } diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..288b58c --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,28 @@ +{ + // Vue 文件类型检查配置 + "volar.takeOverMode.enabled": true, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + + // TypeScript 检查配置 + "typescript.preferences.includePackageJsonAutoImports": "on", + "typescript.suggest.autoImports": true, + + // 启用实时类型检查 + "typescript.validate.enable": true, + "javascript.validate.enable": true, + + // 显示所有诊断信息(包括未使用的变量) + "typescript.preferences.showUnused": true, + "javascript.preferences.showUnused": true, + + // Vue 文件检查 + "volar.codeLens.pugTools": false, + "volar.codeLens.scriptSetupTools": true, + + // 文件关联 + "files.associations": { + "*.vue": "vue" + } +} + diff --git a/frontend/src/api/license.ts b/frontend/src/api/license.ts index 1f3af6f..d528244 100644 --- a/frontend/src/api/license.ts +++ b/frontend/src/api/license.ts @@ -320,6 +320,15 @@ export function downloadLicenseFile(id: string): Promise> { }) } +/** + * 下载授权文件包(授权码+公钥) + */ +export function downloadAuthorizationFile(id: string): Promise> { + return Axios.get(`/api/v1/authorization-codes/${id}/download`, { + responseType: 'blob' + }) +} + // 创建许可证请求(手动绑定设备) export interface LicenseDeviceCreateRequest { authorization_code_id: string diff --git a/frontend/src/components/charts/LicenseTrendChart.vue b/frontend/src/components/charts/LicenseTrendChart.vue index 54bc18b..60a7f78 100644 --- a/frontend/src/components/charts/LicenseTrendChart.vue +++ b/frontend/src/components/charts/LicenseTrendChart.vue @@ -128,8 +128,19 @@ const quickOptions = computed(() => [ // 响应式数据 const { isMobile } = useDevice() -const selectedQuick = ref('week') -const dateRange = ref<[string, string]>(['2024-05-13', '2024-05-17']) // 初始值会被handleQuickSelect('week')覆盖 +const selectedQuick = ref('month') + +const getCurrentMonthRange = (): [string, string] => { + const today = new Date() + const startDate = new Date(today.getFullYear(), today.getMonth(), 1) + const endDate = new Date(today.getFullYear(), today.getMonth() + 1, 0) + return [ + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0] + ] +} + +const dateRange = ref<[string, string]>(getCurrentMonthRange()) const chartRef = ref() const datePickerRef = ref() @@ -141,6 +152,11 @@ const chartLoading = ref(false) const chartOption = computed(() => { const dates = trendData.value.map(item => item.date.substring(5)) // 只显示月-日 const values = trendData.value.map(item => item.value) + const rawMax = values.length ? Math.max(...values) : 0 + const baseMax = Math.max(rawMax, 20) + const minTickCount = 4 // 至少展示4个区间 + const niceInterval = Math.max(5, Math.ceil(baseMax / minTickCount / 5) * 5) + const niceMax = Math.ceil(baseMax / niceInterval) * niceInterval return { grid: { @@ -169,8 +185,8 @@ const chartOption = computed(() => { yAxis: { type: 'value', min: 0, - max: 20, - interval: 5, + max: niceMax, + interval: niceInterval, axisLine: { show: false }, @@ -367,9 +383,9 @@ const updateChartData = () => { onMounted(() => { console.log('LicenseTrendChart 组件已挂载,开始初始化') - // 初始化时直接调用快捷选择,设置为本周 - handleQuickSelect('week') - console.log('已调用 handleQuickSelect(week)') + // 初始化时直接调用快捷选择,设置为本月 + handleQuickSelect('month') + console.log('已调用 handleQuickSelect(month)') }) diff --git a/frontend/src/components/common/layout/NavContent.vue b/frontend/src/components/common/layout/NavContent.vue index aece53b..eb7fe4d 100644 --- a/frontend/src/components/common/layout/NavContent.vue +++ b/frontend/src/components/common/layout/NavContent.vue @@ -98,6 +98,19 @@
+ + +