From 5f6e9f11daf593018145cf80f008c6ec967da03c Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 25 May 2026 19:23:49 +0800 Subject: [PATCH] feat(desktop): add client release updater --- apps/desktop-client/electron-builder.yml | 54 +- apps/desktop-client/scripts/sign-mac.sh | 116 +- .../src/main/account-health.test.ts | 108 ++ .../desktop-client/src/main/account-health.ts | 113 +- apps/desktop-client/src/main/app-version.ts | 9 + apps/desktop-client/src/main/bootstrap.ts | 113 +- .../desktop-client/src/main/client-updater.ts | 240 ++++ .../src/main/runtime-controller.ts | 5 +- .../src/main/transport/api-client.ts | 46 +- apps/desktop-client/src/preload/bridge.ts | 43 +- .../src/renderer/components/DesktopShell.vue | 419 +++++- .../renderer/composables/useClientRelease.ts | 194 +++ .../renderer/composables/useDesktopSession.ts | 23 +- apps/desktop-client/src/renderer/env.d.ts | 18 +- .../src/renderer/views/SettingsView.vue | 267 +++- apps/ops-web/src/layouts/AppShell.vue | 9 +- .../src/lib/desktop-client-releases.ts | 156 +++ apps/ops-web/src/lib/http.ts | 4 +- apps/ops-web/src/main.ts | 4 + apps/ops-web/src/router/index.ts | 6 + .../src/views/DesktopClientReleasesView.vue | 986 ++++++++++++++ packages/shared-types/src/index.ts | 52 + server/cmd/ops-api/main.go | 5 + server/configs/config.yaml | 3 + server/configs/ops-config.yaml | 13 + .../ops/app/desktop_client_release.go | 1170 +++++++++++++++++ .../ops/app/desktop_client_release_test.go | 194 +++ server/internal/ops/config/config.go | 75 +- server/internal/ops/domain/types.go | 29 + .../ops/repository/desktop_client_release.go | 290 ++++ .../desktop_client_release_handler.go | 290 ++++ server/internal/ops/transport/router.go | 13 + server/internal/shared/config/config.go | 28 +- server/internal/shared/config/config_test.go | 28 + .../internal/shared/objectstorage/aliyun.go | 106 +- .../shared/objectstorage/aliyun_test.go | 56 + server/internal/shared/objectstorage/url.go | 30 +- .../internal/shared/objectstorage/url_test.go | 53 + .../internal/shared/swagger/descriptions.go | 17 +- server/internal/shared/swagger/swagger.go | 4 + .../transport/desktop_release_handler.go | 136 ++ server/internal/tenant/transport/router.go | 7 + ...00_create_desktop_client_releases.down.sql | 1 + ...3000_create_desktop_client_releases.up.sql | 64 + ...top_client_release_updater_assets.down.sql | 11 + ...sktop_client_release_updater_assets.up.sql | 60 + 46 files changed, 5508 insertions(+), 160 deletions(-) create mode 100644 apps/desktop-client/src/main/account-health.test.ts create mode 100644 apps/desktop-client/src/main/app-version.ts create mode 100644 apps/desktop-client/src/main/client-updater.ts create mode 100644 apps/desktop-client/src/renderer/composables/useClientRelease.ts create mode 100644 apps/ops-web/src/lib/desktop-client-releases.ts create mode 100644 apps/ops-web/src/views/DesktopClientReleasesView.vue create mode 100644 server/internal/ops/app/desktop_client_release.go create mode 100644 server/internal/ops/app/desktop_client_release_test.go create mode 100644 server/internal/ops/repository/desktop_client_release.go create mode 100644 server/internal/ops/transport/desktop_client_release_handler.go create mode 100644 server/internal/shared/objectstorage/aliyun_test.go create mode 100644 server/internal/shared/objectstorage/url_test.go create mode 100644 server/internal/tenant/transport/desktop_release_handler.go create mode 100644 server/migrations_ops/20260525103000_create_desktop_client_releases.down.sql create mode 100644 server/migrations_ops/20260525103000_create_desktop_client_releases.up.sql create mode 100644 server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.down.sql create mode 100644 server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.up.sql diff --git a/apps/desktop-client/electron-builder.yml b/apps/desktop-client/electron-builder.yml index 63bc095..25f9be9 100644 --- a/apps/desktop-client/electron-builder.yml +++ b/apps/desktop-client/electron-builder.yml @@ -8,37 +8,37 @@ compression: maximum removePackageScripts: true removePackageKeywords: true asar: true -afterPack: build/afterPack.cjs # 签名前裁掉非 en/zh_CN 的本地化文件 +afterPack: build/afterPack.cjs # 签名前裁掉非 en/zh_CN 的本地化文件 asarUnpack: - - "**/node_modules/better-sqlite3/build/Release/**" - - "**/node_modules/better-sqlite3/lib/**" - - "**/node_modules/bindings/**" - - "**/node_modules/file-uri-to-path/**" - - "**/node_modules/playwright-core/lib/**" - - "**/node_modules/playwright-core/bin/**" + - '**/node_modules/better-sqlite3/build/Release/**' + - '**/node_modules/better-sqlite3/lib/**' + - '**/node_modules/bindings/**' + - '**/node_modules/file-uri-to-path/**' + - '**/node_modules/playwright-core/lib/**' + - '**/node_modules/playwright-core/bin/**' files: - out/**/* - package.json - - "!**/*.map" - - "!**/*.ts" - - "!**/*.tsx" - - "!**/*.md" - - "!**/*.markdown" - - "!**/{test,tests,__tests__,__mocks__,example,examples,docs,doc,demo,samples,man}/**" - - "!**/{LICENSE,LICENSE.*,license,CHANGELOG,CHANGELOG.*,README,README.*,HISTORY,HISTORY.*,AUTHORS,CONTRIBUTING}*" - - "!**/.*" - - "!**/node_modules/.bin/**" - - "!**/node_modules/*/{Makefile,Gulpfile.js,Gruntfile.js,CMakeLists.txt,*.coffee,*.flow,*.c,*.cc,*.cpp,*.h,*.hpp,*.gyp,*.gypi,*.tgz,binding.gyp}" - - "!**/node_modules/**/{*.d.ts,tsconfig*.json,jest.config*,rollup.config*,webpack.config*,vite.config*}" - - "!**/node_modules/better-sqlite3/{deps,src,docs,benchmark}/**" - - "!**/node_modules/better-sqlite3/{test_extension.c,download.sh,*.gyp,*.gypi}" - - "!**/node_modules/playwright-core/{types,ThirdPartyNotices.txt}/**" - - "!**/node_modules/**/*.{c,cc,cpp,h,hpp,cmake,m4,am,sh}" + - '!**/*.map' + - '!**/*.ts' + - '!**/*.tsx' + - '!**/*.md' + - '!**/*.markdown' + - '!**/{test,tests,__tests__,__mocks__,example,examples,docs,doc,demo,samples,man}/**' + - '!**/{LICENSE,LICENSE.*,license,CHANGELOG,CHANGELOG.*,README,README.*,HISTORY,HISTORY.*,AUTHORS,CONTRIBUTING}*' + - '!**/.*' + - '!**/node_modules/.bin/**' + - '!**/node_modules/*/{Makefile,Gulpfile.js,Gruntfile.js,CMakeLists.txt,*.coffee,*.flow,*.c,*.cc,*.cpp,*.h,*.hpp,*.gyp,*.gypi,*.tgz,binding.gyp}' + - '!**/node_modules/**/{*.d.ts,tsconfig*.json,jest.config*,rollup.config*,webpack.config*,vite.config*}' + - '!**/node_modules/better-sqlite3/{deps,src,docs,benchmark}/**' + - '!**/node_modules/better-sqlite3/{test_extension.c,download.sh,*.gyp,*.gypi}' + - '!**/node_modules/playwright-core/{types,ThirdPartyNotices.txt}/**' + - '!**/node_modules/**/*.{c,cc,cpp,h,hpp,cmake,m4,am,sh}' mac: category: public.app-category.productivity # identity 不写死:让 electron-builder 自动从 CSC_NAME 环境变量或钥匙串里挑 # 注意:写 `identity: null` 是"明确不签名",不要这么写 - notarize: false # 我们用 notarytool 自己跑,比 electron-builder 内置更稳 + notarize: false # 我们用 notarytool 自己跑,比 electron-builder 内置更稳 hardenedRuntime: true gatekeeperAssess: false entitlements: build/entitlements.mac.plist @@ -57,8 +57,8 @@ mac: target: - target: dmg arch: [arm64, universal] - # - target: zip - # arch: [arm64, universal] + - target: zip + arch: [arm64, universal] dmg: format: ULFO contents: @@ -82,4 +82,6 @@ linux: target: - target: AppImage arch: [x64] -publish: null +publish: + provider: generic + url: https://api.shengxintui.com/api/desktop/releases/updater/stub/universal/stable/0.0.0 diff --git a/apps/desktop-client/scripts/sign-mac.sh b/apps/desktop-client/scripts/sign-mac.sh index 8ce1474..c3ae3fc 100755 --- a/apps/desktop-client/scripts/sign-mac.sh +++ b/apps/desktop-client/scripts/sign-mac.sh @@ -10,6 +10,14 @@ ok() { echo " ${G}✓${N} $1"; } fail() { echo " ${R}✗${N} $1"; } step() { echo ""; echo "${B}── $1 ──${N}"; } +LOCK_DIR="${TMPDIR:-/tmp}/geo-rankly-desktop-sign-mac.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + fail "已有 sign:mac 正在运行,避免多个构建同时写 release/" + echo " 如确认没有进程,可删除: $LOCK_DIR" + exit 1 +fi +trap 'rm -rf "$LOCK_DIR"' EXIT + # ── 前置检查(本地/CI 双模式)───────────────────────────────────── # 本地模式:读 .env.signing,公证走钥匙串 profile # CI 模式:靠环境变量 CSC_NAME / APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID @@ -35,6 +43,12 @@ fi ok "签名身份: $CSC_NAME" ok "Team ID: $APPLE_TEAM_ID" +CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-$CSC_NAME}" +if [[ ! "$CODESIGN_IDENTITY" =~ ^[0-9A-Fa-f]{40}$ && "$CODESIGN_IDENTITY" != Developer\ ID\ Application:* ]]; then + CODESIGN_IDENTITY="Developer ID Application: $CODESIGN_IDENTITY" +fi +ok "codesign 身份: $CODESIGN_IDENTITY" + # ── 1. 构建 ────────────────────────────────────────────────────── step "[1/5] 构建 Electron 产物" pnpm build @@ -43,68 +57,98 @@ ok "构建完成" # ── 2. 打包 + 签名 ─────────────────────────────────────────────── step "[2/5] 打包 + 签名 (electron-builder)" # 清理旧产物,避免 spctl 把上次未公证的 dmg 认成本次产物 +rm -rf release/mac-* 2>/dev/null || true rm -f release/*.dmg release/*-mac.zip release/*.blockmap 2>/dev/null || true pnpm exec electron-builder --mac --arm64 ok "打包完成" -DMG=$(ls -t release/*.dmg 2>/dev/null | head -n1 || true) -ZIP=$(ls -t release/*-mac.zip 2>/dev/null | head -n1 || true) -APP=$(ls -td release/mac*/*.app 2>/dev/null | head -n1 || true) +shopt -s nullglob +DMGS=(release/*.dmg) +ZIPS=(release/*-mac.zip) +APPS=(release/mac*/*.app) +shopt -u nullglob -[ -z "$DMG" ] && { fail "未找到 .dmg 产物"; exit 1; } -ok "DMG: $DMG" -[ -n "$ZIP" ] && ok "ZIP: $ZIP" -[ -n "$APP" ] && ok "APP: $APP" +[ "${#DMGS[@]}" -eq 0 ] && { fail "未找到 .dmg 产物"; exit 1; } +[ "${#APPS[@]}" -eq 0 ] && { fail "未找到 .app 产物"; exit 1; } +for DMG in "${DMGS[@]}"; do ok "DMG: $DMG"; done +for ZIP in "${ZIPS[@]}"; do ok "ZIP: $ZIP"; done +for APP in "${APPS[@]}"; do ok "APP: $APP"; done # 验证签名是否真的盖上了 -if [ -n "$APP" ]; then +for APP in "${APPS[@]}"; do if ! codesign --verify --deep --strict --verbose=2 "$APP" >/dev/null 2>&1; then - fail ".app 签名验证失败,停止公证" + fail ".app 签名验证失败,停止公证: $APP" codesign --verify --deep --strict --verbose=2 "$APP" || true exit 1 fi - ok ".app 签名校验通过" -fi + ok ".app 签名校验通过: $APP" +done + +# electron-builder 会签 .app,但 DMG 本体在部分配置/版本下不会自动签。 +# Gatekeeper 对安装包执行 `spctl -t install` 时要求外层 DMG 也有可用签名。 +for DMG in "${DMGS[@]}"; do + echo " 正在签名 DMG: $DMG" + codesign --force --timestamp --sign "$CODESIGN_IDENTITY" "$DMG" + if ! codesign --verify --verbose=2 "$DMG" >/dev/null 2>&1; then + fail "DMG 签名验证失败,停止公证: $DMG" + codesign --verify --verbose=2 "$DMG" || true + exit 1 + fi + ok "DMG 签名校验通过: $DMG" +done # ── 3. 公证 ────────────────────────────────────────────────────── step "[3/5] 提交公证 (notarytool, 通常 1-5 分钟)" -echo " 正在提交 DMG..." -xcrun notarytool submit "$DMG" \ - "${NOTARY_ARGS[@]}" \ - --wait -ok "DMG 公证通过" - -if [ -n "$ZIP" ]; then - echo " 正在提交 ZIP(用于自动更新)..." - xcrun notarytool submit "$ZIP" \ - --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" \ +for DMG in "${DMGS[@]}"; do + echo " 正在提交 DMG: $DMG" + xcrun notarytool submit "$DMG" \ + "${NOTARY_ARGS[@]}" \ --wait - ok "ZIP 公证通过" -fi + ok "DMG 公证通过: $DMG" +done + +for ZIP in "${ZIPS[@]}"; do + echo " 正在提交 ZIP(用于自动更新): $ZIP" + xcrun notarytool submit "$ZIP" \ + "${NOTARY_ARGS[@]}" \ + --wait + ok "ZIP 公证通过: $ZIP" +done # ── 4. 装订 ticket ─────────────────────────────────────────────── step "[4/5] 装订 ticket (stapler)" -xcrun stapler staple "$DMG" -ok "DMG ticket 已装订(断网也能验证)" +for DMG in "${DMGS[@]}"; do + xcrun stapler staple "$DMG" + ok "DMG ticket 已装订(断网也能验证): $DMG" +done # zip 不能 staple,但用户解压后里面的 .app 已经被 staple 过 # ── 5. 验证 ────────────────────────────────────────────────────── step "[5/5] 最终验证 (Gatekeeper)" -if spctl -a -vv -t install "$DMG" 2>&1 | tee /tmp/spctl.log | grep -q "accepted"; then - ok "Gatekeeper 验证通过" - grep -E "source=|origin=" /tmp/spctl.log | sed 's/^/ /' -else - fail "Gatekeeper 拒绝了产物" - cat /tmp/spctl.log - exit 1 -fi +for DMG in "${DMGS[@]}"; do + LOG_FILE=$(mktemp "${TMPDIR:-/tmp}/spctl.XXXXXX") + if spctl -a -vv -t install "$DMG" 2>&1 | tee "$LOG_FILE" | grep -q "accepted"; then + ok "Gatekeeper 验证通过: $DMG" + grep -E "source=|origin=" "$LOG_FILE" | sed 's/^/ /' + else + fail "Gatekeeper 拒绝了产物: $DMG" + cat "$LOG_FILE" + rm -f "$LOG_FILE" + exit 1 + fi + rm -f "$LOG_FILE" +done # ── 完成 ──────────────────────────────────────────────────────── -SIZE=$(du -h "$DMG" | awk '{print $1}') echo "" echo "${G}${B}━━━━━━━━━━ 全部完成 ━━━━━━━━━━${N}" -echo " 📦 $DMG (${SIZE})" -[ -n "$ZIP" ] && echo " 📦 $ZIP" +for DMG in "${DMGS[@]}"; do + SIZE=$(du -h "$DMG" | awk '{print $1}') + echo " 📦 $DMG (${SIZE})" +done +for ZIP in "${ZIPS[@]}"; do + echo " 📦 $ZIP" +done echo "" echo " 这份 DMG 可以直接发给任何 macOS 用户," echo " 双击安装,首次打开${B}不会${N}弹\"无法验证开发者\"或被 Gatekeeper 拦截。" diff --git a/apps/desktop-client/src/main/account-health.test.ts b/apps/desktop-client/src/main/account-health.test.ts new file mode 100644 index 0000000..cc040df --- /dev/null +++ b/apps/desktop-client/src/main/account-health.test.ts @@ -0,0 +1,108 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuthProbeResult } from './auth-types' + +const probeAIAccountSession = vi.fn() +const probePublishAccountSession = vi.fn() +const silentRefreshAIAccountSession = vi.fn() +const silentRefreshPublishAccountSession = vi.fn() +const getSessionHandle = vi.fn() +const getPersistedPartition = vi.fn() +const emitRuntimeInvalidated = vi.fn() + +vi.mock('electron/main', () => ({ + app: { + getPath: () => join(tmpdir(), 'geo-rankly-account-health-test'), + }, +})) + +vi.mock('./account-binder', () => ({ + probeAIAccountSession, + probePublishAccountSession, + silentRefreshAIAccountSession, + silentRefreshPublishAccountSession, +})) + +vi.mock('./session-registry', () => ({ + getSessionHandle, + getPersistedPartition, +})) + +vi.mock('./runtime-events', () => ({ + emitRuntimeInvalidated, +})) + +const account = { + id: 'account-1', + platform: 'doubao', + platformUid: 'doubao-session:demo', + displayName: '豆包账号', +} + +function challenge(reason: AuthProbeResult['reason'] = 'captcha_gate'): AuthProbeResult { + return { + verdict: 'challenge_required', + reason, + profile: null, + sessionFingerprint: null, + } +} + +function active(): AuthProbeResult { + return { + verdict: 'active', + reason: 'probe_success', + profile: { + displayName: '豆包账号', + avatarUrl: null, + subjectUid: 'doubao-session:demo', + }, + sessionFingerprint: 'fingerprint-live', + } +} + +describe('account health challenge recheck', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + getSessionHandle.mockReturnValue({ partition: 'persist:test' }) + getPersistedPartition.mockReturnValue('persist:test') + silentRefreshAIAccountSession.mockResolvedValue(true) + silentRefreshPublishAccountSession.mockResolvedValue(true) + }) + + it('hides a first challenge result when the automatic recheck succeeds', async () => { + probeAIAccountSession.mockResolvedValueOnce(challenge()).mockResolvedValueOnce(active()) + const { probeTrackedAccount } = await import('./account-health') + + const snapshot = await probeTrackedAccount(account, { + trigger: 'manual', + allowSilentRefresh: true, + force: true, + }) + + expect(probeAIAccountSession).toHaveBeenCalledTimes(2) + expect(snapshot.authState).toBe('active') + expect(snapshot.authReason).toBe('probe_success') + }) + + it('shows manual verification only after the automatic recheck still needs it', async () => { + probeAIAccountSession + .mockResolvedValueOnce(challenge('risk_control')) + .mockResolvedValueOnce(challenge('risk_control')) + const { probeTrackedAccount } = await import('./account-health') + + const snapshot = await probeTrackedAccount(account, { + trigger: 'manual', + allowSilentRefresh: true, + force: true, + }) + + expect(probeAIAccountSession).toHaveBeenCalledTimes(2) + expect(snapshot.authState).toBe('challenge_required') + expect(snapshot.authReason).toBe('risk_control') + }) +}) diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 1df68d2..5e63088 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -13,7 +13,11 @@ import type { AccountProbeState, AuthProbeResult, } from './auth-types' -import { getPlatformAdapter, type PlatformFailureInput } from './platform-auth-adapters' +import { + getPlatformAdapter, + type PlatformAdapter, + type PlatformFailureInput, +} from './platform-auth-adapters' import { emitRuntimeInvalidated } from './runtime-events' import { getPersistedPartition, getSessionHandle } from './session-registry' @@ -30,6 +34,7 @@ const MAX_CONCURRENT_ACCOUNT_PROBES = 2 const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000 const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000 const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const +const CHALLENGE_RECHECK_ATTEMPTS = 1 type ProbeTrigger = 'scheduled' | 'manual' | 'preflight' | 'confirm' @@ -366,6 +371,62 @@ function applyNetworkResult( return commitRecord(record, previousSignature) } +function networkProbeResult(): AuthProbeResult { + return { + verdict: 'network_error', + reason: 'network_error', + profile: null, + sessionFingerprint: null, + } +} + +async function probeAdapter( + adapter: PlatformAdapter, + account: PublishAccountIdentity, + partition: string, +): Promise { + return await adapter.probe(account, partition).catch(() => networkProbeResult()) +} + +async function recheckChallengeResult( + adapter: PlatformAdapter, + account: PublishAccountIdentity, + partition: string, + result: AuthProbeResult, +): Promise { + if (result.verdict !== 'challenge_required') { + return result + } + + let confirmed = result + for (let attempt = 0; attempt < CHALLENGE_RECHECK_ATTEMPTS; attempt += 1) { + confirmed = await probeAdapter(adapter, account, partition) + if (confirmed.verdict !== 'challenge_required') { + return confirmed + } + } + + return confirmed +} + +function applyProbeResult( + record: AccountHealthRecord, + result: AuthProbeResult, + now: number, + trigger: ProbeTrigger, +): AccountHealthSnapshot { + switch (result.verdict) { + case 'active': + return applyActiveResult(record, result, now) + case 'expired': + return applyExpiredResult(record, result, now) + case 'challenge_required': + return applyChallengeResult(record, result, now) + default: + return applyNetworkResult(record, result, now, trigger) + } +} + async function performProbeLocked( account: PublishAccountIdentity, options: { @@ -407,28 +468,18 @@ async function performProbeLocked( } } - const result = await adapter.probe(account, partition).catch(() => ({ - verdict: 'network_error', - reason: 'network_error', - profile: null, - sessionFingerprint: null, - })) + const result = await probeAdapter(adapter, account, partition) if (record.authRevision !== probeAuthRevision) { return toPublicSnapshot(record) } - const now = Date.now() - switch (result.verdict) { - case 'active': - return applyActiveResult(record, result, now) - case 'expired': - return applyExpiredResult(record, result, now) - case 'challenge_required': - return applyChallengeResult(record, result, now) - default: - return applyNetworkResult(record, result, now, options.trigger) + const confirmedResult = await recheckChallengeResult(adapter, account, partition, result) + if (record.authRevision !== probeAuthRevision) { + return toPublicSnapshot(record) } + + return applyProbeResult(record, confirmedResult, Date.now(), options.trigger) } function canUseCachedProbe( @@ -922,15 +973,25 @@ export async function reportAccountFailure( const now = Date.now() if (classification === 'challenge' || classification === 'risk_control') { - record.authState = 'challenge_required' - record.probeState = 'idle' - record.authReason = classification === 'risk_control' ? 'risk_control' : 'captcha_gate' - record.lastAuthFailureAt = now - record.lastProbeAt = now - record.nextProbeAt = nextRegularProbeAt(now) - record.suspectedExpiredAt = null - record.confirmFailureCount = 0 - return commitRecord(record, previousSignature) + record.probeState = 'probing' + commitRecord(record, previousSignature) + + const partition = resolveKnownPartition(account.id) + const result = partition + ? await recheckChallengeResult(adapter, account, partition, { + verdict: 'challenge_required', + reason: classification === 'risk_control' ? 'risk_control' : 'captcha_gate', + profile: null, + sessionFingerprint: null, + }) + : { + verdict: 'expired' as const, + reason: 'missing_partition' as const, + profile: null, + sessionFingerprint: null, + } + + return applyProbeResult(record, result, Date.now(), 'confirm') } if (classification === 'network') { diff --git a/apps/desktop-client/src/main/app-version.ts b/apps/desktop-client/src/main/app-version.ts new file mode 100644 index 0000000..64a2d2a --- /dev/null +++ b/apps/desktop-client/src/main/app-version.ts @@ -0,0 +1,9 @@ +import { app } from 'electron/main' + +export function getDesktopAppVersion(): string { + return app.getVersion() +} + +export function getDesktopReleaseChannel(): string { + return process.env.DESKTOP_RELEASE_CHANNEL ?? (app.isPackaged ? 'stable' : 'dev') +} diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 0a1e24b..b245cfa 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import type { DesktopAccountInfo, + DesktopClientReleaseCheckResponse, DesktopClientRotateResponse, DesktopRuntimeSessionSyncRequest, } from '@geo/shared-types' @@ -31,23 +32,29 @@ import { setDesktopAppSetting, type DesktopAppSettingKey, } from './app-settings' -import { resolveDesktopClientID, resolveDesktopDeviceInfo } from './device-id' +import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' import { - installObservedGlobalFetch, - registerObservedRequestRendererTarget, -} from './network-observer' -import { getPlaywrightCDPPort, startHiddenPlaywrightReaper } from './playwright-cdp' + defaultDesktopUpdateChannel, + startDesktopClientUpdate, + type DesktopClientUpdateProgressEvent, +} from './client-updater' +import { resolveDesktopClientID, resolveDesktopDeviceInfo } from './device-id' import { clearSavedLoginCredentials, getSavedLoginCredentials, saveLoginCredentials, } from './login-credentials' +import { + installObservedGlobalFetch, + registerObservedRequestRendererTarget, +} from './network-observer' +import { getPlaywrightCDPPort, startHiddenPlaywrightReaper } from './playwright-cdp' import { initProcessMetricsSampler } from './process-metrics' import { registerRendererDevtoolsProxyTarget } from './renderer-devtools-proxy' import { + getRuntimeControllerSnapshot, noteRuntimeAccountBound, refreshRuntimeAccounts, - getRuntimeControllerSnapshot, releaseRuntimeSession, requestRuntimeAccountProbe, syncRuntimeSession, @@ -59,8 +66,10 @@ import { initScheduler } from './scheduler' import { initSessionRegistry } from './session-registry' import { initSingleInstance } from './single-instance' import { + checkDesktopClientRelease, initTransport, listDesktopPublishTasks, + resolveDesktopClientReleaseDownloadURL, retryDesktopPublishTask, rotateDesktopClient, } from './transport/api-client' @@ -772,15 +781,73 @@ function isSafeExternalUrl(url: string): boolean { } } +function desktopReleasePlatform(): string { + switch (process.platform) { + case 'darwin': + return 'darwin' + case 'win32': + return 'win32' + case 'linux': + return 'linux' + default: + return process.platform + } +} + +function desktopReleaseArch(): string { + switch (process.arch) { + case 'x64': + case 'arm64': + case 'ia32': + return process.arch + default: + return 'universal' + } +} + +async function checkCurrentDesktopClientRelease(): Promise { + const snapshot = getRuntimeControllerSnapshot() + if (!snapshot.session?.client_token) { + throw new Error('desktop_client_not_registered') + } + + return checkDesktopClientRelease({ + platform: desktopReleasePlatform(), + arch: desktopReleaseArch(), + version: getDesktopAppVersion(), + channel: snapshot.session.desktop_client?.channel || getDesktopReleaseChannel(), + }) +} + +async function resolveCurrentDesktopClientReleaseDownloadURL(): Promise<{ download_url: string }> { + const snapshot = getRuntimeControllerSnapshot() + if (!snapshot.session?.client_token) { + throw new Error('desktop_client_not_registered') + } + + return resolveDesktopClientReleaseDownloadURL({ + platform: desktopReleasePlatform(), + arch: desktopReleaseArch(), + version: getDesktopAppVersion(), + channel: snapshot.session.desktop_client?.channel || getDesktopReleaseChannel(), + }) +} + +function emitClientUpdateProgress(event: DesktopClientUpdateProgressEvent): void { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('desktop:client-update-progress', event) + } + } +} + function registerDesktopProtocolClient(): void { if (app.isDefaultProtocolClient(desktopDeepLinkScheme)) { return } if (process.defaultApp && process.execPath) { - app.setAsDefaultProtocolClient(desktopDeepLinkScheme, process.execPath, [ - process.argv[1] ?? '', - ]) + app.setAsDefaultProtocolClient(desktopDeepLinkScheme, process.execPath, [process.argv[1] ?? '']) return } @@ -847,6 +914,34 @@ function handleDesktopDeepLink(rawUrl: string): void { function registerBridgeHandlers(): void { ipcMain.handle('desktop:ping', () => 'pong') + ipcMain.handle('desktop:version-info', () => ({ + version: getDesktopAppVersion(), + channel: getDesktopReleaseChannel(), + })) + safeHandle('desktop:check-client-release', () => checkCurrentDesktopClientRelease()) + safeHandle('desktop:client-release-download-url', () => + resolveCurrentDesktopClientReleaseDownloadURL(), + ) + safeHandle('desktop:quit-app', async () => { + app.quit() + return null + }) + safeHandle('desktop:start-client-update', async () => { + const snapshot = getRuntimeControllerSnapshot() + if (!snapshot.session?.client_token) { + throw new Error('desktop_client_not_registered') + } + + return startDesktopClientUpdate({ + platform: desktopReleasePlatform(), + arch: desktopReleaseArch(), + channel: defaultDesktopUpdateChannel(snapshot.session.desktop_client?.channel), + notify: emitClientUpdateProgress, + beforeInstall: () => { + quitReleaseInFlight = true + }, + }) + }) ipcMain.handle('desktop:device-info', () => resolveDesktopDeviceInfo()) safeHandle( 'desktop:client-id', diff --git a/apps/desktop-client/src/main/client-updater.ts b/apps/desktop-client/src/main/client-updater.ts new file mode 100644 index 0000000..16689f7 --- /dev/null +++ b/apps/desktop-client/src/main/client-updater.ts @@ -0,0 +1,240 @@ +import { autoUpdater, type ProgressInfo, type UpdateDownloadedEvent } from 'electron-updater' +import { app } from 'electron/main' + +import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' +import { resolveDesktopApiURL } from './transport/api-client' + +export type DesktopClientUpdateStage = + | 'idle' + | 'checking' + | 'downloading' + | 'downloaded' + | 'installing' + | 'not-available' + | 'error' + +export interface DesktopClientUpdateProgressEvent { + stage: DesktopClientUpdateStage + percent: number | null + transferred: number | null + total: number | null + bytesPerSecond: number | null + version: string | null + message?: string +} + +export interface DesktopClientUpdateResult { + update_available: boolean + version: string | null + stage: DesktopClientUpdateStage +} + +interface StartDesktopClientUpdateOptions { + platform: string + arch: string + channel: string + notify: (event: DesktopClientUpdateProgressEvent) => void + beforeInstall?: () => void +} + +let updateInFlight: Promise | null = null +let updaterConfigured = false +let downloadedVersion: string | null = null +let progressNotifier: StartDesktopClientUpdateOptions['notify'] | null = null + +function encodeFeedPathSegment(value: string): string { + return encodeURIComponent(value.trim()) +} + +function updaterFeedURL(options: StartDesktopClientUpdateOptions): string { + return resolveDesktopApiURL( + [ + '/api/desktop/releases/updater', + encodeFeedPathSegment(options.platform), + encodeFeedPathSegment(options.arch), + encodeFeedPathSegment(options.channel), + encodeFeedPathSegment(getDesktopAppVersion()), + '', + ].join('/'), + ) +} + +function emitProgress( + notify: StartDesktopClientUpdateOptions['notify'] | null, + event: DesktopClientUpdateProgressEvent, +): void { + notify?.(event) +} + +function updaterErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isCodeSignatureValidationError(message: string): boolean { + return ( + message.includes('Code signature') || + message.includes('did not pass validation') || + message.includes('代码未能满足指定的代码要求') || + message.includes('code requirements') + ) +} + +function normalizeUpdaterError(error: unknown): string { + const message = updaterErrorMessage(error) + if (isCodeSignatureValidationError(message)) { + return 'desktop_client_update_signature_validation_failed' + } + return message || 'desktop_client_update_failed' +} + +function configureUpdater(options: StartDesktopClientUpdateOptions): void { + progressNotifier = options.notify + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.disableDifferentialDownload = true + autoUpdater.allowDowngrade = false + autoUpdater.allowPrerelease = options.channel !== 'stable' + autoUpdater.channel = null + autoUpdater.logger = console + autoUpdater.setFeedURL({ + provider: 'generic', + url: updaterFeedURL(options), + channel: 'latest', + }) + + if (updaterConfigured) { + return + } + updaterConfigured = true + + autoUpdater.on('download-progress', (info: ProgressInfo) => { + emitProgress(progressNotifier, { + stage: 'downloading', + percent: Number.isFinite(info.percent) ? info.percent : null, + transferred: Number.isFinite(info.transferred) ? info.transferred : null, + total: Number.isFinite(info.total) ? info.total : null, + bytesPerSecond: Number.isFinite(info.bytesPerSecond) ? info.bytesPerSecond : null, + version: downloadedVersion, + }) + }) + + autoUpdater.on('update-downloaded', (event: UpdateDownloadedEvent) => { + downloadedVersion = event.version + emitProgress(progressNotifier, { + stage: 'downloaded', + percent: 100, + transferred: null, + total: null, + bytesPerSecond: null, + version: event.version, + message: 'update_downloaded', + }) + }) + + autoUpdater.on('error', (error) => { + console.warn('[desktop-updater] update failed', error) + emitProgress(progressNotifier, { + stage: 'error', + percent: null, + transferred: null, + total: null, + bytesPerSecond: null, + version: downloadedVersion, + message: normalizeUpdaterError(error), + }) + }) +} + +export function startDesktopClientUpdate( + options: StartDesktopClientUpdateOptions, +): Promise { + if (updateInFlight) { + return updateInFlight + } + + updateInFlight = runDesktopClientUpdate(options).finally(() => { + updateInFlight = null + }) + return updateInFlight +} + +async function runDesktopClientUpdate( + options: StartDesktopClientUpdateOptions, +): Promise { + if (!app.isPackaged && process.env.DESKTOP_ALLOW_DEV_UPDATER !== '1') { + throw new Error('desktop_update_requires_packaged_app') + } + + downloadedVersion = null + configureUpdater(options) + + emitProgress(options.notify, { + stage: 'checking', + percent: null, + transferred: null, + total: null, + bytesPerSecond: null, + version: null, + }) + + let checkResult + try { + checkResult = await autoUpdater.checkForUpdates() + } catch (error) { + throw new Error(normalizeUpdaterError(error)) + } + if (!checkResult?.isUpdateAvailable) { + emitProgress(options.notify, { + stage: 'not-available', + percent: null, + transferred: null, + total: null, + bytesPerSecond: null, + version: checkResult?.updateInfo.version ?? null, + }) + return { + update_available: false, + version: checkResult?.updateInfo.version ?? null, + stage: 'not-available', + } + } + + downloadedVersion = checkResult.updateInfo.version + emitProgress(options.notify, { + stage: 'downloading', + percent: 0, + transferred: 0, + total: null, + bytesPerSecond: null, + version: checkResult.updateInfo.version, + }) + + try { + await autoUpdater.downloadUpdate(checkResult.cancellationToken) + } catch (error) { + throw new Error(normalizeUpdaterError(error)) + } + emitProgress(options.notify, { + stage: 'installing', + percent: 100, + transferred: null, + total: null, + bytesPerSecond: null, + version: checkResult.updateInfo.version, + }) + + setTimeout(() => { + options.beforeInstall?.() + autoUpdater.quitAndInstall(process.platform === 'win32', true) + }, 250) + + return { + update_available: true, + version: checkResult.updateInfo.version, + stage: 'installing', + } +} + +export function defaultDesktopUpdateChannel(clientChannel: string | null | undefined): string { + return clientChannel?.trim() || getDesktopReleaseChannel() +} diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 6e15392..9bfa3f4 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -54,6 +54,7 @@ import { type MonitorAdapter, type PublishAdapter, } from './adapters' +import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version' import { getLeaseManagerSnapshot, noteLeaseExtended, @@ -1096,8 +1097,8 @@ async function sendHeartbeat(source: 'startup' | 'timer'): Promise { device_name: hostname() || state.client?.device_name || `Desktop ${process.platform}`, os: process.platform, cpu_arch: process.arch, - client_version: state.client?.client_version ?? '0.1.0-dev', - channel: state.client?.channel ?? 'dev', + client_version: getDesktopAppVersion(), + channel: state.client?.channel ?? getDesktopReleaseChannel(), startup: source === 'startup', account_ids: state.accounts .filter((account) => shouldReportAccountPresence(account)) diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index 156b7f1..c88e0dd 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -13,6 +13,7 @@ import type { DesktopArticleContent, DesktopClientHeartbeatRequest, DesktopClientHeartbeatResponse, + DesktopClientReleaseCheckResponse, DesktopClientRotateResponse, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, @@ -35,13 +36,13 @@ import type { } from '@geo/shared-types' import { app } from 'electron/main' +import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url' import { failObservedRequest, resolveObservedRequest, startObservedRequest, } from '../network-observer' import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from '../renderer-devtools-proxy' -import { normalizeDesktopApiBaseURL } from '../../shared/api-base-url' type TransportAuthState = 'pending' | 'registered' | 'expired' type TransportDispatchState = 'idle' | 'connecting' | 'streaming' @@ -225,10 +226,7 @@ function normalizeErrorForDiagnostics(error: unknown): Record { } } -function appendAccountUpsertDiagnostic( - payload: UpsertDesktopAccountRequest, - error: unknown, -): void { +function appendAccountUpsertDiagnostic(payload: UpsertDesktopAccountRequest, error: unknown): void { const target = join(app.getPath('userData'), ACCOUNT_UPSERT_DIAGNOSTIC_FILE) const event = { at: new Date().toISOString(), @@ -514,6 +512,44 @@ export async function rotateDesktopClient(): Promise { + const search = new URLSearchParams({ + platform: params.platform, + arch: params.arch, + version: params.version, + }) + if (params.channel?.trim()) { + search.set('channel', params.channel.trim()) + } + return desktopGet( + `/api/desktop/clients/releases/latest?${search.toString()}`, + ) +} + +export async function resolveDesktopClientReleaseDownloadURL(params: { + platform: string + arch: string + version: string + channel?: string +}): Promise<{ download_url: string; expires_in?: number | null }> { + const search = new URLSearchParams({ + platform: params.platform, + arch: params.arch, + version: params.version, + }) + if (params.channel?.trim()) { + search.set('channel', params.channel.trim()) + } + return desktopGet<{ download_url: string; expires_in?: number | null }>( + `/api/desktop/clients/releases/download-url?${search.toString()}`, + ) +} + export async function offlineDesktopClient(): Promise { await desktopPost<{ server_time: string }, Record>( '/api/desktop/clients/offline', diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index bee1caf..90d4f5d 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -1,7 +1,10 @@ import type { CreatePublishJobResponse, DesktopAccountInfo, + DesktopClientReleaseCheckResponse, DesktopClientRotateResponse, + DesktopClientUpdateProgressEvent, + DesktopClientUpdateResult, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, ListDesktopPublishTasksParams, @@ -17,6 +20,11 @@ interface DesktopDeviceInfo { cpu_arch: string } +interface DesktopAppVersionInfo { + version: string + channel: string +} + interface DesktopClientIDScope { tenant_id: number workspace_id: number @@ -115,6 +123,18 @@ if (import.meta.env.DEV) { const desktopBridge = { app: { ping: () => ipcRenderer.invoke('desktop:ping') as Promise, + versionInfo: () => ipcRenderer.invoke('desktop:version-info') as Promise, + checkClientRelease: () => + ipcRenderer.invoke( + 'desktop:check-client-release', + ) as Promise, + clientReleaseDownloadURL: () => + ipcRenderer.invoke('desktop:client-release-download-url') as Promise<{ + download_url: string + }>, + quitApp: () => ipcRenderer.invoke('desktop:quit-app') as Promise, + startClientUpdate: () => + ipcRenderer.invoke('desktop:start-client-update') as Promise, deviceInfo: () => ipcRenderer.invoke('desktop:device-info') as Promise, clientId: (scope: DesktopClientIDScope) => ipcRenderer.invoke('desktop:client-id', scope) as Promise, @@ -153,11 +173,14 @@ const desktopBridge = { getAppSettings: () => ipcRenderer.invoke('desktop:get-app-settings') as Promise, getLoginCredentials: () => - ipcRenderer.invoke('desktop:get-login-credentials') as Promise, + ipcRenderer.invoke( + 'desktop:get-login-credentials', + ) as Promise, saveLoginCredentials: (credentials: DesktopLoginCredentials) => - ipcRenderer.invoke('desktop:save-login-credentials', credentials) as Promise< - DesktopLoginCredentials - >, + ipcRenderer.invoke( + 'desktop:save-login-credentials', + credentials, + ) as Promise, clearLoginCredentials: () => ipcRenderer.invoke('desktop:clear-login-credentials') as Promise, setAppSetting: (key: keyof DesktopAppSettings, value: boolean) => @@ -205,6 +228,15 @@ const desktopBridge = { ipcRenderer.off('desktop:network-observed', wrapped) } }, + onClientUpdateProgress: (listener: (event: DesktopClientUpdateProgressEvent) => void) => { + const wrapped = (_event: IpcRendererEvent, payload: DesktopClientUpdateProgressEvent) => { + listener(payload) + } + ipcRenderer.on('desktop:client-update-progress', wrapped) + return () => { + ipcRenderer.off('desktop:client-update-progress', wrapped) + } + }, }, workbenchNavigation: { getState: () => @@ -212,8 +244,7 @@ const desktopBridge = { 'desktop:workbench-navigation:get-state', ) as Promise, goBack: () => ipcRenderer.invoke('desktop:workbench-navigation:go-back') as Promise, - goForward: () => - ipcRenderer.invoke('desktop:workbench-navigation:go-forward') as Promise, + goForward: () => ipcRenderer.invoke('desktop:workbench-navigation:go-forward') as Promise, reload: () => ipcRenderer.invoke('desktop:workbench-navigation:reload') as Promise, onStateChanged: (listener: (state: WorkbenchNavigationState) => void) => { const wrapped = (_event: IpcRendererEvent, payload: WorkbenchNavigationState) => { diff --git a/apps/desktop-client/src/renderer/components/DesktopShell.vue b/apps/desktop-client/src/renderer/components/DesktopShell.vue index a7508d9..f9ba39c 100644 --- a/apps/desktop-client/src/renderer/components/DesktopShell.vue +++ b/apps/desktop-client/src/renderer/components/DesktopShell.vue @@ -1,15 +1,17 @@ @@ -548,6 +764,195 @@ h1 { z-index: 5; } +:global(.client-update-modal .ant-modal-content) { + overflow: hidden; + border-radius: 12px; + padding: 0; + box-shadow: + 0 22px 48px rgba(15, 23, 42, 0.18), + 0 8px 18px rgba(15, 23, 42, 0.08); +} + +:global(.client-update-modal .ant-modal-close) { + top: 18px; + right: 18px; +} + +.client-update-dialog { + display: flex; + flex-direction: column; + gap: 22px; + padding: 34px 36px 30px; + background: #ffffff; +} + +.client-update-dialog__mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: 8px; + background: #e6f4ff; + color: #1677ff; + font-size: 20px; +} + +.client-update-dialog__copy { + display: flex; + flex-direction: column; + gap: 10px; +} + +.client-update-dialog__copy span { + color: #1677ff; + font-size: 13px; + font-weight: 800; +} + +.client-update-dialog__copy h2 { + margin: 0; + color: #111827; + font-size: 24px; + font-weight: 800; + line-height: 1.25; +} + +.client-update-dialog__copy p { + margin: 0; + color: #374151; + font-size: 16px; + font-weight: 600; + line-height: 1.75; +} + +.client-update-link-button { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 0 2px; + border: 0; + border-bottom: 2px solid rgba(22, 119, 255, 0.28); + background: transparent; + color: #1677ff; + cursor: pointer; + font-size: 15px; + font-weight: 800; + transition: + border-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease; +} + +.client-update-link-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.client-update-link-button:not(:disabled):hover { + border-color: #1677ff; + color: #0958d9; +} + +.client-update-progress { + display: flex; + flex-direction: column; + gap: 10px; +} + +.client-update-progress__meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #64748b; + font-size: 13px; +} + +.client-update-progress__meta strong { + min-width: 0; + color: #334155; + font-size: 13px; + font-weight: 800; +} + +.client-update-progress__meta span { + color: #1677ff; + font-weight: 800; +} + +.client-update-progress__bar { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: #eef2f7; +} + +.client-update-progress__bar span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: #1677ff; + transition: width 0.2s ease; +} + +.client-update-dialog__actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; +} + +.client-update-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 104px; + height: 40px; + padding: 0 18px; + border-radius: 8px; + cursor: pointer; + font-size: 15px; + font-weight: 800; + transition: + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease; +} + +.client-update-button:disabled { + cursor: not-allowed; + opacity: 0.62; +} + +.client-update-button--primary { + border: 1px solid #1677ff; + background: #1677ff; + color: #ffffff; +} + +.client-update-button--primary:not(:disabled):hover { + background: #0958d9; + border-color: #0958d9; +} + +.client-update-button--ghost { + border: 1px solid #dbe3ee; + background: #ffffff; + color: #475569; +} + +.client-update-button--ghost:not(:disabled):hover { + border-color: #b6cff5; + color: #1677ff; + background: #f8fbff; +} + @media (max-width: 1080px) { .sidebar { width: 260px; diff --git a/apps/desktop-client/src/renderer/composables/useClientRelease.ts b/apps/desktop-client/src/renderer/composables/useClientRelease.ts new file mode 100644 index 0000000..3ac6b64 --- /dev/null +++ b/apps/desktop-client/src/renderer/composables/useClientRelease.ts @@ -0,0 +1,194 @@ +import type { + DesktopClientReleaseCheckResponse, + DesktopClientUpdateProgressEvent, +} from '@geo/shared-types' +import { computed, readonly, ref } from 'vue' + +type CheckMode = 'manual' | 'silent' + +const release = ref(null) +const loading = ref(false) +const error = ref(null) +const lastCheckedAt = ref(null) +const updateProgress = ref(null) +const updateError = ref(null) +const updateManualDownloadRequired = ref(false) +const manualDownloadLoading = ref(false) +let inFlight: Promise | null = null +let updateInFlight: Promise | null = null +let progressListenerRegistered = false + +const MANUAL_DOWNLOAD_UPDATE_ERROR = '自动更新包校验失败,请前往官网下载最新版客户端' + +function normalizeReleaseError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('desktop_client_not_registered')) { + return '当前客户端尚未注册' + } + if (message.includes('desktop_client_release_not_configured')) { + return '暂未配置当前平台版本' + } + return message || '版本检查失败' +} + +async function check( + mode: CheckMode = 'manual', +): Promise { + if (inFlight) { + return inFlight + } + + loading.value = true + if (mode === 'manual') { + error.value = null + } + + inFlight = window.desktopBridge.app + .checkClientRelease() + .then((result) => { + release.value = result + error.value = null + lastCheckedAt.value = Date.now() + return result + }) + .catch((err: unknown) => { + const message = normalizeReleaseError(err) + if (mode === 'manual') { + error.value = message + } + return null + }) + .finally(() => { + loading.value = false + inFlight = null + }) + + return inFlight +} + +function clearReleaseCheck(): void { + release.value = null + error.value = null + lastCheckedAt.value = null +} + +function normalizeUpdateError(err: unknown): string { + const message = err instanceof Error ? err.message : String(err) + if (requiresManualDownload(message)) { + return MANUAL_DOWNLOAD_UPDATE_ERROR + } + if (message.includes('desktop_update_requires_packaged_app')) { + return '自动更新需要在打包后的客户端中执行' + } + if (message.includes('desktop_client_release_missing_sha256')) { + return '自动更新需要先配置安装包 SHA256' + } + if (message.includes('desktop_client_update_package_not_supported')) { + return '当前安装包格式不支持自动更新' + } + return message || '自动更新失败' +} + +function requiresManualDownload(message: string): boolean { + return ( + message.includes('desktop_client_update_signature_validation_failed') || + message.includes('Code signature') || + message.includes('did not pass validation') || + message.includes('代码未能满足指定的代码要求') + ) +} + +function ensureProgressListener(): void { + if (progressListenerRegistered) { + return + } + progressListenerRegistered = true + window.desktopBridge.app.onClientUpdateProgress((event) => { + updateProgress.value = event + if (event.stage === 'error') { + const message = event.message ?? '自动更新失败' + updateManualDownloadRequired.value = requiresManualDownload(message) + updateError.value = normalizeUpdateError(message) + } + }) +} + +async function startUpdate(): Promise { + ensureProgressListener() + if (updateInFlight) { + return updateInFlight + } + updateError.value = null + updateManualDownloadRequired.value = false + updateProgress.value = { + stage: 'checking', + percent: null, + transferred: null, + total: null, + bytesPerSecond: null, + version: release.value?.latest_version ?? null, + } + + updateInFlight = window.desktopBridge.app + .startClientUpdate() + .then((result) => result.update_available) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + updateManualDownloadRequired.value = requiresManualDownload(message) + updateError.value = normalizeUpdateError(err) + return false + }) + .finally(() => { + updateInFlight = null + }) + + return updateInFlight +} + +async function openManualDownload(): Promise { + if (manualDownloadLoading.value) { + return false + } + manualDownloadLoading.value = true + try { + const result = await window.desktopBridge.app.clientReleaseDownloadURL() + if (!result.download_url) { + updateError.value = '暂未配置官网下载地址' + return false + } + await window.desktopBridge.app.openExternalUrl(result.download_url) + return true + } catch (err: unknown) { + updateError.value = normalizeReleaseError(err) + return false + } finally { + manualDownloadLoading.value = false + } +} + +export function useClientRelease() { + ensureProgressListener() + return { + release: readonly(release), + loading: readonly(loading), + error: readonly(error), + lastCheckedAt: readonly(lastCheckedAt), + updateProgress: readonly(updateProgress), + updateError: readonly(updateError), + updateManualDownloadRequired: readonly(updateManualDownloadRequired), + manualDownloadLoading: readonly(manualDownloadLoading), + updateAvailable: computed(() => release.value?.update_available === true), + forceUpdate: computed(() => release.value?.force_update === true), + updating: computed( + () => + updateProgress.value?.stage === 'checking' || + updateProgress.value?.stage === 'downloading' || + updateProgress.value?.stage === 'downloaded' || + updateProgress.value?.stage === 'installing', + ), + check, + startUpdate, + openManualDownload, + clearReleaseCheck, + } +} diff --git a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts index 9b06f84..ae34ffb 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopSession.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopSession.ts @@ -479,12 +479,28 @@ async function registerPayload( device?: ResolvedDeviceInfo, ): Promise { const resolvedDevice = device ?? (await resolveDeviceInfo()) + const versionInfo = await resolveVersionInfo() return { client_id: await resolveDesktopClientID(user), device_name: resolvedDevice.device_name, os: resolvedDevice.os, cpu_arch: resolvedDevice.cpu_arch, - client_version: '0.1.0-dev', + client_version: versionInfo.version, + channel: versionInfo.channel, + } +} + +async function resolveVersionInfo(): Promise<{ version: string; channel: string }> { + try { + const info = await window.desktopBridge?.app?.versionInfo?.() + if (info?.version && info.channel) { + return info + } + } catch (err) { + console.warn('[desktop-session] versionInfo bridge failed', err) + } + return { + version: '0.1.0', channel: 'dev', } } @@ -519,6 +535,7 @@ function createPreviewUser(): UserInfo { async function createPreviewClient(): Promise { const now = new Date().toISOString() const device = await resolveDeviceInfo() + const versionInfo = await resolveVersionInfo() return { id: 'preview-client', tenant_id: 0, @@ -527,8 +544,8 @@ async function createPreviewClient(): Promise { device_name: `Preview ${device.device_name}`, os: device.os, cpu_arch: device.cpu_arch, - client_version: '0.1.0-preview', - channel: 'dev', + client_version: versionInfo.version, + channel: versionInfo.channel, created_at: now, last_seen_at: now, last_rotated_at: now, diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index a7f73cd..fcf6d33 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -1,6 +1,9 @@ import type { CreatePublishJobResponse, DesktopAccountInfo, + DesktopClientReleaseCheckResponse, + DesktopClientUpdateProgressEvent, + DesktopClientUpdateResult, DesktopClientRotateResponse, DesktopPublishTaskListResponse, DesktopRuntimeSessionSyncRequest, @@ -36,6 +39,14 @@ declare global { desktopBridge: { app: { ping(): Promise + versionInfo(): Promise<{ + version: string + channel: string + }> + checkClientRelease(): Promise + clientReleaseDownloadURL(): Promise<{ download_url: string }> + quitApp(): Promise + startClientUpdate(): Promise deviceInfo(): Promise<{ device_name: string os: string @@ -70,9 +81,7 @@ declare global { openSettingsWindow(): Promise getAppSettings(): Promise getLoginCredentials(): Promise - saveLoginCredentials( - credentials: DesktopLoginCredentials, - ): Promise + saveLoginCredentials(credentials: DesktopLoginCredentials): Promise clearLoginCredentials(): Promise setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise @@ -94,6 +103,9 @@ declare global { }) => void, ): () => void onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void + onClientUpdateProgress( + listener: (event: DesktopClientUpdateProgressEvent) => void, + ): () => void } workbenchNavigation: { getState(): Promise diff --git a/apps/desktop-client/src/renderer/views/SettingsView.vue b/apps/desktop-client/src/renderer/views/SettingsView.vue index f3ca8ae..6f253b7 100644 --- a/apps/desktop-client/src/renderer/views/SettingsView.vue +++ b/apps/desktop-client/src/renderer/views/SettingsView.vue @@ -1,17 +1,36 @@ + + diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 4c2ee53..dbb9626 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -141,6 +141,58 @@ export interface DesktopClientRotateResponse { client: DesktopClientInfo } +export interface DesktopClientReleaseCheckResponse { + update_available: boolean + latest_version: string + current_version: string + min_supported_version: string | null + force_update: boolean + download_source: 'oss' | 'custom' + download_url?: string | null + oss_object_key?: string | null + file_name?: string | null + file_size_bytes?: number | null + sha256?: string | null + updater_available?: boolean + updater_download_source?: 'oss' | 'custom' | null + updater_oss_object_key?: string | null + updater_file_name?: string | null + updater_file_size_bytes?: number | null + updater_sha256?: string | null + release_notes?: string | null + published_at?: string | null +} + +export interface DesktopClientReleaseDownloadURLResponse { + download_url: string + expires_in?: number | null +} + +export type DesktopClientUpdateStage = + | 'idle' + | 'checking' + | 'downloading' + | 'downloaded' + | 'installing' + | 'not-available' + | 'error' + +export interface DesktopClientUpdateProgressEvent { + stage: DesktopClientUpdateStage + percent: number | null + transferred: number | null + total: number | null + bytesPerSecond: number | null + version: string | null + message?: string +} + +export interface DesktopClientUpdateResult { + update_available: boolean + version: string | null + stage: DesktopClientUpdateStage +} + export interface DesktopAccountInfo { id: string platform: string diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index 444876c..f5ddeb4 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -21,6 +21,7 @@ import ( "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/ipregion" "github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/geo-platform/tenant-api/internal/shared/observability" "github.com/geo-platform/tenant-api/internal/shared/repository/postgres" "github.com/geo-platform/tenant-api/internal/shared/repository/redis" @@ -83,6 +84,7 @@ func main() { kolSubscriptionsRepo := repository.NewKolSubscriptionRepository(pool) auditsRepo := repository.NewAuditRepository(pool) siteDomainMappingsRepo := repository.NewSiteDomainMappingRepository(monitoringPool) + desktopClientReleasesRepo := repository.NewDesktopClientReleaseRepository(pool) schedulerRepo := repository.NewSchedulerRepository(pool) ipRegionResolver, err := ipregion.NewResolver(cfg.IPRegion.V4XDBPath, cfg.IPRegion.V6XDBPath, logger) @@ -118,6 +120,8 @@ func main() { jobSvc := app.NewJobService(jobsRepo, auditSvc, rabbitMQ) kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache) siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc) + objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger) + desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc) complianceSvc := app.NewComplianceService(pool, auditSvc, logger) schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc) @@ -166,6 +170,7 @@ func main() { KolSubs: kolSubscriptionSvc, Audits: auditSvc, SiteDomains: siteDomainMappingSvc, + Releases: desktopClientReleaseSvc, Compliance: complianceSvc, Scheduler: schedulerSvc, }) diff --git a/server/configs/config.yaml b/server/configs/config.yaml index 3be7c63..06dcddf 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -163,6 +163,9 @@ object_storage: bucket: shengxintui use_ssl: true public_base_url: "" + bucket_acl: private + object_acl: default + signed_url_ttl: 30m region: cn-shanghai cache: diff --git a/server/configs/ops-config.yaml b/server/configs/ops-config.yaml index 2e720f6..fe9ad15 100644 --- a/server/configs/ops-config.yaml +++ b/server/configs/ops-config.yaml @@ -40,6 +40,19 @@ redis: cache: driver: redis +object_storage: + provider: aliyun + endpoint: https://oss-cn-shanghai.aliyuncs.com + access_key: LTAI5tAEj8euR8B1gXzMoG84 + secret_key: lJPrKo9ViyHJE8UWIMmMCY0B6Je8v2 + bucket: shengxintui + use_ssl: true + public_base_url: "" + bucket_acl: private + object_acl: default + signed_url_ttl: 30m + region: cn-shanghai + log: level: info format: json diff --git a/server/internal/ops/app/desktop_client_release.go b/server/internal/ops/app/desktop_client_release.go new file mode 100644 index 0000000..29270fe --- /dev/null +++ b/server/internal/ops/app/desktop_client_release.go @@ -0,0 +1,1170 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gopkg.in/yaml.v3" + + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/ops/repository" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +const ( + ActionDesktopClientReleaseCreate = "desktop_client_release.create" + ActionDesktopClientReleaseUpdate = "desktop_client_release.update" + ActionDesktopClientReleaseEnable = "desktop_client_release.enable" + ActionDesktopClientReleaseDisable = "desktop_client_release.disable" + ActionDesktopClientReleaseDelete = "desktop_client_release.delete" +) + +const ( + DesktopClientDownloadSourceOSS = "oss" + DesktopClientDownloadSourceCustom = "custom" +) + +const ( + DesktopClientAssetKindInstaller = "installer" + DesktopClientAssetKindUpdater = "updater" +) + +type DesktopClientReleaseService struct { + releases *repository.DesktopClientReleaseRepository + storage objectstorage.Client + audits *AuditService +} + +func NewDesktopClientReleaseService( + releases *repository.DesktopClientReleaseRepository, + storage objectstorage.Client, + audits *AuditService, +) *DesktopClientReleaseService { + return &DesktopClientReleaseService{releases: releases, storage: storage, audits: audits} +} + +type DesktopClientReleaseView struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Arch string `json:"arch"` + Channel string `json:"channel"` + Version string `json:"version"` + MinSupportedVersion *string `json:"min_supported_version"` + DownloadSource string `json:"download_source"` + OSSObjectKey *string `json:"oss_object_key"` + CustomDownloadURL *string `json:"custom_download_url"` + DownloadURL *string `json:"download_url"` + FileName *string `json:"file_name"` + FileSizeBytes *int64 `json:"file_size_bytes"` + SHA256 *string `json:"sha256"` + UpdaterDownloadSource *string `json:"updater_download_source"` + UpdaterOSSObjectKey *string `json:"updater_oss_object_key"` + UpdaterCustomDownloadURL *string `json:"updater_custom_download_url"` + UpdaterDownloadURL *string `json:"updater_download_url"` + UpdaterFileName *string `json:"updater_file_name"` + UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"` + UpdaterSHA256 *string `json:"updater_sha256"` + ReleaseNotes *string `json:"release_notes"` + ForceUpdate bool `json:"force_update"` + Enabled bool `json:"enabled"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DesktopClientReleaseListInput struct { + Keyword string + Platform string + Channel string + Enabled *bool + Page int + Size int +} + +type DesktopClientReleaseListResult struct { + Items []DesktopClientReleaseView `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + +type DesktopClientReleaseInput struct { + Platform string + Arch string + Channel string + Version string + MinSupportedVersion *string + DownloadSource string + OSSObjectKey *string + CustomDownloadURL *string + FileName *string + FileSizeBytes *int64 + SHA256 *string + UpdaterDownloadSource *string + UpdaterOSSObjectKey *string + UpdaterCustomDownloadURL *string + UpdaterFileName *string + UpdaterFileSizeBytes *int64 + UpdaterSHA256 *string + ReleaseNotes *string + ForceUpdate bool + Enabled bool +} + +type DesktopClientReleaseUploadInput struct { + Platform string + Arch string + Channel string + Version string + Kind string + FileName string + Content []byte +} + +type DesktopClientReleaseUploadResult struct { + OSSObjectKey string `json:"oss_object_key"` + FileName string `json:"file_name"` + FileSizeBytes int64 `json:"file_size_bytes"` + SHA256 string `json:"sha256"` +} + +type DesktopClientReleaseCheckInput struct { + Platform string + Arch string + Channel string + CurrentVersion string +} + +type DesktopClientReleaseCheckResult struct { + UpdateAvailable bool `json:"update_available"` + LatestVersion string `json:"latest_version"` + CurrentVersion string `json:"current_version"` + MinSupportedVersion *string `json:"min_supported_version"` + ForceUpdate bool `json:"force_update"` + DownloadSource string `json:"download_source"` + OSSObjectKey *string `json:"oss_object_key"` + FileName *string `json:"file_name"` + FileSizeBytes *int64 `json:"file_size_bytes"` + SHA256 *string `json:"sha256"` + UpdaterAvailable bool `json:"updater_available"` + UpdaterDownloadSource *string `json:"updater_download_source"` + UpdaterOSSObjectKey *string `json:"updater_oss_object_key"` + UpdaterFileName *string `json:"updater_file_name"` + UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"` + UpdaterSHA256 *string `json:"updater_sha256"` + ReleaseNotes *string `json:"release_notes"` + PublishedAt *time.Time `json:"published_at"` +} + +type DesktopClientReleaseDownloadURLResult struct { + DownloadURL string `json:"download_url"` + ExpiresIn *int64 `json:"expires_in,omitempty"` +} + +type DesktopClientUpdaterFeedResult struct { + Content []byte + FileName string +} + +type desktopClientUpdaterFeed struct { + Version string `yaml:"version"` + Files []desktopClientUpdaterFile `yaml:"files"` + Path string `yaml:"path,omitempty"` + SHA2 string `yaml:"sha2,omitempty"` + ReleaseDate string `yaml:"releaseDate,omitempty"` + ReleaseName string `yaml:"releaseName,omitempty"` + ReleaseNotes string `yaml:"releaseNotes,omitempty"` +} + +type desktopClientUpdaterFile struct { + URL string `yaml:"url"` + SHA2 string `yaml:"sha2,omitempty"` + Size *int64 `yaml:"size,omitempty"` +} + +type desktopClientReleaseAsset struct { + DownloadSource *string + OSSObjectKey *string + CustomDownloadURL *string + FileName *string + FileSizeBytes *int64 + SHA256 *string +} + +func (a desktopClientReleaseAsset) HasTarget() bool { + switch stringPointerValue(a.DownloadSource) { + case DesktopClientDownloadSourceCustom: + return strings.TrimSpace(stringPointerValue(a.CustomDownloadURL)) != "" + default: + return strings.TrimSpace(stringPointerValue(a.OSSObjectKey)) != "" + } +} + +func (s *DesktopClientReleaseService) List(ctx context.Context, in DesktopClientReleaseListInput) (*DesktopClientReleaseListResult, error) { + page := in.Page + if page <= 0 { + page = 1 + } + size := in.Size + if size <= 0 || size > 200 { + size = 20 + } + + items, total, err := s.releases.List(ctx, repository.DesktopClientReleaseFilter{ + Keyword: strings.TrimSpace(in.Keyword), + Platform: normalizeDesktopReleaseEnum(in.Platform), + Channel: normalizeDesktopReleaseEnum(in.Channel), + Enabled: in.Enabled, + Limit: size, + Offset: (page - 1) * size, + }) + if err != nil { + return nil, err + } + + views := make([]DesktopClientReleaseView, 0, len(items)) + for i := range items { + views = append(views, s.toView(&items[i])) + } + return &DesktopClientReleaseListResult{Items: views, Total: total, Page: page, Size: size}, nil +} + +func (s *DesktopClientReleaseService) Create(ctx context.Context, actor *Actor, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) { + normalized, err := normalizeDesktopClientReleaseInput(in) + if err != nil { + return nil, err + } + + item, err := s.releases.Create(ctx, normalized.toRepositoryInput(actorID(actor))) + if err != nil { + if isDuplicateDesktopClientRelease(err) { + return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构和渠道的客户端版本配置已存在") + } + return nil, err + } + s.audit(ctx, actor, ActionDesktopClientReleaseCreate, item) + view := s.toView(item) + return &view, nil +} + +func (s *DesktopClientReleaseService) Update(ctx context.Context, actor *Actor, id int64, in DesktopClientReleaseInput) (*DesktopClientReleaseView, error) { + normalized, err := normalizeDesktopClientReleaseInput(in) + if err != nil { + return nil, err + } + + item, err := s.releases.Update(ctx, id, normalized.toRepositoryInput(actorID(actor))) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") + } + if isDuplicateDesktopClientRelease(err) { + return nil, response.ErrConflict(40930, "duplicate_desktop_client_release", "该平台、架构和渠道的客户端版本配置已存在") + } + return nil, err + } + s.audit(ctx, actor, ActionDesktopClientReleaseUpdate, item) + view := s.toView(item) + return &view, nil +} + +func (s *DesktopClientReleaseService) SetEnabled(ctx context.Context, actor *Actor, id int64, enabled bool) (*DesktopClientReleaseView, error) { + item, err := s.releases.SetEnabled(ctx, id, enabled, actorID(actor)) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") + } + return nil, err + } + action := ActionDesktopClientReleaseEnable + if !enabled { + action = ActionDesktopClientReleaseDisable + } + s.audit(ctx, actor, action, item) + view := s.toView(item) + return &view, nil +} + +func (s *DesktopClientReleaseService) Delete(ctx context.Context, actor *Actor, id int64) error { + item, err := s.releases.GetByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") + } + return err + } + if err := s.releases.Delete(ctx, id); err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") + } + return err + } + s.audit(ctx, actor, ActionDesktopClientReleaseDelete, item) + return nil +} + +func (s *DesktopClientReleaseService) UploadOSS(ctx context.Context, in DesktopClientReleaseUploadInput) (*DesktopClientReleaseUploadResult, error) { + if s.storage == nil { + return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + if err := s.storage.Validate(); err != nil { + return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + + target, fileName, err := normalizeDesktopClientReleaseUploadInput(in) + if err != nil { + return nil, err + } + + kind := normalizeDesktopClientAssetKind(in.Kind) + if kind == DesktopClientAssetKindUpdater { + if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { + return nil, err + } + } + sum := sha256.Sum256(in.Content) + sha256Hex := hex.EncodeToString(sum[:]) + objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) + contentType := detectDesktopReleaseContentType(fileName, in.Content) + exists, err := s.storage.Exists(ctx, objectKey) + if err != nil { + appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) + appErr.Cause = err + return nil, appErr + } + if !exists { + if err := s.storage.PutBytes(ctx, objectKey, in.Content, contentType); err != nil { + appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) + appErr.Cause = err + return nil, appErr + } + } + + return &DesktopClientReleaseUploadResult{ + OSSObjectKey: objectKey, + FileName: fileName, + FileSizeBytes: int64(len(in.Content)), + SHA256: sha256Hex, + }, nil +} + +func (s *DesktopClientReleaseService) CheckLatest(ctx context.Context, in DesktopClientReleaseCheckInput) (*DesktopClientReleaseCheckResult, error) { + target, currentVersion, err := normalizeDesktopClientReleaseCheckInput(in) + if err != nil { + return nil, err + } + + item, err := s.releases.FindEnabled(ctx, target) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本") + } + return nil, err + } + + return &DesktopClientReleaseCheckResult{ + UpdateAvailable: compareVersionStrings(item.Version, currentVersion) > 0, + LatestVersion: item.Version, + CurrentVersion: currentVersion, + MinSupportedVersion: item.MinSupportedVersion, + ForceUpdate: item.ForceUpdate || minSupportedVersionRequiresUpdate(item.MinSupportedVersion, currentVersion), + DownloadSource: item.DownloadSource, + OSSObjectKey: item.OSSObjectKey, + FileName: item.FileName, + FileSizeBytes: item.FileSizeBytes, + SHA256: item.SHA256, + UpdaterAvailable: itemUpdaterAsset(item).HasTarget(), + UpdaterDownloadSource: itemUpdaterAsset(item).DownloadSource, + UpdaterOSSObjectKey: itemUpdaterAsset(item).OSSObjectKey, + UpdaterFileName: itemUpdaterAsset(item).FileName, + UpdaterFileSizeBytes: itemUpdaterAsset(item).FileSizeBytes, + UpdaterSHA256: itemUpdaterAsset(item).SHA256, + ReleaseNotes: item.ReleaseNotes, + PublishedAt: item.PublishedAt, + }, nil +} + +func (s *DesktopClientReleaseService) toView(item *domain.DesktopClientRelease) DesktopClientReleaseView { + return DesktopClientReleaseView{ + ID: item.ID, + Platform: item.Platform, + Arch: item.Arch, + Channel: item.Channel, + Version: item.Version, + MinSupportedVersion: item.MinSupportedVersion, + DownloadSource: item.DownloadSource, + OSSObjectKey: item.OSSObjectKey, + CustomDownloadURL: item.CustomDownloadURL, + DownloadURL: nil, + FileName: item.FileName, + FileSizeBytes: item.FileSizeBytes, + SHA256: item.SHA256, + UpdaterDownloadSource: item.UpdaterDownloadSource, + UpdaterOSSObjectKey: item.UpdaterOSSObjectKey, + UpdaterCustomDownloadURL: item.UpdaterCustomDownloadURL, + UpdaterDownloadURL: nil, + UpdaterFileName: item.UpdaterFileName, + UpdaterFileSizeBytes: item.UpdaterFileSizeBytes, + UpdaterSHA256: item.UpdaterSHA256, + ReleaseNotes: item.ReleaseNotes, + ForceUpdate: item.ForceUpdate, + Enabled: item.Enabled, + PublishedAt: item.PublishedAt, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } +} + +func (s *DesktopClientReleaseService) ResolveDownloadURLByID(ctx context.Context, id int64) (*DesktopClientReleaseDownloadURLResult, error) { + item, err := s.releases.GetByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return nil, response.ErrNotFound(40430, "desktop_client_release_not_found", "客户端版本配置不存在") + } + return nil, err + } + return s.resolveDownloadURLResult(item) +} + +func (s *DesktopClientReleaseService) ResolveLatestDownloadURL(ctx context.Context, in DesktopClientReleaseCheckInput) (*DesktopClientReleaseDownloadURLResult, error) { + target, _, err := normalizeDesktopClientReleaseCheckInput(in) + if err != nil { + return nil, err + } + item, err := s.releases.FindEnabled(ctx, target) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本") + } + return nil, err + } + return s.resolveDownloadURLResult(item) +} + +func (s *DesktopClientReleaseService) ResolveLatestUpdaterFeed(ctx context.Context, in DesktopClientReleaseCheckInput, feedFile string) (*DesktopClientUpdaterFeedResult, error) { + target, _, err := normalizeDesktopClientReleaseCheckInput(in) + if err != nil { + return nil, err + } + item, err := s.releases.FindEnabled(ctx, target) + if err != nil { + if errors.Is(err, repository.ErrDesktopClientReleaseNotFound) { + return nil, response.ErrNotFound(40431, "desktop_client_release_not_configured", "未配置该平台客户端版本") + } + return nil, err + } + updaterAsset := itemUpdaterAsset(item) + if !updaterAsset.HasTarget() { + return nil, response.ErrInternal(50096, "desktop_client_update_package_not_configured", "未配置当前平台自动更新包") + } + downloadURL, err := s.resolveAssetDownloadURL(updaterAsset) + if err != nil { + return nil, err + } + if err := validateDesktopClientUpdaterPackage(item.Platform, updaterAsset.FileName, downloadURL); err != nil { + return nil, err + } + content, err := buildDesktopClientUpdaterFeedYAML(item, updaterAsset, downloadURL) + if err != nil { + return nil, err + } + return &DesktopClientUpdaterFeedResult{ + Content: content, + FileName: sanitizeDesktopUpdaterFeedFile(feedFile), + }, nil +} + +func itemInstallerAsset(item *domain.DesktopClientRelease) desktopClientReleaseAsset { + if item == nil { + return desktopClientReleaseAsset{} + } + source := item.DownloadSource + return desktopClientReleaseAsset{ + DownloadSource: &source, + OSSObjectKey: item.OSSObjectKey, + CustomDownloadURL: item.CustomDownloadURL, + FileName: item.FileName, + FileSizeBytes: item.FileSizeBytes, + SHA256: item.SHA256, + } +} + +func itemUpdaterAsset(item *domain.DesktopClientRelease) desktopClientReleaseAsset { + if item == nil { + return desktopClientReleaseAsset{} + } + if item.UpdaterDownloadSource != nil { + return desktopClientReleaseAsset{ + DownloadSource: item.UpdaterDownloadSource, + OSSObjectKey: item.UpdaterOSSObjectKey, + CustomDownloadURL: item.UpdaterCustomDownloadURL, + FileName: item.UpdaterFileName, + FileSizeBytes: item.UpdaterFileSizeBytes, + SHA256: item.UpdaterSHA256, + } + } + return itemInstallerAsset(item) +} + +func (s *DesktopClientReleaseService) resolveDownloadURLResult(item *domain.DesktopClientRelease) (*DesktopClientReleaseDownloadURLResult, error) { + downloadURL, err := s.resolveAssetDownloadURL(itemInstallerAsset(item)) + if err != nil { + return nil, err + } + return &DesktopClientReleaseDownloadURLResult{DownloadURL: downloadURL}, nil +} + +func (s *DesktopClientReleaseService) resolveAssetDownloadURL(asset desktopClientReleaseAsset) (string, error) { + if !asset.HasTarget() { + return "", nil + } + if stringPointerValue(asset.DownloadSource) == DesktopClientDownloadSourceCustom { + return strings.TrimSpace(stringPointerValue(asset.CustomDownloadURL)), nil + } + + objectKey := strings.TrimSpace(stringPointerValue(asset.OSSObjectKey)) + if objectKey == "" { + return "", response.ErrInternal(50090, "desktop_client_release_missing_object_key", "OSS object key is missing") + } + if s.storage == nil { + return "", response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + downloadURL, err := s.storage.PublicURL(objectKey) + if err != nil { + return "", response.ErrInternal(50091, "object_storage_url_failed", "failed to build object storage download url") + } + return downloadURL, nil +} + +func buildDesktopClientUpdaterFeedYAML(item *domain.DesktopClientRelease, asset desktopClientReleaseAsset, downloadURL string) ([]byte, error) { + if item == nil { + return nil, response.ErrInternal(50093, "desktop_client_release_missing", "客户端版本配置不存在") + } + sha2 := strings.ToLower(strings.TrimSpace(stringPointerValue(asset.SHA256))) + if sha2 == "" { + return nil, response.ErrInternal(50094, "desktop_client_release_missing_sha256", "自动更新需要配置安装包 SHA256") + } + if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(sha2) { + return nil, response.ErrInternal(50095, "desktop_client_release_invalid_sha256", "自动更新安装包 SHA256 格式无效") + } + + file := desktopClientUpdaterFile{ + URL: downloadURL, + SHA2: sha2, + Size: asset.FileSizeBytes, + } + releaseDate := item.UpdatedAt.UTC().Format(time.RFC3339) + if item.PublishedAt != nil { + releaseDate = item.PublishedAt.UTC().Format(time.RFC3339) + } + feed := desktopClientUpdaterFeed{ + Version: item.Version, + Files: []desktopClientUpdaterFile{file}, + Path: downloadURL, + SHA2: sha2, + ReleaseDate: releaseDate, + ReleaseName: item.Version, + } + if notes := strings.TrimSpace(stringPointerValue(item.ReleaseNotes)); notes != "" { + feed.ReleaseNotes = notes + } + content, err := yaml.Marshal(feed) + if err != nil { + return nil, fmt.Errorf("marshal desktop client updater feed: %w", err) + } + return content, nil +} + +func validateDesktopClientUpdaterPackage(platform string, fileName *string, downloadURL string) error { + name := strings.ToLower(strings.TrimSpace(stringPointerValue(fileName))) + if name == "" { + name = strings.ToLower(resolveFileNameFromURL(downloadURL)) + } + switch platform { + case "darwin": + if strings.HasSuffix(name, ".zip") { + return nil + } + return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "macOS 自动更新必须上传 electron-builder 生成的 zip 包") + case "win32": + if strings.HasSuffix(name, ".exe") { + return nil + } + return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "Windows 自动更新必须上传 electron-builder 生成的 NSIS exe 包") + case "linux": + if strings.HasSuffix(name, ".appimage") { + return nil + } + return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "Linux 自动更新必须上传 electron-builder 生成的 AppImage 包") + default: + return response.ErrInternal(50096, "desktop_client_update_package_not_supported", "当前平台暂不支持自动更新") + } +} + +func resolveFileNameFromURL(rawURL string) string { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return "" + } + name := path.Base(parsed.EscapedPath()) + if decoded, err := url.PathUnescape(name); err == nil { + return decoded + } + return name +} + +func sanitizeDesktopUpdaterFeedFile(value string) string { + value = strings.TrimPrefix(strings.TrimSpace(value), "/") + if value == "" { + return "latest.yml" + } + value = path.Base(value) + if !strings.HasSuffix(strings.ToLower(value), ".yml") { + return "latest.yml" + } + return value +} + +func (s *DesktopClientReleaseService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopClientRelease) { + if actor == nil || s.audits == nil || item == nil { + return + } + _ = s.audits.Append(ctx, actor.audit(action, "desktop_client_release", item.ID, desktopClientReleaseAuditMetadata(item))) +} + +func (in DesktopClientReleaseInput) toRepositoryInput(actorID *int64) repository.UpsertDesktopClientReleaseInput { + return repository.UpsertDesktopClientReleaseInput{ + Platform: in.Platform, + Arch: in.Arch, + Channel: in.Channel, + Version: in.Version, + MinSupportedVersion: in.MinSupportedVersion, + DownloadSource: in.DownloadSource, + OSSObjectKey: in.OSSObjectKey, + CustomDownloadURL: in.CustomDownloadURL, + FileName: in.FileName, + FileSizeBytes: in.FileSizeBytes, + SHA256: in.SHA256, + UpdaterDownloadSource: in.UpdaterDownloadSource, + UpdaterOSSObjectKey: in.UpdaterOSSObjectKey, + UpdaterCustomDownloadURL: in.UpdaterCustomDownloadURL, + UpdaterFileName: in.UpdaterFileName, + UpdaterFileSizeBytes: in.UpdaterFileSizeBytes, + UpdaterSHA256: in.UpdaterSHA256, + ReleaseNotes: in.ReleaseNotes, + ForceUpdate: in.ForceUpdate, + Enabled: in.Enabled, + PublishedAtNow: in.Enabled, + ActorID: actorID, + } +} + +func normalizeDesktopClientReleaseInput(in DesktopClientReleaseInput) (DesktopClientReleaseInput, error) { + platform := normalizeDesktopReleaseEnum(in.Platform) + if !isAllowedDesktopReleasePlatform(platform) { + return DesktopClientReleaseInput{}, response.ErrBadRequest(40060, "invalid_desktop_release_platform", "客户端平台必须是 darwin、win32 或 linux") + } + arch := normalizeDesktopReleaseEnum(in.Arch) + if arch == "" { + arch = "universal" + } + if !isAllowedDesktopReleaseArch(arch) { + return DesktopClientReleaseInput{}, response.ErrBadRequest(40061, "invalid_desktop_release_arch", "客户端架构必须是 universal、x64、arm64 或 ia32") + } + channel := normalizeDesktopReleaseEnum(in.Channel) + if channel == "" { + channel = "stable" + } + if !isValidDesktopReleaseToken(channel) { + return DesktopClientReleaseInput{}, response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效") + } + version := strings.TrimSpace(in.Version) + if !isValidDesktopReleaseVersion(version) { + return DesktopClientReleaseInput{}, response.ErrBadRequest(40063, "invalid_desktop_release_version", "版本号格式无效") + } + + minSupportedVersion := trimOptionalReleaseString(in.MinSupportedVersion) + if minSupportedVersion != nil && !isValidDesktopReleaseVersion(*minSupportedVersion) { + return DesktopClientReleaseInput{}, response.ErrBadRequest(40064, "invalid_min_supported_version", "最低支持版本格式无效") + } + + source := normalizeDesktopReleaseEnum(in.DownloadSource) + if source == "" { + source = DesktopClientDownloadSourceOSS + } + if source != DesktopClientDownloadSourceOSS && source != DesktopClientDownloadSourceCustom { + return DesktopClientReleaseInput{}, response.ErrBadRequest(40065, "invalid_download_source", "下载来源必须是 oss 或 custom") + } + + installerAsset, err := normalizeDesktopClientReleaseAsset( + source, + in.OSSObjectKey, + in.CustomDownloadURL, + in.FileName, + in.FileSizeBytes, + in.SHA256, + true, + false, + platform, + ) + if err != nil { + return DesktopClientReleaseInput{}, err + } + + updaterSource := normalizeOptionalDesktopDownloadSource(in.UpdaterDownloadSource) + if updaterSource == nil && hasDesktopClientUpdaterAssetInput(in) { + defaultSource := DesktopClientDownloadSourceOSS + updaterSource = &defaultSource + } + var updaterAsset desktopClientReleaseAsset + if updaterSource != nil { + updaterAsset, err = normalizeDesktopClientReleaseAsset( + *updaterSource, + in.UpdaterOSSObjectKey, + in.UpdaterCustomDownloadURL, + in.UpdaterFileName, + in.UpdaterFileSizeBytes, + in.UpdaterSHA256, + true, + true, + platform, + ) + if err != nil { + return DesktopClientReleaseInput{}, err + } + } + releaseNotes := trimOptionalReleaseString(in.ReleaseNotes) + + return DesktopClientReleaseInput{ + Platform: platform, + Arch: arch, + Channel: channel, + Version: version, + MinSupportedVersion: minSupportedVersion, + DownloadSource: source, + OSSObjectKey: installerAsset.OSSObjectKey, + CustomDownloadURL: installerAsset.CustomDownloadURL, + FileName: installerAsset.FileName, + FileSizeBytes: installerAsset.FileSizeBytes, + SHA256: installerAsset.SHA256, + UpdaterDownloadSource: updaterSource, + UpdaterOSSObjectKey: updaterAsset.OSSObjectKey, + UpdaterCustomDownloadURL: updaterAsset.CustomDownloadURL, + UpdaterFileName: updaterAsset.FileName, + UpdaterFileSizeBytes: updaterAsset.FileSizeBytes, + UpdaterSHA256: updaterAsset.SHA256, + ReleaseNotes: releaseNotes, + ForceUpdate: in.ForceUpdate, + Enabled: in.Enabled, + }, nil +} + +func normalizeDesktopClientReleaseCheckInput(in DesktopClientReleaseCheckInput) (repository.DesktopClientReleaseTarget, string, error) { + platform := normalizeDesktopReleaseEnum(in.Platform) + if !isAllowedDesktopReleasePlatform(platform) { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40060, "invalid_desktop_release_platform", "客户端平台必须是 darwin、win32 或 linux") + } + arch := normalizeDesktopReleaseEnum(in.Arch) + if arch == "" { + arch = "universal" + } + if !isAllowedDesktopReleaseArch(arch) { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40061, "invalid_desktop_release_arch", "客户端架构必须是 universal、x64、arm64 或 ia32") + } + channel := normalizeDesktopReleaseEnum(in.Channel) + if channel == "" { + channel = "stable" + } + if !isValidDesktopReleaseToken(channel) { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40062, "invalid_desktop_release_channel", "发布渠道格式无效") + } + currentVersion := strings.TrimSpace(in.CurrentVersion) + if currentVersion != "" && !isValidDesktopReleaseVersion(currentVersion) { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40063, "invalid_desktop_release_version", "版本号格式无效") + } + return repository.DesktopClientReleaseTarget{Platform: platform, Arch: arch, Channel: channel}, currentVersion, nil +} + +func normalizeOptionalDesktopDownloadSource(value *string) *string { + if value == nil { + return nil + } + source := normalizeDesktopReleaseEnum(*value) + if source == "" { + return nil + } + return &source +} + +func hasDesktopClientUpdaterAssetInput(in DesktopClientReleaseInput) bool { + return trimOptionalReleaseString(in.UpdaterOSSObjectKey) != nil || + trimOptionalReleaseString(in.UpdaterCustomDownloadURL) != nil || + trimOptionalReleaseString(in.UpdaterFileName) != nil || + in.UpdaterFileSizeBytes != nil || + trimOptionalReleaseString(in.UpdaterSHA256) != nil +} + +func normalizeDesktopClientReleaseAsset( + source string, + ossObjectKeyInput *string, + customDownloadURLInput *string, + fileNameInput *string, + fileSizeBytes *int64, + sha256Input *string, + required bool, + updater bool, + platform string, +) (desktopClientReleaseAsset, error) { + source = normalizeDesktopReleaseEnum(source) + if source == "" { + source = DesktopClientDownloadSourceOSS + } + if source != DesktopClientDownloadSourceOSS && source != DesktopClientDownloadSourceCustom { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40065, "invalid_download_source", "下载来源必须是 oss 或 custom") + } + + ossObjectKey := trimOptionalReleaseString(ossObjectKeyInput) + customDownloadURL := trimOptionalReleaseString(customDownloadURLInput) + if source == DesktopClientDownloadSourceOSS { + if required && (ossObjectKey == nil || !isValidDesktopReleaseObjectKey(*ossObjectKey)) { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 不能为空,且不能以 / 开头或包含 ..") + } + if ossObjectKey != nil && !isValidDesktopReleaseObjectKey(*ossObjectKey) { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 不能为空,且不能以 / 开头或包含 ..") + } + customDownloadURL = nil + } else { + if required && (customDownloadURL == nil || !isSafeHTTPURL(*customDownloadURL)) { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40067, "invalid_custom_download_url", "自定义下载地址必须是 http(s) URL") + } + if customDownloadURL != nil && !isSafeHTTPURL(*customDownloadURL) { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40067, "invalid_custom_download_url", "自定义下载地址必须是 http(s) URL") + } + ossObjectKey = nil + } + + fileName := trimOptionalReleaseString(fileNameInput) + if fileName != nil && (len(*fileName) > 255 || strings.ContainsAny(*fileName, `/\`)) { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40068, "invalid_file_name", "文件名不能包含路径且不能超过 255 字符") + } + if fileSizeBytes != nil && *fileSizeBytes < 0 { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40069, "invalid_file_size", "文件大小不能为负数") + } + sha256, err := normalizeDesktopReleaseSHA256(sha256Input) + if err != nil { + return desktopClientReleaseAsset{}, err + } + if updater { + if sha256 == nil { + return desktopClientReleaseAsset{}, response.ErrBadRequest(40070, "invalid_sha256", "自动更新包必须配置 SHA256") + } + fileNameSource := stringPointerValue(customDownloadURL) + if fileNameSource == "" { + fileNameSource = stringPointerValue(ossObjectKey) + } + if err := validateDesktopClientUpdaterPackage(platform, fileName, fileNameSource); err != nil { + return desktopClientReleaseAsset{}, err + } + } + sourcePtr := source + return desktopClientReleaseAsset{ + DownloadSource: &sourcePtr, + OSSObjectKey: ossObjectKey, + CustomDownloadURL: customDownloadURL, + FileName: fileName, + FileSizeBytes: fileSizeBytes, + SHA256: sha256, + }, nil +} + +func normalizeDesktopReleaseSHA256(value *string) (*string, error) { + sha256 := trimOptionalReleaseString(value) + if sha256 == nil { + return nil, nil + } + normalized := strings.ToLower(*sha256) + if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(normalized) { + return nil, response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制") + } + return &normalized, nil +} + +func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) { + if len(in.Content) == 0 { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40071, "empty_release_file", "上传文件不能为空") + } + if len(in.Content) > 500*1024*1024 { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40072, "release_file_too_large", "客户端安装包不能超过 500MB") + } + + target, _, err := normalizeDesktopClientReleaseCheckInput(DesktopClientReleaseCheckInput{ + Platform: in.Platform, + Arch: in.Arch, + Channel: in.Channel, + CurrentVersion: in.Version, + }) + if err != nil { + return repository.DesktopClientReleaseTarget{}, "", err + } + + fileName := sanitizeDesktopReleaseFileName(in.FileName) + if fileName == "" { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40068, "invalid_file_name", "文件名不能为空") + } + if !isAllowedDesktopReleasePackageFile(fileName) { + return repository.DesktopClientReleaseTarget{}, "", response.ErrBadRequest(40073, "invalid_release_file_type", "安装包格式必须是 dmg、pkg、exe、msi、zip、AppImage、deb、rpm 或 tar.gz") + } + if normalizeDesktopClientAssetKind(in.Kind) == DesktopClientAssetKindUpdater { + if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { + return repository.DesktopClientReleaseTarget{}, "", err + } + } + return target, fileName, nil +} + +func normalizeDesktopClientAssetKind(value string) string { + switch normalizeDesktopReleaseEnum(value) { + case DesktopClientAssetKindUpdater: + return DesktopClientAssetKindUpdater + default: + return DesktopClientAssetKindInstaller + } +} + +func normalizeDesktopReleaseEnum(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func isAllowedDesktopReleasePlatform(value string) bool { + switch value { + case "darwin", "win32", "linux": + return true + default: + return false + } +} + +func isAllowedDesktopReleaseArch(value string) bool { + switch value { + case "universal", "x64", "arm64", "ia32": + return true + default: + return false + } +} + +func isValidDesktopReleaseToken(value string) bool { + if value == "" || len(value) > 32 { + return false + } + return regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`).MatchString(value) +} + +func isValidDesktopReleaseVersion(value string) bool { + if value == "" || len(value) > 64 || strings.ContainsAny(value, " \t\r\n/\\") { + return false + } + return regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`).MatchString(value) +} + +func isValidDesktopReleaseObjectKey(value string) bool { + if value == "" || len(value) > 1024 || strings.HasPrefix(value, "/") || strings.Contains(value, "\\") { + return false + } + for _, part := range strings.Split(value, "/") { + if part == "" || part == "." || part == ".." { + return false + } + } + return true +} + +func isSafeHTTPURL(value string) bool { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" +} + +func sanitizeDesktopReleaseFileName(value string) string { + value = strings.TrimSpace(filepath.Base(value)) + value = strings.ReplaceAll(value, "\\", "") + value = strings.TrimSpace(value) + if decoded, err := url.PathUnescape(value); err == nil { + value = strings.TrimSpace(decoded) + } + if value == "." || value == "/" || value == "\\" { + return "" + } + if len(value) > 255 { + return "" + } + return value +} + +func isAllowedDesktopReleasePackageFile(fileName string) bool { + lower := strings.ToLower(strings.TrimSpace(fileName)) + for _, suffix := range []string{ + ".dmg", + ".pkg", + ".exe", + ".msi", + ".zip", + ".appimage", + ".deb", + ".rpm", + ".tar.gz", + ".tgz", + } { + if strings.HasSuffix(lower, suffix) { + return true + } + } + return false +} + +func buildDesktopClientReleaseObjectKey(sha256Hex string, fileName string) string { + sum := strings.TrimSpace(strings.ToLower(sha256Hex)) + if sum == "" { + sum = "unknown" + } + return fmt.Sprintf("desktop-client/releases/blobs/%s%s", sum, desktopReleaseFileExtension(fileName)) +} + +func desktopReleaseFileExtension(fileName string) string { + lower := strings.ToLower(strings.TrimSpace(fileName)) + switch { + case strings.HasSuffix(lower, ".tar.gz"): + return ".tar.gz" + case strings.HasSuffix(lower, ".appimage"): + return ".AppImage" + default: + return strings.ToLower(filepath.Ext(fileName)) + } +} + +func detectDesktopReleaseContentType(fileName string, content []byte) string { + lower := strings.ToLower(fileName) + switch { + case strings.HasSuffix(lower, ".dmg"): + return "application/x-apple-diskimage" + case strings.HasSuffix(lower, ".pkg"): + return "application/octet-stream" + case strings.HasSuffix(lower, ".exe"): + return "application/vnd.microsoft.portable-executable" + case strings.HasSuffix(lower, ".msi"): + return "application/x-msi" + case strings.HasSuffix(lower, ".zip"): + return "application/zip" + case strings.HasSuffix(lower, ".deb"): + return "application/vnd.debian.binary-package" + case strings.HasSuffix(lower, ".rpm"): + return "application/x-rpm" + case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"): + return "application/gzip" + default: + if len(content) > 0 { + return http.DetectContentType(content) + } + return "application/octet-stream" + } +} + +func trimOptionalReleaseString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func stringPointerValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func isDuplicateDesktopClientRelease(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} + +func desktopClientReleaseAuditMetadata(item *domain.DesktopClientRelease) map[string]any { + return map[string]any{ + "platform": item.Platform, + "arch": item.Arch, + "channel": item.Channel, + "version": item.Version, + "download_source": item.DownloadSource, + "oss_object_key": stringPointerValue(item.OSSObjectKey), + "custom_download_url": stringPointerValue(item.CustomDownloadURL), + "force_update": item.ForceUpdate, + "enabled": item.Enabled, + } +} + +func minSupportedVersionRequiresUpdate(minVersion *string, currentVersion string) bool { + if minVersion == nil || strings.TrimSpace(currentVersion) == "" { + return false + } + return compareVersionStrings(currentVersion, *minVersion) < 0 +} + +func compareVersionStrings(left string, right string) int { + leftParts := splitVersionParts(left) + rightParts := splitVersionParts(right) + maxLen := len(leftParts) + if len(rightParts) > maxLen { + maxLen = len(rightParts) + } + for i := 0; i < maxLen; i++ { + l, r := 0, 0 + if i < len(leftParts) { + l = leftParts[i] + } + if i < len(rightParts) { + r = rightParts[i] + } + if l > r { + return 1 + } + if l < r { + return -1 + } + } + return strings.Compare(left, right) +} + +func splitVersionParts(value string) []int { + value = strings.TrimPrefix(strings.TrimSpace(value), "v") + fields := regexp.MustCompile(`[^0-9]+`).Split(value, -1) + parts := make([]int, 0, len(fields)) + for _, field := range fields { + if field == "" { + continue + } + n := 0 + for _, ch := range field { + if ch < '0' || ch > '9' { + break + } + n = n*10 + int(ch-'0') + } + parts = append(parts, n) + } + return parts +} diff --git a/server/internal/ops/app/desktop_client_release_test.go b/server/internal/ops/app/desktop_client_release_test.go new file mode 100644 index 0000000..226a201 --- /dev/null +++ b/server/internal/ops/app/desktop_client_release_test.go @@ -0,0 +1,194 @@ +package app + +import ( + "testing" + "time" + + "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/stretchr/testify/require" +) + +func TestNormalizeDesktopClientReleaseInputOSS(t *testing.T) { + t.Parallel() + + customURL := "https://download.example.com/client.dmg" + ossKey := "desktop/stable/client.dmg" + got, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{ + Platform: " Darwin ", + Arch: "", + Channel: "", + Version: "1.2.3", + DownloadSource: "oss", + OSSObjectKey: &ossKey, + CustomDownloadURL: &customURL, + Enabled: true, + }) + + require.NoError(t, err) + require.Equal(t, "darwin", got.Platform) + require.Equal(t, "universal", got.Arch) + require.Equal(t, "stable", got.Channel) + require.Equal(t, DesktopClientDownloadSourceOSS, got.DownloadSource) + require.NotNil(t, got.OSSObjectKey) + require.Equal(t, "desktop/stable/client.dmg", *got.OSSObjectKey) + require.Nil(t, got.CustomDownloadURL) +} + +func TestNormalizeDesktopClientReleaseInputCustom(t *testing.T) { + t.Parallel() + + customURL := "https://download.example.com/client.exe" + ossKey := "desktop/stable/client.exe" + got, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{ + Platform: "win32", + Arch: "x64", + Channel: "stable", + Version: "1.2.3", + DownloadSource: "custom", + OSSObjectKey: &ossKey, + CustomDownloadURL: &customURL, + Enabled: true, + }) + + require.NoError(t, err) + require.Equal(t, DesktopClientDownloadSourceCustom, got.DownloadSource) + require.Nil(t, got.OSSObjectKey) + require.NotNil(t, got.CustomDownloadURL) + require.Equal(t, customURL, *got.CustomDownloadURL) +} + +func TestNormalizeDesktopClientReleaseInputRejectsInvalidDownloadTargets(t *testing.T) { + t.Parallel() + + invalidOSSKey := "../client.dmg" + _, err := normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{ + Platform: "darwin", + Version: "1.2.3", + DownloadSource: "oss", + OSSObjectKey: &invalidOSSKey, + }) + require.Error(t, err) + + invalidURL := "javascript:alert(1)" + _, err = normalizeDesktopClientReleaseInput(DesktopClientReleaseInput{ + Platform: "win32", + Version: "1.2.3", + DownloadSource: "custom", + CustomDownloadURL: &invalidURL, + }) + require.Error(t, err) +} + +func TestDesktopClientReleaseVersionPolicy(t *testing.T) { + t.Parallel() + + require.Positive(t, compareVersionStrings("1.2.10", "1.2.9")) + require.Negative(t, compareVersionStrings("1.2.9", "1.2.10")) + + minVersion := "1.2.0" + require.True(t, minSupportedVersionRequiresUpdate(&minVersion, "1.1.9")) + require.False(t, minSupportedVersionRequiresUpdate(&minVersion, "1.2.0")) +} + +func TestNormalizeDesktopClientReleaseUploadInput(t *testing.T) { + t.Parallel() + + target, fileName, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + FileName: "省心推-1.2.3-arm64.dmg", + Content: []byte("package"), + }) + + require.NoError(t, err) + require.Equal(t, "darwin", target.Platform) + require.Equal(t, "arm64", target.Arch) + require.Equal(t, "stable", target.Channel) + require.Equal(t, "省心推-1.2.3-arm64.dmg", fileName) +} + +func TestNormalizeDesktopClientReleaseUploadInputDecodesFileName(t *testing.T) { + t.Parallel() + + _, fileName, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + FileName: "%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip", + Content: []byte("package"), + }) + + require.NoError(t, err) + require.Equal(t, "省心推-mac-0.1.19.zip", fileName) +} + +func TestNormalizeDesktopClientReleaseUploadInputRejectsInvalidFile(t *testing.T) { + t.Parallel() + + _, _, err := normalizeDesktopClientReleaseUploadInput(DesktopClientReleaseUploadInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + FileName: "client.txt", + Content: []byte("package"), + }) + + require.Error(t, err) +} + +func TestBuildDesktopClientUpdaterFeedYAMLUsesSignedURL(t *testing.T) { + t.Parallel() + + fileName := "省心推-mac-1.2.3.zip" + sha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + size := int64(1024) + publishedAt := time.Date(2026, 5, 25, 10, 30, 0, 0, time.UTC) + content, err := buildDesktopClientUpdaterFeedYAML(&domain.DesktopClientRelease{ + Platform: "darwin", + Version: "1.2.3", + PublishedAt: &publishedAt, + UpdatedAt: publishedAt, + }, desktopClientReleaseAsset{ + FileName: &fileName, + FileSizeBytes: &size, + SHA256: &sha256, + }, "https://oss.example.com/desktop/%E7%9C%81%E5%BF%83%E6%8E%A8.zip?Expires=123&Signature=abc") + + require.NoError(t, err) + text := string(content) + require.Contains(t, text, "version: 1.2.3") + require.Contains(t, text, "sha2: "+sha256) + require.Contains(t, text, "size: 1024") + require.Contains(t, text, "Expires=123") + require.Contains(t, text, "Signature=abc") + require.NotContains(t, text, "%25E7") +} + +func TestValidateDesktopClientUpdaterPackage(t *testing.T) { + t.Parallel() + + zipName := "省心推-mac-1.2.3.zip" + require.NoError(t, validateDesktopClientUpdaterPackage("darwin", &zipName, "")) + + dmgName := "省心推-mac-1.2.3.dmg" + require.Error(t, validateDesktopClientUpdaterPackage("darwin", &dmgName, "")) + + exeURL := "https://download.example.com/%E7%9C%81%E5%BF%83%E6%8E%A8%20Setup%201.2.3.exe" + require.NoError(t, validateDesktopClientUpdaterPackage("win32", nil, exeURL)) +} + +func TestBuildDesktopClientReleaseObjectKeyReusesContentAddress(t *testing.T) { + t.Parallel() + + sum := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + first := buildDesktopClientReleaseObjectKey(sum, "省心推 Setup 1.2.3.exe") + second := buildDesktopClientReleaseObjectKey(sum, "renamed.exe") + + require.Equal(t, first, second) + require.Equal(t, "desktop-client/releases/blobs/"+sum+".exe", first) +} diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go index ed51b2f..753d8e4 100644 --- a/server/internal/ops/config/config.go +++ b/server/internal/ops/config/config.go @@ -20,18 +20,19 @@ import ( ) type Config struct { - Server sharedconfig.ServerConfig `mapstructure:"server"` - Database sharedconfig.DatabaseConfig `mapstructure:"database"` - MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"` - Redis sharedconfig.RedisConfig `mapstructure:"redis"` - Cache sharedconfig.CacheConfig `mapstructure:"cache"` - RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"` - Log sharedconfig.LogConfig `mapstructure:"log"` - JWT JWTConfig `mapstructure:"jwt"` - Auth sharedconfig.AuthConfig `mapstructure:"auth"` - DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` - AdminUsers AdminUsersConfig `mapstructure:"admin_users"` - IPRegion IPRegionConfig `mapstructure:"ip_region"` + Server sharedconfig.ServerConfig `mapstructure:"server"` + Database sharedconfig.DatabaseConfig `mapstructure:"database"` + MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"` + Redis sharedconfig.RedisConfig `mapstructure:"redis"` + Cache sharedconfig.CacheConfig `mapstructure:"cache"` + RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"` + ObjectStorage sharedconfig.ObjectStorageConfig `mapstructure:"object_storage"` + Log sharedconfig.LogConfig `mapstructure:"log"` + JWT JWTConfig `mapstructure:"jwt"` + Auth sharedconfig.AuthConfig `mapstructure:"auth"` + DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` + AdminUsers AdminUsersConfig `mapstructure:"admin_users"` + IPRegion IPRegionConfig `mapstructure:"ip_region"` } type JWTConfig struct { @@ -237,6 +238,9 @@ func Diff(previous, current *Config) []FieldChange { if previous.RabbitMQ != current.RabbitMQ { add("rabbitmq", false) } + if previous.ObjectStorage != current.ObjectStorage { + add("object_storage", false) + } if previous.Log != current.Log { add("log", false) } @@ -466,9 +470,56 @@ func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" { cfg.RabbitMQ.URL = v } + applyOpsObjectStorageEnvOverrides(cfg) return nil } +func applyOpsObjectStorageEnvOverrides(cfg *Config) { + if endpoint := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ENDPOINT", "OBJECT_STORAGE_ENDPOINT"); endpoint != "" { + cfg.ObjectStorage.Endpoint = endpoint + } + if accessKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_ACCESS_KEY", "OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" { + cfg.ObjectStorage.AccessKey = accessKey + } + if secretKey := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SECRET_KEY", "OBJECT_STORAGE_SECRET_KEY"); secretKey != "" { + cfg.ObjectStorage.SecretKey = secretKey + } + if bucket := firstNonEmptyEnv("OPS_OBJECT_STORAGE_BUCKET", "OBJECT_STORAGE_BUCKET"); bucket != "" { + cfg.ObjectStorage.Bucket = bucket + } + if provider := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PROVIDER", "OBJECT_STORAGE_PROVIDER"); provider != "" { + cfg.ObjectStorage.Provider = provider + } + if publicBaseURL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_PUBLIC_BASE_URL", "OBJECT_STORAGE_PUBLIC_BASE_URL"); publicBaseURL != "" { + cfg.ObjectStorage.PublicBaseURL = publicBaseURL + } + if objectACL := firstNonEmptyEnv("OPS_OBJECT_STORAGE_OBJECT_ACL", "OBJECT_STORAGE_OBJECT_ACL"); objectACL != "" { + cfg.ObjectStorage.ObjectACL = objectACL + } + if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_SIGNED_URL_TTL", "OBJECT_STORAGE_SIGNED_URL_TTL"); v != "" { + if ttl, err := time.ParseDuration(v); err == nil { + cfg.ObjectStorage.SignedURLTTL = ttl + } + } + if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" { + cfg.ObjectStorage.Region = region + } + if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_USE_SSL", "OBJECT_STORAGE_USE_SSL"); v != "" { + if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil { + cfg.ObjectStorage.UseSSL = useSSL + } + } +} + +func firstNonEmptyEnv(names ...string) string { + for _, name := range names { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + } + return "" +} + func parseBoolEnv(name, value string) (bool, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "y", "on": diff --git a/server/internal/ops/domain/types.go b/server/internal/ops/domain/types.go index c821819..95ec085 100644 --- a/server/internal/ops/domain/types.go +++ b/server/internal/ops/domain/types.go @@ -73,3 +73,32 @@ type SiteDomainMapping struct { CreatedAt time.Time UpdatedAt time.Time } + +type DesktopClientRelease struct { + ID int64 + Platform string + Arch string + Channel string + Version string + MinSupportedVersion *string + DownloadSource string + OSSObjectKey *string + CustomDownloadURL *string + FileName *string + FileSizeBytes *int64 + SHA256 *string + UpdaterDownloadSource *string + UpdaterOSSObjectKey *string + UpdaterCustomDownloadURL *string + UpdaterFileName *string + UpdaterFileSizeBytes *int64 + UpdaterSHA256 *string + ReleaseNotes *string + ForceUpdate bool + Enabled bool + PublishedAt *time.Time + CreatedBy *int64 + UpdatedBy *int64 + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/server/internal/ops/repository/desktop_client_release.go b/server/internal/ops/repository/desktop_client_release.go new file mode 100644 index 0000000..90c15ee --- /dev/null +++ b/server/internal/ops/repository/desktop_client_release.go @@ -0,0 +1,290 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/geo-platform/tenant-api/internal/ops/domain" +) + +var ErrDesktopClientReleaseNotFound = errors.New("desktop client release not found") + +type DesktopClientReleaseRepository struct { + pool *pgxpool.Pool +} + +func NewDesktopClientReleaseRepository(pool *pgxpool.Pool) *DesktopClientReleaseRepository { + return &DesktopClientReleaseRepository{pool: pool} +} + +const desktopClientReleaseColumns = ` + id, platform, arch, channel, version, min_supported_version, download_source, + oss_object_key, custom_download_url, file_name, file_size_bytes, sha256, + updater_download_source, updater_oss_object_key, updater_custom_download_url, + updater_file_name, updater_file_size_bytes, updater_sha256, + release_notes, force_update, enabled, published_at, created_by, updated_by, + created_at, updated_at` + +type DesktopClientReleaseFilter struct { + Keyword string + Platform string + Channel string + Enabled *bool + Limit int + Offset int +} + +type DesktopClientReleaseTarget struct { + Platform string + Arch string + Channel string +} + +type UpsertDesktopClientReleaseInput struct { + Platform string + Arch string + Channel string + Version string + MinSupportedVersion *string + DownloadSource string + OSSObjectKey *string + CustomDownloadURL *string + FileName *string + FileSizeBytes *int64 + SHA256 *string + UpdaterDownloadSource *string + UpdaterOSSObjectKey *string + UpdaterCustomDownloadURL *string + UpdaterFileName *string + UpdaterFileSizeBytes *int64 + UpdaterSHA256 *string + ReleaseNotes *string + ForceUpdate bool + Enabled bool + PublishedAtNow bool + ActorID *int64 +} + +func scanDesktopClientRelease(row pgx.Row) (*domain.DesktopClientRelease, error) { + var item domain.DesktopClientRelease + err := row.Scan( + &item.ID, + &item.Platform, + &item.Arch, + &item.Channel, + &item.Version, + &item.MinSupportedVersion, + &item.DownloadSource, + &item.OSSObjectKey, + &item.CustomDownloadURL, + &item.FileName, + &item.FileSizeBytes, + &item.SHA256, + &item.UpdaterDownloadSource, + &item.UpdaterOSSObjectKey, + &item.UpdaterCustomDownloadURL, + &item.UpdaterFileName, + &item.UpdaterFileSizeBytes, + &item.UpdaterSHA256, + &item.ReleaseNotes, + &item.ForceUpdate, + &item.Enabled, + &item.PublishedAt, + &item.CreatedBy, + &item.UpdatedBy, + &item.CreatedAt, + &item.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDesktopClientReleaseNotFound + } + return nil, err + } + return &item, nil +} + +func (r *DesktopClientReleaseRepository) List(ctx context.Context, f DesktopClientReleaseFilter) ([]domain.DesktopClientRelease, int64, error) { + args := []any{} + where := "1=1" + + if f.Keyword != "" { + args = append(args, "%"+f.Keyword+"%") + where += fmt.Sprintf( + " AND (version ILIKE $%d OR platform ILIKE $%d OR arch ILIKE $%d OR channel ILIKE $%d OR COALESCE(file_name, '') ILIKE $%d OR COALESCE(oss_object_key, '') ILIKE $%d OR COALESCE(custom_download_url, '') ILIKE $%d OR COALESCE(updater_file_name, '') ILIKE $%d OR COALESCE(updater_oss_object_key, '') ILIKE $%d OR COALESCE(updater_custom_download_url, '') ILIKE $%d)", + len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), len(args), + ) + } + if f.Platform != "" { + args = append(args, f.Platform) + where += fmt.Sprintf(" AND platform = $%d", len(args)) + } + if f.Channel != "" { + args = append(args, f.Channel) + where += fmt.Sprintf(" AND channel = $%d", len(args)) + } + if f.Enabled != nil { + args = append(args, *f.Enabled) + where += fmt.Sprintf(" AND enabled = $%d", len(args)) + } + + var total int64 + if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM ops.desktop_client_releases WHERE "+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + + limit := f.Limit + if limit <= 0 || limit > 200 { + limit = 20 + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + limitArg := len(args) - 1 + offsetArg := len(args) + + rows, err := r.pool.Query(ctx, fmt.Sprintf(` + SELECT %s + FROM ops.desktop_client_releases + WHERE %s + ORDER BY enabled DESC, channel ASC, platform ASC, arch ASC, updated_at DESC, id DESC + LIMIT $%d OFFSET $%d`, desktopClientReleaseColumns, where, limitArg, offsetArg), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]domain.DesktopClientRelease, 0, limit) + for rows.Next() { + item, scanErr := scanDesktopClientRelease(rows) + if scanErr != nil { + return nil, 0, scanErr + } + items = append(items, *item) + } + return items, total, rows.Err() +} + +func (r *DesktopClientReleaseRepository) GetByID(ctx context.Context, id int64) (*domain.DesktopClientRelease, error) { + return scanDesktopClientRelease(r.pool.QueryRow(ctx, ` + SELECT `+desktopClientReleaseColumns+` + FROM ops.desktop_client_releases + WHERE id = $1`, id)) +} + +func (r *DesktopClientReleaseRepository) GetByTarget(ctx context.Context, target DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) { + return scanDesktopClientRelease(r.pool.QueryRow(ctx, ` + SELECT `+desktopClientReleaseColumns+` + FROM ops.desktop_client_releases + WHERE platform = $1 AND arch = $2 AND channel = $3`, + target.Platform, target.Arch, target.Channel)) +} + +func (r *DesktopClientReleaseRepository) FindEnabled(ctx context.Context, target DesktopClientReleaseTarget) (*domain.DesktopClientRelease, error) { + return scanDesktopClientRelease(r.pool.QueryRow(ctx, ` + SELECT `+desktopClientReleaseColumns+` + FROM ops.desktop_client_releases + WHERE platform = $1 + AND channel = $2 + AND enabled = TRUE + AND (arch = $3 OR arch = 'universal') + ORDER BY CASE WHEN arch = $3 THEN 0 ELSE 1 END, updated_at DESC, id DESC + LIMIT 1`, + target.Platform, target.Channel, target.Arch)) +} + +func (r *DesktopClientReleaseRepository) Create(ctx context.Context, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) { + return scanDesktopClientRelease(r.pool.QueryRow(ctx, ` + INSERT INTO ops.desktop_client_releases ( + platform, arch, channel, version, min_supported_version, download_source, + oss_object_key, custom_download_url, file_name, file_size_bytes, sha256, + updater_download_source, updater_oss_object_key, updater_custom_download_url, + updater_file_name, updater_file_size_bytes, updater_sha256, + release_notes, force_update, enabled, published_at, created_by, updated_by + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, $17, + $18, $19, $20, + CASE WHEN $21 THEN NOW() ELSE NULL END, + $22, $22 + ) + RETURNING `+desktopClientReleaseColumns, + in.Platform, in.Arch, in.Channel, in.Version, in.MinSupportedVersion, in.DownloadSource, + in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256, + in.UpdaterDownloadSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL, + in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256, + in.ReleaseNotes, in.ForceUpdate, in.Enabled, in.PublishedAtNow, in.ActorID)) +} + +func (r *DesktopClientReleaseRepository) Update(ctx context.Context, id int64, in UpsertDesktopClientReleaseInput) (*domain.DesktopClientRelease, error) { + return scanDesktopClientRelease(r.pool.QueryRow(ctx, ` + UPDATE ops.desktop_client_releases + SET platform = $1, + arch = $2, + channel = $3, + version = $4, + min_supported_version = $5, + download_source = $6, + oss_object_key = $7, + custom_download_url = $8, + file_name = $9, + file_size_bytes = $10, + sha256 = $11, + updater_download_source = $12, + updater_oss_object_key = $13, + updater_custom_download_url = $14, + updater_file_name = $15, + updater_file_size_bytes = $16, + updater_sha256 = $17, + release_notes = $18, + force_update = $19, + enabled = $20, + published_at = CASE + WHEN $20 = TRUE AND published_at IS NULL THEN NOW() + WHEN $20 = FALSE THEN NULL + ELSE published_at + END, + updated_by = $21, + updated_at = NOW() + WHERE id = $22 + RETURNING `+desktopClientReleaseColumns, + in.Platform, in.Arch, in.Channel, in.Version, in.MinSupportedVersion, in.DownloadSource, + in.OSSObjectKey, in.CustomDownloadURL, in.FileName, in.FileSizeBytes, in.SHA256, + in.UpdaterDownloadSource, in.UpdaterOSSObjectKey, in.UpdaterCustomDownloadURL, + in.UpdaterFileName, in.UpdaterFileSizeBytes, in.UpdaterSHA256, + in.ReleaseNotes, in.ForceUpdate, in.Enabled, in.ActorID, id)) +} + +func (r *DesktopClientReleaseRepository) SetEnabled(ctx context.Context, id int64, enabled bool, actorID *int64) (*domain.DesktopClientRelease, error) { + return scanDesktopClientRelease(r.pool.QueryRow(ctx, ` + UPDATE ops.desktop_client_releases + SET enabled = $1, + published_at = CASE + WHEN $1 = TRUE AND published_at IS NULL THEN NOW() + WHEN $1 = FALSE THEN NULL + ELSE published_at + END, + updated_by = $2, + updated_at = NOW() + WHERE id = $3 + RETURNING `+desktopClientReleaseColumns, enabled, actorID, id)) +} + +func (r *DesktopClientReleaseRepository) Delete(ctx context.Context, id int64) error { + cmd, err := r.pool.Exec(ctx, `DELETE FROM ops.desktop_client_releases WHERE id = $1`, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrDesktopClientReleaseNotFound + } + return nil +} diff --git a/server/internal/ops/transport/desktop_client_release_handler.go b/server/internal/ops/transport/desktop_client_release_handler.go new file mode 100644 index 0000000..16c94f2 --- /dev/null +++ b/server/internal/ops/transport/desktop_client_release_handler.go @@ -0,0 +1,290 @@ +package transport + +import ( + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type desktopClientReleaseRequest struct { + Platform string `json:"platform" binding:"required"` + Arch string `json:"arch"` + Channel string `json:"channel"` + Version string `json:"version" binding:"required"` + MinSupportedVersion *string `json:"min_supported_version"` + DownloadSource string `json:"download_source"` + OSSObjectKey *string `json:"oss_object_key"` + CustomDownloadURL *string `json:"custom_download_url"` + FileName *string `json:"file_name"` + FileSizeBytes *int64 `json:"file_size_bytes"` + SHA256 *string `json:"sha256"` + UpdaterDownloadSource *string `json:"updater_download_source"` + UpdaterOSSObjectKey *string `json:"updater_oss_object_key"` + UpdaterCustomDownloadURL *string `json:"updater_custom_download_url"` + UpdaterFileName *string `json:"updater_file_name"` + UpdaterFileSizeBytes *int64 `json:"updater_file_size_bytes"` + UpdaterSHA256 *string `json:"updater_sha256"` + ReleaseNotes *string `json:"release_notes"` + ForceUpdate bool `json:"force_update"` + Enabled *bool `json:"enabled"` +} + +type setDesktopClientReleaseEnabledRequest struct { + Enabled bool `json:"enabled"` +} + +func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + result, err := svc.List(c.Request.Context(), app.DesktopClientReleaseListInput{ + Keyword: c.Query("keyword"), + Platform: c.Query("platform"), + Channel: c.Query("channel"), + Enabled: parseOptionalBoolQuery(c.Query("enabled")), + Page: page, + Size: size, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func createDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + var body desktopClientReleaseRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + result, err := svc.Create(c.Request.Context(), actorFromGin(c), body.toInput()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func updateDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body desktopClientReleaseRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + result, err := svc.Update(c.Request.Context(), actorFromGin(c), id, body.toInput()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func setDesktopClientReleaseEnabledHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body setDesktopClientReleaseEnabledRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + result, err := svc.SetEnabled(c.Request.Context(), actorFromGin(c), id, body.Enabled) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func deleteDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + if err := svc.Delete(c.Request.Context(), actorFromGin(c), id); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id}) + } +} + +func resolveDesktopClientReleaseDownloadURLHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + result, err := svc.ResolveDownloadURLByID(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func uploadDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + fileHeader, err := c.FormFile("file") + if err != nil { + response.Error(c, response.ErrBadRequest(40071, "release_file_required", "请选择要上传的客户端安装包")) + return + } + file, err := fileHeader.Open() + if err != nil { + response.Error(c, response.ErrBadRequest(40071, "release_file_open_failed", "客户端安装包读取失败")) + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + response.Error(c, response.ErrBadRequest(40071, "release_file_read_failed", "客户端安装包读取失败")) + return + } + + result, err := svc.UploadOSS(c.Request.Context(), app.DesktopClientReleaseUploadInput{ + Platform: c.PostForm("platform"), + Arch: c.PostForm("arch"), + Channel: c.PostForm("channel"), + Version: c.PostForm("version"), + Kind: c.DefaultPostForm("kind", app.DesktopClientAssetKindInstaller), + FileName: fileHeader.Filename, + Content: content, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func checkDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := svc.CheckLatest(c.Request.Context(), app.DesktopClientReleaseCheckInput{ + Platform: c.Query("platform"), + Arch: c.Query("arch"), + Channel: c.DefaultQuery("channel", "stable"), + CurrentVersion: c.Query("version"), + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func resolveLatestDesktopClientReleaseDownloadURLHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := svc.ResolveLatestDownloadURL(c.Request.Context(), app.DesktopClientReleaseCheckInput{ + Platform: c.Query("platform"), + Arch: c.Query("arch"), + Channel: c.DefaultQuery("channel", "stable"), + CurrentVersion: c.Query("version"), + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func desktopClientReleaseUpdaterFeedHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + feed, err := svc.ResolveLatestUpdaterFeed(c.Request.Context(), app.DesktopClientReleaseCheckInput{ + Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")), + Arch: firstNonEmpty(c.Param("arch"), c.Query("arch")), + Channel: firstNonEmpty(c.Param("channel"), c.DefaultQuery("channel", "stable")), + CurrentVersion: firstNonEmpty(c.Param("version"), c.Query("version")), + }, c.Param("feed")) + if err != nil { + response.Error(c, err) + return + } + + c.Header("Content-Type", "application/x-yaml; charset=utf-8") + c.Header("Cache-Control", "no-store") + c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizeHeaderFileName(feed.FileName))) + c.Data(http.StatusOK, "application/x-yaml; charset=utf-8", feed.Content) + } +} + +func (r desktopClientReleaseRequest) toInput() app.DesktopClientReleaseInput { + enabled := true + if r.Enabled != nil { + enabled = *r.Enabled + } + return app.DesktopClientReleaseInput{ + Platform: r.Platform, + Arch: r.Arch, + Channel: r.Channel, + Version: r.Version, + MinSupportedVersion: r.MinSupportedVersion, + DownloadSource: r.DownloadSource, + OSSObjectKey: r.OSSObjectKey, + CustomDownloadURL: r.CustomDownloadURL, + FileName: r.FileName, + FileSizeBytes: r.FileSizeBytes, + SHA256: r.SHA256, + UpdaterDownloadSource: r.UpdaterDownloadSource, + UpdaterOSSObjectKey: r.UpdaterOSSObjectKey, + UpdaterCustomDownloadURL: r.UpdaterCustomDownloadURL, + UpdaterFileName: r.UpdaterFileName, + UpdaterFileSizeBytes: r.UpdaterFileSizeBytes, + UpdaterSHA256: r.UpdaterSHA256, + ReleaseNotes: r.ReleaseNotes, + ForceUpdate: r.ForceUpdate, + Enabled: enabled, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func sanitizeHeaderFileName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "latest.yml" + } + return strings.Map(func(r rune) rune { + if r == '"' || r == '\\' || r == '\r' || r == '\n' { + return -1 + } + return r + }, value) +} diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index 9ebe33b..97340f8 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -25,6 +25,7 @@ type Deps struct { KolSubs *app.KolSubscriptionService Audits *app.AuditService SiteDomains *app.SiteDomainMappingService + Releases *app.DesktopClientReleaseService Compliance *app.ComplianceService Scheduler *app.SchedulerService } @@ -67,6 +68,10 @@ func RegisterRoutes(d Deps) { } response.Success(c, gin.H{"status": "ready"}) }) + d.Engine.GET("/api/desktop/releases/latest", checkDesktopClientReleaseHandler(d.Releases)) + d.Engine.GET("/api/desktop/releases/download-url", resolveLatestDesktopClientReleaseDownloadURLHandler(d.Releases)) + d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopClientReleaseUpdaterFeedHandler(d.Releases)) + d.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopClientReleaseUpdaterFeedHandler(d.Releases)) api := d.Engine.Group("/api/ops") { @@ -132,6 +137,14 @@ func RegisterRoutes(d Deps) { authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains)) authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(d.SiteDomains)) + authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases)) + authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases)) + authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases)) + authed.PATCH("/desktop-client/releases/:id", updateDesktopClientReleaseHandler(d.Releases)) + authed.GET("/desktop-client/releases/:id/download-url", resolveDesktopClientReleaseDownloadURLHandler(d.Releases)) + authed.POST("/desktop-client/releases/:id/enabled", setDesktopClientReleaseEnabledHandler(d.Releases)) + authed.DELETE("/desktop-client/releases/:id", deleteDesktopClientReleaseHandler(d.Releases)) + compliance := authed.Group("/compliance") compliance.GET("/dictionaries", listComplianceDictionariesHandler(d.Compliance)) compliance.POST("/dictionaries", createComplianceDictionaryHandler(d.Compliance)) diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 3980315..8ca4b59 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -285,14 +285,17 @@ type QdrantConfig struct { } type ObjectStorageConfig struct { - Provider string `mapstructure:"provider"` - Endpoint string `mapstructure:"endpoint"` - AccessKey string `mapstructure:"access_key"` - SecretKey string `mapstructure:"secret_key"` - Bucket string `mapstructure:"bucket"` - UseSSL bool `mapstructure:"use_ssl"` - PublicBaseURL string `mapstructure:"public_base_url"` - Region string `mapstructure:"region"` + Provider string `mapstructure:"provider"` + Endpoint string `mapstructure:"endpoint"` + AccessKey string `mapstructure:"access_key"` + SecretKey string `mapstructure:"secret_key"` + Bucket string `mapstructure:"bucket"` + UseSSL bool `mapstructure:"use_ssl"` + PublicBaseURL string `mapstructure:"public_base_url"` + BucketACL string `mapstructure:"bucket_acl"` + ObjectACL string `mapstructure:"object_acl"` + SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"` + Region string `mapstructure:"region"` } type JWTConfig struct { @@ -956,6 +959,15 @@ func applyEnvOverrides(cfg *Config) { if publicBaseURL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_PUBLIC_BASE_URL"); ok { cfg.ObjectStorage.PublicBaseURL = publicBaseURL } + if bucketACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_BUCKET_ACL"); ok { + cfg.ObjectStorage.BucketACL = bucketACL + } + if objectACL, ok := lookupNonEmptyEnv("OBJECT_STORAGE_OBJECT_ACL"); ok { + cfg.ObjectStorage.ObjectACL = objectACL + } + if ttl, ok := lookupDurationEnv("OBJECT_STORAGE_SIGNED_URL_TTL"); ok { + cfg.ObjectStorage.SignedURLTTL = ttl + } if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok { cfg.ObjectStorage.Region = region } diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index 17d5b36..040b46c 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -108,6 +108,34 @@ retrieval: } } +func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) { + t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private") + t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read") + t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m") + + configPath := writeTestConfig(t, ` +object_storage: + bucket_acl: public-read + object_acl: private + signed_url_ttl: 10m +`) + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.ObjectStorage.BucketACL != "private" { + t.Fatalf("expected env bucket acl, got %q", cfg.ObjectStorage.BucketACL) + } + if cfg.ObjectStorage.ObjectACL != "public-read" { + t.Fatalf("expected env object acl, got %q", cfg.ObjectStorage.ObjectACL) + } + if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute { + t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL) + } +} + func TestLoadAppliesBrandLibraryDefaults(t *testing.T) { configPath := writeTestConfig(t, ` brand_library: {} diff --git a/server/internal/shared/objectstorage/aliyun.go b/server/internal/shared/objectstorage/aliyun.go index 27eb9cc..00e45a4 100644 --- a/server/internal/shared/objectstorage/aliyun.go +++ b/server/internal/shared/objectstorage/aliyun.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" "github.com/aliyun/aliyun-oss-go-sdk/oss" "go.uber.org/zap" @@ -106,11 +107,20 @@ func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content [ return fmt.Errorf("get aliyun bucket: %w", err) } - if err := bucket.PutObject(objectKey, bytes.NewReader(content), oss.ContentType(contentType)); err != nil { + options, err := aliyunPutObjectOptions(c.cfg, contentType) + if err != nil { + return err + } + if err := bucket.PutObject(objectKey, bytes.NewReader(content), options...); err != nil { c.logError(ctx, "aliyun put object failed", zap.String("bucket", c.bucket), zap.String("object_key", objectKey), zap.Int("content_size", len(content)), + zap.String("object_acl", strings.TrimSpace(c.cfg.ObjectACL)), + zap.String("content_type", contentType), + zap.String("aliyun_error_code", aliyunErrorCode(err)), + zap.String("aliyun_request_id", aliyunRequestID(err)), + zap.Int("aliyun_status_code", aliyunStatusCode(err)), zap.Error(err), ) return fmt.Errorf("put aliyun object: %w", err) @@ -119,6 +129,47 @@ func (c *aliyunClient) PutBytes(ctx context.Context, objectKey string, content [ return nil } +func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) { + options := []oss.Option{oss.ContentType(contentType)} + acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL)) + switch acl { + case "", "default": + return options, nil + case "private": + return append(options, oss.ObjectACL(oss.ACLPrivate)), nil + case "public-read": + return append(options, oss.ObjectACL(oss.ACLPublicRead)), nil + case "public-read-write": + return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), nil + default: + return nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL) + } +} + +func aliyunErrorCode(err error) string { + serviceErr, ok := err.(oss.ServiceError) + if !ok { + return "" + } + return serviceErr.Code +} + +func aliyunRequestID(err error) string { + serviceErr, ok := err.(oss.ServiceError) + if !ok { + return "" + } + return serviceErr.RequestID +} + +func aliyunStatusCode(err error) int { + serviceErr, ok := err.(oss.ServiceError) + if !ok { + return 0 + } + return serviceErr.StatusCode +} + func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) { if err := c.Validate(); err != nil { return nil, err @@ -235,9 +286,62 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) { if err := c.Validate(); err != nil { return "", err } + publicRead, err := c.isPublicReadable() + if err != nil { + return "", err + } + if !publicRead { + return c.signedURL(objectKey) + } return buildPublicURL(c.cfg, objectKey, true) } +func (c *aliyunClient) isPublicReadable() (bool, error) { + objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL)) + if isPublicReadACL(objectACL) { + return true, nil + } + if objectACL != "" && objectACL != "default" { + return false, nil + } + + bucketACL := strings.ToLower(strings.TrimSpace(c.cfg.BucketACL)) + if bucketACL != "" { + return isPublicReadACL(bucketACL), nil + } + + result, err := c.client.GetBucketACL(c.bucket) + if err != nil { + return false, fmt.Errorf("get aliyun bucket acl: %w", err) + } + return isPublicReadACL(result.ACL), nil +} + +func (c *aliyunClient) signedURL(objectKey string) (string, error) { + objectKey = normalizeObjectKeyPath(objectKey) + if objectKey == "" { + return "", fmt.Errorf("object key is required") + } + bucket, err := c.client.Bucket(c.bucket) + if err != nil { + return "", fmt.Errorf("get aliyun bucket: %w", err) + } + ttl := c.cfg.SignedURLTTL + if ttl <= 0 { + ttl = 30 * time.Minute + } + return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds())) +} + +func isPublicReadACL(acl string) bool { + switch strings.ToLower(strings.TrimSpace(acl)) { + case "public-read", "public-read-write": + return true + default: + return false + } +} + func (c *aliyunClient) logError(ctx context.Context, msg string, fields ...zap.Field) { if c == nil || c.logger == nil { return diff --git a/server/internal/shared/objectstorage/aliyun_test.go b/server/internal/shared/objectstorage/aliyun_test.go new file mode 100644 index 0000000..6635286 --- /dev/null +++ b/server/internal/shared/objectstorage/aliyun_test.go @@ -0,0 +1,56 @@ +package objectstorage + +import ( + "testing" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +func TestAliyunPutObjectOptionsAcceptsSupportedObjectACL(t *testing.T) { + t.Parallel() + + for _, acl := range []string{"", "default", "private", "public-read", "public-read-write"} { + acl := acl + t.Run(acl, func(t *testing.T) { + t.Parallel() + + options, err := aliyunPutObjectOptions(config.ObjectStorageConfig{ObjectACL: acl}, "application/zip") + if err != nil { + t.Fatalf("aliyunPutObjectOptions(%q) error = %v", acl, err) + } + if len(options) == 0 { + t.Fatalf("expected at least content type option") + } + }) + } +} + +func TestAliyunPutObjectOptionsRejectsUnsupportedObjectACL(t *testing.T) { + t.Parallel() + + _, err := aliyunPutObjectOptions(config.ObjectStorageConfig{ObjectACL: "authenticated-read"}, "application/zip") + if err == nil { + t.Fatal("expected unsupported object acl error") + } +} + +func TestIsPublicReadACL(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + acl string + want bool + }{ + {acl: "", want: false}, + {acl: "default", want: false}, + {acl: "private", want: false}, + {acl: "public-read", want: true}, + {acl: "public-read-write", want: true}, + {acl: " PUBLIC-READ ", want: true}, + } { + got := isPublicReadACL(tc.acl) + if got != tc.want { + t.Fatalf("isPublicReadACL(%q) = %v, want %v", tc.acl, got, tc.want) + } + } +} diff --git a/server/internal/shared/objectstorage/url.go b/server/internal/shared/objectstorage/url.go index f2dc87d..78ed4d3 100644 --- a/server/internal/shared/objectstorage/url.go +++ b/server/internal/shared/objectstorage/url.go @@ -95,12 +95,9 @@ func joinEscapedPath(basePath string, extra ...string) string { return } - for _, segment := range strings.Split(value, "/") { - segment = strings.TrimSpace(segment) - if segment == "" { - continue - } - segments = append(segments, url.PathEscape(segment)) + normalized := normalizeObjectKeyPath(value) + if normalized != "" { + segments = append(segments, strings.Split(normalized, "/")...) } } @@ -114,3 +111,24 @@ func joinEscapedPath(basePath string, extra ...string) string { } return "/" + strings.Join(segments, "/") } + +func normalizeObjectKeyPath(value string) string { + segments := make([]string, 0) + value = strings.TrimSpace(value) + value = strings.Trim(value, "/") + if value == "" { + return "" + } + + for _, segment := range strings.Split(value, "/") { + segment = strings.TrimSpace(segment) + if segment == "" { + continue + } + if decoded, err := url.PathUnescape(segment); err == nil { + segment = decoded + } + segments = append(segments, segment) + } + return strings.Join(segments, "/") +} diff --git a/server/internal/shared/objectstorage/url_test.go b/server/internal/shared/objectstorage/url_test.go new file mode 100644 index 0000000..557e7e4 --- /dev/null +++ b/server/internal/shared/objectstorage/url_test.go @@ -0,0 +1,53 @@ +package objectstorage + +import ( + "testing" + + "github.com/geo-platform/tenant-api/internal/shared/config" +) + +func TestBuildPublicURLDoesNotDoubleEscapeEncodedObjectKey(t *testing.T) { + t.Parallel() + + got, err := buildPublicURL(config.ObjectStorageConfig{ + Endpoint: "https://oss-cn-shanghai.aliyuncs.com", + Bucket: "shengxintui", + UseSSL: true, + }, "desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip", true) + + if err != nil { + t.Fatal(err) + } + want := "https://shengxintui.oss-cn-shanghai.aliyuncs.com/desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip" + if got != want { + t.Fatalf("url = %q, want %q", got, want) + } +} + +func TestBuildPublicURLEscapesRawUnicodeObjectKeyOnce(t *testing.T) { + t.Parallel() + + got, err := buildPublicURL(config.ObjectStorageConfig{ + Endpoint: "https://oss-cn-shanghai.aliyuncs.com", + Bucket: "shengxintui", + UseSSL: true, + }, "desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-省心推-mac-0.1.19.zip", true) + + if err != nil { + t.Fatal(err) + } + want := "https://shengxintui.oss-cn-shanghai.aliyuncs.com/desktop-client/releases/stable/darwin/arm64/0.1.1/20260525015204-%E7%9C%81%E5%BF%83%E6%8E%A8-mac-0.1.19.zip" + if got != want { + t.Fatalf("url = %q, want %q", got, want) + } +} + +func TestNormalizeObjectKeyPathDecodesEncodedSegments(t *testing.T) { + t.Parallel() + + got := normalizeObjectKeyPath("/desktop-client/releases/%E7%9C%81%E5%BF%83%E6%8E%A8.zip") + want := "desktop-client/releases/省心推.zip" + if got != want { + t.Fatalf("object key = %q, want %q", got, want) + } +} diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go index d3e4414..bde8435 100644 --- a/server/internal/shared/swagger/descriptions.go +++ b/server/internal/shared/swagger/descriptions.go @@ -30,12 +30,17 @@ var routeDocs = map[string]routeDoc{ "GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"}, // --- Desktop 客户端:注册 / 心跳 / 鉴权 --- - "POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"}, - "POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"}, - "POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"}, - "POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"}, - "POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"}, - + "GET /api/desktop/releases/latest": {"查询最新客户端版本", "桌面客户端未登录时查询最新版本配置,不提前生成签名下载地址。"}, + "GET /api/desktop/releases/download-url": {"解析客户端下载地址", "用户开始下载时按最新版本配置即时生成下载地址,私有 OSS 会返回签名 URL。"}, + "GET /api/desktop/releases/updater/:platform/:arch/:channel/:version": {"自动更新 Feed", "桌面客户端开始自动更新时读取 Electron updater feed,私有 OSS 会在 feed 中写入短时签名下载地址。"}, + "GET /api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed": {"自动更新 Feed 文件", "兼容 Electron updater 对 latest*.yml 的二次请求,返回动态生成的 updater YAML。"}, + "POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"}, + "POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"}, + "POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"}, + "POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"}, + "POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"}, + "GET /api/desktop/clients/releases/latest": {"查询最新客户端版本(桌面鉴权)", "桌面客户端带 client token 查询最新版本,默认沿用本机注册渠道。"}, + "GET /api/desktop/clients/releases/download-url": {"解析客户端下载地址(桌面鉴权)", "桌面客户端开始更新下载时即时获取下载地址,私有 OSS 会返回签名 URL。"}, // --- Desktop:派单与拉取 --- "GET /api/desktop/dispatch": {"派单 WebSocket", "桌面客户端通过 WebSocket 长连接订阅派单事件,断线后自动重连。"}, "GET /api/desktop/accounts": {"拉取账号列表(桌面)", "桌面客户端拉取本机绑定/可用的媒体账号,支持增量同步。"}, diff --git a/server/internal/shared/swagger/swagger.go b/server/internal/shared/swagger/swagger.go index 624ead0..1c6ce03 100644 --- a/server/internal/shared/swagger/swagger.go +++ b/server/internal/shared/swagger/swagger.go @@ -500,6 +500,10 @@ func errorResponse(description string) map[string]any { func securityForPath(path string) []map[string][]string { if path == "/api/auth/password-key" || path == "/api/auth/login" || path == "/api/auth/refresh" || + path == "/api/desktop/releases/latest" || + path == "/api/desktop/releases/download-url" || + path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version" || + path == "/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed" || strings.HasPrefix(path, "/api/health/") || strings.HasPrefix(path, "/api/public/") { return nil diff --git a/server/internal/tenant/transport/desktop_release_handler.go b/server/internal/tenant/transport/desktop_release_handler.go new file mode 100644 index 0000000..81c72f1 --- /dev/null +++ b/server/internal/tenant/transport/desktop_release_handler.go @@ -0,0 +1,136 @@ +package transport + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/bootstrap" + opsapp "github.com/geo-platform/tenant-api/internal/ops/app" + opsrepo "github.com/geo-platform/tenant-api/internal/ops/repository" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type DesktopReleaseHandler struct { + svc *opsapp.DesktopClientReleaseService +} + +func NewDesktopReleaseHandler(a *bootstrap.App) *DesktopReleaseHandler { + return &DesktopReleaseHandler{ + svc: opsapp.NewDesktopClientReleaseService( + opsrepo.NewDesktopClientReleaseRepository(a.DB), + a.ObjectStorage, + nil, + ), + } +} + +func (h *DesktopReleaseHandler) Latest(c *gin.Context) { + client := MustDesktopClient(c.Request.Context()) + data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{ + Platform: c.Query("platform"), + Arch: c.Query("arch"), + Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel), + CurrentVersion: c.Query("version"), + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopReleaseHandler) DownloadURL(c *gin.Context) { + client := MustDesktopClient(c.Request.Context()) + data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{ + Platform: c.Query("platform"), + Arch: c.Query("arch"), + Channel: defaultDesktopReleaseChannel(c.Query("channel"), client.Channel), + CurrentVersion: c.Query("version"), + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopReleaseHandler) LatestPublic(c *gin.Context) { + data, err := h.svc.CheckLatest(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{ + Platform: c.Query("platform"), + Arch: c.Query("arch"), + Channel: c.DefaultQuery("channel", "stable"), + CurrentVersion: c.Query("version"), + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopReleaseHandler) DownloadURLPublic(c *gin.Context) { + data, err := h.svc.ResolveLatestDownloadURL(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{ + Platform: c.Query("platform"), + Arch: c.Query("arch"), + Channel: c.DefaultQuery("channel", "stable"), + CurrentVersion: c.Query("version"), + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, data) +} + +func (h *DesktopReleaseHandler) UpdaterFeedPublic(c *gin.Context) { + feed, err := h.svc.ResolveLatestUpdaterFeed(c.Request.Context(), opsapp.DesktopClientReleaseCheckInput{ + Platform: firstNonEmpty(c.Param("platform"), c.Query("platform")), + Arch: firstNonEmpty(c.Param("arch"), c.Query("arch")), + Channel: firstNonEmpty(c.Param("channel"), c.DefaultQuery("channel", "stable")), + CurrentVersion: firstNonEmpty(c.Param("version"), c.Query("version")), + }, c.Param("feed")) + if err != nil { + response.Error(c, err) + return + } + + c.Header("Content-Type", "application/x-yaml; charset=utf-8") + c.Header("Cache-Control", "no-store") + c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, sanitizeHeaderFileName(feed.FileName))) + c.Data(http.StatusOK, "application/x-yaml; charset=utf-8", feed.Content) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func defaultDesktopReleaseChannel(queryValue string, clientChannel *string) string { + if queryValue != "" { + return queryValue + } + if clientChannel != nil && *clientChannel != "" { + return *clientChannel + } + return "stable" +} + +func sanitizeHeaderFileName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "latest.yml" + } + return strings.Map(func(r rune) rune { + if r == '"' || r == '\\' || r == '\r' || r == '\n' { + return -1 + } + return r + }, value) +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 9d53b89..ddb2ecc 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -33,6 +33,7 @@ func RegisterRoutes(app *bootstrap.App) { protected.POST("/auth/logout", authHandler.Logout) desktopClientHandler := NewDesktopClientHandler(app) + desktopReleaseHandler := NewDesktopReleaseHandler(app) desktopAccountHandler := NewDesktopAccountHandler(app) desktopTaskHandler := NewDesktopTaskHandler(app) desktopDispatchHandler := NewDesktopDispatchHandler(app) @@ -40,6 +41,10 @@ func RegisterRoutes(app *bootstrap.App) { desktopContentHandler := NewDesktopContentHandler(app) publishJobHandler := NewPublishJobHandler(app) protected.POST("/desktop/clients/register", desktopClientHandler.Register) + app.Engine.GET("/api/desktop/releases/latest", desktopReleaseHandler.LatestPublic) + app.Engine.GET("/api/desktop/releases/download-url", desktopReleaseHandler.DownloadURLPublic) + app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version/:feed", desktopReleaseHandler.UpdaterFeedPublic) + app.Engine.GET("/api/desktop/releases/updater/:platform/:arch/:channel/:version", desktopReleaseHandler.UpdaterFeedPublic) desktopAuth := app.Engine.Group("/api/desktop") desktopAuth.Use(DesktopClientMiddleware(repository.NewDesktopClientRepository(app.DB))) @@ -47,6 +52,8 @@ func RegisterRoutes(app *bootstrap.App) { desktopAuth.POST("/clients/heartbeat", desktopClientHandler.Heartbeat) desktopAuth.POST("/clients/offline", desktopClientHandler.Offline) desktopAuth.POST("/clients/revoke", desktopClientHandler.Revoke) + desktopAuth.GET("/clients/releases/latest", desktopReleaseHandler.Latest) + desktopAuth.GET("/clients/releases/download-url", desktopReleaseHandler.DownloadURL) desktopAuth.GET("/dispatch", desktopDispatchHandler.Dispatch) desktopAuth.GET("/accounts", desktopAccountHandler.List) desktopAuth.GET("/content/articles/:id", desktopContentHandler.Article) diff --git a/server/migrations_ops/20260525103000_create_desktop_client_releases.down.sql b/server/migrations_ops/20260525103000_create_desktop_client_releases.down.sql new file mode 100644 index 0000000..ab384ba --- /dev/null +++ b/server/migrations_ops/20260525103000_create_desktop_client_releases.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ops.desktop_client_releases; diff --git a/server/migrations_ops/20260525103000_create_desktop_client_releases.up.sql b/server/migrations_ops/20260525103000_create_desktop_client_releases.up.sql new file mode 100644 index 0000000..97c68c2 --- /dev/null +++ b/server/migrations_ops/20260525103000_create_desktop_client_releases.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS ops.desktop_client_releases ( + id BIGSERIAL PRIMARY KEY, + platform VARCHAR(32) NOT NULL, + arch VARCHAR(32) NOT NULL DEFAULT 'universal', + channel VARCHAR(32) NOT NULL DEFAULT 'stable', + version VARCHAR(64) NOT NULL, + min_supported_version VARCHAR(64), + download_source VARCHAR(16) NOT NULL DEFAULT 'oss', + oss_object_key TEXT, + custom_download_url TEXT, + file_name TEXT, + file_size_bytes BIGINT, + sha256 VARCHAR(64), + updater_download_source VARCHAR(16), + updater_oss_object_key TEXT, + updater_custom_download_url TEXT, + updater_file_name TEXT, + updater_file_size_bytes BIGINT, + updater_sha256 VARCHAR(64), + release_notes TEXT, + force_update BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + published_at TIMESTAMPTZ, + created_by BIGINT, + updated_by BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_desktop_client_release_file_size + CHECK (file_size_bytes IS NULL OR file_size_bytes >= 0), + CONSTRAINT ck_desktop_client_release_sha256 + CHECK (sha256 IS NULL OR sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_desktop_client_release_updater_file_size + CHECK (updater_file_size_bytes IS NULL OR updater_file_size_bytes >= 0), + CONSTRAINT ck_desktop_client_release_updater_sha256 + CHECK (updater_sha256 IS NULL OR updater_sha256 ~ '^[0-9a-f]{64}$'), + CONSTRAINT ck_desktop_client_release_source + CHECK (download_source IN ('oss', 'custom')), + CONSTRAINT ck_desktop_client_release_updater_source + CHECK (updater_download_source IS NULL OR updater_download_source IN ('oss', 'custom')), + CONSTRAINT ck_desktop_client_release_download_target + CHECK ( + (download_source = 'oss' AND NULLIF(BTRIM(COALESCE(oss_object_key, '')), '') IS NOT NULL) + OR + (download_source = 'custom' AND NULLIF(BTRIM(COALESCE(custom_download_url, '')), '') IS NOT NULL) + ), + CONSTRAINT ck_desktop_client_release_updater_target + CHECK ( + updater_download_source IS NULL + OR + (updater_download_source = 'oss' AND NULLIF(BTRIM(COALESCE(updater_oss_object_key, '')), '') IS NOT NULL) + OR + (updater_download_source = 'custom' AND NULLIF(BTRIM(COALESCE(updater_custom_download_url, '')), '') IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_desktop_client_releases_target + ON ops.desktop_client_releases(platform, arch, channel); + +CREATE INDEX IF NOT EXISTS idx_desktop_client_releases_lookup + ON ops.desktop_client_releases(channel, platform, arch) + WHERE enabled = TRUE; + +CREATE INDEX IF NOT EXISTS idx_desktop_client_releases_updated_at + ON ops.desktop_client_releases(updated_at DESC); diff --git a/server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.down.sql b/server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.down.sql new file mode 100644 index 0000000..17d0117 --- /dev/null +++ b/server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.down.sql @@ -0,0 +1,11 @@ +ALTER TABLE ops.desktop_client_releases + DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_target, + DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_source, + DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_sha256, + DROP CONSTRAINT IF EXISTS ck_desktop_client_release_updater_file_size, + DROP COLUMN IF EXISTS updater_sha256, + DROP COLUMN IF EXISTS updater_file_size_bytes, + DROP COLUMN IF EXISTS updater_file_name, + DROP COLUMN IF EXISTS updater_custom_download_url, + DROP COLUMN IF EXISTS updater_oss_object_key, + DROP COLUMN IF EXISTS updater_download_source; diff --git a/server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.up.sql b/server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.up.sql new file mode 100644 index 0000000..2ffb770 --- /dev/null +++ b/server/migrations_ops/20260525113000_add_desktop_client_release_updater_assets.up.sql @@ -0,0 +1,60 @@ +ALTER TABLE ops.desktop_client_releases + ADD COLUMN IF NOT EXISTS updater_download_source VARCHAR(16), + ADD COLUMN IF NOT EXISTS updater_oss_object_key TEXT, + ADD COLUMN IF NOT EXISTS updater_custom_download_url TEXT, + ADD COLUMN IF NOT EXISTS updater_file_name TEXT, + ADD COLUMN IF NOT EXISTS updater_file_size_bytes BIGINT, + ADD COLUMN IF NOT EXISTS updater_sha256 VARCHAR(64); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_desktop_client_release_updater_file_size' + AND conrelid = 'ops.desktop_client_releases'::regclass + ) THEN + ALTER TABLE ops.desktop_client_releases + ADD CONSTRAINT ck_desktop_client_release_updater_file_size + CHECK (updater_file_size_bytes IS NULL OR updater_file_size_bytes >= 0); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_desktop_client_release_updater_sha256' + AND conrelid = 'ops.desktop_client_releases'::regclass + ) THEN + ALTER TABLE ops.desktop_client_releases + ADD CONSTRAINT ck_desktop_client_release_updater_sha256 + CHECK (updater_sha256 IS NULL OR updater_sha256 ~ '^[0-9a-f]{64}$'); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_desktop_client_release_updater_source' + AND conrelid = 'ops.desktop_client_releases'::regclass + ) THEN + ALTER TABLE ops.desktop_client_releases + ADD CONSTRAINT ck_desktop_client_release_updater_source + CHECK (updater_download_source IS NULL OR updater_download_source IN ('oss', 'custom')); + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'ck_desktop_client_release_updater_target' + AND conrelid = 'ops.desktop_client_releases'::regclass + ) THEN + ALTER TABLE ops.desktop_client_releases + ADD CONSTRAINT ck_desktop_client_release_updater_target + CHECK ( + updater_download_source IS NULL + OR + (updater_download_source = 'oss' AND NULLIF(BTRIM(COALESCE(updater_oss_object_key, '')), '') IS NOT NULL) + OR + (updater_download_source = 'custom' AND NULLIF(BTRIM(COALESCE(updater_custom_download_url, '')), '') IS NOT NULL) + ); + END IF; +END $$;