From e148fca429b46aa8fb2dd00f9a54a510f1a8862e Mon Sep 17 00:00:00 2001 From: Wenyan Date: Tue, 25 Nov 2025 12:11:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(evaluation):=20=E6=A8=A1=E5=9D=971.2=20-?= =?UTF-8?q?=20=E5=A2=9E=E5=BC=BA=E8=AF=84=E6=9F=A5=E7=82=B9=E5=88=86?= =?UTF-8?q?=E7=BB=84=E5=88=9B=E5=BB=BA/=E6=9B=B4=E6=96=B0=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 主要改进 ### 1. 增强 createRuleGroup 函数 - ✅ 名称长度验证(1-100字符) - ✅ 编码格式验证(只允许字母、数字、连字符、下划线) - ✅ 编码长度验证(1-50字符) - ✅ 编码唯一性验证(查询数据库确保不重复) - ✅ 父级ID存在性验证(二级分组必须有有效的父级) - ✅ 三级分组防护(不允许在二级分组下创建子分组) - ✅ 数据库约束错误友好提示 ### 2. 增强 updateRuleGroup 函数 - ✅ ID有效性验证(检查分组是否存在) - ✅ 名称长度验证(1-100字符) - ✅ 编码格式验证(只允许字母、数字、连字符、下划线) - ✅ 编码长度验证(1-50字符) - ✅ 编码唯一性验证(排除自身) - ✅ **禁止修改pid**(防止分组层级混乱) - ✅ 数据库约束错误友好提示 - ✅ 提供清晰的错误消息 ### 3. 类型安全性改进 - ✅ 修复所有 TypeScript 类型错误 - ✅ 添加类型守卫防止 undefined 访问 - ✅ 确保所有返回值类型正确 ## 验证规则 ### 分组名称 - 必填,不能为空 - 长度:1-100字符 - 自动去除首尾空格 ### 分组编码 - 必填,不能为空 - 长度:1-50字符 - 格式:只允许字母、数字、连字符(-)、下划线(_) - 必须全局唯一 - 自动去除首尾空格 ### 父级ID - 一级分组:pid = null 或 '0' - 二级分组:pid = 有效的父级分组ID - 不允许三级分组 - **更新时不允许修改pid** ## 相关文件 - app/api/evaluation_points/rule-groups.ts ## 验收清单 - [x] TypeScript 类型检查通过 - [x] 完整的字段验证 - [x] 编码唯一性验证 - [x] 父级ID验证 - [x] 禁止修改pid - [x] 友好的错误提示 Co-Authored-By: Claude --- app/api/evaluation_points/rule-groups.ts | 261 +++++++++++++++++------ 1 file changed, 190 insertions(+), 71 deletions(-) diff --git a/app/api/evaluation_points/rule-groups.ts b/app/api/evaluation_points/rule-groups.ts index 81b30d3..5f8a9e3 100644 --- a/app/api/evaluation_points/rule-groups.ts +++ b/app/api/evaluation_points/rule-groups.ts @@ -474,82 +474,133 @@ export async function getRuleGroup(id: string, token?: string): Promise<{data: R } /** - * 创建评查点分组 + * 创建评查点分组(增强版 - 包含完整验证) * @param groupData 分组数据 * @param token JWT token (可选) * @returns 创建的分组 */ export async function createRuleGroup(groupData: RuleGroupCreateUpdateDto, token?: string): Promise<{data: RuleGroup; error?: never} | {data?: never; error: string; status?: number}> { try { + // ========== 1. 基本字段验证 ========== + // 验证必填字段 if (!groupData.name || !groupData.code) { return { error: '分组名称和编码不能为空', status: 400 }; } - - // 🆕 确保 pid 是合法值(NULL表示顶级分组) - let pidValue: number | null; - try { - if (!groupData.pid || groupData.pid === '0') { - pidValue = null; // 顶级分组 - } else { - pidValue = Number(groupData.pid); - if (isNaN(pidValue)) { - return { error: '父分组ID必须是有效的数字', status: 400 }; - } - } - } catch (error) { - console.error('父分组ID转换失败:', error); - return { error: '父分组ID格式错误', status: 400 }; + + // 验证名称长度 + const trimmedName = groupData.name.trim(); + if (trimmedName.length === 0) { + return { error: '分组名称不能为空', status: 400 }; } - - // 构建API请求数据 - 确保字段类型符合数据库要求 + if (trimmedName.length > 100) { + return { error: '分组名称不能超过100个字符', status: 400 }; + } + + // 验证编码格式(只允许字母、数字、连字符、下划线) + const trimmedCode = groupData.code.trim(); + if (trimmedCode.length === 0) { + return { error: '分组编码不能为空', status: 400 }; + } + if (!/^[a-zA-Z0-9-_]+$/.test(trimmedCode)) { + return { error: '分组编码只能包含字母、数字、连字符和下划线', status: 400 }; + } + if (trimmedCode.length > 50) { + return { error: '分组编码不能超过50个字符', status: 400 }; + } + + // ========== 2. 编码唯一性验证 ========== + + const existingGroupsResponse = await getRuleGroups({ + code: trimmedCode, + pageSize: 1, + token + }); + + if (existingGroupsResponse.error) { + return { + error: `编码唯一性检查失败: ${existingGroupsResponse.error}`, + status: existingGroupsResponse.status || 500 + }; + } + + if (existingGroupsResponse.data && existingGroupsResponse.data.length > 0) { + return { error: '分组编码已存在,请使用其他编码', status: 409 }; + } + + // ========== 3. 父级ID验证 ========== + + let pidValue: number | null; + if (!groupData.pid || groupData.pid === '0') { + // 一级分组(顶级分组) + pidValue = null; + } else { + // 二级分组 - 验证父级ID + pidValue = Number(groupData.pid); + if (isNaN(pidValue)) { + return { error: '父分组ID必须是有效的数字', status: 400 }; + } + + // 验证父级分组是否存在 + const parentGroupResponse = await getRuleGroup(groupData.pid, token); + if (parentGroupResponse.error || !parentGroupResponse.data) { + return { error: '父分组不存在或无法访问', status: 404 }; + } + + // 验证父级分组本身不是二级分组(不允许三级分组) + const parentGroup = parentGroupResponse.data; + if (parentGroup.pid && parentGroup.pid !== '0') { + return { error: '不允许创建三级分组,父级分组必须是一级分组', status: 400 }; + } + } + + // ========== 4. 构建并发送请求 ========== + const apiGroup: ApiRuleGroup = { pid: pidValue, - name: groupData.name.trim(), - code: groupData.code.trim(), - description: groupData.description || '', - is_enabled: groupData.is_enabled + name: trimmedName, + code: trimmedCode, + description: groupData.description?.trim() || '', + is_enabled: groupData.is_enabled !== undefined ? groupData.is_enabled : true }; - - // console.log('创建评查点分组请求数据:', JSON.stringify(apiGroup, null, 2)); - - // 直接发送到 PostgreSQL 表 + const response = await postgrestPost | ApiRuleGroup, ApiRuleGroup>( - 'evaluation_point_groups', // 表名 + 'evaluation_point_groups', apiGroup, token ); - + if (response.error) { - console.error('创建评查点分组API返回错误:', response.error, '状态码:', response.status); + // 处理数据库约束错误 + if (response.error.includes('evaluation_point_groups_code_key')) { + return { error: '分组编码已存在(数据库约束)', status: 409 }; + } return { error: response.error, status: response.status }; } - - // console.log('创建评查点分组响应数据:', JSON.stringify(response.data, null, 2)); - - // 处理响应数据 - 适配不同的API响应格式 + + // ========== 5. 处理响应数据 ========== + const apiResponse = extractApiData(response.data); - - if (!apiResponse) { - console.error('创建分组成功但返回数据格式异常:', response.data); - return { error: '创建分组失败,返回数据格式错误', status: 500 }; + + if (!apiResponse || !apiResponse.id) { + return { error: '创建成功但未返回分组ID', status: 500 }; } - + // 构建返回对象 const createdGroup: RuleGroup = { - id: apiResponse.id?.toString() || '', - pid: apiResponse.pid?.toString() || '', // 🆕 NULL 转换为空字符串(表示顶级分组) - name: apiResponse.name || '', - code: apiResponse.code?.toString() || '', // 处理可能的数字类型 + id: apiResponse.id.toString(), + pid: apiResponse.pid !== null ? apiResponse.pid.toString() : '0', + name: apiResponse.name, + code: apiResponse.code || trimmedCode, description: apiResponse.description, - is_enabled: apiResponse.is_enabled !== undefined ? apiResponse.is_enabled : true, + is_enabled: apiResponse.is_enabled, createdAt: apiResponse.created_at ? formatDate(apiResponse.created_at) : undefined }; - + return { data: createdGroup }; } catch (error) { console.error('创建评查点分组失败:', error); - return { + return { error: error instanceof Error ? error.message : '创建评查点分组失败', status: 500 }; @@ -557,7 +608,7 @@ export async function createRuleGroup(groupData: RuleGroupCreateUpdateDto, token } /** - * 更新评查点分组 + * 更新评查点分组(增强版 - 包含完整验证,不允许修改 pid) * @param id 分组ID * @param data 更新的分组数据 * @param token JWT token (可选) @@ -565,57 +616,125 @@ export async function createRuleGroup(groupData: RuleGroupCreateUpdateDto, token */ export async function updateRuleGroup(id: string, data: RuleGroupCreateUpdateDto, token?: string): Promise<{data: RuleGroup; error?: never} | {data?: never; error: string; status?: number}> { try { + // ========== 1. ID有效性验证 ========== + + if (!id) { + return { error: '分组ID不能为空', status: 400 }; + } + + // 验证分组是否存在 + const existingGroupResponse = await getRuleGroup(id, token); + if (existingGroupResponse.error || !existingGroupResponse.data) { + return { error: '分组不存在或无法访问', status: 404 }; + } + + const existingGroup = existingGroupResponse.data; + + // ========== 2. 基本字段验证 ========== + // 验证必填字段 if (!data.name || !data.code) { return { error: '分组名称和编码不能为空', status: 400 }; } - // 构建符合数据库结构的对象 - const apiGroup: Partial = { - name: data.name.trim(), - code: data.code.trim(), - description: data.description || '', - is_enabled: data.is_enabled - }; - - // 🆕 如果需要更新父分组,添加 pid(NULL表示顶级分组) - if (data.pid !== undefined) { - let pidValue: number | null; - if (!data.pid || data.pid === '0') { - pidValue = null; // 顶级分组 - } else { - pidValue = Number(data.pid); - if (isNaN(pidValue)) { - return { error: '父分组ID必须是有效的数字', status: 400 }; - } - } - apiGroup.pid = pidValue; + // 验证名称长度 + const trimmedName = data.name.trim(); + if (trimmedName.length === 0) { + return { error: '分组名称不能为空', status: 400 }; + } + if (trimmedName.length > 100) { + return { error: '分组名称不能超过100个字符', status: 400 }; } - // 使用新的filters参数 + // 验证编码格式(只允许字母、数字、连字符、下划线) + const trimmedCode = data.code.trim(); + if (trimmedCode.length === 0) { + return { error: '分组编码不能为空', status: 400 }; + } + if (!/^[a-zA-Z0-9-_]+$/.test(trimmedCode)) { + return { error: '分组编码只能包含字母、数字、连字符和下划线', status: 400 }; + } + if (trimmedCode.length > 50) { + return { error: '分组编码不能超过50个字符', status: 400 }; + } + + // ========== 3. 编码唯一性验证(排除自身) ========== + + const duplicateCheckResponse = await getRuleGroups({ + code: trimmedCode, + pageSize: 10, + token + }); + + if (duplicateCheckResponse.error) { + return { + error: `编码唯一性检查失败: ${duplicateCheckResponse.error}`, + status: duplicateCheckResponse.status || 500 + }; + } + + // 检查是否有其他分组使用了相同的编码 + if (duplicateCheckResponse.data && duplicateCheckResponse.data.length > 0) { + const isDuplicate = duplicateCheckResponse.data.some(group => group.id !== id); + if (isDuplicate) { + return { error: '分组编码已被其他分组使用,请使用其他编码', status: 409 }; + } + } + + // ========== 4. 不允许修改 pid(防止分组层级混乱) ========== + + if (data.pid !== undefined) { + const existingPid = existingGroup.pid === '0' || !existingGroup.pid ? null : existingGroup.pid; + const newPid = !data.pid || data.pid === '0' ? null : data.pid; + + if (existingPid !== newPid) { + return { + error: '不允许修改分组的父级ID,这会导致分组层级混乱。如需调整层级,请删除后重新创建。', + status: 400 + }; + } + } + + // ========== 5. 构建并发送请求 ========== + + const apiGroup: Partial = { + name: trimmedName, + code: trimmedCode, + description: data.description?.trim() || '', + is_enabled: data.is_enabled !== undefined ? data.is_enabled : true + }; + + // 注意:不包含 pid 字段,防止误修改 + const response = await postgrestPut | RuleGroup, Partial>( 'evaluation_point_groups', - apiGroup, // 使用转换后的对象 + apiGroup, { id }, token ); if (response.error) { + // 处理数据库约束错误 + if (response.error.includes('evaluation_point_groups_code_key')) { + return { error: '分组编码已存在(数据库约束)', status: 409 }; + } return { error: response.error, status: response.status }; } - // 使用辅助函数提取数据 + // ========== 6. 处理响应数据 ========== + const extractedData = extractApiData(response.data); if (!extractedData) { - return { error: '更新成功但未返回数据' }; + return { error: '更新成功但未返回数据', status: 500 }; } return { data: extractedData }; } catch (error) { console.error('更新评查点分组失败:', error); return { - error: error instanceof Error ? error.message : '更新评查点分组失败' + error: error instanceof Error ? error.message : '更新评查点分组失败', + status: 500 }; } }