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 @@
---
## [2026-02-02] - 鉴权重构 - SessionId 动态获取规范
### 文档
- 更新鉴权架构文档,明确 sessionid 动态获取规范
- 每次请求前从 `localStorage.sessionid` 读取,设置到请求头的 `cookie` 字段
- 确保所有请求都带上最新的 sessionid
- 影响文件:docs/specs/2026-02-02-auth-refactoring.md
### 重构
- 分离微信授权(openid)和用户登录两个独立概念
- 移除接口白名单机制,所有接口直接发送
- 简化 401 处理,统一跳转登录页
- 新增 `src/utils/openid.js` - 微信授权管理
- 新增 `src/stores/user.js` - 用户状态管理(Pinia)
- 删除旧的 `src/utils/authRedirect.js` 授权逻辑
- 影响文件:src/app.js, src/utils/request.js, src/pages/login/index.vue, src/pages/mine/index.vue
---
**详细信息**
- **影响文件**: docs/specs/2026-02-02-auth-refactoring.md, src/utils/request.js
- **技术栈**: Taro, Pinia, Axios
- **测试状态**: ⏳ 待测试
- **备注**:
- **重要设定**:sessionid 必须动态获取并设置到请求头
- 实现位置:`src/utils/request.js` 请求拦截器(第157-166行)
- 存储:`localStorage.sessionid`
- 请求头:`config.headers.cookie = sessionid`
---
## [2026-02-02] - 修复 NavHeader 警告
### 修复
......
......@@ -15,7 +15,7 @@
### 核心原则
1. **sessionid 只存储和传递**:前端存储 sessionid 并在请求中发送给后端,但前端不依赖它来判断是否登录
1. **sessionid 动态获取并设置到请求头**:每次请求前动态读取 `localStorage.sessionid`,设置到请求头的 `cookie` 字段
2. **401 统一跳转登录页**:所有接口返回 401 时,跳转到登录页
3. **不需要白名单**:后端自己决定哪些接口需要鉴权,返回 401 即可
4. **启动时检查状态**:通过 `loginStatusAPI` 检查 openid 和登录状态
......@@ -26,10 +26,12 @@
|------|------|----------|
| **微信授权(openid)** | 后端通过 `miniProgramAuthAPI` 授权获取用户的 openid | `loginStatusAPI.data.is_openid` |
| **用户登录** | 用户通过 `loginAPI` 输入账号密码后绑定 openid | `loginStatusAPI.data.is_login` |
| **sessionid** | 存储在 `localStorage.sessionid`,每次请求动态读取并设置到请求头 `cookie` 字段 | `localStorage.sessionid` |
| **401 响应** | 表示**用户未登录**,跳转到登录页 | 后端接口响应 |
**关键理解**
- sessionid 只是传递给后端的凭证,前端不依赖它做判断
-**sessionid 必须动态获取并设置到请求头**:每次请求前从 `localStorage.sessionid` 读取,设置到 `config.headers.cookie`
- 前端不依赖 sessionid 判断是否登录(由后端通过 401 判断)
- 所有请求直接发送,401 统一处理
- 不需要白名单机制
......@@ -490,38 +492,68 @@ export function hasSessionId() {
### 3. 请求拦截器 (`utils/request.js`)
**⭐ sessionid 动态获取并设置到请求头**(重要):
```javascript
// 请求拦截器
instance.interceptors.request.use((config) => {
// 后端通过 cookie 自动处理 sessionid
// 前端不需要添加任何 sessionid 相关的逻辑
service.interceptors.request.use(config => {
// 合并默认参数...
/**
* ⭐ 动态获取 sessionid 并设置到请求头
* - 确保每个请求都带上最新的 sessionid
* - 从 localStorage.sessionid 读取
* - 设置到 config.headers.cookie 字段
*/
const sessionid = getSessionId() // 从 localStorage 读取
if (sessionid) {
config.headers = config.headers || {}
config.headers.cookie = sessionid // 设置到 cookie 字段
}
// 增加时间戳
if (config.method === 'get') {
config.params = { ...config.params, timestamp: (new Date()).valueOf() }
}
return config
})
```
**响应拦截器**
```javascript
// 响应拦截器
instance.interceptors.response.use(
(response) => {
// 正常响应
return response
},
(error) => {
// 错误响应
if (error.response?.status === 401) {
// 401 表示用户未登录
service.interceptors.response.use(
async response => {
const res = response.data
// 401 未授权处理
if (res.code === 401) {
// 跳转到登录页
Taro.navigateTo({
url: '/pages/login/index'
}).catch(() => {
// 如果跳转失败(如已经在登录页),则忽略
console.warn('跳转登录页失败,可能已在登录页')
})
}
return response
},
async error => {
// 处理弱网/断网
if (await should_handle_bad_network(error)) {
handle_request_timeout()
}
return Promise.reject(error)
}
)
```
**关键点**
- **⭐ sessionid 动态获取**:每次请求前从 `localStorage.sessionid` 读取
- **⭐ 设置到 cookie 字段**`config.headers.cookie = sessionid`
- **不需要白名单**:所有接口直接发送
- **不处理 sessionid**:后端通过 cookie 自动处理
- **401 统一处理**:跳转登录页
### 4. 用户状态管理 (`stores/user.js`)
......@@ -731,12 +763,35 @@ function App(props) {
## ⚠️ 注意事项
### 1. sessionid 的作用
### 1. sessionid 的处理(⭐ 重要)
- **动态获取**:每次请求前从 `localStorage.sessionid` 读取,确保使用最新的 sessionid
- **设置到请求头**:将 sessionid 设置到 `config.headers.cookie` 字段
- **存储位置**`localStorage.sessionid`(注意:不是 `localStorage.user_info`
- **设置时机**
- `miniProgramAuthAPI` 授权成功后(如果后端返回 sessionid)
- `loginAPI` 登录成功后(如果后端返回 sessionid)
- **清除时机**:401 响应时清除、用户登出时清除
**代码示例**(已在 `src/utils/request.js` 中实现):
```javascript
// 请求拦截器
service.interceptors.request.use(config => {
// 动态获取 sessionid 并设置到请求头
const sessionid = getSessionId() // 从 localStorage 读取
if (sessionid) {
config.headers = config.headers || {}
config.headers.cookie = sessionid // 设置到 cookie 字段
}
return config
})
```
### 2. 不需要白名单
- **miniProgramAuthAPI 后端自动处理 sessionid**(如通过 cookie),前端不需要保存
- **loginAPI 不返回 sessionid**,后端通过 cookie 或其他方式自动处理
- 前端**不依赖** sessionid 判断用户是否登录
- 是否登录由后端通过 401 判断
- 所有接口直接发送
- 后端自己决定哪些接口需要鉴权
- 返回 401 的接口统一跳转登录页
### 2. 不需要白名单
......
......@@ -71,6 +71,7 @@
import { ref } from 'vue'
import { useGo } from '@/hooks/useGo'
import { mainStore } from '@/stores/main'
import { useUserStore } from '@/stores/user'
import IconFont from '@/components/IconFont.vue'
import TabBar from '@/components/TabBar.vue'
import NavHeader from '@/components/NavHeader.vue'
......@@ -81,6 +82,7 @@ import defaultAvatar from '@/assets/images/icon/avatar.svg'
const go = useGo()
const store = mainStore()
const userStore = useUserStore()
/**
* @description 用户信息(响应式)
......@@ -135,20 +137,33 @@ const handleMenuClick = (item) => {
}
}
// 退出登录
const handleLogout = () => {
/**
* 退出登录
* @description 调用 logoutAPI 解绑 openid,清除本地状态
*/
const handleLogout = async () => {
Taro.showModal({
title: '提示',
content: '确定要退出登录吗?',
confirmText: '确定',
cancelText: '取消',
confirmColor: '#EF4444',
success: (res) => {
success: async (res) => {
if (res.confirm) {
// 清除用户信息
// 显示加载提示
Taro.showLoading({
title: '退出中...',
mask: true
})
try {
Taro.removeStorageSync('user_info')
Taro.removeStorageSync('sessionid')
// 调用 userStore 的 logout 方法(会调用 logoutAPI)
await userStore.logout()
// 清除 mainStore 中的用户信息
store.changeUserInfo(null)
Taro.hideLoading()
// 跳转到首页
Taro.reLaunch({
......@@ -160,9 +175,10 @@ const handleLogout = () => {
icon: 'success'
})
} catch (error) {
Taro.hideLoading()
console.error('退出登录失败:', error)
Taro.showToast({
title: '退出失败,请重试',
title: error.message || '退出失败,请重试',
icon: 'none'
})
}
......
/*
* @Date: 2022-09-19 14:11:06
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-02-02 18:00:00
* @LastEditTime: 2026-02-02 18:32:59
* @FilePath: /manulife-weapp/src/utils/request.js
* @Description: HTTP 请求封装(简化版)
*/
......@@ -11,6 +11,51 @@ import { parseQueryString } from './tools'
import BASE_URL, { REQUEST_DEFAULT_PARAMS } from './config'
/**
* @description 获取 sessionid 的工具函数
* - sessionid 由 authRedirect.refreshSession 写入
* - 每次请求前动态读取,避免旧会话导致的 401
* @returns {string|null} sessionid或null
*/
export const getSessionId = () => {
try {
return Taro.getStorageSync('sessionid') || null
} catch (error) {
console.error('获取sessionid失败:', error)
return null
}
}
/**
* @description 设置 sessionid(一般不需要手动调用)
* - 正常情况下由 authRedirect.refreshSession 写入
* - 保留该方法用于极端场景的手动修复/兼容旧逻辑
* @param {string} sessionid cookie 字符串
* @returns {void} 无返回值
*/
export const setSessionId = sessionid => {
try {
if (!sessionid) {
return
}
Taro.setStorageSync('sessionid', sessionid)
} catch (error) {
console.error('设置sessionid失败:', error)
}
}
/**
* @description 清空 sessionid(一般不需要手动调用)
* @returns {void} 无返回值
*/
export const clearSessionId = () => {
try {
Taro.removeStorageSync('sessionid')
} catch (error) {
console.error('清空sessionid失败:', error)
}
}
/**
* @description axios 实例
* - 统一 baseURL / timeout
* - 通过拦截器处理:默认参数、401 跳转登录页、弱网降级
......@@ -109,6 +154,17 @@ service.interceptors.request.use(
...(config.params || {})
}
/**
* 动态获取 sessionid 并设置到请求头
* - 确保每个请求都带上最新的 sessionid
* - 注意:axios-miniprogram 的 headers 可能不存在,需要先兜底
*/
const sessionid = getSessionId()
if (sessionid) {
config.headers = config.headers || {}
config.headers.cookie = sessionid
}
// 增加时间戳
if (config.method === 'get') {
config.params = { ...config.params, timestamp: (new Date()).valueOf() }
......