From c916f566d90a3b0befeef9a1f39372f89bc994b6 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 12 Jun 2026 14:12:03 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=8E=A7=E5=88=B6=E5=8F=B0?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-image.yml | 3 +- .github/workflows/build-release.yml | 7 +- Makefile | 2 +- docker/Dockerfile | 3 +- docker/Dockerfile.backend | 3 +- docker/Dockerfile.cross | 3 +- docs/docs.go | 148 ++++++ docs/swagger.json | 148 ++++++ docs/swagger.yaml | 92 ++++ .../components/common/settings/info-tab.tsx | 154 +++++- frontend/lib/services/admin/admin.service.ts | 11 + frontend/lib/services/admin/index.ts | 1 + frontend/lib/services/admin/types.ts | 20 +- frontend/lib/services/index.ts | 1 + go.mod | 2 +- .../apps/admin/system_config/routers_test.go | 2 +- internal/apps/admin/updater/errs.go | 17 + internal/apps/admin/updater/logics.go | 454 ++++++++++++++++++ internal/apps/admin/updater/logics_test.go | 91 ++++ internal/apps/admin/updater/restart_unix.go | 37 ++ .../apps/admin/updater/restart_windows.go | 12 + internal/apps/admin/updater/routers.go | 67 +++ internal/buildinfo/buildinfo.go | 12 + internal/cmd/root.go | 10 +- .../postgres/202606090001_initial_schema.sql | 3 +- ..._add_update_upstream_repository_config.sql | 7 + .../sqlite/202606090001_initial_schema.sql | 3 +- ..._add_update_upstream_repository_config.sql | 7 + internal/db/migrator/migrator_test.go | 2 +- internal/model/system_configs.go | 1 + internal/router/router.go | 5 + internal/testhelper/test_helper.go | 6 + scripts/swagger.sh | 2 +- scripts/update_go_license.sh | 1 + 34 files changed, 1297 insertions(+), 40 deletions(-) create mode 100644 internal/apps/admin/updater/errs.go create mode 100644 internal/apps/admin/updater/logics.go create mode 100644 internal/apps/admin/updater/logics_test.go create mode 100644 internal/apps/admin/updater/restart_unix.go create mode 100644 internal/apps/admin/updater/restart_windows.go create mode 100644 internal/apps/admin/updater/routers.go create mode 100644 internal/buildinfo/buildinfo.go create mode 100644 internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql create mode 100644 internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index b2d3690a..e013df1c 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -59,7 +59,7 @@ jobs: fi echo "VERSION=$VERSION" >> "$GITHUB_ENV" - echo "BUILD_DATE=$(date -u +'%Y-%m-%d %H:%M:%S')" >> "$GITHUB_ENV" + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -197,4 +197,3 @@ jobs: else echo "Webhook URL is not set, skipping." fi - diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 91a27dac..e445d510 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -95,7 +95,7 @@ jobs: { echo "version=$version" echo "version_without_v=${version#v}" - echo "build_date=$(date -u +'%Y-%m-%d %H:%M:%S')" + echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" } >> "$GITHUB_OUTPUT" - name: Create release @@ -201,6 +201,8 @@ jobs: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" + VERSION: ${{ needs.create-release.outputs.version }} + BUILD_DATE: ${{ needs.create-release.outputs.build_date }} run: | set -euo pipefail @@ -210,9 +212,10 @@ jobs: binary_name="${binary_name}.exe" fi + ldflags="$GO_LDFLAGS -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=$VERSION -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=$BUILD_DATE" build_args=( -trimpath - -ldflags "$GO_LDFLAGS" + -ldflags "$ldflags" -o "dist/$binary_name" ) diff --git a/Makefile b/Makefile index 675fae86..7b316f4a 100644 --- a/Makefile +++ b/Makefile @@ -45,7 +45,7 @@ cross-build: --file docker/Dockerfile.cross \ --target export \ --build-arg VERSION=$(or $(VERSION),dev) \ - --build-arg BUILD_DATE="$(shell date -u +'%Y-%m-%d %H:%M:%S')" \ + --build-arg BUILD_DATE="$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')" \ $(if $(GOOS),--build-arg TARGET_OS=$(GOOS)) \ $(if $(GOARCH),--build-arg TARGET_ARCH=$(GOARCH)) \ --output type=local,dest=./bin \ diff --git a/docker/Dockerfile b/docker/Dockerfile index 5419ceab..76f652ef 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -30,6 +30,7 @@ RUN pnpm build:embed FROM golang:${GO_VERSION}-alpine AS backend-builder ARG VERSION=dev +ARG BUILD_DATE="" RUN apk add --no-cache ca-certificates git @@ -44,7 +45,7 @@ COPY --from=frontend-builder /workspace/frontend/out ./internal/router/dist RUN CGO_ENABLED=0 GOOS=linux go build \ -tags embed_frontend \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/cmd.Version=${VERSION}" \ + -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ -o /out/wavelet \ ./main.go diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index e8edbc2c..861bd6fc 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -6,6 +6,7 @@ ARG ALPINE_VERSION=3.23 FROM golang:${GO_VERSION}-alpine AS builder ARG VERSION=dev +ARG BUILD_DATE="" RUN apk add --no-cache ca-certificates git @@ -18,7 +19,7 @@ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/cmd.Version=${VERSION}" \ + -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ -o /out/wavelet \ ./main.go diff --git a/docker/Dockerfile.cross b/docker/Dockerfile.cross index 8167f660..6a203edc 100644 --- a/docker/Dockerfile.cross +++ b/docker/Dockerfile.cross @@ -54,6 +54,7 @@ RUN pnpm build:embed FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder ARG VERSION=dev +ARG BUILD_DATE="" # Empty string means "build all"; set to a specific value to filter. ARG TARGET_OS= ARG TARGET_ARCH= @@ -95,7 +96,7 @@ RUN set -e; \ go build \ -tags embed_frontend \ -trimpath \ - -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/cmd.Version=${VERSION}" \ + -ldflags="-s -w -X github.com/Rain-kl/Wavelet/internal/buildinfo.Version=${VERSION} -X github.com/Rain-kl/Wavelet/internal/buildinfo.BuildTime=${BUILD_DATE}" \ -o "${OUTPUT}" \ ./main.go; \ done; \ diff --git a/docs/docs.go b/docs/docs.go index 48e913fc..cbfc8fa4 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2514,6 +2514,110 @@ const docTemplate = `{ } } }, + "/api/v1/admin/update": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取应用更新状态", + "responses": { + "200": { + "description": "更新状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/updater.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "查询失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/update/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下载并应用应用更新", + "responses": { + "200": { + "description": "升级已准备并即将重启", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "400": { + "description": "当前版本不可升级", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "升级准备失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/uploads/types": { "get": { "security": [ @@ -5727,6 +5831,50 @@ const docTemplate = `{ } } }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, "upload.batchDownloadRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index fdec8678..b059317d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2507,6 +2507,110 @@ } } }, + "/api/v1/admin/update": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取应用更新状态", + "responses": { + "200": { + "description": "更新状态", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/updater.Status" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "查询失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/update/apply": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "下载并应用应用更新", + "responses": { + "200": { + "description": "升级已准备并即将重启", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "400": { + "description": "当前版本不可升级", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "升级准备失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/uploads/types": { "get": { "security": [ @@ -5720,6 +5824,50 @@ } } }, + "updater.Status": { + "type": "object", + "properties": { + "asset_name": { + "type": "string" + }, + "build_time": { + "type": "string" + }, + "can_upgrade": { + "type": "boolean" + }, + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "prerelease": { + "type": "boolean" + }, + "published_at": { + "type": "string" + }, + "release_name": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "release_url": { + "type": "string" + }, + "update_available": { + "type": "boolean" + }, + "upstream_repository": { + "type": "string" + } + } + }, "upload.batchDownloadRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fc6bdad3..ce0b7e0b 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -858,6 +858,35 @@ definitions: - name - type type: object + updater.Status: + properties: + asset_name: + type: string + build_time: + type: string + can_upgrade: + type: boolean + current_version: + type: string + latest_version: + type: string + platform: + type: string + prerelease: + type: boolean + published_at: + type: string + release_name: + type: string + release_notes: + type: string + release_url: + type: string + update_available: + type: boolean + upstream_repository: + type: string + type: object upload.batchDownloadRequest: properties: ids: @@ -2544,6 +2573,69 @@ paths: summary: 更新模板 tags: - admin + /api/v1/admin/update: + get: + description: 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较 + produces: + - application/json + responses: + "200": + description: 更新状态 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/updater.Status' + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 查询失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取应用更新状态 + tags: + - admin + /api/v1/admin/update/apply: + post: + description: 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程 + produces: + - application/json + responses: + "200": + description: 升级已准备并即将重启 + schema: + $ref: '#/definitions/util.ResponseAny' + "400": + description: 当前版本不可升级 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 升级准备失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 下载并应用应用更新 + tags: + - admin /api/v1/admin/uploads/types: get: description: 返回数据库中所有已上传文件实际拥有的业务类型列表 diff --git a/frontend/components/common/settings/info-tab.tsx b/frontend/components/common/settings/info-tab.tsx index cb072630..b6b49c07 100644 --- a/frontend/components/common/settings/info-tab.tsx +++ b/frontend/components/common/settings/info-tab.tsx @@ -1,10 +1,25 @@ "use client" -import {Info, Server} from "lucide-react" -import packageJson from "../../../package.json" -import {APP_VERSION, APP_BUILD_DATE} from "@/lib/app-info" -import {apiConfig} from "@/lib/services" +import {useMutation, useQuery} from "@tanstack/react-query" +import {ExternalLink, RefreshCw, Server, Sparkles} from "lucide-react" +import {toast} from "sonner" + +import {AdminService, apiConfig} from "@/lib/services" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from "@/components/ui/alert-dialog" +import {Badge} from "@/components/ui/badge" +import {Button} from "@/components/ui/button" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {Spinner} from "@/components/ui/spinner" function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { return ( @@ -21,26 +36,131 @@ interface InfoTabProps { } export function InfoTab({ systemConfigsLength, authSourcesLength }: InfoTabProps) { + const updateQuery = useQuery({ + queryKey: ["admin", "update"], + queryFn: () => AdminService.getUpdateStatus(), + refetchInterval: 30 * 60 * 1000, + staleTime: 5 * 60 * 1000, + }) + + const applyUpdateMutation = useMutation({ + mutationFn: () => AdminService.applyUpdate(), + onSuccess: () => { + toast.success("升级包已校验完成,服务正在重启") + }, + onError: (error: Error) => { + toast.error(error.message || "应用升级失败") + }, + }) + + const update = updateQuery.data + return (
-
-
- -
-
- 应用信息 - 当前前端应用的版本与构建信息 +
+
+
+ +
+
+ 应用更新 + + 检查上游 GitHub Actions Release 并升级当前服务 + +
+ {update?.update_available ? ( + 发现新版本 + ) : update ? ( + 已是最新 + ) : null}
- - - - {APP_BUILD_DATE && } - }).dependencies?.next} /> - }).dependencies?.react} /> + + {updateQuery.isLoading ? ( +
+ +
+ ) : updateQuery.isError ? ( +
+

+ {updateQuery.error.message || "无法获取上游版本信息"} +

+ +
+ ) : update ? ( + <> +
+ + + {update.build_time && } + + + +
+ + {update.release_notes && ( +
+

更新说明

+

+ {update.release_notes} +

+
+ )} + +
+ + {update.release_url && ( + + )} + + + + + + + 升级到 {update.latest_version}? + + 服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。 + + + + 取消 + applyUpdateMutation.mutate()}> + 确认升级 + + + + +
+ + {!update.can_upgrade && ( +

+ {update.current_version === "dev" + ? "开发构建没有可比较的 Release 版本,不能执行自动升级。" + : update.update_available + ? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。" + : "当前版本无需升级。"} +

+ )} + + ) : null}
diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index e5f944ca..0736a171 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -1,6 +1,7 @@ import {BaseService} from '@/lib/services'; import type { AdminUser, + AppUpdateStatus, AuthSource, AuthSourceRequest, CacheConfig, @@ -136,6 +137,16 @@ export class AdminService extends BaseService { return this.post<{ success: boolean; log: string; error: string }>('/system-configs/smtp/test', request); } + // ==================== 应用更新 ==================== + + static async getUpdateStatus(): Promise { + return this.get('/update'); + } + + static async applyUpdate(): Promise { + return this.post('/update/apply'); + } + /** * 获取所有已存在的文件业务类型及默认内置类型列表 * @returns 业务类型列表 diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index a8c7ba66..4ababbc8 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -42,6 +42,7 @@ export type { UpdateUserStatusRequest, SystemStatus, DatabaseInfo, + AppUpdateStatus, Schedule, CreateScheduleRequest, UpdateScheduleRequest, diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index d1f7557e..324887c2 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -439,6 +439,25 @@ export interface DatabaseInfo { version: string; } +/** + * 应用更新状态 + */ +export interface AppUpdateStatus { + current_version: string; + build_time: string; + latest_version: string; + update_available: boolean; + can_upgrade: boolean; + prerelease: boolean; + release_name: string; + release_notes: string; + release_url: string; + published_at: string; + upstream_repository: string; + asset_name: string; + platform: string; +} + // ==================== 缓存管理 ==================== /** @@ -470,4 +489,3 @@ export interface CacheConfig { /** 是否启用 LRU 淘汰 */ lru_enabled: boolean; } - diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 54aaef66..b019d54f 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -112,6 +112,7 @@ export type { UpdateUserStatusRequest, SystemStatus, DatabaseInfo, + AppUpdateStatus, Schedule, CreateScheduleRequest, UpdateScheduleRequest, diff --git a/go.mod b/go.mod index 0c5ea5c9..d932d63d 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.51.0 + golang.org/x/mod v0.36.0 golang.org/x/oauth2 v0.32.0 golang.org/x/sync v0.21.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -156,7 +157,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/image v0.42.0 // indirect - golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 5f4dc6d0..80ee5e22 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -23,7 +23,7 @@ import ( "github.com/gin-gonic/gin" ) -const expectedDefaultConfigsCount = 28 +const expectedDefaultConfigsCount = 29 func setupTestRouter(authUser *model.User) *gin.Engine { gin.SetMode(gin.TestMode) diff --git a/internal/apps/admin/updater/errs.go b/internal/apps/admin/updater/errs.go new file mode 100644 index 00000000..ecdbbecd --- /dev/null +++ b/internal/apps/admin/updater/errs.go @@ -0,0 +1,17 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package updater manages GitHub Release checks and in-place application upgrades. +package updater + +const ( + errInvalidRepository = "上游仓库地址无效" + errReleaseRequestFailed = "获取上游版本失败" + errReleaseResponseInvalid = "上游版本响应无效" + errNoCompatibleRelease = "未找到兼容的 Release" + errNoCompatibleAsset = "未找到当前系统对应的 Release 资产" + errDevelopmentBuild = "开发版本无法执行自动升级" + errAlreadyUpToDate = "当前已是最新版本" + errUpgradeAlreadyRunning = "已有升级任务正在执行" + errAutomaticUpgradeBlocked = "当前平台暂不支持自动替换二进制" +) diff --git a/internal/apps/admin/updater/logics.go b/internal/apps/admin/updater/logics.go new file mode 100644 index 00000000..da025cdd --- /dev/null +++ b/internal/apps/admin/updater/logics.go @@ -0,0 +1,454 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package updater + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/Rain-kl/Wavelet/internal/buildinfo" + "github.com/Rain-kl/Wavelet/internal/model" + "golang.org/x/mod/semver" +) + +const ( + githubAPIBaseURL = "https://api.github.com" + maxArchiveSize = int64(1024 * 1024 * 1024) + maxReleaseSize = int64(4 * 1024 * 1024) + repositoryParts = 2 + windowsOS = "windows" + archiveFileMode = 0o600 + stagedBinaryMode = 0o700 +) + +type releaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` + State string `json:"state"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Published time.Time `json:"published_at"` + Assets []releaseAsset `json:"assets"` +} + +// Status describes the current build and the newest compatible upstream release. +type Status struct { + CurrentVersion string `json:"current_version"` + BuildTime string `json:"build_time"` + LatestVersion string `json:"latest_version"` + UpdateAvailable bool `json:"update_available"` + CanUpgrade bool `json:"can_upgrade"` + Prerelease bool `json:"prerelease"` + ReleaseName string `json:"release_name"` + ReleaseNotes string `json:"release_notes"` + ReleaseURL string `json:"release_url"` + PublishedAt string `json:"published_at"` + UpstreamRepository string `json:"upstream_repository"` + AssetName string `json:"asset_name"` + Platform string `json:"platform"` +} + +type releaseClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type manager struct { + client releaseClient + mu sync.Mutex + upgrading bool +} + +var defaultManager = &manager{ + client: &http.Client{Timeout: 10 * time.Minute}, +} + +func normalizeVersion(version string) string { + version = strings.TrimSpace(version) + if version == "" || version == "dev" { + return "" + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if !semver.IsValid(version) { + return "" + } + return version +} + +func parseRepository(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errors.New(errInvalidRepository) + } + + if !strings.Contains(raw, "://") { + repo := strings.TrimSuffix(strings.Trim(raw, "/"), ".git") + if len(strings.Split(repo, "/")) == repositoryParts { + return repo, nil + } + return "", errors.New(errInvalidRepository) + } + + parsed, err := url.Parse(raw) + if err != nil || !strings.EqualFold(parsed.Hostname(), "github.com") { + return "", errors.New(errInvalidRepository) + } + repo := strings.TrimSuffix(strings.Trim(parsed.Path, "/"), ".git") + if len(strings.Split(repo, "/")) != repositoryParts { + return "", errors.New(errInvalidRepository) + } + return repo, nil +} + +func expectedAssetName(tag string) string { + extension := "tar.gz" + if runtime.GOOS == windowsOS { + extension = "zip" + } + return fmt.Sprintf("wavelet_%s_%s_%s.%s", tag, runtime.GOOS, runtime.GOARCH, extension) +} + +func selectLatestRelease(releases []githubRelease) (githubRelease, releaseAsset, error) { + var selected githubRelease + var selectedAsset releaseAsset + selectedVersion := "" + + for _, release := range releases { + version := normalizeVersion(release.TagName) + if release.Draft || version == "" { + continue + } + expectedName := expectedAssetName(release.TagName) + for _, asset := range release.Assets { + if asset.Name != expectedName || asset.BrowserDownloadURL == "" || asset.State != "uploaded" { + continue + } + if selectedVersion == "" || semver.Compare(version, selectedVersion) > 0 { + selected = release + selectedAsset = asset + selectedVersion = version + } + } + } + + if selectedVersion == "" { + return githubRelease{}, releaseAsset{}, errors.New(errNoCompatibleRelease) + } + return selected, selectedAsset, nil +} + +func (m *manager) fetchRelease(ctx context.Context, repository string) (githubRelease, releaseAsset, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + fmt.Sprintf("%s/repos/%s/releases?per_page=30", githubAPIBaseURL, repository), + nil, + ) + if err != nil { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errReleaseRequestFailed, err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "Wavelet-Updater") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := m.client.Do(req) + if err != nil { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errReleaseRequestFailed, err) + } + defer func() { + // The response body is read-only; close errors cannot affect the parsed result. + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: HTTP %d", errReleaseRequestFailed, resp.StatusCode) + } + + var releases []githubRelease + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseSize)) + if err := decoder.Decode(&releases); err != nil { + return githubRelease{}, releaseAsset{}, fmt.Errorf("%s: %w", errReleaseResponseInvalid, err) + } + return selectLatestRelease(releases) +} + +func loadRepository(ctx context.Context) (string, error) { + var config model.SystemConfig + if err := config.GetByKey(ctx, model.ConfigKeyUpdateUpstreamRepository); err != nil { + return "", fmt.Errorf("%s: %w", errInvalidRepository, err) + } + return parseRepository(config.Value) +} + +func (m *manager) status(ctx context.Context) (Status, releaseAsset, error) { + repository, err := loadRepository(ctx) + if err != nil { + return Status{}, releaseAsset{}, err + } + release, asset, err := m.fetchRelease(ctx, repository) + if err != nil { + return Status{}, releaseAsset{}, err + } + + currentVersion := normalizeVersion(buildinfo.Version) + latestVersion := normalizeVersion(release.TagName) + updateAvailable := currentVersion != "" && semver.Compare(latestVersion, currentVersion) > 0 + + return Status{ + CurrentVersion: buildinfo.Version, + BuildTime: buildinfo.BuildTime, + LatestVersion: release.TagName, + UpdateAvailable: updateAvailable, + CanUpgrade: updateAvailable && runtime.GOOS != windowsOS, + Prerelease: release.Prerelease, + ReleaseName: release.Name, + ReleaseNotes: release.Body, + ReleaseURL: release.HTMLURL, + PublishedAt: release.Published.Format(time.RFC3339), + UpstreamRepository: repository, + AssetName: asset.Name, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + }, asset, nil +} + +func downloadArchive(ctx context.Context, client releaseClient, asset releaseAsset, destination string) error { + if asset.Size <= 0 || asset.Size > maxArchiveSize { + return fmt.Errorf("release 资产大小无效: %d", asset.Size) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.BrowserDownloadURL, nil) + if err != nil { + return fmt.Errorf("创建升级下载请求失败: %w", err) + } + req.Header.Set("User-Agent", "Wavelet-Updater") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("下载升级资产失败: %w", err) + } + defer func() { + // The downloaded body has already been validated by size before use. + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载升级资产失败: HTTP %d", resp.StatusCode) + } + + file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, archiveFileMode) //nolint:gosec // destination is created inside the verified executable directory. + if err != nil { + return fmt.Errorf("创建升级归档失败: %w", err) + } + + written, err := io.Copy(file, io.LimitReader(resp.Body, maxArchiveSize+1)) + if err != nil { + _ = file.Close() + return fmt.Errorf("写入升级归档失败: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("关闭升级归档失败: %w", err) + } + if written > maxArchiveSize || written != asset.Size { + return fmt.Errorf("升级归档大小不匹配: got %d, want %d", written, asset.Size) + } + return nil +} + +func safeArchivePath(destination, name string) (string, error) { + cleanName := filepath.Clean(name) + if filepath.IsAbs(cleanName) || cleanName == "." || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("归档包含非法路径: %s", name) + } + target := filepath.Join(destination, cleanName) + relative, err := filepath.Rel(destination, target) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("归档路径越界: %s", name) + } + return target, nil +} + +func extractTarGz(archivePath, destination, binaryName string) (string, error) { + file, err := os.Open(archivePath) //nolint:gosec // archivePath is created by prepareUpgrade in the executable directory. + if err != nil { + return "", err + } + defer func() { + // Read-only archive close errors do not change extraction validity. + _ = file.Close() + }() + gzipReader, err := gzip.NewReader(file) + if err != nil { + return "", err + } + defer func() { + // The gzip checksum is verified while reading the selected file. + _ = gzipReader.Close() + }() + + reader := tar.NewReader(gzipReader) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return "", err + } + if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != binaryName { + continue + } + target, err := safeArchivePath(destination, binaryName) + if err != nil { + return "", err + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, stagedBinaryMode) //nolint:gosec // target is constrained by safeArchivePath. + if err != nil { + return "", err + } + written, copyErr := io.Copy(output, io.LimitReader(reader, maxArchiveSize+1)) + closeErr := output.Close() + if copyErr != nil { + return "", copyErr + } + if closeErr != nil { + return "", closeErr + } + if written > maxArchiveSize { + return "", errors.New("解压后的程序文件超过大小限制") + } + return target, nil + } + return "", errors.New(errNoCompatibleAsset) +} + +func extractZip(archivePath, destination, binaryName string) (string, error) { + reader, err := zip.OpenReader(archivePath) + if err != nil { + return "", err + } + defer func() { + // Read-only archive close errors do not change extraction validity. + _ = reader.Close() + }() + for _, file := range reader.File { + if file.FileInfo().IsDir() || filepath.Base(file.Name) != binaryName { + continue + } + target, err := safeArchivePath(destination, binaryName) + if err != nil { + return "", err + } + input, err := file.Open() + if err != nil { + return "", err + } + output, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, stagedBinaryMode) //nolint:gosec // target is constrained by safeArchivePath. + if err != nil { + // The output was not opened, so there is no useful recovery action for a read-only close failure. + _ = input.Close() + return "", err + } + written, copyErr := io.Copy(output, io.LimitReader(input, maxArchiveSize+1)) + inputCloseErr := input.Close() + outputCloseErr := output.Close() + if copyErr != nil { + return "", copyErr + } + if inputCloseErr != nil { + return "", inputCloseErr + } + if outputCloseErr != nil { + return "", outputCloseErr + } + if written > maxArchiveSize { + return "", errors.New("解压后的程序文件超过大小限制") + } + return target, nil + } + return "", errors.New(errNoCompatibleAsset) +} + +func (m *manager) prepareUpgrade(ctx context.Context) (string, string, error) { + if runtime.GOOS == windowsOS { + return "", "", errors.New(errAutomaticUpgradeBlocked) + } + if normalizeVersion(buildinfo.Version) == "" { + return "", "", errors.New(errDevelopmentBuild) + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.upgrading { + return "", "", errors.New(errUpgradeAlreadyRunning) + } + + status, asset, err := m.status(ctx) + if err != nil { + return "", "", err + } + if !status.UpdateAvailable { + return "", "", errors.New(errAlreadyUpToDate) + } + + executable, err := os.Executable() + if err != nil { + return "", "", fmt.Errorf("定位当前程序失败: %w", err) + } + executable, err = filepath.EvalSymlinks(executable) + if err != nil { + return "", "", fmt.Errorf("解析当前程序路径失败: %w", err) + } + tempDir, err := os.MkdirTemp(filepath.Dir(executable), ".wavelet-update-*") + if err != nil { + return "", "", fmt.Errorf("创建升级目录失败: %w", err) + } + + archivePath := filepath.Join(tempDir, asset.Name) + if err := downloadArchive(ctx, m.client, asset, archivePath); err != nil { + // Cleanup is best effort because the download error is the actionable failure. + _ = os.RemoveAll(tempDir) + return "", "", err + } + binaryName := "wavelet" + if runtime.GOOS == windowsOS { + binaryName += ".exe" + } + stagedBinary, err := extractTarGz(archivePath, tempDir, binaryName) + if strings.HasSuffix(asset.Name, ".zip") { + stagedBinary, err = extractZip(archivePath, tempDir, binaryName) + } + if err != nil { + // Cleanup is best effort because the extraction error is the actionable failure. + _ = os.RemoveAll(tempDir) + return "", "", fmt.Errorf("解压升级资产失败: %w", err) + } + m.upgrading = true + return executable, stagedBinary, nil +} + +func (m *manager) finishUpgrade() { + m.mu.Lock() + defer m.mu.Unlock() + m.upgrading = false +} diff --git a/internal/apps/admin/updater/logics_test.go b/internal/apps/admin/updater/logics_test.go new file mode 100644 index 00000000..f12dfa19 --- /dev/null +++ b/internal/apps/admin/updater/logics_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package updater + +import ( + "runtime" + "testing" + "time" +) + +func TestParseRepository(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "short form", input: "Rain-kl/Wavelet", want: "Rain-kl/Wavelet"}, + {name: "GitHub URL", input: "https://github.com/Rain-kl/Wavelet.git", want: "Rain-kl/Wavelet"}, + {name: "unsupported host", input: "https://example.com/Rain-kl/Wavelet", wantErr: true}, + {name: "missing owner", input: "Wavelet", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseRepository(tt.input) + if gotErr := err != nil; gotErr != tt.wantErr { + t.Errorf("parseRepository(%q) error = %v, want error presence = %t", tt.input, err, tt.wantErr) + } + if got != tt.want { + t.Errorf("parseRepository(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSelectLatestRelease(t *testing.T) { + assetNameV1 := expectedAssetName("v1.0.0") + assetNameV2 := expectedAssetName("v2.0.0") + releases := []githubRelease{ + { + TagName: "v1.0.0", + Published: time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC), + Assets: []releaseAsset{{ + Name: assetNameV1, + BrowserDownloadURL: "https://example.com/v1", + State: "uploaded", + }}, + }, + { + TagName: "v2.0.0", + Published: time.Date(2026, time.June, 2, 0, 0, 0, 0, time.UTC), + Assets: []releaseAsset{{ + Name: assetNameV2, + BrowserDownloadURL: "https://example.com/v2", + State: "uploaded", + }}, + }, + { + TagName: "v3.0.0", + Assets: []releaseAsset{{ + Name: "wavelet_v3.0.0_other_platform.tar.gz", + BrowserDownloadURL: "https://example.com/v3", + State: "uploaded", + }}, + }, + } + + release, asset, err := selectLatestRelease(releases) + if err != nil { + t.Fatalf("selectLatestRelease() error = %v", err) + } + if release.TagName != "v2.0.0" { + t.Errorf("selectLatestRelease() tag = %q, want %q", release.TagName, "v2.0.0") + } + if asset.Name != assetNameV2 { + t.Errorf("selectLatestRelease() asset = %q, want %q", asset.Name, assetNameV2) + } +} + +func TestExpectedAssetName(t *testing.T) { + extension := "tar.gz" + if runtime.GOOS == "windows" { + extension = "zip" + } + want := "wavelet_v1.2.3_" + runtime.GOOS + "_" + runtime.GOARCH + "." + extension + if got := expectedAssetName("v1.2.3"); got != want { + t.Errorf("expectedAssetName(%q) = %q, want %q", "v1.2.3", got, want) + } +} diff --git a/internal/apps/admin/updater/restart_unix.go b/internal/apps/admin/updater/restart_unix.go new file mode 100644 index 00000000..d4b6f622 --- /dev/null +++ b/internal/apps/admin/updater/restart_unix.go @@ -0,0 +1,37 @@ +//go:build !windows + +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package updater + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +const installedBinaryMode = 0o755 + +func replaceAndRestart(executable, stagedBinary string) error { + backup := executable + ".old" + if err := os.Remove(backup); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除旧备份失败: %w", err) + } + if err := os.Rename(executable, backup); err != nil { + return fmt.Errorf("备份当前程序失败: %w", err) + } + if err := os.Rename(stagedBinary, executable); err != nil { + _ = os.Rename(backup, executable) + return fmt.Errorf("替换当前程序失败: %w", err) + } + if err := os.Chmod(executable, installedBinaryMode); err != nil { //nolint:gosec // the installed application binary must be executable. + _ = os.Remove(executable) + _ = os.Rename(backup, executable) + return fmt.Errorf("设置程序执行权限失败: %w", err) + } + // Cleanup is best effort; a leftover staging directory must not block restart. + _ = os.RemoveAll(filepath.Dir(stagedBinary)) + return syscall.Exec(executable, os.Args, os.Environ()) //nolint:gosec // executable is resolved from os.Executable and never supplied by a request. +} diff --git a/internal/apps/admin/updater/restart_windows.go b/internal/apps/admin/updater/restart_windows.go new file mode 100644 index 00000000..a454cd30 --- /dev/null +++ b/internal/apps/admin/updater/restart_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package updater + +import "errors" + +func replaceAndRestart(_, _ string) error { + return errors.New(errAutomaticUpgradeBlocked) +} diff --git a/internal/apps/admin/updater/routers.go b/internal/apps/admin/updater/routers.go new file mode 100644 index 00000000..59869ea8 --- /dev/null +++ b/internal/apps/admin/updater/routers.go @@ -0,0 +1,67 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package updater + +import ( + "context" + "net/http" + "time" + + "github.com/Rain-kl/Wavelet/internal/logger" + "github.com/Rain-kl/Wavelet/internal/util" + "github.com/gin-gonic/gin" +) + +// GetUpdateStatus 获取应用更新状态 +// @Summary 获取应用更新状态 +// @Description 从系统配置指定的 GitHub 上游仓库查询最新兼容 Release,并与当前服务版本比较 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=updater.Status} "更新状态" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "查询失败" +// @Router /api/v1/admin/update [get] +func GetUpdateStatus(c *gin.Context) { + status, _, err := defaultManager.status(c.Request.Context()) + if err != nil { + logger.ErrorF(c.Request.Context(), "[Updater] check release failed: %v", err) + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OK(status)) +} + +// ApplyUpdate 下载并应用应用更新 +// @Summary 下载并应用应用更新 +// @Description 下载当前平台对应的 GitHub Actions Release 资产,替换当前二进制并重启进程 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny "升级已准备并即将重启" +// @Failure 400 {object} util.ResponseAny "当前版本不可升级" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "升级准备失败" +// @Router /api/v1/admin/update/apply [post] +func ApplyUpdate(c *gin.Context) { + executable, stagedBinary, err := defaultManager.prepareUpgrade(c.Request.Context()) + if err != nil { + logger.ErrorF(c.Request.Context(), "[Updater] prepare upgrade failed: %v", err) + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + logger.InfoF(c.Request.Context(), "[Updater] upgrade prepared; restarting with %s", stagedBinary) + c.JSON(http.StatusOK, util.OKNil()) + + go func() { + time.Sleep(time.Second) + if err := replaceAndRestart(executable, stagedBinary); err != nil { + defaultManager.finishUpgrade() + logger.ErrorF(context.Background(), "[Updater] replace and restart failed: %v", err) + } + }() +} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 00000000..bbd1246b --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,12 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package buildinfo exposes metadata injected by the release workflow. +package buildinfo + +var ( + // Version is the application version. + Version = "dev" + // BuildTime is the UTC release build timestamp. + BuildTime = "" +) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c90760c1..d8d8ede1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -7,17 +7,11 @@ package cmd import ( "log" + "github.com/Rain-kl/Wavelet/internal/buildinfo" "github.com/Rain-kl/Wavelet/internal/db/migrator" "github.com/spf13/cobra" ) -// Version is the application version string. It is set at link time via: -// -// -ldflags="-X github.com/Rain-kl/Wavelet/internal/cmd.Version=" -// -// When not set (e.g. local `go run`), the value defaults to "dev". -var Version = "dev" - var rootCmd = &cobra.Command{ Use: "wavelet", PreRun: func(_ *cobra.Command, _ []string) { @@ -46,7 +40,7 @@ var rootCmd = &cobra.Command{ } func init() { - rootCmd.Version = Version + rootCmd.Version = buildinfo.Version rootCmd.CompletionOptions.DisableDefaultCmd = true } diff --git a/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql b/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql index 44238e11..95487010 100644 --- a/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql +++ b/internal/db/migrator/goose/postgres/202606090001_initial_schema.sql @@ -159,7 +159,8 @@ INSERT INTO system_configs (key, value, type, visibility, description, created_a ('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING; INSERT INTO users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) diff --git a/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql b/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql new file mode 100644 index 00000000..19c4f73d --- /dev/null +++ b/internal/db/migrator/goose/postgres/202606120002_add_update_upstream_repository_config.sql @@ -0,0 +1,7 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'update_upstream_repository'; diff --git a/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql b/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql index 8b338fd7..82defab4 100644 --- a/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql +++ b/internal/db/migrator/goose/sqlite/202606090001_initial_schema.sql @@ -159,7 +159,8 @@ INSERT INTO system_configs (key, value, type, visibility, description, created_a ('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('email_register_verification_enabled', 'false', 'system', 1, '是否开启邮箱注册验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), ('menu_display_config', '{}', 'system', 1, '目录显示配置(JSON 字符串,格式为 {url: enabled})', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), - ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ('search_engine_indexing_enabled', 'false', 'system', 1, '是否允许搜索引擎爬取/检索该站点(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (key) DO NOTHING; INSERT INTO users (id, username, password, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at) diff --git a/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql b/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql new file mode 100644 index 00000000..19c4f73d --- /dev/null +++ b/internal/db/migrator/goose/sqlite/202606120002_add_update_upstream_repository_config.sql @@ -0,0 +1,7 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES ('update_upstream_repository', 'Rain-kl/Wavelet', 'system', 0, 'GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'update_upstream_repository'; diff --git a/internal/db/migrator/migrator_test.go b/internal/db/migrator/migrator_test.go index 01435bb3..d89f3f24 100644 --- a/internal/db/migrator/migrator_test.go +++ b/internal/db/migrator/migrator_test.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm" ) -const expectedMigratedSystemConfigCount = 28 +const expectedMigratedSystemConfigCount = 29 func TestMigrateInitializesSQLiteDatabase(t *testing.T) { sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 22641192..4fd9e37e 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -48,6 +48,7 @@ const ( ConfigKeyDiskCacheTTLMinutes = "disk_cache_ttl_minutes" // 磁盘缓存默认有效期 (分钟) ConfigKeyDiskCacheLRUEnabled = "disk_cache_lru_enabled" // 是否启用 LRU 淘汰机制 ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期) + ConfigKeyUpdateUpstreamRepository = "update_upstream_repository" // GitHub Actions Release 上游仓库 ) const ( diff --git a/internal/router/router.go b/internal/router/router.go index bee16a37..c5d6bd9b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -23,6 +23,7 @@ import ( admin_status "github.com/Rain-kl/Wavelet/internal/apps/admin/status" admin_task "github.com/Rain-kl/Wavelet/internal/apps/admin/task" admin_template "github.com/Rain-kl/Wavelet/internal/apps/admin/template" + admin_updater "github.com/Rain-kl/Wavelet/internal/apps/admin/updater" admin_user "github.com/Rain-kl/Wavelet/internal/apps/admin/user" capApp "github.com/Rain-kl/Wavelet/internal/apps/cap" publicconfig "github.com/Rain-kl/Wavelet/internal/apps/config" @@ -235,6 +236,10 @@ func registerRoutes(r *gin.Engine) { adminRouter.POST("/cache/config", admin_cache.UpdateCacheConfig) adminRouter.POST("/cache/clear", admin_cache.ClearCache) + // Application update + adminRouter.GET("/update", admin_updater.GetUpdateStatus) + adminRouter.POST("/update/apply", admin_updater.ApplyUpdate) + // System logs adminRouter.GET("/logs", admin_logs.GetLogs) adminRouter.GET("/logs/access", admin_logs.GetAccessLogs) diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index c6f2723c..6c68de4c 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -255,6 +255,12 @@ func getSeedConfigsPart2() []model.SystemConfig { Type: configTypeSystem, Description: "登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)", }, + { + Key: model.ConfigKeyUpdateUpstreamRepository, + Value: "Rain-kl/Wavelet", + Type: configTypeSystem, + Description: "GitHub Actions Release 上游仓库(owner/repo 或 GitHub 仓库地址)", + }, } } diff --git a/scripts/swagger.sh b/scripts/swagger.sh index 1f7b8a93..0fab43ff 100755 --- a/scripts/swagger.sh +++ b/scripts/swagger.sh @@ -3,4 +3,4 @@ # execute this first # go install github.com/swaggo/swag/cmd/swag@latest -swag init -o docs --parseDependency --parseInternal +swag init -o docs --parseDependency --parseInternal --exclude fast-note-sync-service-source diff --git a/scripts/update_go_license.sh b/scripts/update_go_license.sh index 1675b95b..1a4e91b9 100755 --- a/scripts/update_go_license.sh +++ b/scripts/update_go_license.sh @@ -51,6 +51,7 @@ changed=0 find_go_files() { find . \ \( -path './.*' \ + -o -path './*-source' \ -o -path './docs' \ -o -path './frontend/node_modules' \ -o -path './frontend/.next' \