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>
1 +# Sessionid 管理最佳实践
2 +
3 +> **适用于**:微信小程序、H5 等需要后端 session 认证的前端项目
4 +
5 +## 📋 核心原则
6 +
7 +### ⚠️ 前端必须主动管理 sessionid
8 +
9 +**错误认知**:后端会自动设置 cookie,前端无需处理
10 +**正确做法****前端必须从响应中提取 sessionid 并写入本地存储**
11 +
12 +**原因**
13 +- 小程序没有浏览器的自动 cookie 管理机制
14 +- 每次请求需要**手动**从本地存储读取 sessionid 并添加到请求头
15 +
16 +---
17 +
18 +## 🔴 常见问题
19 +
20 +### 问题 1:后端返回重复的 Set-Cookie
21 +
22 +**现象**
23 +```http
24 +Set-Cookie: PHPSESSID=xxx; expires=...; path=/
25 +Set-Cookie: PHPSESSID=xxx; expires=...; path=/
26 +```
27 +
28 +**原因**
29 +- PHP/Nginx 的正常行为
30 +- 可能是多层代理导致的重复设置
31 +
32 +**后果**
33 +如果前端直接用 `,` 连接所有 cookie,会导致:
34 +```http
35 +cookie: PHPSESSID=xxx; ...,PHPSESSID=xxx; ...
36 +```
37 +
38 +每次请求都携带重复的 sessionid,增加请求头大小。
39 +
40 +---
41 +
42 +## ✅ 解决方案
43 +
44 +### 步骤 1:从响应中提取 cookie
45 +
46 +**关键点**
47 +-**必须直接使用 axios**(不要用 `fn()` 包装)
48 +-`fn()` 只返回 `{ code, data, msg }`,无法访问响应头
49 +- ✅ axios 返回完整响应对象,包含 `headers``cookies`
50 +
51 +**示例代码**`src/utils/openid.js`):
52 +
53 +```javascript
54 +import axios from '@/utils/request'
55 +import { setSessionId } from '@/utils/request'
56 +
57 +export async function miniProgramAuth() {
58 + try {
59 + // 1. 调用 wx.login 获取 code
60 + const { code } = await Taro.login()
61 +
62 + // 2. 直接使用 axios 调用授权接口
63 + const response = await axios.post('/srv/?a=openid', { code })
64 +
65 + // 3. 从响应中提取 cookie
66 + const cookie = extractCookieFromResponse(response)
67 +
68 + // 4. 写入本地存储
69 + if (cookie) {
70 + setSessionId(cookie)
71 + }
72 +
73 + return response.data.data?.user || null
74 + } catch (err) {
75 + console.error('授权失败:', err)
76 + throw err
77 + }
78 +}
79 +```
80 +
81 +### 步骤 2:处理 cookie 去重
82 +
83 +**场景**:后端返回重复的 `set-cookie` 数组
84 +
85 +**解决方案**:使用 `Set` 去重,只保留第一个
86 +
87 +```javascript
88 +function normalizeCookies(cookies) {
89 + if (!cookies) return null
90 +
91 + // 如果是单个 cookie 字符串,直接返回
92 + if (typeof cookies === 'string') {
93 + return cookies
94 + }
95 +
96 + // 如果是数组,使用 Set 去重
97 + if (Array.isArray(cookies)) {
98 + const uniqueCookies = [...new Set(cookies)]
99 +
100 + // 只返回第一个唯一的 cookie
101 + return uniqueCookies[0] || null
102 + }
103 +
104 + return null
105 +}
106 +```
107 +
108 +### 步骤 3:兼容多种响应格式
109 +
110 +**不同环境的 cookie 位置**
111 +
112 +```javascript
113 +function extractCookieFromResponse(response) {
114 + // 尝试从 response.headers 中提取
115 + if (response?.headers) {
116 + // 标准的 set-cookie 头
117 + if (response.headers['set-cookie']) {
118 + return normalizeCookies(response.headers['set-cookie'])
119 + }
120 +
121 + // 小写版本(某些环境)
122 + if (response.headers['Set-Cookie']) {
123 + return normalizeCookies(response.headers['Set-Cookie'])
124 + }
125 +
126 + // 小程序环境中可能在 cookies 字段
127 + if (response.headers.cookies) {
128 + return normalizeCookies(response.headers.cookies)
129 + }
130 + }
131 +
132 + // 尝试从 response.cookies 中提取(axios-miniprogram)
133 + if (response?.cookies) {
134 + const cookies = response.cookies
135 + // 如果是对象数组格式 [{name, value}, ...]
136 + if (Array.isArray(cookies) && cookies.length > 0 && cookies[0].name) {
137 + return cookies.map(c => `${c.name}=${c.value}`).join('; ')
138 + }
139 + // 如果是字符串数组
140 + return normalizeCookies(cookies)
141 + }
142 +
143 + return null
144 +}
145 +```
146 +
147 +### 步骤 4:自动清理已存在的重复 cookie
148 +
149 +**问题**:如果 `localStorage.sessionid` 已经存储了重复的旧值怎么办?
150 +
151 +**解决方案**:在 `setSessionId()` 时自动清理
152 +
153 +```javascript
154 +// src/utils/request.js
155 +export const setSessionId = sessionid => {
156 + try {
157 + if (!sessionid) {
158 + return
159 + }
160 +
161 + // 自动清理重复的 cookie
162 + const cleaned = cleanupDuplicateCookies(sessionid)
163 +
164 + Taro.setStorageSync('sessionid', cleaned)
165 + } catch (error) {
166 + console.error('设置sessionid失败:', error)
167 + }
168 +}
169 +
170 +function cleanupDuplicateCookies(cookies) {
171 + if (!cookies || typeof cookies !== 'string') {
172 + return cookies
173 + }
174 +
175 + // 按逗号分割(重复的 cookie 用逗号连接)
176 + const parts = cookies.split(',')
177 +
178 + // 如果只有一个部分,直接返回
179 + if (parts.length === 1) {
180 + return cookies
181 + }
182 +
183 + // 提取 cookie 名称(用于去重)
184 + const extractCookieName = (cookieStr) => {
185 + const match = cookieStr.match(/^([^=]+)=/)
186 + return match ? match[1].trim() : null
187 + }
188 +
189 + // 去重:只保留第一次出现的 cookie
190 + const seen = new Set()
191 + const uniqueParts = []
192 +
193 + for (const part of parts) {
194 + const name = extractCookieName(part)
195 + if (name && !seen.has(name)) {
196 + seen.add(name)
197 + uniqueParts.push(part)
198 + }
199 + }
200 +
201 + // 如果去重后只剩一个,直接返回
202 + if (uniqueParts.length === 1) {
203 + return uniqueParts[0]
204 + }
205 +
206 + // 否则用逗号连接
207 + return uniqueParts.join(',')
208 +}
209 +```
210 +
211 +---
212 +
213 +## 🔄 完整流程
214 +
215 +### 授权流程
216 +
217 +```
218 +┌─────────────────────────────────────────────────────────┐
219 +│ 1. 前端调用 Taro.login() 获取微信 code │
220 +└────────────────────┬────────────────────────────────────┘
221 +
222 +┌─────────────────────────────────────────────────────────┐
223 +│ 2. 调用后端授权接口 │
224 +│ POST /srv/?a=openid │
225 +│ Body: { code: "xxx" } │
226 +└────────────────────┬────────────────────────────────────┘
227 +
228 +┌─────────────────────────────────────────────────────────┐
229 +│ 3. 后端返回响应 │
230 +│ Data: { code: 1, data: { user: {...} } } │
231 +│ Set-Cookie: PHPSESSID=xxx; ... (重复) │
232 +└────────────────────┬────────────────────────────────────┘
233 +
234 +┌─────────────────────────────────────────────────────────┐
235 +│ 4. 前端提取 cookie 并去重 │
236 +│ extractCookieFromResponse() │
237 +│ normalizeCookies() │
238 +└────────────────────┬────────────────────────────────────┘
239 +
240 +┌─────────────────────────────────────────────────────────┐
241 +│ 5. 写入本地存储 │
242 +│ setSessionId(cookie) │
243 +│ localStorage.sessionid = "PHPSESSID=xxx; ..." │
244 +└────────────────────┬────────────────────────────────────┘
245 +
246 +┌─────────────────────────────────────────────────────────┐
247 +│ 6. 后续请求自动携带 sessionid │
248 +│ request.js 拦截器自动读取并添加到请求头 │
249 +└─────────────────────────────────────────────────────────┘
250 +```
251 +
252 +### 请求流程(自动携带 sessionid)
253 +
254 +```javascript
255 +// src/utils/request.js 拦截器
256 +service.interceptors.request.use(config => {
257 + // 动态获取 sessionid 并设置到请求头
258 + const sessionid = getSessionId()
259 + if (sessionid) {
260 + config.headers = config.headers || {}
261 + config.headers.cookie = sessionid
262 + }
263 + return config
264 +})
265 +```
266 +
267 +---
268 +
269 +## 🎯 最佳实践
270 +
271 +### 1. 统一使用 axios 直接调用
272 +
273 +**❌ 错误**:通过 `fn()` 包装的 API
274 +```javascript
275 +const res = await miniProgramAuthAPI({ code }) // ❌ 无法访问响应头
276 +```
277 +
278 +**✅ 正确**:直接使用 axios
279 +```javascript
280 +const response = await axios.post('/srv/?a=openid', { code }) // ✅ 可访问 headers
281 +```
282 +
283 +### 2. 封装 cookie 提取逻辑
284 +
285 +**位置**`src/utils/openid.js`
286 +
287 +**函数**
288 +- `extractCookieFromResponse()` - 从响应中提取 cookie
289 +- `normalizeCookies()` - 标准化并去重
290 +
291 +### 3. 自动清理重复 cookie
292 +
293 +**位置**`src/utils/request.js`
294 +
295 +**函数**
296 +- `setSessionId()` - 自动调用 `cleanupDuplicateCookies()`
297 +- `cleanupDuplicateCookies()` - 清理重复的 cookie
298 +
299 +### 4. 统一的 sessionid 管理
300 +
301 +**工具函数**`src/utils/request.js`):
302 +```javascript
303 +// 获取 sessionid
304 +export const getSessionId = () => {
305 + return Taro.getStorageSync('sessionid') || null
306 +}
307 +
308 +// 设置 sessionid(自动清理重复)
309 +export const setSessionId = (sessionid) => {
310 + const cleaned = cleanupDuplicateCookies(sessionid)
311 + Taro.setStorageSync('sessionid', cleaned)
312 +}
313 +
314 +// 清空 sessionid
315 +export const clearSessionId = () => {
316 + Taro.removeStorageSync('sessionid')
317 +}
318 +```
319 +
320 +---
321 +
322 +## 🧪 测试验证
323 +
324 +### 验证点 1:检查响应中的 cookie
325 +
326 +```javascript
327 +// 在授权接口调用后添加日志
328 +const response = await axios.post('/srv/?a=openid', { code })
329 +console.log('响应头:', response.headers)
330 +console.log('Cookie:', response.cookies)
331 +```
332 +
333 +**预期**
334 +- 小程序:`response.cookies``response.headers['set-cookie']`
335 +- H5:`response.headers['set-cookie']`
336 +
337 +### 验证点 2:检查本地存储
338 +
339 +```javascript
340 +// 控制台执行
341 +console.log(Taro.getStorageSync('sessionid'))
342 +```
343 +
344 +**预期**
345 +- ✅ 单个 cookie:`PHPSESSID=xxx; expires=...; path=/`
346 +- ❌ 重复 cookie:`PHPSESSID=xxx; ...,PHPSESSID=xxx; ...`
347 +
348 +### 验证点 3:检查请求头
349 +
350 +打开微信开发者工具 → Network 面板:
351 +- 查看任意请求的 `Request Headers`
352 +- 确认 `cookie` 字段只有单个 sessionid
353 +
354 +---
355 +
356 +## 🔧 故障排查
357 +
358 +### 问题:请求头中的 cookie 还是重复的
359 +
360 +**原因**`localStorage.sessionid` 中存储的是旧的重复值
361 +
362 +**解决方案**
363 +
364 +```javascript
365 +// 方法 1:手动清理
366 +Taro.removeStorageSync('sessionid')
367 +// 然后重新登录
368 +
369 +// 方法 2:在控制台执行自动清理脚本
370 +const oldSessionid = Taro.getStorageSync('sessionid')
371 +const cleaned = cleanupDuplicateCookies(oldSessionid)
372 +Taro.setStorageSync('sessionid', cleaned)
373 +```
374 +
375 +### 问题:授权成功后接口还是返回 401
376 +
377 +**排查步骤**
378 +
379 +1. 检查 `localStorage.sessionid` 是否有值
380 +2. 检查请求头中是否携带了 `cookie` 字段
381 +3. 检查 cookie 值是否正确(没有重复)
382 +4. 检查后端是否验证了这个 sessionid
383 +
384 +---
385 +
386 +## 📚 相关文档
387 +
388 +- **授权流程**[auth-debug-guide.md](../auth-debug-guide.md)
389 +- **请求封装**`src/utils/request.js`
390 +- **授权实现**`src/utils/openid.js`
391 +
392 +---
393 +
394 +## 📝 总结
395 +
396 +| 场景 | 解决方案 |
397 +|------|----------|
398 +| 提取 cookie | 直接使用 axios,不用 `fn()` 包装 |
399 +| 重复的 set-cookie | 使用 `Set` 去重,只保留第一个 |
400 +| 兼容多种格式 | 尝试多个可能的 cookie 位置 |
401 +| 自动清理重复 | 在 `setSessionId()` 时自动清理 |
402 +| 携带 sessionid | 请求拦截器自动读取并添加 |
403 +
404 +**核心原则**
405 +1. ✅ 前端必须主动管理 sessionid
406 +2. ✅ 使用 axios 直接调用授权接口
407 +3. ✅ 提取 cookie 时自动去重
408 +4. ✅ 写入时自动清理重复项
409 +5. ✅ 请求时自动携带到请求头
...@@ -6,12 +6,14 @@ ...@@ -6,12 +6,14 @@
6 */ 6 */
7 7
8 import Taro from '@tarojs/taro' 8 import Taro from '@tarojs/taro'
9 -import { miniProgramAuthAPI } from '@/api/wechat' 9 +import axios from '@/utils/request'
10 +import { setSessionId } from '@/utils/request'
10 import { loginStatusAPI } from '@/api/user' 11 import { loginStatusAPI } from '@/api/user'
11 12
12 /** 13 /**
13 * 小程序授权 14 * 小程序授权
14 * @description 调用 wx.login 获取 code,由后端授权获取 openid 15 * @description 调用 wx.login 获取 code,由后端授权获取 openid
16 + * @description 授权成功后会自动将 sessionid 写入本地存储
15 * @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录) 17 * @returns {Promise<{user: Object|null}>} 返回用户信息(如果已自动登录)
16 * 18 *
17 * @example 19 * @example
...@@ -31,14 +33,23 @@ export async function miniProgramAuth() { ...@@ -31,14 +33,23 @@ export async function miniProgramAuth() {
31 throw new Error('获取微信 code 失败') 33 throw new Error('获取微信 code 失败')
32 } 34 }
33 35
34 - // 2. 调用后端授权接口 36 + // 2. 调用后端授权接口(直接使用 axios 以访问响应头)
35 - const res = await miniProgramAuthAPI({ code }) 37 + const response = await axios.post('/srv/?a=openid', { code })
36 38
37 - if (res.code === 1) { 39 + // 检查响应状态
38 - return res.data.user || null 40 + if (!response?.data || response.data.code !== 1) {
39 - } else { 41 + throw new Error(response?.data?.msg || '小程序授权失败')
40 - throw new Error(res.msg || '小程序授权失败')
41 } 42 }
43 +
44 + // 3. 从响应中提取 cookie 并写入本地存储
45 + const cookie = extractCookieFromResponse(response)
46 + if (cookie) {
47 + setSessionId(cookie)
48 + console.log('授权成功,sessionid 已写入本地存储')
49 + }
50 +
51 + // 4. 返回用户信息(如果已自动登录)
52 + return response.data.data?.user || null
42 } catch (err) { 53 } catch (err) {
43 console.error('小程序授权失败:', err) 54 console.error('小程序授权失败:', err)
44 throw err 55 throw err
...@@ -46,6 +57,88 @@ export async function miniProgramAuth() { ...@@ -46,6 +57,88 @@ export async function miniProgramAuth() {
46 } 57 }
47 58
48 /** 59 /**
60 + * 从 axios 响应中提取 cookie
61 + * @description 尝试从多个可能的位置提取 cookie,并去重
62 + * @param {Object} response axios 响应对象
63 + * @returns {string|null} cookie 字符串或 null
64 + *
65 + * @example
66 + * const cookie = extractCookieFromResponse(response)
67 + * if (cookie) {
68 + * setSessionId(cookie)
69 + * }
70 + */
71 +function extractCookieFromResponse(response) {
72 + // 尝试从 response.headers 中提取
73 + if (response?.headers) {
74 + // 标准的 set-cookie 头
75 + if (response.headers['set-cookie']) {
76 + const cookies = response.headers['set-cookie']
77 + return normalizeCookies(cookies)
78 + }
79 +
80 + // 小写版本(某些环境)
81 + if (response.headers['Set-Cookie']) {
82 + const cookies = response.headers['Set-Cookie']
83 + return normalizeCookies(cookies)
84 + }
85 +
86 + // 小程序环境中可能在 cookies 字段
87 + if (response.headers.cookies) {
88 + const cookies = response.headers.cookies
89 + return normalizeCookies(cookies)
90 + }
91 + }
92 +
93 + // 尝试从 response.cookies 中提取(axios-miniprogram)
94 + if (response?.cookies) {
95 + const cookies = response.cookies
96 + // 如果是对象数组格式 [{name, value}, ...]
97 + if (Array.isArray(cookies) && cookies.length > 0 && cookies[0].name) {
98 + return cookies.map(c => `${c.name}=${c.value}`).join('; ')
99 + }
100 + // 如果是字符串数组
101 + return normalizeCookies(cookies)
102 + }
103 +
104 + console.warn('未能从响应中提取 cookie')
105 + return null
106 +}
107 +
108 +/**
109 + * 标准化 cookie 数组并去重
110 + * @description 将 cookie 数组转换为字符串,只提取唯一的 cookie(忽略重复项)
111 + * @param {string|string[]} cookies cookie 字符串或数组
112 + * @returns {string|null} 标准化后的 cookie 字符串
113 + *
114 + * @example
115 + * normalizeCookies(['PHPSESSID=xxx; path=/', 'PHPSESSID=xxx; path=/'])
116 + * // 返回: 'PHPSESSID=xxx; path=/'
117 + *
118 + * normalizeCookies('PHPSESSID=xxx; path=/')
119 + * // 返回: 'PHPSESSID=xxx; path=/'
120 + */
121 +function normalizeCookies(cookies) {
122 + if (!cookies) return null
123 +
124 + // 如果是单个 cookie 字符串,直接返回
125 + if (typeof cookies === 'string') {
126 + return cookies
127 + }
128 +
129 + // 如果是数组,只取第一个 cookie(忽略重复项)
130 + if (Array.isArray(cookies)) {
131 + // 使用 Set 去重
132 + const uniqueCookies = [...new Set(cookies)]
133 +
134 + // 只返回第一个唯一的 cookie
135 + return uniqueCookies[0] || null
136 + }
137 +
138 + return null
139 +}
140 +
141 +/**
49 * 检查 openid 状态 142 * 检查 openid 状态
50 * @description 调用 loginStatusAPI 检查 is_openid 143 * @description 调用 loginStatusAPI 检查 is_openid
51 * @returns {Promise<boolean>} 是否已授权 144 * @returns {Promise<boolean>} 是否已授权
......
...@@ -29,6 +29,7 @@ export const getSessionId = () => { ...@@ -29,6 +29,7 @@ export const getSessionId = () => {
29 * @description 设置 sessionid(一般不需要手动调用) 29 * @description 设置 sessionid(一般不需要手动调用)
30 * - 正常情况下由 authRedirect.refreshSession 写入 30 * - 正常情况下由 authRedirect.refreshSession 写入
31 * - 保留该方法用于极端场景的手动修复/兼容旧逻辑 31 * - 保留该方法用于极端场景的手动修复/兼容旧逻辑
32 + * - 自动清理重复的 cookie(防止后端返回重复的 set-cookie)
32 * @param {string} sessionid cookie 字符串 33 * @param {string} sessionid cookie 字符串
33 * @returns {void} 无返回值 34 * @returns {void} 无返回值
34 */ 35 */
...@@ -37,13 +38,68 @@ export const setSessionId = sessionid => { ...@@ -37,13 +38,68 @@ export const setSessionId = sessionid => {
37 if (!sessionid) { 38 if (!sessionid) {
38 return 39 return
39 } 40 }
40 - Taro.setStorageSync('sessionid', sessionid) 41 +
42 + // 自动清理重复的 cookie
43 + // 例如:"PHPSESSID=xxx; ...,PHPSESSID=xxx; ..." -> "PHPSESSID=xxx; ..."
44 + const cleaned = cleanupDuplicateCookies(sessionid)
45 +
46 + Taro.setStorageSync('sessionid', cleaned)
41 } catch (error) { 47 } catch (error) {
42 console.error('设置sessionid失败:', error) 48 console.error('设置sessionid失败:', error)
43 } 49 }
44 } 50 }
45 51
46 /** 52 /**
53 + * @description 清理重复的 cookie
54 + * @description 如果 cookie 字符串中有重复项,只保留第一个
55 + * @param {string} cookies cookie 字符串
56 + * @returns {string} 清理后的 cookie 字符串
57 + *
58 + * @example
59 + * cleanupDuplicateCookies('PHPSESSID=xxx; ...,PHPSESSID=xxx; ...')
60 + * // 返回: 'PHPSESSID=xxx; ...'
61 + */
62 +function cleanupDuplicateCookies(cookies) {
63 + if (!cookies || typeof cookies !== 'string') {
64 + return cookies
65 + }
66 +
67 + // 按逗号分割(重复的 cookie 用逗号连接)
68 + const parts = cookies.split(',')
69 +
70 + // 如果只有一个部分,直接返回
71 + if (parts.length === 1) {
72 + return cookies
73 + }
74 +
75 + // 提取 cookie 名称(用于去重)
76 + const extractCookieName = (cookieStr) => {
77 + const match = cookieStr.match(/^([^=]+)=/)
78 + return match ? match[1].trim() : null
79 + }
80 +
81 + // 去重:只保留第一次出现的 cookie
82 + const seen = new Set()
83 + const uniqueParts = []
84 +
85 + for (const part of parts) {
86 + const name = extractCookieName(part)
87 + if (name && !seen.has(name)) {
88 + seen.add(name)
89 + uniqueParts.push(part)
90 + }
91 + }
92 +
93 + // 如果去重后只剩一个,直接返回
94 + if (uniqueParts.length === 1) {
95 + return uniqueParts[0]
96 + }
97 +
98 + // 否则用逗号连接
99 + return uniqueParts.join(',')
100 +}
101 +
102 +/**
47 * @description 清空 sessionid(一般不需要手动调用) 103 * @description 清空 sessionid(一般不需要手动调用)
48 * @returns {void} 无返回值 104 * @returns {void} 无返回值
49 */ 105 */
......