From b8495eeeb37121acb77ce587b08e8861398eaf40 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 23:34:29 +0800 Subject: [PATCH 1/6] surface account test config writeability and save failures --- internal/admin/handler_accounts_testing.go | 20 +++++++++++++++--- internal/deepseek/client_auth.go | 13 ++++++++++-- internal/deepseek/client_auth_refresh_test.go | 21 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 internal/deepseek/client_auth_refresh_test.go diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 93b0c8d..e528de0 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -89,7 +89,15 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { start := time.Now() identifier := acc.Identifier() - result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0} + result := map[string]any{ + "account": identifier, + "success": false, + "response_time": 0, + "message": "", + "model": model, + "session_count": 0, + "config_writable": !h.Store.IsEnvBacked(), + } defer func() { status := "failed" if ok, _ := result["success"].(bool); ok { @@ -105,7 +113,10 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me return result } token = newToken - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { + result["message"] = "登录成功但写入配置失败: " + err.Error() + return result + } } authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token} sessionID, err := h.DS.CreateSession(ctx, authCtx, 1) @@ -117,7 +128,10 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me } token = newToken authCtx.DeepSeekToken = token - _ = h.Store.UpdateAccountToken(acc.Identifier(), token) + if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil { + result["message"] = "刷新 token 成功但写入配置失败: " + err.Error() + return result + } sessionID, err = h.DS.CreateSession(ctx, authCtx, 1) if err != nil { result["message"] = "创建会话失败: " + err.Error() diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index bedb75e..00c72a1 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -73,7 +73,7 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte } config.Logger.Warn("[create_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID) if a.UseConfigToken { - if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { + if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue @@ -118,7 +118,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in } config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID) if a.UseConfigToken { - if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed { + if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) { if c.Auth.RefreshToken(ctx, a) { refreshed = true continue @@ -160,6 +160,15 @@ func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string strings.Contains(msg, "invalid jwt") } +func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg string) bool { + if isTokenInvalid(status, code, bizCode, msg, bizMsg) { + return true + } + // Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code. + // In managed-account mode this is commonly stale login state, so try one refresh. + return status == http.StatusOK && code == 0 && bizCode != 0 +} + func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) { code = intFrom(resp["code"]) msg, _ = resp["msg"].(string) diff --git a/internal/deepseek/client_auth_refresh_test.go b/internal/deepseek/client_auth_refresh_test.go new file mode 100644 index 0000000..b411bb7 --- /dev/null +++ b/internal/deepseek/client_auth_refresh_test.go @@ -0,0 +1,21 @@ +package deepseek + +import "testing" + +func TestShouldAttemptRefreshOnTokenInvalidSignal(t *testing.T) { + if !shouldAttemptRefresh(401, 0, 0, "unauthorized", "") { + t.Fatal("expected refresh when response indicates invalid token") + } +} + +func TestShouldAttemptRefreshOnBizCodeOnlyFailure(t *testing.T) { + if !shouldAttemptRefresh(200, 0, 400123, "", "session create failed") { + t.Fatal("expected refresh on non-zero biz_code with HTTP 200/code=0") + } +} + +func TestShouldAttemptRefreshFalseOnGenericServerError(t *testing.T) { + if shouldAttemptRefresh(500, 500, 0, "internal error", "") { + t.Fatal("did not expect refresh on generic server error") + } +} From dc73e8a6dac66e7a9b7865311cf235cd220a6f45 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 23:54:13 +0800 Subject: [PATCH 2/6] Gate biz_code refresh attempts to auth-indicative failures --- internal/deepseek/client_auth.go | 36 +++++++++++++++++-- internal/deepseek/client_auth_refresh_test.go | 12 +++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/internal/deepseek/client_auth.go b/internal/deepseek/client_auth.go index 00c72a1..a510a27 100644 --- a/internal/deepseek/client_auth.go +++ b/internal/deepseek/client_auth.go @@ -165,8 +165,40 @@ func shouldAttemptRefresh(status int, code int, bizCode int, msg string, bizMsg return true } // Some DeepSeek failures come back as HTTP 200/code=0 but with non-zero biz_code. - // In managed-account mode this is commonly stale login state, so try one refresh. - return status == http.StatusOK && code == 0 && bizCode != 0 + // Only attempt refresh when these biz failures still look auth-related. + return status == http.StatusOK && + code == 0 && + bizCode != 0 && + isAuthIndicativeBizFailure(msg, bizMsg) +} + +func isAuthIndicativeBizFailure(msg string, bizMsg string) bool { + combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg)) + authKeywords := []string{ + "auth", + "authorization", + "credential", + "expired", + "invalid jwt", + "jwt", + "login", + "not login", + "session expired", + "token", + "unauthorized", + "登录", + "未登录", + "认证", + "凭证", + "会话过期", + "令牌", + } + for _, keyword := range authKeywords { + if strings.Contains(combined, keyword) { + return true + } + } + return false } func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) { diff --git a/internal/deepseek/client_auth_refresh_test.go b/internal/deepseek/client_auth_refresh_test.go index b411bb7..2506a00 100644 --- a/internal/deepseek/client_auth_refresh_test.go +++ b/internal/deepseek/client_auth_refresh_test.go @@ -8,9 +8,15 @@ func TestShouldAttemptRefreshOnTokenInvalidSignal(t *testing.T) { } } -func TestShouldAttemptRefreshOnBizCodeOnlyFailure(t *testing.T) { - if !shouldAttemptRefresh(200, 0, 400123, "", "session create failed") { - t.Fatal("expected refresh on non-zero biz_code with HTTP 200/code=0") +func TestShouldAttemptRefreshOnAuthIndicativeBizCodeFailure(t *testing.T) { + if !shouldAttemptRefresh(200, 0, 400123, "", "login expired, token invalid") { + t.Fatal("expected refresh on auth-indicative biz_code failure") + } +} + +func TestShouldAttemptRefreshFalseOnNonAuthBizCodeFailure(t *testing.T) { + if shouldAttemptRefresh(200, 0, 400123, "", "session create failed: quota reached") { + t.Fatal("did not expect refresh on non-auth biz_code failure") } } From 0c2743a48cc02315c34cf4a830fd120d26b613a2 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 23:55:10 +0800 Subject: [PATCH 3/6] fix: align build version source with tags and VERSION fallback --- .github/workflows/release-artifacts.yml | 6 +- .github/workflows/release-dockerhub.yml | 2 + .github/workflows/release.yml | 258 ++++++++++++------------ Dockerfile | 5 +- VERSION | 2 +- internal/admin/handler.go | 1 + internal/admin/handler_version.go | 75 +++++++ internal/version/version.go | 129 ++++++++++++ internal/version/version_test.go | 24 +++ webui/src/layout/DashboardShell.jsx | 39 +++- webui/src/locales/en.json | 4 +- webui/src/locales/zh.json | 4 +- 12 files changed, 415 insertions(+), 134 deletions(-) create mode 100644 internal/admin/handler_version.go create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 3293fb8..d2ba851 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -51,6 +51,10 @@ jobs: run: | set -euo pipefail TAG="${RELEASE_TAG}" + BUILD_VERSION="${TAG}" + if [ -z "${BUILD_VERSION}" ] && [ -f VERSION ]; then + BUILD_VERSION="$(cat VERSION | tr -d '[:space:]')" + fi mkdir -p dist targets=( @@ -73,7 +77,7 @@ jobs: mkdir -p "${STAGE}/static" CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \ - go build -trimpath -ldflags="-s -w" -o "${STAGE}/${BIN}" ./cmd/ds2api + go build -trimpath -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION}" -o "${STAGE}/${BIN}" ./cmd/ds2api cp config.example.json .env.example sha3_wasm_bg.7b9ca65ddd.wasm LICENSE README.MD README.en.md "${STAGE}/" cp -R static/admin "${STAGE}/static/admin" diff --git a/.github/workflows/release-dockerhub.yml b/.github/workflows/release-dockerhub.yml index 536c3dc..815ab8d 100644 --- a/.github/workflows/release-dockerhub.yml +++ b/.github/workflows/release-dockerhub.yml @@ -123,5 +123,7 @@ jobs: labels: | org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} org.opencontainers.image.revision=${{ github.sha }} + build-args: | + BUILD_VERSION=${{ steps.next_version.outputs.new_tag }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da3264f..ebd0cef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,128 +1,130 @@ -name: Release to Aliyun CR - -on: - workflow_dispatch: - inputs: - version_type: - description: '版本类型' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get current version - id: get_version - run: | - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - TAG_VERSION=${LATEST_TAG#v} - - if [ -f VERSION ]; then - FILE_VERSION=$(cat VERSION | tr -d '[:space:]') - else - FILE_VERSION="0.0.0" - fi - - function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } - - if version_gt "$FILE_VERSION" "$TAG_VERSION"; then - VERSION="$FILE_VERSION" - else - VERSION="$TAG_VERSION" - fi - - echo "Current version: $VERSION" - echo "current_version=$VERSION" >> $GITHUB_OUTPUT - - - name: Calculate next version - id: next_version - env: - VERSION_TYPE: ${{ github.event.inputs.version_type }} - run: | - VERSION="${{ steps.get_version.outputs.current_version }}" - BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') - - IFS='.' read -r -a version_parts <<< "$BASE_VERSION" - MAJOR="${version_parts[0]:-0}" - MINOR="${version_parts[1]:-0}" - PATCH="${version_parts[2]:-0}" - - case "$VERSION_TYPE" in - major) - NEW_VERSION="$((MAJOR + 1)).0.0" - ;; - minor) - NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" - ;; - *) - NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" - ;; - esac - - echo "New version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Update VERSION file - run: | - echo "${{ steps.next_version.outputs.new_version }}" > VERSION - - - name: Commit VERSION and create tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add VERSION - if ! git diff --cached --quiet; then - git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" - fi - - NEW_TAG="${{ steps.next_version.outputs.new_tag }}" - git tag -a "$NEW_TAG" -m "Release $NEW_TAG" - git push origin HEAD:main "$NEW_TAG" - - # Docker 构建并推送到阿里云 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Aliyun Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ secrets.ALIYUN_REGISTRY }} - username: ${{ secrets.ALIYUN_REGISTRY_USER }} - password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: | - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} - ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest - labels: | - org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} - org.opencontainers.image.revision=${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max +name: Release to Aliyun CR + +on: + workflow_dispatch: + inputs: + version_type: + description: '版本类型' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get current version + id: get_version + run: | + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + TAG_VERSION=${LATEST_TAG#v} + + if [ -f VERSION ]; then + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + else + FILE_VERSION="0.0.0" + fi + + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + else + VERSION="$TAG_VERSION" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + id: next_version + env: + VERSION_TYPE: ${{ github.event.inputs.version_type }} + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') + + IFS='.' read -r -a version_parts <<< "$BASE_VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + case "$VERSION_TYPE" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" + ;; + *) + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" + ;; + esac + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + - name: Commit VERSION and create tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add VERSION + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to ${{ steps.next_version.outputs.new_tag }} [skip ci]" + fi + + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + # Docker 构建并推送到阿里云 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Aliyun Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.ALIYUN_REGISTRY }} + username: ${{ secrets.ALIYUN_REGISTRY_USER }} + password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_tag }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:${{ steps.next_version.outputs.new_version }} + ${{ secrets.ALIYUN_REGISTRY }}/${{ secrets.ALIYUN_REGISTRY_NAMESPACE }}/ds2api:latest + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + build-args: | + BUILD_VERSION=${{ steps.next_version.outputs.new_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 6996f43..b40570c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,16 @@ FROM golang:1.24 AS go-builder WORKDIR /app ARG TARGETOS ARG TARGETARCH +ARG BUILD_VERSION COPY go.mod go.sum* ./ RUN go mod download COPY . . RUN set -eux; \ GOOS="${TARGETOS:-$(go env GOOS)}"; \ GOARCH="${TARGETARCH:-$(go env GOARCH)}"; \ - CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -o /out/ds2api ./cmd/ds2api + BUILD_VERSION_RESOLVED="${BUILD_VERSION}"; \ + if [ -z "${BUILD_VERSION_RESOLVED}" ] && [ -f VERSION ]; then BUILD_VERSION_RESOLVED="$(cat VERSION | tr -d "[:space:]")"; fi; \ + CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X ds2api/internal/version.BuildVersion=${BUILD_VERSION_RESOLVED}" -o /out/ds2api ./cmd/ds2api FROM busybox:1.36.1-musl AS busybox-tools diff --git a/VERSION b/VERSION index 7ecf123..e75da3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +2.3.6 diff --git a/internal/admin/handler.go b/internal/admin/handler.go index 96111e2..fc20533 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -39,5 +39,6 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Get("/export", h.exportConfig) pr.Get("/dev/captures", h.getDevCaptures) pr.Delete("/dev/captures", h.clearDevCaptures) + pr.Get("/version", h.getVersion) }) } diff --git a/internal/admin/handler_version.go b/internal/admin/handler_version.go new file mode 100644 index 0000000..1c38dfe --- /dev/null +++ b/internal/admin/handler_version.go @@ -0,0 +1,75 @@ +package admin + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "ds2api/internal/version" +) + +const latestReleaseAPI = "https://api.github.com/repos/CJackHwang/ds2api/releases/latest" + +type latestReleasePayload struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + PublishedAt string `json:"published_at"` +} + +func (h *Handler) getVersion(w http.ResponseWriter, _ *http.Request) { + current, source := version.Current() + resp := map[string]any{ + "success": true, + "current_version": current, + "current_tag": version.Tag(current), + "source": source, + "checked_at": time.Now().UTC().Format(time.RFC3339), + } + + req, err := http.NewRequest(http.MethodGet, latestReleaseAPI, nil) + if err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "ds2api-version-check") + + client := &http.Client{Timeout: 4 * time.Second} + r, err := client.Do(req) + if err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + defer r.Body.Close() + if r.StatusCode < 200 || r.StatusCode >= 300 { + resp["check_error"] = "github api status: " + r.Status + writeJSON(w, http.StatusOK, resp) + return + } + + var data latestReleasePayload + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + resp["check_error"] = err.Error() + writeJSON(w, http.StatusOK, resp) + return + } + + latest := strings.TrimSpace(data.TagName) + if latest == "" { + resp["check_error"] = "missing latest tag" + writeJSON(w, http.StatusOK, resp) + return + } + latestVersion := strings.TrimPrefix(latest, "v") + + resp["latest_tag"] = latest + resp["latest_version"] = latestVersion + resp["release_url"] = data.HTMLURL + resp["published_at"] = data.PublishedAt + resp["has_update"] = version.Compare(current, latestVersion) < 0 + + writeJSON(w, http.StatusOK, resp) +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..5f29a0e --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,129 @@ +package version + +import ( + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" +) + +// BuildVersion can be injected at build time via -ldflags. +// In release builds it should come from Git tag (e.g. v2.3.5). +var BuildVersion = "" + +var ( + currentOnce sync.Once + currentVal string + sourceVal string +) + +func Current() (value string, source string) { + currentOnce.Do(func() { + if build := strings.TrimSpace(BuildVersion); build != "" { + currentVal = normalize(build) + sourceVal = "build-ldflags" + return + } + if fv := readVersionFile(); fv != "" { + currentVal = normalize(fv) + sourceVal = "file:VERSION" + return + } + currentVal = "dev" + sourceVal = "default" + }) + return currentVal, sourceVal +} + +func readVersionFile() string { + candidates := []string{"VERSION"} + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(wd, "VERSION")) + } + if _, file, _, ok := runtime.Caller(0); ok { + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "../..")) + candidates = append(candidates, filepath.Join(repoRoot, "VERSION")) + } + seen := map[string]struct{}{} + for _, c := range candidates { + c = filepath.Clean(strings.TrimSpace(c)) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + b, err := os.ReadFile(c) + if err != nil { + continue + } + if v := strings.TrimSpace(string(b)); v != "" { + return v + } + } + return "" +} + +func normalize(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + return strings.TrimPrefix(v, "v") +} + +func Tag(v string) string { + v = normalize(v) + if v == "" || v == "dev" { + return v + } + return "v" + v +} + +func Compare(a, b string) int { + pa := parse(normalize(a)) + pb := parse(normalize(b)) + for i := 0; i < 3; i++ { + if pa[i] < pb[i] { + return -1 + } + if pa[i] > pb[i] { + return 1 + } + } + return 0 +} + +func parse(v string) [3]int { + var out [3]int + parts := strings.SplitN(v, ".", 4) + for i := 0; i < 3 && i < len(parts); i++ { + n := readLeadingInt(parts[i]) + out[i] = n + } + return out +} + +func readLeadingInt(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + i := 0 + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + break + } + } + if i == 0 { + return 0 + } + n, err := strconv.Atoi(s[:i]) + if err != nil { + return 0 + } + return n +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..c42d115 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,24 @@ +package version + +import "testing" + +func TestNormalizeAndTag(t *testing.T) { + if got := normalize("v2.3.5"); got != "2.3.5" { + t.Fatalf("normalize failed: %q", got) + } + if got := Tag("2.3.5"); got != "v2.3.5" { + t.Fatalf("tag failed: %q", got) + } +} + +func TestCompare(t *testing.T) { + if Compare("2.3.5", "2.3.5") != 0 { + t.Fatal("expected equal") + } + if Compare("2.3.5", "2.3.6") >= 0 { + t.Fatal("expected less") + } + if Compare("v2.10.0", "2.3.9") <= 0 { + t.Fatal("expected greater") + } +} diff --git a/webui/src/layout/DashboardShell.jsx b/webui/src/layout/DashboardShell.jsx index 399833b..5e91f8a 100644 --- a/webui/src/layout/DashboardShell.jsx +++ b/webui/src/layout/DashboardShell.jsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { LayoutDashboard, Upload, @@ -47,6 +47,29 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s return res }, [onLogout, t, token]) + + const [versionInfo, setVersionInfo] = useState(null) + + useEffect(() => { + let disposed = false + async function loadVersion() { + try { + const res = await authFetch('/admin/version') + const data = await res.json() + if (!disposed) { + setVersionInfo(data) + } + } catch (_err) { + if (!disposed) { + setVersionInfo(null) + } + } + } + loadVersion() + return () => { + disposed = true + } + }, [authFetch]) const renderTab = () => { switch (activeTab) { case 'accounts': @@ -135,6 +158,20 @@ export default function DashboardShell({ token, onLogout, config, fetchConfig, s
{config.keys?.length || 0}
+
+
{t('sidebar.version')}
+
{versionInfo?.current_tag || '-'}
+ {versionInfo?.has_update && ( + + {t('sidebar.updateAvailable', { latest: versionInfo.latest_tag || '' })} + + )} +