fix(buyer): 修复登录过期异常及全局请求错误处理

This commit is contained in:
pikachu1995@126.com
2026-11-09 16:06:33 +08:00
parent b2981b3ad7
commit ecbb80e24a
11 changed files with 384 additions and 100 deletions

View File

@@ -1,3 +1,4 @@
import { setupRequestErrorHandling } from "@/utils/requestError";
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import "core-js/stable"; import "core-js/stable";
@@ -24,6 +25,7 @@ if (aMapSecurityJsCode) {
} }
const app = createApp(App); const app = createApp(App);
setupRequestErrorHandling(app);
setupElementPlus(app); setupElementPlus(app);
setupLegacyMessage(app); setupLegacyMessage(app);

View File

@@ -200,7 +200,7 @@ function handleSubmit () { // 提交密码
Message.success('修改密码成功'); Message.success('修改密码成功');
router.push('login'); router.push('login');
} }
}).catch(() => { loading.value = false; }); }).catch(() => { loading1.value = false; });
}; };
}); });
} }

View File

@@ -137,6 +137,7 @@
</template> </template>
<script setup> <script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
@@ -230,6 +231,8 @@ function handleSubmit() {
params.content = ""; params.content = "";
getDetail(); getDetail();
} }
}).catch(catchHandledRequestError).finally(() => {
submitLoading.value = false;
}); });
} }

View File

