diff --git a/backend/src/admin/router.rs b/backend/src/admin/router.rs index 74cfdba60..2a55de747 100644 --- a/backend/src/admin/router.rs +++ b/backend/src/admin/router.rs @@ -28,7 +28,7 @@ use super::{ update_settings, }, system::routes::{ - check_updates, perform_update, restart, rollback, update_event_stream, update_status, + perform_update, restart, rollback, update_detail, update_event_stream, update_status, version, }, }; @@ -49,7 +49,7 @@ pub fn router() -> Router { post(regenerate_admin_api_key), ) .route("/api/admin/system/version", get(version)) - .route("/api/admin/system/check-updates", get(check_updates)) + .route("/api/admin/system/update-detail", get(update_detail)) .route("/api/admin/system/update-events", get(update_event_stream)) .route("/api/admin/system/update", post(perform_update)) .route("/api/admin/system/update-status", get(update_status)) diff --git a/backend/src/admin/system/routes.rs b/backend/src/admin/system/routes.rs index a58a51766..0ea259788 100644 --- a/backend/src/admin/system/routes.rs +++ b/backend/src/admin/system/routes.rs @@ -1,5 +1,5 @@ //! 管理端系统路由入口。 pub(crate) use super::updater::{ - check_updates, perform_update, restart, rollback, update_event_stream, update_status, version, + perform_update, restart, rollback, update_detail, update_event_stream, update_status, version, }; diff --git a/backend/src/admin/system/updater/mod.rs b/backend/src/admin/system/updater/mod.rs index 0b5ee22b8..e69ef66db 100644 --- a/backend/src/admin/system/updater/mod.rs +++ b/backend/src/admin/system/updater/mod.rs @@ -43,7 +43,7 @@ use archive::{extract_release_archive, replace_release_files, rollback_release_u mod release; use release::{ check_latest_release, fetch_latest_release, select_release_archive, update_info_from_release, - GitHubRelease, + GitHubRelease, UpdateInfoData, }; const APP_BINARY_NAME: &str = "codex-proxy-rs"; @@ -58,8 +58,8 @@ static UPDATE_EVENT_SENDER: OnceLock> = Onc #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct CheckUpdateQuery { - force: Option, +pub(crate) struct UpdateDetailQuery { + refresh: Option, } #[derive(Debug, Serialize)] @@ -71,6 +71,10 @@ struct VersionData { deployment_mode: String, deployment_mode_label: String, update_channel: String, + latest_version: String, + has_update: bool, + update_cached: bool, + update_warning: Option, } #[derive(Debug, Serialize)] @@ -123,19 +127,20 @@ struct SystemUpdateConfig { /// `GET /api/admin/system/version` pub(crate) async fn version(_auth: AdminAuth) -> Result { let config = SystemUpdateConfig::from_env(); + let update_info = check_latest_release(&config, false).await; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(config.version_data()), + AdminEnvelope::ok(config.version_data(&update_info)), )) } -/// `GET /api/admin/system/check-updates` -pub(crate) async fn check_updates( +/// `GET /api/admin/system/update-detail` +pub(crate) async fn update_detail( _auth: AdminAuth, - Query(query): Query, + Query(query): Query, ) -> Result { let config = SystemUpdateConfig::from_env(); - let info = check_latest_release(&config, query.force.unwrap_or(false)).await; + let info = check_latest_release(&config, query.refresh.unwrap_or(false)).await; Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(info))) } @@ -388,7 +393,7 @@ impl SystemUpdateConfig { } } - fn version_data(&self) -> VersionData { + fn version_data(&self, update_info: &UpdateInfoData) -> VersionData { VersionData { version: self.version.clone(), git_sha: self.git_sha.clone(), @@ -396,6 +401,10 @@ impl SystemUpdateConfig { deployment_mode: self.deployment_mode.clone(), deployment_mode_label: deployment_mode_label(&self.deployment_mode).to_string(), update_channel: self.update_channel.clone(), + latest_version: update_info.latest_version.clone(), + has_update: update_info.has_update, + update_cached: update_info.cached, + update_warning: update_info.warning.clone(), } } diff --git a/backend/src/web/headers.rs b/backend/src/web/headers.rs index 00b622552..f3f8730a0 100644 --- a/backend/src/web/headers.rs +++ b/backend/src/web/headers.rs @@ -5,7 +5,7 @@ const ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; const CONTENT_SECURITY_POLICY: &str = concat!( "default-src 'self'; ", "script-src 'self'; ", - "style-src 'self'; ", + "style-src 'self' 'unsafe-inline'; ", "img-src 'self' data:; ", "font-src 'self' data:; ", "connect-src 'self'; ", diff --git a/backend/tests/admin/system/mod.rs b/backend/tests/admin/system/mod.rs index 0069cfd33..5feb0eb80 100644 --- a/backend/tests/admin/system/mod.rs +++ b/backend/tests/admin/system/mod.rs @@ -46,7 +46,10 @@ const VERSION_NEW: &str = "0.2.0"; #[tokio::test] async fn version_should_return_backend_build_metadata() { let _guard = SYSTEM_ENV_LOCK.lock().await; - set_system_update_env("zyycn/codex-proxy-rs-version-route", "http://127.0.0.1:9"); + let repository = "zyycn/codex-proxy-rs-version-route"; + let github = MockServer::start().await; + mount_latest_release(&github, repository, "0.2.0").await; + set_system_update_env(repository, &github.uri()); std::env::set_var("CPR_GIT_SHA", "version-test-sha"); std::env::set_var("CPR_BUILD_TIME", "2026-07-01T00:00:00Z"); let (app, _dir) = admin_system_test_app("system-version.sqlite").await; @@ -72,22 +75,26 @@ async fn version_should_return_backend_build_metadata() { && body["data"]["deploymentMode"] == "docker" && body["data"]["deploymentModeLabel"] == "Docker" && body["data"]["updateChannel"] == "stable" + && body["data"]["latestVersion"] == VERSION_NEW + && body["data"]["hasUpdate"] == true + && body["data"]["updateCached"] == false + && body["data"]["updateWarning"].is_null() ); } #[tokio::test] -async fn check_updates_should_use_cached_release_when_not_forced() { +async fn update_detail_should_use_cached_release_when_not_refreshed() { let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-cache-route"; let success_server = MockServer::start().await; mount_latest_release(&success_server, repository, "0.2.0").await; set_system_update_env(repository, &success_server.uri()); let (app, _dir) = admin_system_test_app("system-cache.sqlite").await; - let initial = get_check_updates(&app, true, "req_system_cache_initial").await; + let initial = get_update_detail(&app, true, "req_system_cache_initial").await; let failing_server = MockServer::start().await; set_system_update_env(repository, &failing_server.uri()); - let cached = get_check_updates(&app, false, "req_system_cache_cached").await; + let cached = get_update_detail(&app, false, "req_system_cache_cached").await; assert!( initial["hasUpdate"] == true @@ -97,14 +104,14 @@ async fn check_updates_should_use_cached_release_when_not_forced() { } #[tokio::test] -async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() { +async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() { let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-cache-forced-route"; let success_server = MockServer::start().await; mount_latest_release(&success_server, repository, "0.3.0").await; set_system_update_env(repository, &success_server.uri()); let (app, _dir) = admin_system_test_app("system-cache-forced.sqlite").await; - let initial = get_check_updates(&app, true, "req_system_cache_forced_initial").await; + let initial = get_update_detail(&app, true, "req_system_cache_forced_initial").await; let failing_server = MockServer::start().await; Mock::given(method("GET")) @@ -113,7 +120,7 @@ async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() { .mount(&failing_server) .await; set_system_update_env(repository, &failing_server.uri()); - let fallback = get_check_updates(&app, true, "req_system_cache_forced_fallback").await; + let fallback = get_update_detail(&app, true, "req_system_cache_forced_fallback").await; assert!( initial["hasUpdate"] == true @@ -124,7 +131,7 @@ async fn check_updates_should_fallback_to_cache_when_forced_fetch_fails() { } #[tokio::test] -async fn check_updates_should_report_source_build_as_unsupported() { +async fn update_detail_should_report_source_build_as_unsupported() { let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-source-route"; let github = MockServer::start().await; @@ -133,7 +140,7 @@ async fn check_updates_should_report_source_build_as_unsupported() { std::env::set_var("CPR_BUILD_TYPE", "source"); let (app, _dir) = admin_system_test_app("system-source-build.sqlite").await; - let info = get_check_updates(&app, true, "req_system_source_build").await; + let info = get_update_detail(&app, true, "req_system_source_build").await; assert!( info["currentVersion"] == VERSION_OLD @@ -147,7 +154,7 @@ async fn check_updates_should_report_source_build_as_unsupported() { } #[tokio::test] -async fn check_updates_should_reject_untrusted_github_api_base() { +async fn update_detail_should_reject_untrusted_github_api_base() { let _guard = SYSTEM_ENV_LOCK.lock().await; set_system_update_env( "zyycn/codex-proxy-rs-untrusted-api-base-route", @@ -155,7 +162,7 @@ async fn check_updates_should_reject_untrusted_github_api_base() { ); let (app, _dir) = admin_system_test_app("system-untrusted-api-base.sqlite").await; - let info = get_check_updates(&app, true, "req_system_untrusted_api_base").await; + let info = get_update_detail(&app, true, "req_system_untrusted_api_base").await; assert!( info["hasUpdate"] == false @@ -763,13 +770,13 @@ async fn update_events_should_open_authenticated_sse_stream() { assert!(response.status() == StatusCode::OK && content_type.starts_with("text/event-stream")); } -async fn get_check_updates(app: &axum::Router, force: bool, request_id: &str) -> Value { +async fn get_update_detail(app: &axum::Router, refresh: bool, request_id: &str) -> Value { let response = app .clone() .oneshot( Request::builder() .method("GET") - .uri(format!("/api/admin/system/check-updates?force={force}")) + .uri(format!("/api/admin/system/update-detail?refresh={refresh}")) .header("cookie", "cpr_admin_session=session_1") .header("x-request-id", request_id) .body(Body::empty()) diff --git a/backend/tests/http/web_assets/mod.rs b/backend/tests/http/web_assets/mod.rs index a8e43dfed..f7df26880 100644 --- a/backend/tests/http/web_assets/mod.rs +++ b/backend/tests/http/web_assets/mod.rs @@ -66,7 +66,7 @@ async fn server_router_should_serve_frontend_assets_without_shadowing_api_routes .and_then(|value| value.to_str().ok()) .unwrap_or_default(); assert!( - csp.contains("style-src 'self';") && !csp.contains("unsafe-inline"), + csp.contains("script-src 'self';") && csp.contains("style-src 'self' 'unsafe-inline';"), "unexpected content security policy: {csp}" ); assert_eq!( diff --git a/docs/architecture.md b/docs/architecture.md index a7c6c56fc..4e184dd07 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -180,7 +180,7 @@ GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。 核心接口: - `GET /api/admin/system/version` -- `GET /api/admin/system/check-updates` +- `GET /api/admin/system/update-detail` - `GET /api/admin/system/update-events` - `GET /api/admin/system/update-status` - `POST /api/admin/system/update` diff --git a/frontend/src/api/modules/system.ts b/frontend/src/api/modules/system.ts index c365912f1..999664c8c 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -10,6 +10,10 @@ export interface SystemVersion { deploymentMode: string deploymentModeLabel: string updateChannel: string + latestVersion: string + hasUpdate: boolean + updateCached: boolean + updateWarning?: string } export interface SystemUpdateInfo { @@ -49,11 +53,11 @@ export function getSystemVersion() { }) } -export function checkSystemUpdates(force = false) { +export function getSystemUpdateDetail(refresh = false) { return request({ - url: '/api/admin/system/check-updates', + url: '/api/admin/system/update-detail', method: 'GET', - params: { force }, + params: { refresh }, }) } diff --git a/frontend/src/composables/useSystemUpdate.ts b/frontend/src/composables/useSystemUpdate.ts index e38411719..8737ebfc2 100644 --- a/frontend/src/composables/useSystemUpdate.ts +++ b/frontend/src/composables/useSystemUpdate.ts @@ -2,8 +2,8 @@ import { computed, ref, shallowRef } from 'vue' import { SYSTEM_UPDATE_EVENTS_URL, - checkSystemUpdates, getSystemVersion, + getSystemUpdateDetail, performSystemUpdate, restartSystem, type SystemUpdateInfo, @@ -38,9 +38,11 @@ const updateStreamError = shallowRef('') const updateEventSource = shallowRef(null) let restartTimer: number | undefined +let loadVersionPromise: Promise | undefined +let loadSystemPromise: Promise | undefined const maxUpdateLogs = 200 -const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate)) +const hasUpdate = computed(() => Boolean(updateInfo.value?.hasUpdate ?? version.value?.hasUpdate)) const isReleaseBuild = computed(() => updateInfo.value?.buildType === 'release') const canUpdate = computed( () => @@ -121,30 +123,47 @@ function disconnectUpdateEvents() { updateStreaming.value = false } -async function loadSystem(force = false) { - if (loading.value) return +async function loadSystem(refresh = false) { + if (loadSystemPromise) return loadSystemPromise loading.value = true - try { - const [versionData, updateData] = await Promise.all([ - getSystemVersion(), - checkSystemUpdates(force), - ]) - version.value = versionData + loadSystemPromise = (async () => { + const updateData = await getSystemUpdateDetail(refresh) updateInfo.value = updateData + if (!version.value) { + version.value = await getSystemVersion() + } loadedOnce.value = true + })() + + try { + await loadSystemPromise } finally { loading.value = false + loadSystemPromise = undefined } } -async function checkUpdates(force = true) { +async function loadVersion() { + if (loadVersionPromise) return loadVersionPromise + + loadVersionPromise = getSystemVersion() + try { + const versionData = await loadVersionPromise + version.value = versionData + return versionData + } finally { + loadVersionPromise = undefined + } +} + +async function checkUpdates(refresh = true) { if (checking.value) return updateInfo.value checking.value = true resetUpdateResult() try { - updateInfo.value = await checkSystemUpdates(force) + updateInfo.value = await getSystemUpdateDetail(refresh) if (!version.value) { version.value = await getSystemVersion() } @@ -268,6 +287,7 @@ export function useSystemUpdate() { hasUpdate, isReleaseBuild, canUpdate, + loadVersion, loadSystem, checkUpdates, updateNow, diff --git a/frontend/src/layout/components/AppSidebar.vue b/frontend/src/layout/components/AppSidebar.vue index e7475ee72..1c75ad7d1 100644 --- a/frontend/src/layout/components/AppSidebar.vue +++ b/frontend/src/layout/components/AppSidebar.vue @@ -33,7 +33,7 @@ const uiStore = useUiStore() const { effectiveTheme } = storeToRefs(uiStore) const { toggleTheme } = uiStore const preferredMotion = usePreferredReducedMotion() -const { version, hasUpdate, loadSystem } = useSystemUpdate() +const { version, hasUpdate, loadedOnce, loadVersion, loadSystem } = useSystemUpdate() const navItems = [ { label: '概览', icon: LayoutDashboard, path: '/' }, @@ -52,6 +52,24 @@ function navigate(path: string) { router.push(path) } +const systemUpdateOpening = shallowRef(false) + +async function openSystemUpdate() { + if (systemUpdateOpen.value || systemUpdateOpening.value) return + + systemUpdateOpening.value = true + try { + if (!loadedOnce.value) { + await loadSystem(false) + } + } catch { + // 弹窗打开后由弹窗内的加载逻辑提示失败原因。 + } finally { + systemUpdateOpening.value = false + systemUpdateOpen.value = true + } +} + async function handleLogout() { await authStore.logout() router.push('/login') @@ -77,7 +95,9 @@ const systemUpdateOpen = shallowRef(false) const themeToggleLabel = computed(() => effectiveTheme.value === 'dark' ? '切换浅色模式' : '切换暗黑模式', ) -const versionLabel = computed(() => `v${version.value?.version || '...'}`) +const versionText = computed(() => version.value?.version.trim() ?? '') +const hasVersionLabel = computed(() => versionText.value.length > 0) +const versionLabel = computed(() => `v${versionText.value}`) const updateButtonLabel = computed(() => hasUpdate.value ? '发现新版本,打开系统更新' : '打开系统更新', ) @@ -200,7 +220,7 @@ onMounted(() => { }) } animateSidebarLabels(Boolean(props.collapsed)) - void loadSystem(false).catch(() => undefined) + void loadVersion().catch(() => undefined) }) watch( @@ -250,15 +270,18 @@ watch( Rust build