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 @@
---
### 10:51:02 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:51:49 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:52:54 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:53:57 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:57:47 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:58:24 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:59:19 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
### 10:59:46 - 完成任务
**影响文件**:
- `.husky/pre-commit`
- `.husky/prepare-commit-msg`
**变更摘要**:
- 无详细描述
......
......@@ -34,6 +34,7 @@ const App = createApp({
setTimeout(() => {
const env = process.env.NODE_ENV || 'unknown'
const platform = process.env.TARO_ENV || 'unknown'
const isDev = env === 'development'
console.log('\n==================== 环境信息 ====================')
console.log(`📱 当前环境: ${env === 'development' ? '开发环境' : env === 'production' ? '生产环境' : env}`)
......@@ -59,14 +60,38 @@ const App = createApp({
console.log('🔐 会话状态: 无法获取')
}
// 打印用户登录状态
// 打印用户登录状态(生产环境不显示敏感信息)
console.log('\n==================== 用户信息 ====================')
if (userStore.isLoggedIn && userStore.userInfo) {
console.log(`✅ 登录状态: 已登录`)
// 仅开发环境显示详细用户信息
if (isDev) {
console.log(`👤 用户ID: ${userStore.userInfo.id || '未知'}`)
console.log(`👤 用户名: ${userStore.userInfo.name || userStore.userInfo.nickname || '未知'}`)
console.log(`📱 手机号: ${userStore.userInfo.phone || userStore.userInfo.mobile || '未绑定'}`)
} else {
// 生产环境:只显示脱敏信息
const phone = userStore.userInfo.phone || userStore.userInfo.mobile
const maskedPhone = phone ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : '未绑定'
// 用户名脱敏:保留首字和末字,中间用 * 代替
const userName = userStore.userInfo.name || userStore.userInfo.nickname || ''
let maskedName = '***'
if (userName && userName.length > 0) {
if (userName.length === 1) {
maskedName = '*'
} else if (userName.length === 2) {
maskedName = userName[0] + '*'
} else {
maskedName = userName[0] + '*'.repeat(userName.length - 2) + userName[userName.length - 1]
}
}
console.log(`👤 用户名: ${maskedName}`)
console.log(`📱 手机号: ${maskedPhone}`)
}
} else {
console.log(`❌ 登录状态: 未登录`)
}
......
......@@ -17,23 +17,48 @@ import { PLAN_FIELD_DEFINITIONS, FIELD_GROUPS, getFieldsByGroup } from '@/config
import { viewAPI } from '@/api/plan'
// Mock Taro API
vi.mock('@tarojs/taro', () => ({
vi.mock('@tarojs/taro', () => {
const showToast = vi.fn()
const showModal = vi.fn()
const showLoading = vi.fn()
const hideLoading = vi.fn()
const showActionSheet = vi.fn()
const navigateTo = vi.fn()
const redirectTo = vi.fn()
return {
default: {
showToast: vi.fn(),
showModal: vi.fn(),
showLoading: vi.fn(),
hideLoading: vi.fn(),
showActionSheet: vi.fn(),
navigateTo: vi.fn(),
redirectTo: vi.fn()
showToast,
showModal,
showLoading,
hideLoading,
showActionSheet,
navigateTo,
redirectTo
},
// 导出命名导出以匹配 useFileOperation 中的用法
showToast,
showModal,
showLoading,
hideLoading,
showActionSheet,
navigateTo,
redirectTo
}
}))
})
// Mock viewAPI
vi.mock('@/api/plan', () => ({
viewAPI: vi.fn()
}))
// Mock useFileOperation
vi.mock('@/composables/useFileOperation', () => ({
useFileOperation: () => ({
viewFile: vi.fn().mockResolvedValue(true) // 模拟文件预览成功
})
}))
describe('计划书模块集成测试', () => {
beforeEach(() => {
vi.clearAllMocks()
......
......@@ -64,7 +64,7 @@ describe('搜索页面测试', () => {
expect(wrapper.vm.shouldEnableScrollLoad).toBe(false)
expect(wrapper.vm.tabsData.length).toBe(2)
expect(wrapper.vm.tabsData[0].id).toBe('product')
expect(wrapper.vm.tabsData[1].id).toBe('file')
expect(wrapper.vm.tabsData[1].id).toBe('article')
})
it('空关键词时提示并保持未搜索状态', async () => {
......@@ -87,8 +87,8 @@ describe('搜索页面测试', () => {
list: [{ id: 1, product_name: '保险A', tags: [] }],
total: 1
},
files: {
list: [{ id: 10, name: '培训资料', value: 'url', size: '1MB', extension: 'pdf', is_favorite: 1 }],
article: {
list: [{ id: 10, post_title: '培训指南', post_excerpt: '这是培训资料简介', post_date: '2026-01-01', is_favorite: 1 }],
total: 1
}
}
......@@ -114,9 +114,9 @@ describe('搜索页面测试', () => {
list: [{ id: 1, product_name: '保险A', tags: [] }],
total: 1
},
files: {
list: [],
total: 0
article: {
list: [{ id: 20, post_title: '相关文章', post_excerpt: '摘要', post_date: '2026-01-01', is_favorite: 0 }],
total: 1
}
}
})
......@@ -132,18 +132,18 @@ describe('搜索页面测试', () => {
list: [],
total: 0
},
files: {
list: [{ id: 10, name: '培训资料', value: 'url', size: '1MB', extension: 'pdf', is_favorite: 0 }],
article: {
list: [{ id: 10, post_title: '培训资料', post_excerpt: '摘要', post_date: '2026-01-01', is_favorite: 0 }],
total: 1
}
}
})
await wrapper.vm.onTabClick('file')
await wrapper.vm.onTabClick('article')
expect(wrapper.vm.activeTab).toBe('file')
expect(wrapper.vm.activeTab).toBe('article')
expect(wrapper.vm.currentList.length).toBe(1)
expect(searchAPI).toHaveBeenCalledWith({ keyword: '保险', page: 0, limit: 20, type: 'file' })
expect(searchAPI).toHaveBeenCalledWith({ keyword: '保险', page: 0, limit: 20, type: 'article' })
})
it('清空搜索后重置状态', async () => {
......@@ -156,7 +156,7 @@ describe('搜索页面测试', () => {
list: [{ id: 1, product_name: '保险A', tags: [] }],
total: 1
},
files: {
article: {
list: [],
total: 0
}
......
......@@ -281,11 +281,11 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
: await searchAPI(params)
if (res.code === 1) {
// 映射产品列表
const newProducts = res.data.products.list || []
// 映射产品列表(使用可选链防止后端字段缺失时抛错)
const newProducts = res.data.products?.list || []
// 映射文章列表(参考本周热门文章的映射方式)
const newArticles = (res.data.article.list || []).map(item => ({
const newArticles = (res.data.article?.list || []).map(item => ({
id: item.id,
title: item.post_title || '未命名文章',
excerpt: item.post_excerpt || '',
......@@ -307,8 +307,8 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
articles.value = newArticles
}
productsTotal.value = res.data.products.total || 0
articlesTotal.value = res.data.article.total || 0
productsTotal.value = res.data.products?.total || 0
articlesTotal.value = res.data.article?.total || 0
// ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore
// 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
......
......@@ -106,15 +106,18 @@ function extractCookieFromResponse(response) {
}
/**
* 标准化 cookie 数组并去重
* @description 将 cookie 数组转换为字符串,只提取唯一的 cookie(忽略重复项)
* 标准化 cookie 数组并提取会话 cookie
* @description 将 cookie 数组转换为字符串,优先提取包含会话标识的 cookie
* @param {string|string[]} cookies cookie 字符串或数组
* @returns {string|null} 标准化后的 cookie 字符串
*
* @example
* normalizeCookies(['PHPSESSID=xxx; path=/', 'PHPSESSID=xxx; path=/'])
* // 返回: 'PHPSESSID=xxx; path=/'
* // 多个 Set-Cookie,优先返回包含 sessionid/PHPSESSID 的
* normalizeCookies(['sessionid=xxx; path=/', 'csrftoken=yyy; path=/'])
* // 返回: 'sessionid=xxx; path=/'
*
* @example
* // 单个 cookie 字符串
* normalizeCookies('PHPSESSID=xxx; path=/')
* // 返回: 'PHPSESSID=xxx; path=/'
*/
......@@ -126,12 +129,27 @@ function normalizeCookies(cookies) {
return cookies
}
// 如果是数组,只取第一个 cookie(忽略重复项)
// 如果是数组,智能提取会话 cookie
if (Array.isArray(cookies)) {
// 使用 Set 去重
// 去重
const uniqueCookies = [...new Set(cookies)]
// 只返回第一个唯一的 cookie
// 常见的会话 cookie 名称
const sessionCookieNames = ['sessionid', 'PHPSESSID', 'jsessionid', 'JSESSIONID', 'sid', 'SID']
// 优先查找包含会话标识的 cookie
for (const cookie of uniqueCookies) {
const cookieLower = cookie.toLowerCase()
for (const name of sessionCookieNames) {
if (cookieLower.includes(name.toLowerCase() + '=')) {
console.log(`[openid] 找到会话 cookie: ${name}`)
return cookie
}
}
}
// 如果没有找到会话 cookie,返回第一个(兼容旧逻辑)
console.warn('[openid] 未找到会话 cookie,使用第一个 cookie')
return uniqueCookies[0] || null
}
......
......@@ -28,11 +28,13 @@ export const getSessionId = () => {
/**
* @description 设置 sessionid(一般不需要手动调用)
* - 正常情况下由 authRedirect.refreshSession 写入
* - 正常情况下由 authRedirect.refreshSession 或 miniProgramAuth 写入
* - 保留该方法用于极端场景的手动修复/兼容旧逻辑
* - 自动清理重复的 cookie(防止后端返回重复的 set-cookie)
* @param {string} sessionid cookie 字符串
* @returns {void} 无返回值
*
* @note 输入的 sessionid 已经是标准化后的单个 cookie 字符串
* (由 openid.js 的 normalizeCookies 处理过),无需额外清理
*/
export const setSessionId = sessionid => {
try {
......@@ -40,67 +42,16 @@ export const setSessionId = sessionid => {
return
}
// 自动清理重复的 cookie
// 例如:"PHPSESSID=xxx; ...,PHPSESSID=xxx; ..." -> "PHPSESSID=xxx; ..."
const cleaned = cleanupDuplicateCookies(sessionid)
Taro.setStorageSync('sessionid', cleaned)
// 直接存储 sessionid
// 注意:输入已经是标准化后的 cookie 字符串,不需要 split(',') 处理
// split(',') 会破坏 Expires 属性中的日期逗号(如 "Wed, 21 Oct 2025")
Taro.setStorageSync('sessionid', sessionid)
} 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} 无返回值
*/
......@@ -153,7 +104,9 @@ const service = axios.create({
timeout: 5000,
})
let has_shown_timeout_modal = false
// 网络提示状态管理
let last_timeout_prompt_time = 0
const TIMEOUT_PROMPT_COOLDOWN = 30000 // 30 秒内不重复提示
/**
* @description 判断是否为超时错误
......@@ -205,12 +158,21 @@ const should_handle_bad_network = async (error) => {
/**
* @description 处理请求超时/弱网错误
* - 弹出弱网提示
* - 使用时间窗口防抖,30 秒内不重复提示
* - 提示用户检查网络连接
* @returns {Promise<void>} 无返回值
*/
const handle_request_timeout = async () => {
if (has_shown_timeout_modal) return
has_shown_timeout_modal = true
const now = Date.now()
// 检查是否在冷却时间内
if (now - last_timeout_prompt_time < TIMEOUT_PROMPT_COOLDOWN) {
console.log('[request] 网络提示在冷却期内,跳过')
return
}
// 更新上次提示时间
last_timeout_prompt_time = now
// 提示用户检查网络连接
try {
......