cangku/frontend/src/api/request.js
lunanting b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

稳定性:
- 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500
- 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML
- 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗
- 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00

191 lines
6.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import axios from 'axios';
import { message } from 'antd';
import { getRequestErrorMessage, isAuthSessionFailure } from './authSessionPolicy';
import { requestDesktopSessionRenewal } from '../utils/desktopEmbedSession';
const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(
Number(import.meta.env.VITE_API_REQUEST_TIMEOUT_MS) || 120000,
15000
);
const request = axios.create({
baseURL: '', // Proxy handles the base URL
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
});
const AUTH_USER_STORAGE_KEY = 'auth_user';
// 后端重启窗口内的自动重连
// ------------------------------------------------------------------
// 本地/线上后端在「隧道掉线 -> 监督器重启后端」期间会有 20~90 秒不可用,
// 此时 Vite 代理拿不到后端,会直接回 500(响应体是 HTML,没有 error 字段),
// 用户看到的就是一句没头没尾的 "Request failed with status code 500"。
// 这里对**幂等请求**(GET/HEAD/OPTIONS)在网络错误或 5xx 时做退避重试,
// 重启窗口过去后请求会自己成功,页面不再报错。
// 累计等待约 80 秒,覆盖最坏情况下的后端启动耗时。
const RETRY_BACKOFF_MS = [1500, 3000, 5000, 8000, 13000, 21000, 30000];
const RETRYABLE_METHODS = ['get', 'head', 'options'];
const RETRY_NOTICE_KEY = 'api-reconnect-notice';
function isRetryableRequest(config) {
if (!config || config.retry === false) {
return false;
}
const method = String(config.method || 'get').toLowerCase();
return RETRYABLE_METHODS.includes(method);
}
function isRetryableError(error) {
if (axios.isCancel?.(error) || error?.code === 'ERR_CANCELED') {
return false;
}
const status = error?.response?.status;
// 无响应:网络中断、连接被拒(后端还没起来)、代理转发失败
if (!status) {
return true;
}
// 5xx:后端内部错误 / 网关与代理错误
return status >= 500 && status <= 599;
}
function sleep(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
function notifyReconnecting() {
message.open({
key: RETRY_NOTICE_KEY,
type: 'loading',
content: '服务正在重启,正在自动重连…',
duration: 0,
});
}
function clearReconnectNotice() {
message.destroy(RETRY_NOTICE_KEY);
}
function clearAuthSession() {
localStorage.removeItem('token');
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
}
function parseJwtPayload(token) {
if (!token) {
return null;
}
try {
const [, payload] = token.split('.');
if (!payload) {
return null;
}
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=');
return JSON.parse(window.atob(padded));
} catch {
return null;
}
}
function isLocalPreviewSession() {
const isLocalHost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
if (!isLocalHost) {
return false;
}
const payload = parseJwtPayload(localStorage.getItem('token'));
return payload?.authSource === 'local-preview';
}
function isPublicAuthPage() {
return ['/login', '/callback', '/install'].includes(window.location.pathname);
}
function shouldBypass401Redirect(error) {
const url = error?.config?.url || '';
return url.includes('/api/auth/verify') || isLocalPreviewSession();
}
// Request interceptor
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
request.interceptors.response.use(
(response) => {
const res = response.data;
// Compatible with backend interfaces that don't return code
if (res && res.code && res.code !== 200) {
const shouldBypassRedirect = res.code === 401 && shouldBypass401Redirect(response);
if (!shouldBypassRedirect && !response.config?.silentError) {
message.error(res.message || 'Error');
}
if (res.code === 401 && !shouldBypassRedirect) {
// Handle unauthorized - 但如果已在登录页则不重定向
clearAuthSession();
if (!isPublicAuthPage()) {
if (!requestDesktopSessionRenewal('api-unauthorized')) {
window.location.href = '/login';
}
}
}
return Promise.reject(new Error(res.message || 'Error'));
}
clearReconnectNotice();
return res;
},
async (error) => {
const config = error?.config;
// ---- 后端重启窗口内自动重试(只对幂等请求、只对网络错误/5xx)----
// 说明:这里用 `return request(config)`(不带 await)而不是 await,
// 这样重试失败时由「内层」这一分支继续退避;外层调用栈不会重新进入本函数,
// 因此最终失败提示天然只会出现一次,也不会把重试次数叠加错。
if (isRetryableRequest(config) && !config.__retryExhausted && isRetryableError(error)) {
const attempt = Number(config.__retryAttempt || 0);
if (attempt < RETRY_BACKOFF_MS.length) {
config.__retryAttempt = attempt + 1;
if (attempt === 0 && !config.silentError) {
notifyReconnecting();
}
await sleep(RETRY_BACKOFF_MS[attempt]);
return request(config);
}
// 次数用尽:标记后不再重试,并把连接提示收掉
config.__retryExhausted = true;
config.__retryAttempt = 0;
clearReconnectNotice();
}
const authSessionFailure = isAuthSessionFailure(error.response);
if (!authSessionFailure && !error?.config?.silentError) {
message.error(getRequestErrorMessage(error));
}
if (authSessionFailure && !shouldBypass401Redirect(error)) {
clearAuthSession();
// 如果已在登录页则不重定向,避免无限循环
if (!isPublicAuthPage()) {
if (!requestDesktopSessionRenewal('api-unauthorized')) {
window.location.href = '/login';
}
}
}
return Promise.reject(error);
}
);
export default request;