feat(evaluation): 模块1.2 - 增强评查点分组创建/更新接口验证

## 主要改进

### 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 <noreply@anthropic.com>
This commit is contained in:
2025-11-25 12:11:12 +08:00
parent d3b9403d64
commit e148fca429
+190 -71
View File
@@ -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<ApiResponse<ApiRuleGroup> | 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<ApiRuleGroup>(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<ApiRuleGroup> = {
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<ApiRuleGroup> = {
name: trimmedName,
code: trimmedCode,
description: data.description?.trim() || '',
is_enabled: data.is_enabled !== undefined ? data.is_enabled : true
};
// 注意:不包含 pid 字段,防止误修改
const response = await postgrestPut<ApiResponse<RuleGroup> | RuleGroup, Partial<ApiRuleGroup>>(
'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<RuleGroup>(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
};
}
}