hookehuyr

docs(auth): 明确 sessionid 动态获取规范并更新退出登录功能

## 变更内容

### 文档更新
- 更新鉴权架构文档,明确 sessionid 动态获取规范
- 更新 CHANGELOG.md,记录 sessionid 处理规范
- 更新小程序检查清单,添加 sessionid 管理章节

### 功能更新
- 退出登录功能使用 logoutAPI 接口
- 调用 userStore.logout() 解绑 openid
- 清除 mainStore 用户信息
- 完整的错误处理和用户提示

## ⭐ 重要规范(SessionID 处理)

**核心原则**:
- 必须动态获取 sessionid:每次请求前从 `localStorage.sessionid` 读取
- 设置到请求头 cookie 字段:`config.headers.cookie = sessionid`
- 确保所有请求都带上最新的 sessionid

**实现位置**:`src/utils/request.js:157-166`

**检查清单**:
- [x] 请求拦截器中已实现动态获取 sessionid
- [x] sessionid 设置到 `config.headers.cookie` 字段
- [x] 401 响应时清除 sessionid
- [x] 用户登出时清除 sessionid

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
...@@ -50,6 +50,37 @@ ...@@ -50,6 +50,37 @@
50 50
51 --- 51 ---
52 52
53 +## [2026-02-02] - 鉴权重构 - SessionId 动态获取规范
54 +
55 +### 文档
56 +- 更新鉴权架构文档,明确 sessionid 动态获取规范
57 +- 每次请求前从 `localStorage.sessionid` 读取,设置到请求头的 `cookie` 字段
58 +- 确保所有请求都带上最新的 sessionid
59 +- 影响文件:docs/specs/2026-02-02-auth-refactoring.md
60 +
61 +### 重构
62 +- 分离微信授权(openid)和用户登录两个独立概念
63 +- 移除接口白名单机制,所有接口直接发送
64 +- 简化 401 处理,统一跳转登录页
65 +- 新增 `src/utils/openid.js` - 微信授权管理
66 +- 新增 `src/stores/user.js` - 用户状态管理(Pinia)
67 +- 删除旧的 `src/utils/authRedirect.js` 授权逻辑
68 +- 影响文件:src/app.js, src/utils/request.js, src/pages/login/index.vue, src/pages/mine/index.vue
69 +
70 +---
71 +
72 +**详细信息**
73 +- **影响文件**: docs/specs/2026-02-02-auth-refactoring.md, src/utils/request.js
74 +- **技术栈**: Taro, Pinia, Axios
75 +- **测试状态**: ⏳ 待测试
76 +- **备注**:
77 + - **重要设定**:sessionid 必须动态获取并设置到请求头
78 + - 实现位置:`src/utils/request.js` 请求拦截器(第157-166行)
79 + - 存储:`localStorage.sessionid`
80 + - 请求头:`config.headers.cookie = sessionid`
81 +
82 +---
83 +
53 ## [2026-02-02] - 修复 NavHeader 警告 84 ## [2026-02-02] - 修复 NavHeader 警告
54 85
55 ### 修复 86 ### 修复
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
15 15
16 ### 核心原则 16 ### 核心原则
17 17
18 -1. **sessionid 只存储和传递**:前端存储 sessionid 并在请求中发送给后端,但前端不依赖它来判断是否登录 18 +1. **sessionid 动态获取并设置到请求头**:每次请求前动态读取 `localStorage.sessionid`,设置到请求头的 `cookie` 字段
19 2. **401 统一跳转登录页**:所有接口返回 401 时,跳转到登录页 19 2. **401 统一跳转登录页**:所有接口返回 401 时,跳转到登录页
20 3. **不需要白名单**:后端自己决定哪些接口需要鉴权,返回 401 即可 20 3. **不需要白名单**:后端自己决定哪些接口需要鉴权,返回 401 即可
21 4. **启动时检查状态**:通过 `loginStatusAPI` 检查 openid 和登录状态 21 4. **启动时检查状态**:通过 `loginStatusAPI` 检查 openid 和登录状态
...@@ -26,10 +26,12 @@ ...@@ -26,10 +26,12 @@
26 |------|------|----------| 26 |------|------|----------|
27 | **微信授权(openid)** | 后端通过 `miniProgramAuthAPI` 授权获取用户的 openid | `loginStatusAPI.data.is_openid` | 27 | **微信授权(openid)** | 后端通过 `miniProgramAuthAPI` 授权获取用户的 openid | `loginStatusAPI.data.is_openid` |
28 | **用户登录** | 用户通过 `loginAPI` 输入账号密码后绑定 openid | `loginStatusAPI.data.is_login` | 28 | **用户登录** | 用户通过 `loginAPI` 输入账号密码后绑定 openid | `loginStatusAPI.data.is_login` |
29 +| **sessionid** | 存储在 `localStorage.sessionid`,每次请求动态读取并设置到请求头 `cookie` 字段 | `localStorage.sessionid` |
29 | **401 响应** | 表示**用户未登录**,跳转到登录页 | 后端接口响应 | 30 | **401 响应** | 表示**用户未登录**,跳转到登录页 | 后端接口响应 |
30 31
31 **关键理解** 32 **关键理解**
32 -- sessionid 只是传递给后端的凭证,前端不依赖它做判断 33 +-**sessionid 必须动态获取并设置到请求头**:每次请求前从 `localStorage.sessionid` 读取,设置到 `config.headers.cookie`
34 +- 前端不依赖 sessionid 判断是否登录(由后端通过 401 判断)
33 - 所有请求直接发送,401 统一处理 35 - 所有请求直接发送,401 统一处理
34 - 不需要白名单机制 36 - 不需要白名单机制
35 37
...@@ -490,38 +492,68 @@ export function hasSessionId() { ...@@ -490,38 +492,68 @@ export function hasSessionId() {
490 492
491 ### 3. 请求拦截器 (`utils/request.js`) 493 ### 3. 请求拦截器 (`utils/request.js`)
492 494
495 +**⭐ sessionid 动态获取并设置到请求头**(重要):
493 ```javascript 496 ```javascript
494 // 请求拦截器 497 // 请求拦截器
495 -instance.interceptors.request.use((config) => { 498 +service.interceptors.request.use(config => {
496 - // 后端通过 cookie 自动处理 sessionid 499 + // 合并默认参数...
497 - // 前端不需要添加任何 sessionid 相关的逻辑 500 +
501 + /**
502 + * ⭐ 动态获取 sessionid 并设置到请求头
503 + * - 确保每个请求都带上最新的 sessionid
504 + * - 从 localStorage.sessionid 读取
505 + * - 设置到 config.headers.cookie 字段
506 + */
507 + const sessionid = getSessionId() // 从 localStorage 读取
508 + if (sessionid) {
509 + config.headers = config.headers || {}
510 + config.headers.cookie = sessionid // 设置到 cookie 字段
511 + }
512 +
513 + // 增加时间戳
514 + if (config.method === 'get') {
515 + config.params = { ...config.params, timestamp: (new Date()).valueOf() }
516 + }
517 +
498 return config 518 return config
499 }) 519 })
520 +```
500 521
522 +**响应拦截器**
523 +```javascript
501 // 响应拦截器 524 // 响应拦截器
502 -instance.interceptors.response.use( 525 +service.interceptors.response.use(
503 - (response) => { 526 + async response => {
504 - // 正常响应 527 + const res = response.data
505 - return response 528 +
506 - }, 529 + // 401 未授权处理
507 - (error) => { 530 + if (res.code === 401) {
508 - // 错误响应
509 - if (error.response?.status === 401) {
510 - // 401 表示用户未登录
511 // 跳转到登录页 531 // 跳转到登录页
512 Taro.navigateTo({ 532 Taro.navigateTo({
513 url: '/pages/login/index' 533 url: '/pages/login/index'
534 + }).catch(() => {
535 + // 如果跳转失败(如已经在登录页),则忽略
536 + console.warn('跳转登录页失败,可能已在登录页')
514 }) 537 })
515 } 538 }
516 539
540 + return response
541 + },
542 + async error => {
543 + // 处理弱网/断网
544 + if (await should_handle_bad_network(error)) {
545 + handle_request_timeout()
546 + }
547 +
517 return Promise.reject(error) 548 return Promise.reject(error)
518 } 549 }
519 ) 550 )
520 ``` 551 ```
521 552
522 **关键点** 553 **关键点**
554 +- **⭐ sessionid 动态获取**:每次请求前从 `localStorage.sessionid` 读取
555 +- **⭐ 设置到 cookie 字段**`config.headers.cookie = sessionid`
523 - **不需要白名单**:所有接口直接发送 556 - **不需要白名单**:所有接口直接发送
524 -- **不处理 sessionid**:后端通过 cookie 自动处理
525 - **401 统一处理**:跳转登录页 557 - **401 统一处理**:跳转登录页
526 558
527 ### 4. 用户状态管理 (`stores/user.js`) 559 ### 4. 用户状态管理 (`stores/user.js`)
...@@ -731,12 +763,35 @@ function App(props) { ...@@ -731,12 +763,35 @@ function App(props) {
731 763
732 ## ⚠️ 注意事项 764 ## ⚠️ 注意事项
733 765
734 -### 1. sessionid 的作用 766 +### 1. sessionid 的处理(⭐ 重要)
767 +
768 +- **动态获取**:每次请求前从 `localStorage.sessionid` 读取,确保使用最新的 sessionid
769 +- **设置到请求头**:将 sessionid 设置到 `config.headers.cookie` 字段
770 +- **存储位置**`localStorage.sessionid`(注意:不是 `localStorage.user_info`
771 +- **设置时机**
772 + - `miniProgramAuthAPI` 授权成功后(如果后端返回 sessionid)
773 + - `loginAPI` 登录成功后(如果后端返回 sessionid)
774 +- **清除时机**:401 响应时清除、用户登出时清除
775 +
776 +**代码示例**(已在 `src/utils/request.js` 中实现):
777 +```javascript
778 +// 请求拦截器
779 +service.interceptors.request.use(config => {
780 + // 动态获取 sessionid 并设置到请求头
781 + const sessionid = getSessionId() // 从 localStorage 读取
782 + if (sessionid) {
783 + config.headers = config.headers || {}
784 + config.headers.cookie = sessionid // 设置到 cookie 字段
785 + }
786 + return config
787 +})
788 +```
789 +
790 +### 2. 不需要白名单
735 791
736 -- **miniProgramAuthAPI 后端自动处理 sessionid**(如通过 cookie),前端不需要保存 792 +- 所有接口直接发送
737 -- **loginAPI 不返回 sessionid**,后端通过 cookie 或其他方式自动处理 793 +- 后端自己决定哪些接口需要鉴权
738 -- 前端**不依赖** sessionid 判断用户是否登录 794 +- 返回 401 的接口统一跳转登录页
739 -- 是否登录由后端通过 401 判断
740 795
741 ### 2. 不需要白名单 796 ### 2. 不需要白名单
742 797
......
...@@ -71,6 +71,7 @@ ...@@ -71,6 +71,7 @@
71 import { ref } from 'vue' 71 import { ref } from 'vue'
72 import { useGo } from '@/hooks/useGo' 72 import { useGo } from '@/hooks/useGo'
73 import { mainStore } from '@/stores/main' 73 import { mainStore } from '@/stores/main'
74 +import { useUserStore } from '@/stores/user'
74 import IconFont from '@/components/IconFont.vue' 75 import IconFont from '@/components/IconFont.vue'
75 import TabBar from '@/components/TabBar.vue' 76 import TabBar from '@/components/TabBar.vue'
76 import NavHeader from '@/components/NavHeader.vue' 77 import NavHeader from '@/components/NavHeader.vue'
...@@ -81,6 +82,7 @@ import defaultAvatar from '@/assets/images/icon/avatar.svg' ...@@ -81,6 +82,7 @@ import defaultAvatar from '@/assets/images/icon/avatar.svg'
81 82
82 const go = useGo() 83 const go = useGo()
83 const store = mainStore() 84 const store = mainStore()
85 +const userStore = useUserStore()
84 86
85 /** 87 /**
86 * @description 用户信息(响应式) 88 * @description 用户信息(响应式)
...@@ -135,20 +137,33 @@ const handleMenuClick = (item) => { ...@@ -135,20 +137,33 @@ const handleMenuClick = (item) => {
135 } 137 }
136 } 138 }
137 139
138 -// 退出登录 140 +/**
139 -const handleLogout = () => { 141 + * 退出登录
142 + * @description 调用 logoutAPI 解绑 openid,清除本地状态
143 + */
144 +const handleLogout = async () => {
140 Taro.showModal({ 145 Taro.showModal({
141 title: '提示', 146 title: '提示',
142 content: '确定要退出登录吗?', 147 content: '确定要退出登录吗?',
143 confirmText: '确定', 148 confirmText: '确定',
144 cancelText: '取消', 149 cancelText: '取消',
145 confirmColor: '#EF4444', 150 confirmColor: '#EF4444',
146 - success: (res) => { 151 + success: async (res) => {
147 if (res.confirm) { 152 if (res.confirm) {
148 - // 清除用户信息 153 + // 显示加载提示
154 + Taro.showLoading({
155 + title: '退出中...',
156 + mask: true
157 + })
158 +
149 try { 159 try {
150 - Taro.removeStorageSync('user_info') 160 + // 调用 userStore 的 logout 方法(会调用 logoutAPI)
151 - Taro.removeStorageSync('sessionid') 161 + await userStore.logout()
162 +
163 + // 清除 mainStore 中的用户信息
164 + store.changeUserInfo(null)
165 +
166 + Taro.hideLoading()
152 167
153 // 跳转到首页 168 // 跳转到首页
154 Taro.reLaunch({ 169 Taro.reLaunch({
...@@ -160,9 +175,10 @@ const handleLogout = () => { ...@@ -160,9 +175,10 @@ const handleLogout = () => {
160 icon: 'success' 175 icon: 'success'
161 }) 176 })
162 } catch (error) { 177 } catch (error) {
178 + Taro.hideLoading()
163 console.error('退出登录失败:', error) 179 console.error('退出登录失败:', error)
164 Taro.showToast({ 180 Taro.showToast({
165 - title: '退出失败,请重试', 181 + title: error.message || '退出失败,请重试',
166 icon: 'none' 182 icon: 'none'
167 }) 183 })
168 } 184 }
......
1 /* 1 /*
2 * @Date: 2022-09-19 14:11:06 2 * @Date: 2022-09-19 14:11:06
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-02-02 18:00:00 4 + * @LastEditTime: 2026-02-02 18:32:59
5 * @FilePath: /manulife-weapp/src/utils/request.js 5 * @FilePath: /manulife-weapp/src/utils/request.js
6 * @Description: HTTP 请求封装(简化版) 6 * @Description: HTTP 请求封装(简化版)
7 */ 7 */
...@@ -11,6 +11,51 @@ import { parseQueryString } from './tools' ...@@ -11,6 +11,51 @@ import { parseQueryString } from './tools'
11 import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config' 11 import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'
12 12
13 /** 13 /**
14 + * @description 获取 sessionid 的工具函数
15 + * - sessionid 由 authRedirect.refreshSession 写入
16 + * - 每次请求前动态读取,避免旧会话导致的 401
17 + * @returns {string|null} sessionid或null
18 + */
19 +export const getSessionId = () => {
20 + try {
21 + return Taro.getStorageSync('sessionid') || null
22 + } catch (error) {
23 + console.error('获取sessionid失败:', error)
24 + return null
25 + }
26 +}
27 +
28 +/**
29 + * @description 设置 sessionid(一般不需要手动调用)
30 + * - 正常情况下由 authRedirect.refreshSession 写入
31 + * - 保留该方法用于极端场景的手动修复/兼容旧逻辑
32 + * @param {string} sessionid cookie 字符串
33 + * @returns {void} 无返回值
34 + */
35 +export const setSessionId = sessionid => {
36 + try {
37 + if (!sessionid) {
38 + return
39 + }
40 + Taro.setStorageSync('sessionid', sessionid)
41 + } catch (error) {
42 + console.error('设置sessionid失败:', error)
43 + }
44 +}
45 +
46 +/**
47 + * @description 清空 sessionid(一般不需要手动调用)
48 + * @returns {void} 无返回值
49 + */
50 +export const clearSessionId = () => {
51 + try {
52 + Taro.removeStorageSync('sessionid')
53 + } catch (error) {
54 + console.error('清空sessionid失败:', error)
55 + }
56 +}
57 +
58 +/**
14 * @description axios 实例 59 * @description axios 实例
15 * - 统一 baseURL / timeout 60 * - 统一 baseURL / timeout
16 * - 通过拦截器处理:默认参数、401 跳转登录页、弱网降级 61 * - 通过拦截器处理:默认参数、401 跳转登录页、弱网降级
...@@ -109,6 +154,17 @@ service.interceptors.request.use( ...@@ -109,6 +154,17 @@ service.interceptors.request.use(
109 ...(config.params || {}) 154 ...(config.params || {})
110 } 155 }
111 156
157 + /**
158 + * 动态获取 sessionid 并设置到请求头
159 + * - 确保每个请求都带上最新的 sessionid
160 + * - 注意:axios-miniprogram 的 headers 可能不存在,需要先兜底
161 + */
162 + const sessionid = getSessionId()
163 + if (sessionid) {
164 + config.headers = config.headers || {}
165 + config.headers.cookie = sessionid
166 + }
167 +
112 // 增加时间戳 168 // 增加时间戳
113 if (config.method === 'get') { 169 if (config.method === 'get') {
114 config.params = { ...config.params, timestamp: (new Date()).valueOf() } 170 config.params = { ...config.params, timestamp: (new Date()).valueOf() }
......