@@ -17,7 +17,9 @@
</div> </div>
</template> </template>
<!-- 列表 --> <!-- 列表 -->
<empty v-if="orderList.length === 0"/> <el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="orderList.length === 0" />
<div class="order-content" v-else> <div class="order-content" v-else>
<div class="order-cols"> <div class="order-cols">
<span>订单详情</span> <span>订单详情</span>
@@ -77,7 +79,7 @@
</div> </div>
</div> </div>
</div> </div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div> </div>
<!-- 分页 --> <!-- 分页 -->
<div class="page-size"> <div class="page-size">
@@ -134,6 +136,7 @@
</template> </template>
<script setup> <script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { nextTick, onMounted, reactive, ref } from "vue"; import { nextTick, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message"; import { Message, Modal } from "@/utils/message";
@@ -157,6 +160,8 @@ const params = reactive({ // 请求参数
order: 'desc' order: 'desc'
}); });
const total = ref(0); // 订单总数 const total = ref(0); // 订单总数
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 加载状态 const spinShow = ref(false); // 加载状态
const companyList = ref([]); // 物流公司列表 const companyList = ref([]); // 物流公司列表
const logisticsShow = ref(false); // 物流信息modal const logisticsShow = ref(false); // 物流信息modal
@@ -230,14 +235,27 @@ function change(index) {
getList() getList()
} }
function getList() { // 获取售后列表 function getList() { // 获取售后列表
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true; spinShow.value = true;
let paramsData = JSON.parse(JSON.stringify(params)) let paramsData = JSON.parse(JSON.stringify(params))
afterSaleList(paramsData).then(res => { afterSaleList(paramsData).then(res => {
spinShow.value = false if (version !== listRequestVersion) return;
if (res.success) { if (res.success) {
orderList.value = res.result.records; orderList.value = res.result.records;
total.value = res.result.total; total.value = res.result.total;
} }
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
orderList.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
}); });
} }
function changePageNum(val) { // 修改页码 function changePageNum(val) { // 修改页码

View File

@@ -21,7 +21,9 @@
</template> </template>
<!-- 订单列表 --> <!-- 订单列表 -->
<empty v-if="orderList.length === 0" /> <el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="orderList.length === 0" />
<div class="order-content" v-else> <div class="order-content" v-else>
<div class="order-cols"> <div class="order-cols">
<span>订单详情</span> <span>订单详情</span>
@@ -91,7 +93,7 @@
</div> </div>
</div> </div>
</div> </div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div> </div>
<!-- 分页 --> <!-- 分页 -->
@@ -103,7 +105,9 @@
</UserCenterLayout> </UserCenterLayout>
<!-- 个人中心首页订单列表 --> <!-- 个人中心首页订单列表 -->
<empty v-if="homePage && orderList.length === 0" /> <el-skeleton v-if="homePage && spinShow" :rows="5" animated />
<el-empty v-else-if="homePage && queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="homePage && orderList.length === 0" />
<div class="order-content" v-else-if="homePage"> <div class="order-content" v-else-if="homePage">
<div class="order-cols"> <div class="order-cols">
<span>订单详情</span> <span>订单详情</span>
@@ -173,7 +177,7 @@
</div> </div>
</div> </div>
</div> </div>
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div> </div>
<el-dialog v-model="cancelAvail" title="请选择取消订单原因"> <el-dialog v-model="cancelAvail" title="请选择取消订单原因">
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" /> <el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
@@ -193,6 +197,7 @@
</template> </template>
<script setup> <script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { onMounted, reactive, ref, watch } from "vue"; import { onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message"; import { Message, Modal } from "@/utils/message";
@@ -227,6 +232,8 @@ const cancelParams = reactive({ // 取消售后参数
const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']); const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0); const activeOrderTab = ref(0);
const total = ref(0); // 数据总数 const total = ref(0); // 数据总数
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 加载状态 const spinShow = ref(false); // 加载状态
const cancelIsPickup = ref(false); const cancelIsPickup = ref(false);
const cancelAvail = ref(false); // 取消订单modal控制 const cancelAvail = ref(false); // 取消订单modal控制
@@ -322,17 +329,30 @@ function delOrder (sn) { // 删除订单
}); });
} }
function getList () { function getList () {
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true; spinShow.value = true;
let _params = JSON.parse(JSON.stringify(params)) let _params = JSON.parse(JSON.stringify(params))
if (_params.orderStatus === 'ALL') { if (_params.orderStatus === 'ALL') {
delete _params.orderStatus delete _params.orderStatus
} }
getOrderList(_params).then(res => { getOrderList(_params).then(res => {
spinShow.value = false if (version !== listRequestVersion) return;
if (res.success) { if (res.success) {
orderList.value = res.result.records; orderList.value = res.result.records;
total.value = res.result.total; total.value = res.result.total;
} }
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
orderList.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
}); });
} }
function mapAfterSaleOrder (item) { function mapAfterSaleOrder (item) {

View File

@@ -5,7 +5,9 @@
<el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button> <el-button class="del-btn" @click="clearAll" type="primary">删除全部</el-button>
</template> </template>
<!-- 订单列表 --> <!-- 订单列表 -->
<empty v-if="list.length === 0" /> <el-skeleton v-if="spinShow" :rows="5" animated />
<el-empty v-else-if="queryFailed" description="加载失败,请重试"><el-button type="primary" @click="getList">重新加载</el-button></el-empty>
<empty v-else-if="list.length === 0" />
<ul class="track-list" v-else> <ul class="track-list" v-else>
<li <li
v-for="(item, index) in list" v-for="(item, index) in list"
@@ -39,6 +41,7 @@
</template> </template>
<script setup> <script setup>
import { catchHandledRequestError } from "@/utils/requestError";
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message, Modal } from "@/utils/message"; import { Message, Modal } from "@/utils/message";
@@ -50,6 +53,8 @@ defineOptions({ name: "MyTrack" });
const router = useRouter(); const router = useRouter();
const list = ref([]); // 我的足迹,商品列表 const list = ref([]); // 我的足迹,商品列表
const queryFailed = ref(false); // 区分加载失败与暂无记录,支持重新查询
let listRequestVersion = 0; // 仅允许最后一次查询更新列表与加载状态
const spinShow = ref(false); // 控制loading是否加载 const spinShow = ref(false); // 控制loading是否加载
const params = reactive({ const params = reactive({
pageNumber: 1, pageNumber: 1,
@@ -118,9 +123,11 @@ function changePageSize(val) {
function getList() { function getList() {
// 获取足迹列表 // 获取足迹列表
const version = ++listRequestVersion;
queryFailed.value = false;
spinShow.value = true; spinShow.value = true;
tracksList(params).then((res) => { tracksList(params).then((res) => {
spinShow.value = false; if (version !== listRequestVersion) return;
if (res.success && res.result.records.length) { if (res.success && res.result.records.length) {
list.value = res.result.records.filter(item =>{ list.value = res.result.records.filter(item =>{
return item != null return item != null
@@ -129,6 +136,17 @@ function getList() {
} else { } else {
list.value = []; list.value = [];
} }
}).catch((error) => {
if (version !== listRequestVersion) {
catchHandledRequestError(error);
return;
}
queryFailed.value = true;
list.value = [];
total.value = 0;
catchHandledRequestError(error);
}).finally(() => {
if (version === listRequestVersion) spinShow.value = false;
}); });
} }

View File

@@ -163,6 +163,7 @@ function getGoodsDetail() {
} }
}) })
.catch(() => { .catch(() => {
isLoading.value = false;
router.push("/"); router.push("/");
}); });
} }

View File

@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { markHandledRequestError, isHandledRequestError } from "@/utils/requestError";
import { ElLoading } from "element-plus"; import { ElLoading } from "element-plus";
import { Message, Modal } from "@/utils/message"; import { Message, Modal } from "@/utils/message";
import Storage from "./storage"; import Storage from "./storage";
@@ -14,7 +15,7 @@ export const buyerUrl =
? BASE.API_DEV.buyer ? BASE.API_DEV.buyer
: BASE.API_PROD.buyer; : BASE.API_PROD.buyer;
let isRefreshToken = 0; let loginPrompt = null;
const refreshToken = getTokenDebounce(); const refreshToken = getTokenDebounce();
/** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */ /** 会员中心(/home/*)页面使用局部 loading,不启用全屏遮罩 */
@@ -37,20 +38,22 @@ const service = axios.create({
}); });
service.interceptors.request.use( service.interceptors.request.use(
(config) => { async (config) => {
const { loading } = config; const { loading } = config;
const isPutPost = config.method === "put" || config.method === "post"; const isPutPost = config.method === "put" || config.method === "post";
const isJson = config.headers["Content-Type"] === "application/json"; const isJson = config.headers["Content-Type"] === "application/json";
const isFile = config.headers["Content-Type"] === "multipart/form-data"; const isFile = config.headers["Content-Type"] === "multipart/form-data";
if (isPutPost && isJson) { if (!config._dataSerialized && isPutPost && isJson) {
config.data = JSON.stringify(config.data); config.data = JSON.stringify(config.data);
} }
if (isPutPost && !isFile && !isJson) { if (!config._dataSerialized && isPutPost && !isFile && !isJson) {
config.data = qs.stringify(config.data, { config.data = qs.stringify(config.data, {
arrayFormat: "repeat", arrayFormat: "repeat",
}); });
} }
config._dataSerialized = true;
const method = (config.method || "get").toLowerCase(); const method = (config.method || "get").toLowerCase();
const isReadRequest = method === "get" || method === "head"; const isReadRequest = method === "get" || method === "head";
const shouldShowLoading = const shouldShowLoading =
@@ -58,7 +61,7 @@ service.interceptors.request.use(
(!isReadRequest && loading !== false && !isMemberCenterRoute()); (!isReadRequest && loading !== false && !isMemberCenterRoute());
if (typeof window !== "undefined" && shouldShowLoading) { if (typeof window !== "undefined" && shouldShowLoading) {
config.loading = ElLoading.service({ fullscreen: true }); config._loadingInstance = ElLoading.service({ fullscreen: true });
} }
let uuid = Storage.getItem("uuid"); let uuid = Storage.getItem("uuid");
@@ -82,7 +85,7 @@ service.interceptors.request.use(
const expired = jwtData.exp < Math.round(new Date() / 1000); const expired = jwtData.exp < Math.round(new Date() / 1000);
if (expired) { if (expired) {
if (config.needToken) { if (config.needToken) {
return refresh({ response: { config } }); return refresh(config);
} }
// 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录 // 公开接口:丢弃过期 token,匿名继续请求,避免浏览商品时被引导登录
Storage.removeItem("accessToken"); Storage.removeItem("accessToken");
@@ -94,7 +97,7 @@ service.interceptors.request.use(
Storage.removeItem("accessToken"); Storage.removeItem("accessToken");
Storage.removeItem("refreshToken"); Storage.removeItem("refreshToken");
if (config.needToken) { if (config.needToken) {
return refresh({ response: { config } }); return refresh(config);
} }
} }
} }
@@ -104,59 +107,64 @@ service.interceptors.request.use(
(error) => Promise.reject(error) (error) => Promise.reject(error)
); );
/** 保留请求配置、状态与错误码,供页面判断业务失败并定位原因。 */
function buildRejectError(error, errorData = {}) { function buildRejectError(error, errorData = {}) {
const message = const message =
errorData.message || errorData.message ||
(error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!"); (error?.code === "ECONNABORTED" ? "连接超时,请稍候再试!" : "网络错误,请稍后再试!");
return Object.assign(new Error(message), { data: errorData }); return Object.assign(new Error(message), { data: errorData, config: error.config, response: error.response, code: error.code });
} }
async function refresh(error) { /**
const getTokenRes = await refreshToken(); * 刷新凭证并返回请求配置;请求拦截器不能返回业务响应。每个业务请求最多刷新一次。
if (getTokenRes === "success") { * 刷新失败时保持 Promise 拒绝,阻止后续支付、订单等成功回调继续执行。
if (error?.response?.config) { */
error.response.config.headers.accessToken = Storage.getItem("accessToken"); async function refresh(config) {
return service(error.response.config); if (!config._retryAfterRefresh) {
config._retryAfterRefresh = true;
const result = await refreshToken();
if (result === "success") {
config.headers.accessToken = Storage.getItem("accessToken");
return config;
} }
router.go(0);
return new Promise(() => {});
} }
Storage.removeItem("accessToken"); Storage.removeItem("accessToken");
Storage.removeItem("refreshToken"); Storage.removeItem("refreshToken");
Storage.removeItem("userInfo"); Storage.removeItem("userInfo");
Storage.setItem("cartNum", 0); Storage.setItem("cartNum", 0);
useAppStore().setCartNum(0); useAppStore().setCartNum(0);
const currentRoute = router.currentRoute.value; // 多个受保护接口同时失败时共用一个弹窗,关闭后允许再次提示。
Modal.confirm({ if (!loginPrompt) {
title: "请登录", const currentRoute = router.currentRoute.value;
content: "请登录后执行此操作", loginPrompt = Modal.confirm({
okText: "立即登录", title: "请登录",
cancelText: "继续浏览", content: "请登录后执行此操作",
onOk: () => { okText: "立即登录",
router.push({ cancelText: "继续浏览",
onOk: () => router.push({
path: "/login", path: "/login",
query: { query: { rePath: currentRoute.path, query: JSON.stringify(currentRoute.query) },
rePath: currentRoute.path, }),
query: JSON.stringify(currentRoute.query), onCancel: () => router.push("/"),
}, }).finally(() => { loginPrompt = null; });
}); }
}, const error = Object.assign(new Error("登录已过期"), { code: "AUTH_EXPIRED", config });
onCancel: () => { throw markHandledRequestError(error);
router.push("/");
Modal.remove();
},
});
return Promise.reject(new Error("登录已过期"));
} }
service.interceptors.response.use( service.interceptors.response.use(
async (response) => { async (response) => {
await closeLoading(response); await closeLoading(response);
const config = response.config;
if (!config.skipAuthHandler && [401, 403, 20004].includes(response.data?.code)) {
return service(await refresh(config));
}
return response.data; return response.data;
}, },
async (error) => { async (error) => {
if (typeof window === "undefined") return Promise.reject(error); if (typeof window === "undefined") return Promise.reject(error);
await closeLoading(error); await closeLoading(error);
if (isHandledRequestError(error)) return Promise.reject(error);
const errorResponse = error.response || {}; const errorResponse = error.response || {};
const errorData = errorResponse.data || {}; const errorData = errorResponse.data || {};
@@ -166,32 +174,26 @@ service.interceptors.response.use(
errorResponse.status === 403 || errorResponse.status === 403 ||
error.response?.data?.code === 20004) error.response?.data?.code === 20004)
) { ) {
isRefreshToken++; return service(await refresh(error.config || errorResponse.config));
if (isRefreshToken === 1) {
isRefreshToken = 0;
return refresh(error);
}
return Promise.reject(buildRejectError(error, errorData));
} }
if (errorResponse.status === 404) { if (!axios.isAxiosError(error) || axios.isCancel(error)) return Promise.reject(error);
return Promise.reject(buildRejectError(error, { message: "请求的资源不存在" })); const rejected = buildRejectError(error, errorResponse.status === 404
? { ...errorData, message: "请求的资源不存在" }
: errorData);
if (!error.config?.skipMessage && axios.isAxiosError(error) && !axios.isCancel(error)) {
Message.error(rejected.message);
markHandledRequestError(rejected);
} }
if (error.message && !error.config?.skipMessage) { return Promise.reject(rejected);
const _message =
error.code === "ECONNABORTED"
? "连接超时,请稍候再试!"
: "网络错误,请稍后再试!";
Message.error(errorData.message || _message);
}
return Promise.reject(buildRejectError(error, errorData));
} }
); );
const closeLoading = (target) => { const closeLoading = (target) => {
if (!target.config || !target.config.loading) return true; if (typeof target.config?._loadingInstance?.close !== "function") return true;
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
target.config.loading.close(); target.config._loadingInstance.close();
delete target.config._loadingInstance;
resolve(); resolve();
}, 200); }, 200);
}); });
@@ -208,42 +210,22 @@ export default function request(options) {
return service(options); return service(options);
} }
/** 并发刷新共用同一个 Promise;不轮询,不保留上次刷新结果,失败后允许下次重试。 */
function getTokenDebounce() { function getTokenDebounce() {
let lock = false; let pending = null;
let success = false;
return function () { return function () {
if (!lock) { if (pending) return pending;
const oldRefreshToken = Storage.getItem("refreshToken"); const token = Storage.getItem("refreshToken");
// 无 refreshToken(未登录)时不发起刷新请求,直接判失败,避免请求 /refresh/undefined 造成拦截器死锁 if (!token) return Promise.resolve("fail");
if (!oldRefreshToken) { pending = handleRefreshToken(token)
return Promise.resolve("fail"); .then((res) => {
} if (!res.success || !res.result?.accessToken || !res.result?.refreshToken) return "fail";
lock = true; Storage.setItem("accessToken", res.result.accessToken);
handleRefreshToken(oldRefreshToken) Storage.setItem("refreshToken", res.result.refreshToken);
.then((res) => { return "success";
if (res.success) { })
const { accessToken, refreshToken: newRefreshToken } = res.result; .catch(() => "fail")
Storage.setItem("accessToken", accessToken); .finally(() => { pending = null; });
Storage.setItem("refreshToken", newRefreshToken); return pending;
success = true;
lock = false;
} else {
success = false;
lock = false;
}
})
.catch(() => {
success = false;
lock = false;
});
}
return new Promise((resolve) => {
const timer = setInterval(() => {
if (!lock) {
clearInterval(timer);
resolve(success ? "success" : "fail");
}
}, 500);
});
}; };
} }

