hookehuyr

fix(auth): 修复授权接口重复 cookie 问题并自动去重

- 修改 openid.js 使用 axios 直接调用授权接口以访问响应头
- 添加 extractCookieFromResponse() 从响应中提取 cookie
- 添加 normalizeCookies() 处理重复的 set-cookie
- 修改 request.js 的 setSessionId() 自动清理重复的 cookie
- 添加 cleanupDuplicateCookies() 函数处理重复 cookie
- 新增 SESSIONID_MANAGEMENT.md 文档记录 sessionid 管理最佳实践

问题:后端返回重复的 Set-Cookie 导致请求头携带重复的 sessionid
解决:前端提取 cookie 时自动去重,只保留第一个

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
# Sessionid 管理最佳实践
> **适用于**:微信小程序、H5 等需要后端 session 认证的前端项目
## 📋 核心原则
### ⚠️ 前端必须主动管理 sessionid
**错误认知**:后端会自动设置 cookie,前端无需处理
**正确做法****前端必须从响应中提取 sessionid 并写入本地存储**
**原因**
- 小程序没有浏览器的自动 cookie 管理机制
- 每次请求需要**手动**从本地存储读取 sessionid 并添加到请求头
---
## 🔴 常见问题
### 问题 1:后端返回重复的 Set-Cookie
**现象**
```http
Set-Cookie: PHPSESSID=xxx; expires=...; path=/
Set-Cookie: PHPSESSID=xxx; expires=...; path=/
```
**原因**
- PHP/Nginx 的正常行为
- 可能是多层代理导致的重复设置
**后果**
如果前端直接用 `,` 连接所有 cookie,会导致:
```http
cookie: PHPSESSID=xxx; ...,PHPSESSID=xxx; ...
```
每次请求都携带重复的 sessionid,增加请求头大小。
---
## ✅ 解决方案
### 步骤 1:从响应中提取 cookie
**关键点**
-**必须直接使用 axios**(不要用 `fn()` 包装)
-`fn()` 只返回 `{ code, data, msg }`,无法访问响应头
- ✅ axios 返回完整响应对象,包含 `headers``cookies`
**示例代码**`src/utils/openid.js`):
```javascript
import axios from '@/utils/request'
import { setSessionId } from '@/utils/request'
export async function miniProgramAuth() {
try {
// 1. 调用 wx.login 获取 code
const { code } = await Taro.login()
// 2. 直接使用 axios 调用授权接口
const response = await axios.post('/srv/?a=openid', { code })
// 3. 从响应中提取 cookie
const cookie = extractCookieFromResponse(response)
// 4. 写入本地存储
if (cookie) {
setSessionId(cookie)
}
return response.data.data?.user || null
} catch (err) {
console.error('授权失败:', err)
throw err
}
}
```
### 步骤 2:处理 cookie 去重
**场景**:后端返回重复的 `set-cookie` 数组
**解决方案**:使用 `Set` 去重,只保留第一个
```javascript
function normalizeCookies(cookies) {
if (!cookies) return null
// 如果是单个 cookie 字符串,直接返回
if (typeof cookies === 'string') {
return cookies
}
// 如果是数组,使用 Set 去重
if (Array.isArray(cookies)) {
const uniqueCookies = [...new Set(cookies)]
// 只返回第一个唯一的 cookie
return uniqueCookies[0] || null
}
return null
}
```
### 步骤 3:兼容多种响应格式
**不同环境的 cookie 位置**
```javascript
function extractCookieFromResponse(response) {
// 尝试从 response.headers 中提取
if (response?.headers) {
// 标准的 set-cookie 头
if (response.headers['set-cookie']) {
return normalizeCookies(response.headers['set-cookie'])
}
// 小写版本(某些环境)
if (response.headers['Set-Cookie']) {
return normalizeCookies(response.headers['Set-Cookie'])
}
// 小程序环境中可能在 cookies 字段
if (response.headers.cookies) {
return normalizeCookies(response.headers.cookies)
}
}
// 尝试从 response.cookies 中提取(axios-miniprogram)
if (response?.cookies) {
const cookies = response.cookies
// 如果是对象数组格式 [{name, value}, ...]
if (Array.isArray(cookies) && cookies.length > 0 && cookies[0].name) {
return cookies.map(c => `${c.name}=${c.value}`).join('; ')
}
// 如果是字符串数组
return normalizeCookies(cookies)
}
return null
}
```
### 步骤 4:自动清理已存在的重复 cookie
**问题**:如果 `localStorage.sessionid` 已经存储了重复的旧值怎么办?
**解决方案**:在 `setSessionId()` 时自动清理
```javascript
// src/utils/request.js
export const setSessionId = sessionid => {
try {
if (!sessionid) {
return
}
// 自动清理重复的 cookie
const cleaned = cleanupDuplicateCookies(sessionid)
Taro.setStorageSync('sessionid', cleaned)
} catch (error) {
console.error('设置sessionid失败:', error)
}
}
function cleanupDuplicateCookies(cookies) {
if (!cookies || typeof cookies !== 'string') {
return cookies
}
// 按逗号分割(重复的 cookie 用逗号连接)
const parts = cookies.split(',')
// 如果只有一个部分,直接返回
if (parts.length === 1) {
return cookies
}
// 提取 cookie 名称(用于去重)
const extractCookieName = (cookieStr) => {
const match = cookieStr.match(/^([^=]+)=/)
return match ? match[1].trim() : null
}
// 去重:只保留第一次出现的 cookie
const seen = new Set()
const uniqueParts = []
for (const part of parts) {
const name = extractCookieName(part)
if (name && !seen.has(name)) {
seen.add(name)
uniqueParts.push(part)
}
}
// 如果去重后只剩一个,直接返回
if (uniqueParts.length === 1) {
return uniqueParts[0]
}
// 否则用逗号连接
return uniqueParts.join(',')
}
```
---
## 🔄 完整流程
### 授权流程
```
┌─────────────────────────────────────────────────────────┐
│ 1. 前端调用 Taro.login() 获取微信 code │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 2. 调用后端授权接口 │
│ POST /srv/?a=openid │
│ Body: { code: "xxx" } │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 3. 后端返回响应 │
│ Data: { code: 1, data: { user: {...} } } │
│ Set-Cookie: PHPSESSID=xxx; ... (重复) │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 4. 前端提取 cookie 并去重 │
│ extractCookieFromResponse() │
│ normalizeCookies() │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 5. 写入本地存储 │
│ setSessionId(cookie) │
│ localStorage.sessionid = "PHPSESSID=xxx; ..." │
└────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 6. 后续请求自动携带 sessionid │
│ request.js 拦截器自动读取并添加到请求头 │
└─────────────────────────────────────────────────────────┘
```
### 请求流程(自动携带 sessionid)
```javascript
// src/utils/request.js 拦截器
service.interceptors.request.use(config => {
// 动态获取 sessionid 并设置到请求头
const sessionid = getSessionId()
if (sessionid) {
config.headers = config.headers || {}
config.headers.cookie = sessionid
}
return config
})
```
---
## 🎯 最佳实践
### 1. 统一使用 axios 直接调用
**❌ 错误**:通过 `fn()` 包装的 API
```javascript
const res = await miniProgramAuthAPI({ code }) // ❌ 无法访问响应头
```
**✅ 正确**:直接使用 axios
```javascript
const response = await axios.post('/srv/?a=openid', { code }) // ✅ 可访问 headers
```
### 2. 封装 cookie 提取逻辑
**位置**`src/utils/openid.js`
**函数**
- `extractCookieFromResponse()` - 从响应中提取 cookie
- `normalizeCookies()` - 标准化并去重
### 3. 自动清理重复 cookie
**位置**`src/utils/request.js`
**函数**
- `setSessionId()` - 自动调用 `cleanupDuplicateCookies()`
- `cleanupDuplicateCookies()` - 清理重复的 cookie
### 4. 统一的 sessionid 管理
**工具函数**`src/utils/request.js`):
```javascript
// 获取 sessionid
export const getSessionId = () => {
return Taro.getStorageSync('sessionid') || null
}
// 设置 sessionid(自动清理重复)
export const setSessionId = (sessionid) => {
const cleaned = cleanupDuplicateCookies(sessionid)
Taro.setStorageSync('sessionid', cleaned)
}
// 清空 sessionid
export const clearSessionId = () => {
Taro.removeStorageSync('sessionid')
}
```
---
## 🧪 测试验证
### 验证点 1:检查响应中的 cookie
```javascript
// 在授权接口调用后添加日志
const response = await axios.post('/srv/?a=openid', { code })
console.log('响应头:', response.headers)
console.log('Cookie:', response.cookies)
```
**预期**
- 小程序:`response.cookies``response.headers['set-cookie']`
- H5:`response.headers['set-cookie']`
### 验证点 2:检查本地存储
```javascript
// 控制台执行
console.log(Taro.getStorageSync('sessionid'))
```
**预期**
- ✅ 单个 cookie:`PHPSESSID=xxx; expires=...; path=/`
- ❌ 重复 cookie:`PHPSESSID=xxx; ...,PHPSESSID=xxx; ...`
### 验证点 3:检查请求头
打开微信开发者工具 → Network 面板:
- 查看任意请求的 `Request Headers`
- 确认 `cookie` 字段只有单个 sessionid
---
## 🔧 故障排查
### 问题:请求头中的 cookie 还是重复的
**原因**`localStorage.sessionid` 中存储的是旧的重复值
**解决方案**
```javascript
// 方法 1:手动清理
Taro.removeStorageSync('sessionid')
// 然后重新登录
// 方法 2:在控制台执行自动清理脚本
const oldSessionid = Taro.getStorageSync('sessionid')
const cleaned = cleanupDuplicateCookies(oldSessionid)
Taro.setStorageSync('sessionid', cleaned)
```
### 问题:授权成功后接口还是返回 401
**排查步骤**
1. 检查 `localStorage.sessionid` 是否有值
2. 检查请求头中是否携带了 `cookie` 字段
3. 检查 cookie 值是否正确(没有重复)
4. 检查后端是否验证了这个 sessionid
---
## 📚 相关文档
- **授权流程**[auth-debug-guide.md](../auth-debug-guide.md)
- **请求封装**`src/utils/request.js`
- **授权实现**`src/utils/openid.js`
---
## 📝 总结
| 场景 | 解决方案 |
|------|----------|
| 提取 cookie | 直接使用 axios,不用 `fn()` 包装 |
| 重复的 set-cookie | 使用 `Set` 去重,只保留第一个 |
| 兼容多种格式 | 尝试多个可能的 cookie 位置 |
| 自动清理重复 | 在 `setSessionId()` 时自动清理 |
| 携带 sessionid | 请求拦截器自动读取并添加 |
**核心原则**
1. ✅ 前端必须主动管理 sessionid
2. ✅ 使用 axios 直接调用授权接口
3. ✅ 提取 cookie 时自动去重
4. ✅ 写入时自动清理重复项
5. ✅ 请求时自动携带到请求头
......@@ -6,12 +6,14 @@
*/
import Taro from '@tarojs/taro'
import { miniProgramAuthAPI } from '@/api/wechat'
import axios from '@/utils/request'
import { setSessionId } from '@/utils/request'
import { loginStatusAPI } from '@/api/user'
/**
* 小程序授权
* @description 调用 wx.login 获取 code,由后端授权获取 openid
* @description 授权成功后会自动将 sessionid 写入本地存储
* @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录)
*
* @example
......@@ -31,14 +33,23 @@ export async function miniProgramAuth() {
throw new Error('获取微信 code 失败')
}
// 2. 调用后端授权接口
const res = await miniProgramAuthAPI({ code })
// 2. 调用后端授权接口(直接使用 axios 以访问响应头)
const response = await axios.post('/srv/?a=openid', { code })
if (res.code === 1) {
return res.data.user || null
} else {
throw new Error(res.msg || '小程序授权失败')
// 检查响应状态
if (!response?.data || response.data.code !== 1) {
throw new Error(response?.data?.msg || '小程序授权失败')
}
// 3. 从响应中提取 cookie 并写入本地存储
const cookie = extractCookieFromResponse(response)
if (cookie) {
setSessionId(cookie)
console.log('授权成功,sessionid 已写入本地存储')
}
// 4. 返回用户信息(如果已自动登录)
return response.data.data?.user || null
} catch (err) {
console.error('小程序授权失败:', err)
throw err
......@@ -46,6 +57,88 @@ export async function miniProgramAuth() {
}
/**
* 从 axios 响应中提取 cookie
* @description 尝试从多个可能的位置提取 cookie,并去重
* @param {Object} response axios 响应对象
* @returns {string|null} cookie 字符串或 null
*
* @example
* const cookie = extractCookieFromResponse(response)
* if (cookie) {
* setSessionId(cookie)
* }
*/
function extractCookieFromResponse(response) {
// 尝试从 response.headers 中提取
if (response?.headers) {
// 标准的 set-cookie 头
if (response.headers['set-cookie']) {
const cookies = response.headers['set-cookie']
return normalizeCookies(cookies)
}
// 小写版本(某些环境)
if (response.headers['Set-Cookie']) {
const cookies = response.headers['Set-Cookie']
return normalizeCookies(cookies)
}
// 小程序环境中可能在 cookies 字段
if (response.headers.cookies) {
const cookies = response.headers.cookies
return normalizeCookies(cookies)
}
}
// 尝试从 response.cookies 中提取(axios-miniprogram)
if (response?.cookies) {
const cookies = response.cookies
// 如果是对象数组格式 [{name, value}, ...]
if (Array.isArray(cookies) && cookies.length > 0 && cookies[0].name) {
return cookies.map(c => `${c.name}=${c.value}`).join('; ')
}
// 如果是字符串数组
return normalizeCookies(cookies)
}
console.warn('未能从响应中提取 cookie')
return null
}
/**
* 标准化 cookie 数组并去重
* @description 将 cookie 数组转换为字符串,只提取唯一的 cookie(忽略重复项)
* @param {string|string[]} cookies cookie 字符串或数组
* @returns {string|null} 标准化后的 cookie 字符串
*
* @example
* normalizeCookies(['PHPSESSID=xxx; path=/', 'PHPSESSID=xxx; path=/'])
* // 返回: 'PHPSESSID=xxx; path=/'
*
* normalizeCookies('PHPSESSID=xxx; path=/')
* // 返回: 'PHPSESSID=xxx; path=/'
*/
function normalizeCookies(cookies) {
if (!cookies) return null
// 如果是单个 cookie 字符串,直接返回
if (typeof cookies === 'string') {
return cookies
}
// 如果是数组,只取第一个 cookie(忽略重复项)
if (Array.isArray(cookies)) {
// 使用 Set 去重
const uniqueCookies = [...new Set(cookies)]
// 只返回第一个唯一的 cookie
return uniqueCookies[0] || null
}
return null
}
/**
* 检查 openid 状态
* @description 调用 loginStatusAPI 检查 is_openid
* @returns {Promise<boolean>} 是否已授权
......
......@@ -29,6 +29,7 @@ export const getSessionId = () => {
* @description 设置 sessionid(一般不需要手动调用)
* - 正常情况下由 authRedirect.refreshSession 写入
* - 保留该方法用于极端场景的手动修复/兼容旧逻辑
* - 自动清理重复的 cookie(防止后端返回重复的 set-cookie)
* @param {string} sessionid cookie 字符串
* @returns {void} 无返回值
*/
......@@ -37,13 +38,68 @@ export const setSessionId = sessionid => {
if (!sessionid) {
return
}
Taro.setStorageSync('sessionid', sessionid)
// 自动清理重复的 cookie
// 例如:"PHPSESSID=xxx; ...,PHPSESSID=xxx; ..." -> "PHPSESSID=xxx; ..."
const cleaned = cleanupDuplicateCookies(sessionid)
Taro.setStorageSync('sessionid', cleaned)
} catch (error) {
console.error('设置sessionid失败:', error)
}
}
/**
* @description 清理重复的 cookie
* @description 如果 cookie 字符串中有重复项,只保留第一个
* @param {string} cookies cookie 字符串
* @returns {string} 清理后的 cookie 字符串
*
* @example
* cleanupDuplicateCookies('PHPSESSID=xxx; ...,PHPSESSID=xxx; ...')
* // 返回: 'PHPSESSID=xxx; ...'
*/
function cleanupDuplicateCookies(cookies) {
if (!cookies || typeof cookies !== 'string') {
return cookies
}
// 按逗号分割(重复的 cookie 用逗号连接)
const parts = cookies.split(',')
// 如果只有一个部分,直接返回
if (parts.length === 1) {
return cookies
}
// 提取 cookie 名称(用于去重)
const extractCookieName = (cookieStr) => {
const match = cookieStr.match(/^([^=]+)=/)
return match ? match[1].trim() : null
}
// 去重:只保留第一次出现的 cookie
const seen = new Set()
const uniqueParts = []
for (const part of parts) {
const name = extractCookieName(part)
if (name && !seen.has(name)) {
seen.add(name)
uniqueParts.push(part)
}
}
// 如果去重后只剩一个,直接返回
if (uniqueParts.length === 1) {
return uniqueParts[0]
}
// 否则用逗号连接
return uniqueParts.join(',')
}
/**
* @description 清空 sessionid(一般不需要手动调用)
* @returns {void} 无返回值
*/
......