From a603248159bb25e2226a26c484b0997807fbe997 Mon Sep 17 00:00:00 2001 From: cysamurai Date: Thu, 16 Apr 2026 17:17:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 180 ++++++++++++++ broadcast-client/src-tauri/src/lib.rs | 78 ++++++ broadcast-client/src/views/ConfigView.vue | 52 ++++ call-client/src-tauri/src/commands/mod.rs | 1 + call-client/src-tauri/src/commands/update.rs | 78 ++++++ call-client/src-tauri/src/lib.rs | 2 + call-client/src/host/session.ts | 4 +- call-client/src/host/types.ts | 1 + call-client/src/utils/service.ts | 151 +++++++++++- call-client/src/views/LoginView.vue | 118 ++++++++- call-client/src/views/MainView.vue | 238 +++++++++++-------- scripts/build-apt-repo.sh | 206 ++++++++++++++++ scripts/docker/run-build.sh | 29 +++ scripts/inject-deb-bootstrap.sh | 167 +++++++++++++ scripts/verify-apt-repo.sh | 102 ++++++++ scripts/verify-deb-bootstrap.sh | 129 ++++++++++ 16 files changed, 1425 insertions(+), 111 deletions(-) create mode 100644 call-client/src-tauri/src/commands/update.rs create mode 100644 scripts/build-apt-repo.sh create mode 100644 scripts/inject-deb-bootstrap.sh create mode 100644 scripts/verify-apt-repo.sh create mode 100644 scripts/verify-deb-bootstrap.sh diff --git a/README.md b/README.md index a657f70..f91554c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ chmod +x ./scripts/docker/run-build.sh ./scripts/docker/run-build.sh ``` +> 默认会在打包结束后自动执行: +> 1) `scripts/inject-deb-bootstrap.sh`(向 deb 注入 `postinst`,安装时自动写源与公钥) +> 2) `scripts/build-apt-repo.sh`(生成可直接部署的 `dist/repo/`) +> 3) `scripts/verify-apt-repo.sh`(发布前完整性自检) + 只打指定项目: ```bash @@ -25,6 +30,11 @@ chmod +x ./scripts/docker/run-build.sh - 混合模式:`./scripts/docker/run-build.sh --hybrid` - 宿主机构建 `amd64` - 容器构建 `arm64` +- 若仅需 `.deb` 不生成仓库目录:`GENERATE_APT_REPO=0 ./scripts/docker/run-build.sh` +- 默认使用签名 Key:`com.jgzy.product`(密钥缺失时会自动创建) +- 如需禁用自动创建密钥:`APT_GPG_AUTO_CREATE=0 ./scripts/docker/run-build.sh` +- 如需跳过 deb 自动初始化注入:`INJECT_DEB_BOOTSTRAP=0 ./scripts/docker/run-build.sh` +- 默认会在生成仓库后自动自检(`verify-apt-repo.sh`);如需跳过:`VERIFY_APT_REPO=0 ./scripts/docker/run-build.sh` ## 2. 打包产物位置 @@ -35,6 +45,11 @@ chmod +x ./scripts/docker/run-build.sh - `dist/linux-deb/broadcast-client/amd64/*.deb` - `dist/linux-deb/broadcast-client/arm64/*.deb` +自动生成的 APT 仓库目录(可直接打包上传仓库机): + +- `dist/repo/dists/v10/...` +- `dist/repo/pool/main/...` + Tauri 原始输出目录: - `call-client/src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/` @@ -110,3 +125,168 @@ npm run tauri -- icon ./src-tauri/icons/call_icon.png - 这里使用的是 **`@tauri-apps/cli` 自带的 `icon` 子命令**,无需再装名为 `tauri-icon` 的独立包;若文档里写作「tauri-icon 工具」,一般即指该命令。 - 源图若不是 RGBA,构建阶段仍可能报 `icon ... is not RGBA`,请在上游导出时勾选透明度。 + +## 5. Windows Nginx + 内网 APT 仓库(麒麟 V10)模板 + +以下模板用于“客户端检查更新 + 引导 apt 升级”,适用于 `call-client` 与 `broadcast-client`。 + +### 5.1 仓库目录模板 + +```text +repo/ +├─ dists/ +│ └─ v10/ +│ ├─ InRelease +│ ├─ Release +│ ├─ Release.gpg +│ └─ main/ +│ ├─ binary-amd64/ +│ │ ├─ Packages +│ │ └─ Packages.gz +│ └─ binary-arm64/ +│ ├─ Packages +│ └─ Packages.gz +└─ pool/ + └─ main/ + ├─ c/call-client/call-client__amd64.deb + ├─ c/call-client/call-client__arm64.deb + ├─ b/broadcast-client/broadcast-client__amd64.deb + └─ b/broadcast-client/broadcast-client__arm64.deb +``` + +### 5.2 发布步骤(每次发版) + +由于仓库机是内网且不安装构建环境,统一在**发布机器**执行: + +```bash +cd /path/to/TauriClient +chmod +x ./scripts/docker/run-build.sh ./scripts/build-apt-repo.sh ./scripts/inject-deb-bootstrap.sh ./scripts/verify-apt-repo.sh +./scripts/docker/run-build.sh +``` + +执行完成后会得到: + +- `dist/linux-deb/...`:原始构建产物 `.deb` +- `dist/repo/...`:可直接部署到仓库机的 APT 仓库目录(含索引与签名) +- `dist/repo/zyyun-archive-keyring.asc`:客户端导入用 ASCII 公钥 +- `dist/repo/zyyun-archive-keyring.gpg`:客户端可直接使用的 keyring 二进制公钥 + +最后只需把 `dist/repo` 整体打包上传到仓库机并对外提供 `http://80.12.140.29:80/apt`。 + +> 脚本内置的签名密钥生成命令如下(密钥不存在且 `APT_GPG_AUTO_CREATE=1` 时自动执行): +> `gpg --batch --pinentry-mode loopback --passphrase "" --quick-gen-key "com.jgzy.product" rsa4096 sign 5y` + +手工初始化(仅在未使用 deb 自动初始化时作为兜底): + +```bash +curl -fsSL http://80.12.140.29:80/apt/zyyun-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/zyyun-archive-keyring.gpg +echo "deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main" | sudo tee /etc/apt/sources.list.d/zyyun.list +sudo apt update +``` + +发布后可手工复核(脚本已在 `run-build.sh` 里自动执行一次): + +```bash +bash ./scripts/verify-apt-repo.sh +``` + +该脚本会校验以下关键项: + +- `InRelease`、`Release`、`Release.gpg` +- 公钥文件 `zyyun-archive-keyring.asc`、`zyyun-archive-keyring.gpg` +- 各架构 `Packages.gz` 是否存在且可正常解压 +- `pool/main` 下是否存在 `.deb` 包 + +#### 5.2.1 deb 自动初始化(已默认启用) + +`run-build.sh` 默认会调用 `scripts/inject-deb-bootstrap.sh`,对构建出的 deb 注入 `postinst` 与内置公钥。 +用户只需执行 deb 安装(图形安装器或 `dpkg -i`),安装阶段会自动: + +1. 安装公钥到 `/usr/share/keyrings/zyyun-archive-keyring.gpg` +2. 写入源文件 `/etc/apt/sources.list.d/zyyun.list` +3. 执行 `apt-get update`(失败不阻断安装) + +内置源固定为: + +```bash +deb [signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main +``` + +发版前可快速确认某个包已注入 bootstrap: + +```bash +bash ./scripts/verify-deb-bootstrap.sh dist/linux-deb/call-client/amd64/*.deb +``` + +或校验全部 deb: + +```bash +bash ./scripts/verify-deb-bootstrap.sh --all +``` + +### 5.3 Windows Nginx 反向代理模板 + +```nginx +server { + listen 80; + server_name 80.12.140.29; + + location /apt/ { + proxy_pass http://inner-repo-server/; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 300s; + } +} +``` + +客户端源配置示例: + +```bash +deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main +``` + +### 5.3.1 方案二:80 端口被其他服务占用时 + +如果 `80.12.140.29:80` 已由 IIS / Apache / 其他网关占用,推荐做法是: + +1. 保持现有 80 入口不变(外部地址仍为 `http://80.12.140.29:80/apt`)。 +2. 让 Nginx 改为监听本机其他端口(例如 `127.0.0.1:8080`)。 +3. 由现有 80 入口服务将 `/apt/` 路径反向代理到 `http://127.0.0.1:8080/apt/`。 + +Nginx(仓库中转)示例: + +```nginx +server { + listen 127.0.0.1:8080; + server_name localhost; + + location /apt/ { + proxy_pass http://inner-repo-server/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +80 入口服务需要保证: + +- 对外保留路径 `/apt/`。 +- 不对 `dists/`、`pool/`、`Packages.gz`、`InRelease` 做重写或鉴权跳转。 +- 转发后客户端源配置保持不变: + - `deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main` + +### 5.4 客户端交互建议 + +- “检查更新”按钮只做检测与引导,不在应用内直接执行 `sudo`。 +- 检测成功有新版本时,提示并复制命令: + - `sudo apt update && sudo apt install --only-upgrade call-client` + - `sudo apt update && sudo apt install --only-upgrade broadcast-client` diff --git a/broadcast-client/src-tauri/src/lib.rs b/broadcast-client/src-tauri/src/lib.rs index 82412ce..5c73837 100644 --- a/broadcast-client/src-tauri/src/lib.rs +++ b/broadcast-client/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ use std::{ use chrono::Local; use serde::{Deserialize, Serialize}; use tauri::Manager; +#[cfg(target_os = "linux")] +use std::process::Command; const SOCKET_PORT: u16 = 9501; const SOCKET_STATUS_EVENT: &str = "socket-status"; @@ -80,6 +82,18 @@ struct SocketCallEventPayload { display_text: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AptUpdateCheckResult { + package_name: String, + current_version: String, + installed_version: String, + candidate_version: String, + has_update: bool, + source_available: bool, + update_command: String, +} + /// 应用入口:注册插件、菜单事件与前端命令。 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -89,6 +103,7 @@ pub fn run() { start_socket_service, stop_socket_service, get_socket_service_status, + check_apt_update, quit_app ]) .run(tauri::generate_context!()) @@ -220,6 +235,69 @@ fn get_socket_service_status(state: tauri::State<'_, SocketServiceState>) -> Soc } } +#[cfg(target_os = "linux")] +fn extract_policy_value(output: &str, key: &str) -> String { + output + .lines() + .find_map(|line| line.trim().strip_prefix(key).map(|value| value.trim().to_string())) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "(unknown)".to_string()) +} + +#[tauri::command] +fn check_apt_update(package_name: String, current_version: String) -> Result { + #[cfg(not(target_os = "linux"))] + { + let _ = (&package_name, ¤t_version); + return Err("当前系统不支持 apt 更新检测,仅支持 Linux。".to_string()); + } + + #[cfg(target_os = "linux")] + { + let output = Command::new("apt-cache") + .arg("policy") + .arg(&package_name) + .output() + .map_err(|error| format!("执行 apt-cache 失败: {error}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + "apt-cache policy 执行失败".to_string() + } else { + format!("apt-cache policy 执行失败: {stderr}") + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let installed_version = extract_policy_value(&stdout, "Installed:"); + let candidate_version = extract_policy_value(&stdout, "Candidate:"); + let baseline_version = if installed_version == "(none)" || installed_version == "(unknown)" { + current_version.clone() + } else { + installed_version.clone() + }; + let source_available = candidate_version != "(none)" && candidate_version != "(unknown)"; + let has_update = source_available + && candidate_version != baseline_version + && !candidate_version.trim().is_empty(); + let update_command = format!( + "sudo apt update && sudo apt install --only-upgrade {}", + package_name + ); + + Ok(AptUpdateCheckResult { + package_name, + current_version, + installed_version, + candidate_version, + has_update, + source_available, + update_command, + }) + } +} + fn emit_socket_status(app: &tauri::AppHandle, running: bool) { let _ = app.emit_all( SOCKET_STATUS_EVENT, diff --git a/broadcast-client/src/views/ConfigView.vue b/broadcast-client/src/views/ConfigView.vue index 2119d3e..8cdb9e1 100644 --- a/broadcast-client/src/views/ConfigView.vue +++ b/broadcast-client/src/views/ConfigView.vue @@ -302,6 +302,9 @@
保存配置 + + 检查更新 + 启动 Socket 服务 @@ -320,6 +323,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue"; import { appWindow, currentMonitor } from "@tauri-apps/api/window"; import { invoke } from "@tauri-apps/api/tauri"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { ElMessage } from "element-plus"; import type { BroadcastConfig, ChildWindowAreaConfig, @@ -340,12 +344,26 @@ interface SocketStatusPayload { port: number; } +interface AptUpdateCheckResult { + packageName: string; + currentVersion: string; + installedVersion: string; + candidateVersion: string; + hasUpdate: boolean; + sourceAvailable: boolean; + updateCommand: string; +} + // 当前主屏宽度(用于限制总长度与分段长度)。 const screenWidth = ref(normalizeScreenWidth(window.screen.width || 1920)); const { config, patchConfig } = useBroadcastConfig(); const saveMessage = ref("修改后请点击“保存配置”。"); const activePanels = ref(["base", "segments", "areas", "subtitles"]); const socketRunning = ref(false); +const checkingUpdate = ref(false); +const APT_SOURCE_ENTRY = + "deb [arch=amd64 signed-by=/usr/share/keyrings/zyyun-archive-keyring.gpg] http://80.12.140.29:80/apt v10 main"; +const APT_SOURCE_SETUP_COMMAND = `echo "${APT_SOURCE_ENTRY}" | sudo tee /etc/apt/sources.list.d/zyyun.list && sudo apt update`; // 避免 store -> draft 回填触发“未保存”提示。 const syncingFromStore = ref(false); let socketStatusUnlisten: UnlistenFn | null = null; @@ -532,6 +550,40 @@ async function closeConfigWindow() { } } +async function handleCheckUpdate() { + if (checkingUpdate.value) { + return; + } + + checkingUpdate.value = true; + try { + const result = await invoke("check_apt_update", { + packageName: "broadcast-client", + currentVersion: "0.1.0", + }); + + if (!result.sourceAvailable) { + ElMessage.warning( + `未检测到可用更新源,请先执行:${APT_SOURCE_SETUP_COMMAND}`, + ); + return; + } + + if (!result.hasUpdate) { + ElMessage.success(`当前已是最新版本(${result.installedVersion})`); + return; + } + + ElMessage.warning( + `发现新版本 ${result.candidateVersion},请在终端执行:${result.updateCommand}`, + ); + } catch (error) { + ElMessage.error(`检查更新失败:${String(error)}`); + } finally { + checkingUpdate.value = false; + } +} + /** * 手动保存配置到持久化存储,并广播给同步屏窗口实时生效。 */ diff --git a/call-client/src-tauri/src/commands/mod.rs b/call-client/src-tauri/src/commands/mod.rs index 031654e..4eb5e49 100644 --- a/call-client/src-tauri/src/commands/mod.rs +++ b/call-client/src-tauri/src/commands/mod.rs @@ -2,4 +2,5 @@ pub mod config; pub mod events; pub mod logger; pub mod session; +pub mod update; pub mod window; diff --git a/call-client/src-tauri/src/commands/update.rs b/call-client/src-tauri/src/commands/update.rs new file mode 100644 index 0000000..d8a7e86 --- /dev/null +++ b/call-client/src-tauri/src/commands/update.rs @@ -0,0 +1,78 @@ +use serde::Serialize; +#[cfg(target_os = "linux")] +use std::process::Command; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AptUpdateCheckResult { + package_name: String, + current_version: String, + installed_version: String, + candidate_version: String, + has_update: bool, + source_available: bool, + update_command: String, +} + +#[cfg(target_os = "linux")] +fn extract_policy_value(output: &str, key: &str) -> String { + output + .lines() + .find_map(|line| line.trim().strip_prefix(key).map(|value| value.trim().to_string())) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "(unknown)".to_string()) +} + +#[tauri::command] +pub fn check_apt_update(package_name: String, current_version: String) -> Result { + #[cfg(not(target_os = "linux"))] + { + let _ = (&package_name, ¤t_version); + return Err("当前系统不支持 apt 更新检测,仅支持 Linux。".to_string()); + } + + #[cfg(target_os = "linux")] + { + let output = Command::new("apt-cache") + .arg("policy") + .arg(&package_name) + .output() + .map_err(|error| format!("执行 apt-cache 失败: {error}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + "apt-cache policy 执行失败".to_string() + } else { + format!("apt-cache policy 执行失败: {stderr}") + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let installed_version = extract_policy_value(&stdout, "Installed:"); + let candidate_version = extract_policy_value(&stdout, "Candidate:"); + let baseline_version = if installed_version == "(none)" || installed_version == "(unknown)" { + current_version.clone() + } else { + installed_version.clone() + }; + let source_available = candidate_version != "(none)" && candidate_version != "(unknown)"; + let has_update = source_available + && candidate_version != baseline_version + && !candidate_version.trim().is_empty(); + let update_command = format!( + "sudo apt update && sudo apt install --only-upgrade {}", + package_name + ); + + Ok(AptUpdateCheckResult { + package_name, + current_version, + installed_version, + candidate_version, + has_update, + source_available, + update_command, + }) + } +} diff --git a/call-client/src-tauri/src/lib.rs b/call-client/src-tauri/src/lib.rs index 332d6c5..ac17612 100644 --- a/call-client/src-tauri/src/lib.rs +++ b/call-client/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ use commands::{ events::{emit_to_window, list_windows}, logger::app_log, session::{session_clear, session_get, session_set}, + update::check_apt_update, window::{ close_ticket_window, ensure_main_window, focus_window, open_login_window, open_main_window, open_ticket_window, quit_app, @@ -30,6 +31,7 @@ pub fn run() { app_log, emit_to_window, list_windows, + check_apt_update, open_ticket_window, close_ticket_window, focus_window, diff --git a/call-client/src/host/session.ts b/call-client/src/host/session.ts index 67074a4..1771008 100644 --- a/call-client/src/host/session.ts +++ b/call-client/src/host/session.ts @@ -5,6 +5,7 @@ const DEFAULT_SESSION: SessionState = { empUid: null, winUid: null, queueToken: null, + refreshToken: null, }; function normalizeSession(raw: unknown): SessionState { @@ -15,8 +16,9 @@ function normalizeSession(raw: unknown): SessionState { const winUid = typeof source.winUid === "number" && Number.isFinite(source.winUid) ? source.winUid : null; const queueToken = typeof source.queueToken === "string" ? source.queueToken : null; + const refreshToken = typeof source.refreshToken === "string" ? source.refreshToken : null; - return { empUid, winUid, queueToken }; + return { empUid, winUid, queueToken, refreshToken }; } /** diff --git a/call-client/src/host/types.ts b/call-client/src/host/types.ts index 8fe55c6..921f57f 100644 --- a/call-client/src/host/types.ts +++ b/call-client/src/host/types.ts @@ -6,6 +6,7 @@ export interface SessionState { empUid: number | null; winUid: number | null; queueToken: string | null; + refreshToken: string | null; } export interface NativeConfirmOptions { diff --git a/call-client/src/utils/service.ts b/call-client/src/utils/service.ts index 16431c0..7286877 100644 --- a/call-client/src/utils/service.ts +++ b/call-client/src/utils/service.ts @@ -1,10 +1,31 @@ -import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"; +import axios, { type AxiosRequestConfig } from "axios"; import { showErrorNative } from "../host/dialog"; -import { getSession } from "../host/session"; +import { getSession, setSession } from "../host/session"; import type { SessionState } from "../host/types"; export const API_QUEUE_CALLER_PATH = "/api/queue/caller"; const DEFAULT_API_PORT = 8845; +const AUTH_LOGIN_PATH = "/auth/login"; +const AUTH_REFRESH_PATH = "/auth/refresh"; + +type ApiEnvelope = { + code: number; + msg?: string; + message?: string; + data: T; +}; + +type RefreshTokenResponse = { + queueToken?: string; + refreshToken?: string; +}; + +type RetryableAxiosRequestConfig = AxiosRequestConfig & { + _retry?: boolean; + _skipAuthRefresh?: boolean; +}; + +let refreshTokenPromise: Promise | null = null; /** * 根据配置中的服务器地址拼出后端 baseURL。 @@ -36,6 +57,101 @@ const instance = axios.create({ }, }); +function isApiSuccessCode(code: unknown): boolean { + return code === 200 || code === 0; +} + +function getApiMessage(payload: unknown): string { + const data = (payload ?? {}) as { message?: unknown; msg?: unknown }; + if (typeof data.message === "string" && data.message.trim() !== "") { + return data.message; + } + if (typeof data.msg === "string" && data.msg.trim() !== "") { + return data.msg; + } + return "请求失败"; +} + +function isAuthEndpoint(url?: string): boolean { + if (!url) { + return false; + } + return url.includes(AUTH_LOGIN_PATH) || url.includes(AUTH_REFRESH_PATH); +} + +function setAuthHeader(config: AxiosRequestConfig, queueToken: string): void { + const headers = (config.headers ?? {}) as Record; + headers.Authorization = `Bearer ${queueToken}`; + config.headers = headers; +} + +async function refreshQueueToken(): Promise { + if (refreshTokenPromise) { + return refreshTokenPromise; + } + + refreshTokenPromise = (async () => { + const sessionState = await getSession(); + const refreshToken = sessionState.refreshToken; + if (!refreshToken) { + return null; + } + + const response = await axios.request>({ + baseURL: instance.defaults.baseURL, + timeout: instance.defaults.timeout, + method: "POST", + url: AUTH_REFRESH_PATH, + data: { refreshToken }, + headers: { "Content-Type": "application/json" }, + }); + + const payload = response.data; + if (!isApiSuccessCode(payload?.code) || !payload?.data?.queueToken) { + return null; + } + + const refreshedQueueToken = payload.data.queueToken; + await setSession({ + ...sessionState, + queueToken: refreshedQueueToken, + refreshToken: payload.data.refreshToken ?? refreshToken, + }); + + return refreshedQueueToken; + })(); + + try { + return await refreshTokenPromise; + } finally { + refreshTokenPromise = null; + } +} + +function canRefreshAndRetry(config?: RetryableAxiosRequestConfig): boolean { + if (!config) { + return false; + } + if (config._retry || config._skipAuthRefresh) { + return false; + } + return !isAuthEndpoint(config.url); +} + +async function retryWithRefreshedToken(config: RetryableAxiosRequestConfig): Promise { + const nextToken = await refreshQueueToken(); + if (!nextToken) { + throw new Error("会话已过期,请重新登录"); + } + + const retryConfig: RetryableAxiosRequestConfig = { + ...config, + _retry: true, + }; + setAuthHeader(retryConfig, nextToken); + return instance.request(retryConfig); +} + /** * 将配置中的服务器地址应用到 axios 实例。 */ @@ -63,8 +179,8 @@ instance.interceptors.request.use( token = null; } - if (token && !config.url?.includes("/login")) { - config.headers.Authorization = `Bearer ${token}`; + if (token && !isAuthEndpoint(config.url)) { + setAuthHeader(config, token); } return config; @@ -75,22 +191,35 @@ instance.interceptors.request.use( ); instance.interceptors.response.use( - (response: AxiosResponse) => { + async (response) => { const { data } = response; - if (data.code !== 200) { - void showErrorNative(data.message || "请求失败"); + const payload = data as ApiEnvelope; - if (data.code === 401) { - void showErrorNative("请求 token 过期,请重新登录"); + if (!isApiSuccessCode(payload?.code)) { + const requestConfig = response.config as RetryableAxiosRequestConfig; + if (payload?.code === 401 && canRefreshAndRetry(requestConfig)) { + return retryWithRefreshedToken(requestConfig); } - throw new Error(data.message || "请求失败"); + const message = getApiMessage(payload); + await showErrorNative(message); + throw new Error(message); } - return data.data; + return payload.data; }, async (error) => { if (error.response) { + const requestConfig = error.config as RetryableAxiosRequestConfig; + if (error.response.status === 401 && canRefreshAndRetry(requestConfig)) { + try { + return await retryWithRefreshedToken(requestConfig); + } catch { + await showErrorNative("未授权,请重新登录"); + throw error; + } + } + switch (error.response.status) { case 400: await showErrorNative("请求错误"); diff --git a/call-client/src/views/LoginView.vue b/call-client/src/views/LoginView.vue index 64bfb1e..db61a26 100644 --- a/call-client/src/views/LoginView.vue +++ b/call-client/src/views/LoginView.vue @@ -1,6 +1,9 @@