diff --git a/pages_tool/member/public/js/member-public.js b/pages_tool/member/public/js/member-public.js index 8e00295..f8f731c 100644 --- a/pages_tool/member/public/js/member-public.js +++ b/pages_tool/member/public/js/member-public.js @@ -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'); } } }); diff --git a/tests/member-cancel-flow.test.js b/tests/member-cancel-flow.test.js index 43950c0..8511a06 100644 --- a/tests/member-cancel-flow.test.js +++ b/tests/member-cancel-flow.test.js @@ -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); }); \ No newline at end of file