fix(member): 修复退出登录后可返回旧页面的问题

1.  退出登录和注销流程兜底分支都改用reLaunch跳转登录页,清空页面栈防止返回旧会员页
2.  新增完整的单元测试覆盖logout和getCancelStatus的异常分支、调用顺序和跳转模式
This commit is contained in:
2026-07-23 15:17:08 +08:00
parent b9cb6bf75b
commit acbac773db
2 changed files with 172 additions and 2 deletions

View File

@@ -17,7 +17,8 @@ export default {
success: (res) => {
if (res.confirm) {
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到会员中心等历史页面
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
}
}
});
@@ -51,7 +52,8 @@ export default {
}
} else if (res.code == -1) {
this.clearLoginState();
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL);
// 使用 reLaunch 清空页面栈,避免通过标题栏/导航栏返回到注销流程的历史页面
this.$util.redirectTo(this.$util.LOGIN_PAGE_URL, {}, 'reLaunch');
}
}
});

View File

@@ -29,6 +29,13 @@
* 场景 K (静态): member-public.js / cancelsuccess.vue 源码里真的用了 'reLaunch'
* 场景 L (回归): clearLoginState 必须早于 redirectTo, 不允许反过来
*
* 退出登录 (logout) 流程:
* 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页
* 场景 N: 退出登录 modal 取消 → 不清登录态, 不跳转
* 场景 O: 退出登录跳登录页必须用 reLaunch 模式 (静态)
* 场景 P: getCancelStatus 兜底 (res.code == -1) → clearLoginState + reLaunch
* 场景 Q (回归): logout 调用顺序 — clearLoginState 必须在 redirectTo 之前
*
* 跑法:
*
* node --test tests/member-cancel-flow.test.js
@@ -525,4 +532,165 @@ test('场景 L (回归): submitCancelApply 的源码顺序 — clearLoginState
// 第一个 clearLoginState 必须早于第一个 redirectTo
assert.ok(idxClear[0] < idxRedirect[0],
'第一个 clearLoginState(' + idxClear[0] + ') 必须早于第一个 redirectTo(' + idxRedirect[0] + ')');
});
// =============================================================
// 退出登录 (logout) — 同样的"页面栈没清"问题修复
//
// 修复前 bug: 用户在会员中心退出登录 → redirectTo 默认是 navigateTo 模式 →
// 跳到登录页 → 按标题栏/导航栏返回 → 回到会员中心看到仍渲染的旧会员信息。
//
// 修复: logout / getCancelStatus 兜底分支都用 reLaunch 模式
// =============================================================
// 场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页
test('场景 M: 退出登录确认 → clearLoginState + reLaunch 到登录页 (清栈防回退)', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.logout();
// ★ 关键修复: 跳登录页必须用 reLaunch (清空页面栈)
assert.equal(calls.redirectTo.length, 1);
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
'退出登录跳转模式必须是 reLaunch (否则按返回键会回到会员中心), 实际: ' + calls.redirectTo[0].mode);
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login',
'退出登录应跳登录页');
// 登录态必须清
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
'退出登录必须清 token');
assert.ok(calls.commit.some(([t]) => t === 'setMemberInfo'),
'退出登录必须清 setMemberInfo');
assert.equal(calls.commit.find(([t]) => t === 'setToken')[1], '',
'setToken 应清成空串');
// emptyCart 必须派发
assert.ok(calls.dispatch.some(([t]) => t === 'emptyCart'),
'退出登录必须派发 emptyCart');
// localStorage 必须清
assert.ok(calls.removeStorage.includes('authInfo'),
'退出登录必须清 authInfo localStorage');
assert.ok(calls.removeStorage.includes('privacyAgreed'),
'退出登录必须清 privacyAgreed localStorage');
// ★ 关键: clearLoginState 必须在 redirectTo 之前调用 (否则页面栈先 push 登录页)
// 找到 clearLoginState 引发的首个 commit (setToken)
const tokenIdx = calls.commit.findIndex(([t]) => t === 'setToken');
assert.ok(tokenIdx >= 0, '应有 setToken commit');
// commit 是同步的, redirectTo 在 commit 之后 (因为 success 回调里先调 clearLoginState 再调 redirectTo)
// 但 redirectTo 的调用顺序晚于所有 commit, 这里检查 calls 的总顺序
// 通过检查 calls.commit 中最后一个 commit 是否为 setMemberInfo 来佐证
assert.equal(calls.commit[calls.commit.length - 1][0], 'setMemberInfo',
'setMemberInfo 应是最后一次 commit (clearLoginState 内最后一步)');
});
// 场景 N: 退出登录 modal 取消 → 不清登录态, 不跳转
test('场景 N: 退出登录 modal 点取消 → 不清登录态, 不跳转', () => {
const { mockThis, calls } = createThisMock({ showModalChoice: 'cancel' });
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
mockThis.logout();
// 不应有任何 commit / dispatch / redirectTo / removeStorage
assert.equal(calls.commit.length, 0, '点取消不应清登录态');
assert.equal(calls.dispatch.length, 0, '点取消不应派发 emptyCart');
assert.equal(calls.redirectTo.length, 0, '点取消不应跳转');
assert.equal(calls.removeStorage.length, 0, '点取消不应清 localStorage');
});
// 场景 O (静态): logout 跳登录页的 redirectTo 必须用 reLaunch
test('场景 O (静态): member-public.js logout 内跳登录页的 redirectTo 必须用 reLaunch 模式', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
'utf8',
);
// 找到 logout 函数体
const m = src.match(/logout\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(m, '应能找到 logout 函数定义');
const body = m[1];
// 提取所有 this.$util.redirectTo( 调用
const redirectCalls = body.match(/this\.\$util\.redirectTo\s*\([^)]+\)/g) || [];
assert.ok(redirectCalls.length >= 1,
'logout 应至少有 1 处 redirectTo (到登录页), 实际 ' + redirectCalls.length);
for (const call of redirectCalls) {
assert.match(call, /['"]reLaunch['"]/,
'logout 内的 redirectTo 必须用 reLaunch 模式 (防回退看到旧会员信息), 实际: ' + call.trim());
}
// 跳的必须是登录页
assert.ok(redirectCalls[0].includes('LOGIN_PAGE_URL'),
'logout 跳的目标必须是 LOGIN_PAGE_URL, 实际: ' + redirectCalls[0].trim());
});
// 场景 P: getCancelStatus 兜底分支 (res.code == -1) → clearLoginState + reLaunch
test('场景 P: getCancelStatus res.code == -1 → clearLoginState + reLaunch 到登录页', () => {
const { mockThis, calls } = createThisMock();
bindMethods(mockThis, loadMemberPublic({ uni: mockThis.uni }));
// 模拟后端返回 -1 (token 失效)
mockThis.$api.sendRequest = (opts) => {
calls.api.push({ url: opts.url });
if (typeof opts.success === 'function') {
opts.success({ code: -1, message: 'token invalid', data: null });
}
};
mockThis.getCancelStatus();
// 必须清登录态
assert.ok(calls.commit.some(([t]) => t === 'setToken'),
'getCancelStatus 兜底分支必须清 token');
// 必须 reLaunch (不能用 navigateTo, 否则页面栈残留)
assert.equal(calls.redirectTo.length, 1);
assert.equal(calls.redirectTo[0].mode, 'reLaunch',
'getCancelStatus 兜底分支跳转必须是 reLaunch, 实际: ' + calls.redirectTo[0].mode);
assert.equal(calls.redirectTo[0].to, '/pages_tool/login/login',
'getCancelStatus 兜底分支应跳登录页');
});
// 场景 Q (回归): logout 调用顺序 — clearLoginState 必须在 redirectTo 之前
test('场景 Q (回归): member-public.js logout 内 clearLoginState 必须早于 redirectTo', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
'utf8',
);
const m = src.match(/logout\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(m, '应能找到 logout 函数定义');
const body = m[1];
const clearIdx = body.indexOf('this.clearLoginState()');
const redirectIdx = body.indexOf('this.$util.redirectTo(');
assert.ok(clearIdx > -1, 'logout 必须调用 clearLoginState()');
assert.ok(redirectIdx > -1, 'logout 必须调用 redirectTo()');
assert.ok(clearIdx < redirectIdx,
'clearLoginState 必须在 redirectTo 之前调用, 实际位置: clear=' + clearIdx + ', redirect=' + redirectIdx);
});
// 场景 R (回归): getCancelStatus 兜底分支 也满足顺序
test('场景 R (回归): member-public.js getCancelStatus res.code == -1 分支: clearLoginState 必须早于 redirectTo', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'pages_tool', 'member', 'public', 'js', 'member-public.js'),
'utf8',
);
// 找到 getCancelStatus 内 res.code == -1 分支
const funcMatch = src.match(/getCancelStatus\s*\(\s*\)\s*\{([\s\S]*?)\n\s{3}\}/);
assert.ok(funcMatch, '应能找到 getCancelStatus 函数定义');
const body = funcMatch[1];
// 找 res.code == -1 那个 else if 分支体
const branchMatch = body.match(/res\.code\s*==\s*-1[\s\S]*?(?=\n\s{3}\}|\n\s{4}\})/);
assert.ok(branchMatch, '应能找到 res.code == -1 分支');
const branchBody = branchMatch[0];
const clearIdx = branchBody.indexOf('this.clearLoginState()');
const redirectIdx = branchBody.indexOf('this.$util.redirectTo(');
assert.ok(clearIdx > -1, 'res.code == -1 分支必须调用 clearLoginState()');
assert.ok(redirectIdx > -1, 'res.code == -1 分支必须调用 redirectTo()');
assert.ok(clearIdx < redirectIdx,
'res.code == -1 分支: clearLoginState 必须在 redirectTo 之前, 实际: clear=' + clearIdx + ', redirect=' + redirectIdx);
});