View File

@@ -0,0 +1,40 @@
/**
* 已向用户提示的请求异常兜底;只识别本模块登记的错误,不屏蔽代码异常。
* @author Bulbasaur
* @since 2026-11-09
*/
const handledErrors = new WeakSet();
/** 登记已由请求层提示的错误,仍保持原请求的拒绝语义。 */
export function markHandledRequestError(error) {
handledErrors.add(error);
// 开发服务器的错误遮罩有独立监听器,通过同一 Symbol 识别已提示的异常。
Object.defineProperty(error, Symbol.for("lilishop.buyer.handledRequestError"), { value: true });
return error;
}
/** 判断错误是否已由请求层向用户展示。 */
export function isHandledRequestError(error) {
return !!error && typeof error === "object" && handledErrors.has(error);
}
/** 页面处理预期请求失败;程序错误继续向上抛出,避免被静默吞掉。 */
export function catchHandledRequestError(error) {
if (!isHandledRequestError(error)) throw error;
}
/**
* 覆盖未返回 Promise 的事件回调及 Vue 异步钩子;只有已提示的请求异常免于重复报错。
* 未登记异常沿用已有 Vue 错误处理,无处理器时重新抛出供开发环境定位。
*/
export function setupRequestErrorHandling(app, target = window) {
target.addEventListener("unhandledrejection", (event) => {
if (isHandledRequestError(event.reason)) event.preventDefault();
});
const previous = app.config.errorHandler;
app.config.errorHandler = (error, instance, info) => {
if (isHandledRequestError(error)) return;
if (previous) return previous(error, instance, info);
throw error;
};
}

