hookehuyr

fix(security): 修复 Codex 审查发现的多个安全和质量问题

## 修复内容

### P1 级别(高优先级)
- 修复 Cookie 解析潜在破坏风险(删除 cleanupDuplicateCookies 的 split(',') 逻辑)
- 修复授权响应只保留第一个 cookie 的问题(智能识别 sessionid)
- 增强搜索响应的防御性访问(添加可选链 ?. 防止字段缺失抛错)
- 修复启动日志泄露用户隐私(生产环境手机号和用户名脱敏)

### P2 级别(中优先级)
- 修复弱网提示只会出现一次的问题(时间窗口防抖 30 秒)
- 修复搜索测试用例与实现不一致(file → article)
- 修复 usePlanView 集成测试失败(添加 useFileOperation mock)

## 测试结果
- ✅ 所有测试通过 (178/178)
- ✅ ESLint 检查通过(0 errors, 37 warnings 已存在)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
...@@ -7535,3 +7535,75 @@ ...@@ -7535,3 +7535,75 @@
7535 7535
7536 --- 7536 ---
7537 7537
7538 +
7539 +### 10:51:02 - 完成任务
7540 +
7541 +**影响文件**:
7542 +- `.husky/pre-commit`
7543 +- `.husky/prepare-commit-msg`
7544 +
7545 +**变更摘要**:
7546 +- 无详细描述
7547 +
7548 +### 10:51:49 - 完成任务
7549 +
7550 +**影响文件**:
7551 +- `.husky/pre-commit`
7552 +- `.husky/prepare-commit-msg`
7553 +
7554 +**变更摘要**:
7555 +- 无详细描述
7556 +
7557 +### 10:52:54 - 完成任务
7558 +
7559 +**影响文件**:
7560 +- `.husky/pre-commit`
7561 +- `.husky/prepare-commit-msg`
7562 +
7563 +**变更摘要**:
7564 +- 无详细描述
7565 +
7566 +### 10:53:57 - 完成任务
7567 +
7568 +**影响文件**:
7569 +- `.husky/pre-commit`
7570 +- `.husky/prepare-commit-msg`
7571 +
7572 +**变更摘要**:
7573 +- 无详细描述
7574 +
7575 +### 10:57:47 - 完成任务
7576 +
7577 +**影响文件**:
7578 +- `.husky/pre-commit`
7579 +- `.husky/prepare-commit-msg`
7580 +
7581 +**变更摘要**:
7582 +- 无详细描述
7583 +
7584 +### 10:58:24 - 完成任务
7585 +
7586 +**影响文件**:
7587 +- `.husky/pre-commit`
7588 +- `.husky/prepare-commit-msg`
7589 +
7590 +**变更摘要**:
7591 +- 无详细描述
7592 +
7593 +### 10:59:19 - 完成任务
7594 +
7595 +**影响文件**:
7596 +- `.husky/pre-commit`
7597 +- `.husky/prepare-commit-msg`
7598 +
7599 +**变更摘要**:
7600 +- 无详细描述
7601 +
7602 +### 10:59:46 - 完成任务
7603 +
7604 +**影响文件**:
7605 +- `.husky/pre-commit`
7606 +- `.husky/prepare-commit-msg`
7607 +
7608 +**变更摘要**:
7609 +- 无详细描述
......
...@@ -34,6 +34,7 @@ const App = createApp({ ...@@ -34,6 +34,7 @@ const App = createApp({
34 setTimeout(() => { 34 setTimeout(() => {
35 const env = process.env.NODE_ENV || 'unknown' 35 const env = process.env.NODE_ENV || 'unknown'
36 const platform = process.env.TARO_ENV || 'unknown' 36 const platform = process.env.TARO_ENV || 'unknown'
37 + const isDev = env === 'development'
37 38
38 console.log('\n==================== 环境信息 ====================') 39 console.log('\n==================== 环境信息 ====================')
39 console.log(`📱 当前环境: ${env === 'development' ? '开发环境' : env === 'production' ? '生产环境' : env}`) 40 console.log(`📱 当前环境: ${env === 'development' ? '开发环境' : env === 'production' ? '生产环境' : env}`)
...@@ -59,13 +60,37 @@ const App = createApp({ ...@@ -59,13 +60,37 @@ const App = createApp({
59 console.log('🔐 会话状态: 无法获取') 60 console.log('🔐 会话状态: 无法获取')
60 } 61 }
61 62
62 - // 打印用户登录状态 63 + // 打印用户登录状态(生产环境不显示敏感信息)
63 console.log('\n==================== 用户信息 ====================') 64 console.log('\n==================== 用户信息 ====================')
64 if (userStore.isLoggedIn && userStore.userInfo) { 65 if (userStore.isLoggedIn && userStore.userInfo) {
65 console.log(`✅ 登录状态: 已登录`) 66 console.log(`✅ 登录状态: 已登录`)
66 - console.log(`👤 用户ID: ${userStore.userInfo.id || '未知'}`) 67 +
67 - console.log(`👤 用户名: ${userStore.userInfo.name || userStore.userInfo.nickname || '未知'}`) 68 + // 仅开发环境显示详细用户信息
68 - console.log(`📱 手机号: ${userStore.userInfo.phone || userStore.userInfo.mobile || '未绑定'}`) 69 + if (isDev) {
70 + console.log(`👤 用户ID: ${userStore.userInfo.id || '未知'}`)
71 + console.log(`👤 用户名: ${userStore.userInfo.name || userStore.userInfo.nickname || '未知'}`)
72 + console.log(`📱 手机号: ${userStore.userInfo.phone || userStore.userInfo.mobile || '未绑定'}`)
73 + } else {
74 + // 生产环境:只显示脱敏信息
75 + const phone = userStore.userInfo.phone || userStore.userInfo.mobile
76 + const maskedPhone = phone ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : '未绑定'
77 +
78 + // 用户名脱敏:保留首字和末字,中间用 * 代替
79 + const userName = userStore.userInfo.name || userStore.userInfo.nickname || ''
80 + let maskedName = '***'
81 + if (userName && userName.length > 0) {
82 + if (userName.length === 1) {
83 + maskedName = '*'
84 + } else if (userName.length === 2) {
85 + maskedName = userName[0] + '*'
86 + } else {
87 + maskedName = userName[0] + '*'.repeat(userName.length - 2) + userName[userName.length - 1]
88 + }
89 + }
90 +
91 + console.log(`👤 用户名: ${maskedName}`)
92 + console.log(`📱 手机号: ${maskedPhone}`)
93 + }
69 } else { 94 } else {
70 console.log(`❌ 登录状态: 未登录`) 95 console.log(`❌ 登录状态: 未登录`)
71 } 96 }
......
...@@ -17,23 +17,48 @@ import { PLAN_FIELD_DEFINITIONS, FIELD_GROUPS, getFieldsByGroup } from '@/config ...@@ -17,23 +17,48 @@ import { PLAN_FIELD_DEFINITIONS, FIELD_GROUPS, getFieldsByGroup } from '@/config
17 import { viewAPI } from '@/api/plan' 17 import { viewAPI } from '@/api/plan'
18 18
19 // Mock Taro API 19 // Mock Taro API
20 -vi.mock('@tarojs/taro', () => ({ 20 +vi.mock('@tarojs/taro', () => {
21 - default: { 21 + const showToast = vi.fn()
22 - showToast: vi.fn(), 22 + const showModal = vi.fn()
23 - showModal: vi.fn(), 23 + const showLoading = vi.fn()
24 - showLoading: vi.fn(), 24 + const hideLoading = vi.fn()
25 - hideLoading: vi.fn(), 25 + const showActionSheet = vi.fn()
26 - showActionSheet: vi.fn(), 26 + const navigateTo = vi.fn()
27 - navigateTo: vi.fn(), 27 + const redirectTo = vi.fn()
28 - redirectTo: vi.fn() 28 +
29 + return {
30 + default: {
31 + showToast,
32 + showModal,
33 + showLoading,
34 + hideLoading,
35 + showActionSheet,
36 + navigateTo,
37 + redirectTo
38 + },
39 + // 导出命名导出以匹配 useFileOperation 中的用法
40 + showToast,
41 + showModal,
42 + showLoading,
43 + hideLoading,
44 + showActionSheet,
45 + navigateTo,
46 + redirectTo
29 } 47 }
30 -})) 48 +})
31 49
32 // Mock viewAPI 50 // Mock viewAPI
33 vi.mock('@/api/plan', () => ({ 51 vi.mock('@/api/plan', () => ({
34 viewAPI: vi.fn() 52 viewAPI: vi.fn()
35 })) 53 }))
36 54
55 +// Mock useFileOperation
56 +vi.mock('@/composables/useFileOperation', () => ({
57 + useFileOperation: () => ({
58 + viewFile: vi.fn().mockResolvedValue(true) // 模拟文件预览成功
59 + })
60 +}))
61 +
37 describe('计划书模块集成测试', () => { 62 describe('计划书模块集成测试', () => {
38 beforeEach(() => { 63 beforeEach(() => {
39 vi.clearAllMocks() 64 vi.clearAllMocks()
......
...@@ -64,7 +64,7 @@ describe('搜索页面测试', () => { ...@@ -64,7 +64,7 @@ describe('搜索页面测试', () => {
64 expect(wrapper.vm.shouldEnableScrollLoad).toBe(false) 64 expect(wrapper.vm.shouldEnableScrollLoad).toBe(false)
65 expect(wrapper.vm.tabsData.length).toBe(2) 65 expect(wrapper.vm.tabsData.length).toBe(2)
66 expect(wrapper.vm.tabsData[0].id).toBe('product') 66 expect(wrapper.vm.tabsData[0].id).toBe('product')
67 - expect(wrapper.vm.tabsData[1].id).toBe('file') 67 + expect(wrapper.vm.tabsData[1].id).toBe('article')
68 }) 68 })
69 69
70 it('空关键词时提示并保持未搜索状态', async () => { 70 it('空关键词时提示并保持未搜索状态', async () => {
...@@ -87,8 +87,8 @@ describe('搜索页面测试', () => { ...@@ -87,8 +87,8 @@ describe('搜索页面测试', () => {
87 list: [{ id: 1, product_name: '保险A', tags: [] }], 87 list: [{ id: 1, product_name: '保险A', tags: [] }],
88 total: 1 88 total: 1
89 }, 89 },
90 - files: { 90 + article: {
91 - list: [{ id: 10, name: '培训资料', value: 'url', size: '1MB', extension: 'pdf', is_favorite: 1 }], 91 + list: [{ id: 10, post_title: '培训指南', post_excerpt: '这是培训资料简介', post_date: '2026-01-01', is_favorite: 1 }],
92 total: 1 92 total: 1
93 } 93 }
94 } 94 }
...@@ -114,9 +114,9 @@ describe('搜索页面测试', () => { ...@@ -114,9 +114,9 @@ describe('搜索页面测试', () => {
114 list: [{ id: 1, product_name: '保险A', tags: [] }], 114 list: [{ id: 1, product_name: '保险A', tags: [] }],
115 total: 1 115 total: 1
116 }, 116 },
117 - files: { 117 + article: {
118 - list: [], 118 + list: [{ id: 20, post_title: '相关文章', post_excerpt: '摘要', post_date: '2026-01-01', is_favorite: 0 }],
119 - total: 0 119 + total: 1
120 } 120 }
121 } 121 }
122 }) 122 })
...@@ -132,18 +132,18 @@ describe('搜索页面测试', () => { ...@@ -132,18 +132,18 @@ describe('搜索页面测试', () => {
132 list: [], 132 list: [],
133 total: 0 133 total: 0
134 }, 134 },
135 - files: { 135 + article: {
136 - list: [{ id: 10, name: '培训资料', value: 'url', size: '1MB', extension: 'pdf', is_favorite: 0 }], 136 + list: [{ id: 10, post_title: '培训资料', post_excerpt: '摘要', post_date: '2026-01-01', is_favorite: 0 }],
137 total: 1 137 total: 1
138 } 138 }
139 } 139 }
140 }) 140 })
141 141
142 - await wrapper.vm.onTabClick('file') 142 + await wrapper.vm.onTabClick('article')
143 143
144 - expect(wrapper.vm.activeTab).toBe('file') 144 + expect(wrapper.vm.activeTab).toBe('article')
145 expect(wrapper.vm.currentList.length).toBe(1) 145 expect(wrapper.vm.currentList.length).toBe(1)
146 - expect(searchAPI).toHaveBeenCalledWith({ keyword: '保险', page: 0, limit: 20, type: 'file' }) 146 + expect(searchAPI).toHaveBeenCalledWith({ keyword: '保险', page: 0, limit: 20, type: 'article' })
147 }) 147 })
148 148
149 it('清空搜索后重置状态', async () => { 149 it('清空搜索后重置状态', async () => {
...@@ -156,7 +156,7 @@ describe('搜索页面测试', () => { ...@@ -156,7 +156,7 @@ describe('搜索页面测试', () => {
156 list: [{ id: 1, product_name: '保险A', tags: [] }], 156 list: [{ id: 1, product_name: '保险A', tags: [] }],
157 total: 1 157 total: 1
158 }, 158 },
159 - files: { 159 + article: {
160 list: [], 160 list: [],
161 total: 0 161 total: 0
162 } 162 }
......
...@@ -281,11 +281,11 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo ...@@ -281,11 +281,11 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
281 : await searchAPI(params) 281 : await searchAPI(params)
282 282
283 if (res.code === 1) { 283 if (res.code === 1) {
284 - // 映射产品列表 284 + // 映射产品列表(使用可选链防止后端字段缺失时抛错)
285 - const newProducts = res.data.products.list || [] 285 + const newProducts = res.data.products?.list || []
286 286
287 // 映射文章列表(参考本周热门文章的映射方式) 287 // 映射文章列表(参考本周热门文章的映射方式)
288 - const newArticles = (res.data.article.list || []).map(item => ({ 288 + const newArticles = (res.data.article?.list || []).map(item => ({
289 id: item.id, 289 id: item.id,
290 title: item.post_title || '未命名文章', 290 title: item.post_title || '未命名文章',
291 excerpt: item.post_excerpt || '', 291 excerpt: item.post_excerpt || '',
...@@ -307,8 +307,8 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo ...@@ -307,8 +307,8 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
307 articles.value = newArticles 307 articles.value = newArticles
308 } 308 }
309 309
310 - productsTotal.value = res.data.products.total || 0 310 + productsTotal.value = res.data.products?.total || 0
311 - articlesTotal.value = res.data.article.total || 0 311 + articlesTotal.value = res.data.article?.total || 0
312 312
313 // ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore 313 // ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore
314 // 如果不传 type,自动选择有数据的 tab(仅首次搜索时) 314 // 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
......
...@@ -106,15 +106,18 @@ function extractCookieFromResponse(response) { ...@@ -106,15 +106,18 @@ function extractCookieFromResponse(response) {
106 } 106 }
107 107
108 /** 108 /**
109 - * 标准化 cookie 数组并去重 109 + * 标准化 cookie 数组并提取会话 cookie
110 - * @description 将 cookie 数组转换为字符串,只提取唯一的 cookie(忽略重复项) 110 + * @description 将 cookie 数组转换为字符串,优先提取包含会话标识的 cookie
111 * @param {string|string[]} cookies cookie 字符串或数组 111 * @param {string|string[]} cookies cookie 字符串或数组
112 * @returns {string|null} 标准化后的 cookie 字符串 112 * @returns {string|null} 标准化后的 cookie 字符串
113 * 113 *
114 * @example 114 * @example
115 - * normalizeCookies(['PHPSESSID=xxx; path=/', 'PHPSESSID=xxx; path=/']) 115 + * // 多个 Set-Cookie,优先返回包含 sessionid/PHPSESSID 的
116 - * // 返回: 'PHPSESSID=xxx; path=/' 116 + * normalizeCookies(['sessionid=xxx; path=/', 'csrftoken=yyy; path=/'])
117 + * // 返回: 'sessionid=xxx; path=/'
117 * 118 *
119 + * @example
120 + * // 单个 cookie 字符串
118 * normalizeCookies('PHPSESSID=xxx; path=/') 121 * normalizeCookies('PHPSESSID=xxx; path=/')
119 * // 返回: 'PHPSESSID=xxx; path=/' 122 * // 返回: 'PHPSESSID=xxx; path=/'
120 */ 123 */
...@@ -126,12 +129,27 @@ function normalizeCookies(cookies) { ...@@ -126,12 +129,27 @@ function normalizeCookies(cookies) {
126 return cookies 129 return cookies
127 } 130 }
128 131
129 - // 如果是数组,只取第一个 cookie(忽略重复项) 132 + // 如果是数组,智能提取会话 cookie
130 if (Array.isArray(cookies)) { 133 if (Array.isArray(cookies)) {
131 - // 使用 Set 去重 134 + // 去重
132 const uniqueCookies = [...new Set(cookies)] 135 const uniqueCookies = [...new Set(cookies)]
133 136
134 - // 只返回第一个唯一的 cookie 137 + // 常见的会话 cookie 名称
138 + const sessionCookieNames = ['sessionid', 'PHPSESSID', 'jsessionid', 'JSESSIONID', 'sid', 'SID']
139 +
140 + // 优先查找包含会话标识的 cookie
141 + for (const cookie of uniqueCookies) {
142 + const cookieLower = cookie.toLowerCase()
143 + for (const name of sessionCookieNames) {
144 + if (cookieLower.includes(name.toLowerCase() + '=')) {
145 + console.log(`[openid] 找到会话 cookie: ${name}`)
146 + return cookie
147 + }
148 + }
149 + }
150 +
151 + // 如果没有找到会话 cookie,返回第一个(兼容旧逻辑)
152 + console.warn('[openid] 未找到会话 cookie,使用第一个 cookie')
135 return uniqueCookies[0] || null 153 return uniqueCookies[0] || null
136 } 154 }
137 155
......
...@@ -28,11 +28,13 @@ export const getSessionId = () => { ...@@ -28,11 +28,13 @@ export const getSessionId = () => {
28 28
29 /** 29 /**
30 * @description 设置 sessionid(一般不需要手动调用) 30 * @description 设置 sessionid(一般不需要手动调用)
31 - * - 正常情况下由 authRedirect.refreshSession 写入 31 + * - 正常情况下由 authRedirect.refreshSession 或 miniProgramAuth 写入
32 * - 保留该方法用于极端场景的手动修复/兼容旧逻辑 32 * - 保留该方法用于极端场景的手动修复/兼容旧逻辑
33 - * - 自动清理重复的 cookie(防止后端返回重复的 set-cookie)
34 * @param {string} sessionid cookie 字符串 33 * @param {string} sessionid cookie 字符串
35 * @returns {void} 无返回值 34 * @returns {void} 无返回值
35 + *
36 + * @note 输入的 sessionid 已经是标准化后的单个 cookie 字符串
37 + * (由 openid.js 的 normalizeCookies 处理过),无需额外清理
36 */ 38 */
37 export const setSessionId = sessionid => { 39 export const setSessionId = sessionid => {
38 try { 40 try {
...@@ -40,67 +42,16 @@ export const setSessionId = sessionid => { ...@@ -40,67 +42,16 @@ export const setSessionId = sessionid => {
40 return 42 return
41 } 43 }
42 44
43 - // 自动清理重复的 cookie 45 + // 直接存储 sessionid
44 - // 例如:"PHPSESSID=xxx; ...,PHPSESSID=xxx; ..." -> "PHPSESSID=xxx; ..." 46 + // 注意:输入已经是标准化后的 cookie 字符串,不需要 split(',') 处理
45 - const cleaned = cleanupDuplicateCookies(sessionid) 47 + // split(',') 会破坏 Expires 属性中的日期逗号(如 "Wed, 21 Oct 2025")
46 - 48 + Taro.setStorageSync('sessionid', sessionid)
47 - Taro.setStorageSync('sessionid', cleaned)
48 } catch (error) { 49 } catch (error) {
49 console.error('设置sessionid失败:', error) 50 console.error('设置sessionid失败:', error)
50 } 51 }
51 } 52 }
52 53
53 /** 54 /**
54 - * @description 清理重复的 cookie
55 - * @description 如果 cookie 字符串中有重复项,只保留第一个
56 - * @param {string} cookies cookie 字符串
57 - * @returns {string} 清理后的 cookie 字符串
58 - *
59 - * @example
60 - * cleanupDuplicateCookies('PHPSESSID=xxx; ...,PHPSESSID=xxx; ...')
61 - * // 返回: 'PHPSESSID=xxx; ...'
62 - */
63 -function cleanupDuplicateCookies(cookies) {
64 - if (!cookies || typeof cookies !== 'string') {
65 - return cookies
66 - }
67 -
68 - // 按逗号分割(重复的 cookie 用逗号连接)
69 - const parts = cookies.split(',')
70 -
71 - // 如果只有一个部分,直接返回
72 - if (parts.length === 1) {
73 - return cookies
74 - }
75 -
76 - // 提取 cookie 名称(用于去重)
77 - const extractCookieName = (cookieStr) => {
78 - const match = cookieStr.match(/^([^=]+)=/)
79 - return match ? match[1].trim() : null
80 - }
81 -
82 - // 去重:只保留第一次出现的 cookie
83 - const seen = new Set()
84 - const uniqueParts = []
85 -
86 - for (const part of parts) {
87 - const name = extractCookieName(part)
88 - if (name && !seen.has(name)) {
89 - seen.add(name)
90 - uniqueParts.push(part)
91 - }
92 - }
93 -
94 - // 如果去重后只剩一个,直接返回
95 - if (uniqueParts.length === 1) {
96 - return uniqueParts[0]
97 - }
98 -
99 - // 否则用逗号连接
100 - return uniqueParts.join(',')
101 -}
102 -
103 -/**
104 * @description 清空 sessionid(一般不需要手动调用) 55 * @description 清空 sessionid(一般不需要手动调用)
105 * @returns {void} 无返回值 56 * @returns {void} 无返回值
106 */ 57 */
...@@ -153,7 +104,9 @@ const service = axios.create({ ...@@ -153,7 +104,9 @@ const service = axios.create({
153 timeout: 5000, 104 timeout: 5000,
154 }) 105 })
155 106
156 -let has_shown_timeout_modal = false 107 +// 网络提示状态管理
108 +let last_timeout_prompt_time = 0
109 +const TIMEOUT_PROMPT_COOLDOWN = 30000 // 30 秒内不重复提示
157 110
158 /** 111 /**
159 * @description 判断是否为超时错误 112 * @description 判断是否为超时错误
...@@ -205,12 +158,21 @@ const should_handle_bad_network = async (error) => { ...@@ -205,12 +158,21 @@ const should_handle_bad_network = async (error) => {
205 158
206 /** 159 /**
207 * @description 处理请求超时/弱网错误 160 * @description 处理请求超时/弱网错误
208 - * - 弹出弱网提示 161 + * - 使用时间窗口防抖,30 秒内不重复提示
162 + * - 提示用户检查网络连接
209 * @returns {Promise<void>} 无返回值 163 * @returns {Promise<void>} 无返回值
210 */ 164 */
211 const handle_request_timeout = async () => { 165 const handle_request_timeout = async () => {
212 - if (has_shown_timeout_modal) return 166 + const now = Date.now()
213 - has_shown_timeout_modal = true 167 +
168 + // 检查是否在冷却时间内
169 + if (now - last_timeout_prompt_time < TIMEOUT_PROMPT_COOLDOWN) {
170 + console.log('[request] 网络提示在冷却期内,跳过')
171 + return
172 + }
173 +
174 + // 更新上次提示时间
175 + last_timeout_prompt_time = now
214 176
215 // 提示用户检查网络连接 177 // 提示用户检查网络连接
216 try { 178 try {
......