View File

@@ -0,0 +1,194 @@
/**
* 买家端请求认证回归:验证真实 Axios 拦截链、并发刷新和已提示异常边界。
* @author Bulbasaur
* @since 2026-11-09
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const axios = require('axios');
/** 加载业务源码,替换浏览器与界面依赖;请求拦截和重试仍使用真实 Axios。 */
function harness({ expired = false, refresh = async () => ({ success: false }), adapter } = {}) {
const values = new Map();
const jwt = (exp) => 'header.' + Buffer.from(JSON.stringify({ exp })).toString('base64url') + '.sig';
if (expired) values.set('accessToken', jwt(1));
values.set('refreshToken', 'refresh-old');
const Storage = { getItem: k => values.get(k), setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
const helperSource = fs.readFileSync(path.join(__dirname, '../src/utils/requestError.js'), 'utf8').replace(/export /g, '');
const helper = new Function(helperSource + '; return { markHandledRequestError, isHandledRequestError, catchHandledRequestError, setupRequestErrorHandling };')();
let prompts = 0, refreshCalls = 0, closes = 0, opens = 0;
const messages = [], sent = [];
let dismiss, promptOptions;
const Modal = { confirm(options) { prompts++; promptOptions = options; return new Promise(resolve => { dismiss = resolve; }); } };
const router = { currentRoute: { value: { path: '/home/MyOrder', query: { tag: 'ALL' } } }, push: async () => {} };
const axiosProxy = { ...axios, create(options) { return axios.create({ ...options, adapter: async config => {
sent.push(config);
return adapter ? adapter(config, sent.length) : { data: { success: true }, status: 200, config, headers: {} };
} }); } };
const source = fs.readFileSync(path.join(__dirname, '../src/plugins/request.js'), 'utf8')
.replace(/^import .*;\r?\n/gm, '').replace(/export default function request/, 'function request').replace(/export const /g, 'const ');
const names = ['axios','markHandledRequestError','isHandledRequestError','ElLoading','Message','Modal','Storage','router','useAppStore','handleRefreshToken','uuidv4','require','process','BASE','window','setTimeout'];
const request = new Function(...names, source + '; return request;')(
axiosProxy, helper.markHandledRequestError, helper.isHandledRequestError,
{ service() { opens++; return { close() { closes++; } }; } }, { error: m => messages.push(m) }, Modal, Storage, router,
() => ({ setCartNum() {} }), async () => { refreshCalls++; return refresh(jwt); }, () => 'uuid', require,
{ env: { NODE_ENV: 'development' } }, { API_DEV: { buyer: '/api' } }, { atob: v => Buffer.from(v,'base64').toString('binary') }, fn => fn()
);
return { request, values, helper, sent, messages, get prompts() { return prompts; }, get refreshCalls() { return refreshCalls; }, get opens() { return opens; }, get closes() { return closes; }, dismiss: () => dismiss(), get promptOptions() { return promptOptions; } };
}
/** 构造后端拒绝登录的 HTTP 响应。 */
function unauthorized(config) {
throw new axios.AxiosError('unauthorized', 'ERR_BAD_REQUEST', config, null, { status: 401, data: {}, config });
}
test('并发过期请求只刷新一次,返回配置并各发送一次业务请求', async () => {
const h = harness({ expired: true, refresh: async jwt => {
await new Promise(resolve => setImmediate(resolve));
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new-refresh' } };
} });
const results = await Promise.all([1,2,3].map(() => h.request({ url: '/orders', needToken: true })));
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 3);
assert.ok(results.every(r => r.success));
assert.equal(h.prompts, 0);
});
test('刷新失败保持拒绝、只弹一次登录框并清理身份与加载', async () => {
const h = harness({ expired: true });
let successes = 0;
const results = await Promise.allSettled([1,2,3].map(() => h.request({ url: '/orders', needToken: true, loading: true }).then(() => { successes++; })));
assert.equal(successes, 0);
assert.equal(h.sent.length, 0);
assert.equal(h.prompts, 1);
assert.equal(h.closes, h.opens);
assert.equal(h.values.get('accessToken'), undefined);
assert.ok(results.every(r => r.status === 'rejected' && h.helper.isHandledRequestError(r.reason)));
assert.equal(h.messages.length, 0);
h.dismiss();
await new Promise(resolve => setImmediate(resolve));
await assert.rejects(h.request({ url: '/orders', needToken: true, adapter: unauthorized }));
assert.equal(h.prompts, 2);
assert.equal(h.refreshCalls, 1);
});
test('401 重试上限防止刷新死循环,POST 请求体保持一致', async () => {
const h = harness({ adapter: unauthorized, refresh: async jwt => ({ success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } }) });
await assert.rejects(h.request({ url: '/orders', method: 'post', data: { amount: 12 }, headers: { 'Content-Type': 'application/json' }, needToken: true, loading: true }), e => e.code === 'AUTH_EXPIRED');
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 2);
assert.equal(h.sent[0].data, '{"amount":12}');
assert.equal(h.sent[1].data, h.sent[0].data);
assert.equal(h.opens, h.closes);
assert.equal(h.prompts, 1);
});
test('HTTP 200 内认证失败进入同一处理,刷新接口不递归', async () => {
const h = harness({ adapter: async config => ({ config, status: 200, data: { code: 20004 }, headers: {} }) });
await assert.rejects(h.request({ url: '/orders' }), e => e.code === 'AUTH_EXPIRED');
const skipped = await h.request({ url: '/refresh', skipAuthHandler: true, skipMessage: true });
assert.equal(skipped.code, 20004);
assert.equal(h.refreshCalls, 1);
});
test('网络失败只提示一次,skipMessage 与程序错误不登记为已处理', async () => {
const h = harness({ adapter: config => { throw new axios.AxiosError('timeout', 'ECONNABORTED', config); } });
await assert.rejects(h.request({ url: '/orders' }), e => h.helper.isHandledRequestError(e));
assert.deepEqual(h.messages, ['连接超时,请稍候再试!']);
await assert.rejects(h.request({ url: '/refresh', skipMessage: true }), e => !h.helper.isHandledRequestError(e));
const bug = new TypeError('bug');
const b = harness({ adapter: () => { throw bug; } });
await assert.rejects(b.request({ url: '/orders' }), e => e === bug);
});
test('全局兜底和开发遮罩仅过滤已提示错误,未知错误仍上报', () => {
const h = harness();
const handled = h.helper.markHandledRequestError(new Error('登录已过期'));
const bug = new TypeError('bug');
const listeners = {};
const app = { config: {} };
h.helper.setupRequestErrorHandling(app, { addEventListener: (name, fn) => { listeners[name] = fn; } });
let prevented = 0;
listeners.unhandledrejection({ reason: handled, preventDefault: () => prevented++ });
listeners.unhandledrejection({ reason: bug, preventDefault: () => prevented++ });
assert.equal(prevented, 1);
assert.doesNotThrow(() => app.config.errorHandler(handled));
assert.throws(() => app.config.errorHandler(bug), e => e === bug);
assert.throws(() => h.helper.catchHandledRequestError(bug), e => e === bug);
const filter = require('../vue.config.js').devServer.client.overlay.runtimeErrors;
assert.equal(filter(handled), false);
assert.equal(filter(bug), true);
});
test('未登录不刷新 undefined,公开接口保持匿名可访问', async () => {
const h = harness({ adapter: config => {
if (config.needToken) return unauthorized(config);
return { config, status: 200, data: { success: true }, headers: {} };
} });
h.values.delete('refreshToken');
await assert.rejects(h.request({ url: '/orders', needToken: true }), e => e.code === 'AUTH_EXPIRED');
const result = await h.request({ url: '/goods', optionalToken: true });
assert.equal(result.success, true);
assert.equal(h.refreshCalls, 0);
assert.equal(h.prompts, 1);
});
test('HTTP 401 并发共用刷新,表单 POST 重试不重复编码', async () => {
const h = harness({
adapter: config => {
if (!config.headers.accessToken) return unauthorized(config);
return { config, status: 200, data: { success: true }, headers: {} };
},
refresh: async jwt => {
await new Promise(resolve => setImmediate(resolve));
return { success: true, result: { accessToken: jwt(4000000000), refreshToken: 'new' } };
},
});
await Promise.all([1,2].map(() => h.request({ url: '/orders', method: 'post', data: { name: 'a b' }, needToken: true })));
assert.equal(h.refreshCalls, 1);
assert.equal(h.sent.length, 4);
assert.ok(h.sent.every(c => c.data === 'name=a%20b'));
});
/** 从真实 Vue 页面提取查询函数,验证失败收尾与请求乱序,界面依赖用响应式状态桩替换。 */
function listHarness(file, apiName) {
const { parse } = require('@vue/compiler-sfc');
const parser = require('@babel/parser');
const source = parse(fs.readFileSync(path.join(__dirname, '../src/pages/home', file), 'utf8')).descriptor.scriptSetup.content;
const declaration = parser.parse(source, { sourceType: 'module' }).program.body.find(n => n.type === 'FunctionDeclaration' && n.id.name === 'getList');
const state = { spinShow: { value: false }, queryFailed: { value: false }, orderList: { value: [] }, list: { value: [] }, total: { value: 0 } };
const calls = [];
const api = () => new Promise((resolve, reject) => calls.push({ resolve, reject }));
const getList = new Function(...Object.keys(state), 'params', apiName, 'catchHandledRequestError',
'let listRequestVersion = 0; ' + source.slice(declaration.start, declaration.end) + '; return getList;')(
...Object.values(state), { pageNumber: 1 }, api, () => {}
);
return { ...state, calls, getList };
}
for (const [file, api] of [['orderCenter/MyOrder.vue','getOrderList'], ['orderCenter/AfterSale.vue','afterSaleList'], ['userCenter/MyTracks.vue','tracksList']]) {
test(file + ' 失败结束加载并可重试,旧查询不能覆盖新列表', async () => {
const h = listHarness(file, api);
h.getList();
assert.equal(h.spinShow.value, true);
h.calls[0].reject(new Error('network'));
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.spinShow.value, false);
assert.equal(h.queryFailed.value, true);
h.getList();
h.getList();
h.calls[2].resolve({ success: true, result: { records: [{ id: 'new' }], total: 1 } });
await new Promise(resolve => setImmediate(resolve));
h.calls[1].resolve({ success: true, result: { records: [{ id: 'old' }], total: 20 } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(h.spinShow.value, false);
assert.equal(h.queryFailed.value, false);
assert.equal(h.total.value, 1);
const list = file.includes('MyTracks') ? h.list : h.orderList;
assert.deepEqual(list.value, [{ id: 'new' }]);
});
}

View File

@@ -33,6 +33,12 @@ module.exports = {
}, },
devServer: { devServer: {
port: configs.port, port: configs.port,
client: {
overlay: {
// 保留编译错误和程序异常遮罩,仅跳过请求层已向用户提示的错误。
runtimeErrors: (error) => error?.[Symbol.for("lilishop.buyer.handledRequestError")] !== true,
},
},
}, },
productionSourceMap: false, productionSourceMap: false,
configureWebpack: { configureWebpack: {