From e7646d17a67dc4a3f0d5faa32d8f8e8268101690 Mon Sep 17 00:00:00 2001 From: Wenyan Date: Wed, 26 Nov 2025 10:05:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(evaluation-groups):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=80=E7=BA=A7=E5=88=86=E7=BB=84=E6=98=BE=E7=A4=BA=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=92=8C=20React=20key=20=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修复内容 ### 1. 修复一级分组过滤问题 - **问题**: getEvaluationPointGroups 函数忽略了 pid 参数,导致返回所有分组(包括二级分组) - **修复**: 添加 pid 参数处理逻辑,支持传递 "null" 字符串来查询一级分组 - **文件**: app/api/evaluation_points/rule-groups.ts:1186-1198 ### 2. 修复 React key 重复警告 - **问题**: 父分组和子分组可能有相同的 ID,导致 "Encountered two children with the same key" 警告 - **修复**: 将 rowKey 从简单的 "id" 改为根据 isParent 生成唯一 key - **文件**: app/routes/rule-groups._index.tsx:817 ### 3. 新增后端 API 规范文档 - **文件**: docs/evaluation/evaluation_point_groups_backend_api_spec.md - **内容**: - 完整的 9 个 FastAPI v3 接口规范 - Python Pydantic 模型定义 - TypeScript 接口定义 - pid 参数处理说明(字符串 "null" 转换为 None) - 10 个完整测试用例 - 数据库表结构建议 ## 技术细节 **pid 参数处理**: ```typescript // 前端发送 GET /api/v3/evaluation-point-groups?pid=null&page=1 // 后端需要识别字符串 "null" 并转换为 None/NULL if (pid == "null") { query = query.filter(EvaluationPointGroup.pid.is_(None)) } ``` **唯一 key 生成**: ```typescript rowKey={(record) => record.isParent ? `parent-${record.id}` : `child-${record.id}`} ``` 🔗 相关文档: docs/evaluation/evaluation_point_groups_backend_api_spec.md --- app/api/evaluation_points/rule-groups.ts | 14 + app/routes/rule-groups._index.tsx | 2 +- ...valuation_point_groups_backend_api_spec.md | 1568 +++++++++++++++++ 3 files changed, 1583 insertions(+), 1 deletion(-) create mode 100644 docs/evaluation/evaluation_point_groups_backend_api_spec.md diff --git a/app/api/evaluation_points/rule-groups.ts b/app/api/evaluation_points/rule-groups.ts index 5c6f58b..057cb0e 100644 --- a/app/api/evaluation_points/rule-groups.ts +++ b/app/api/evaluation_points/rule-groups.ts @@ -1171,6 +1171,7 @@ export async function getEvaluationPointGroups( name, code, is_enabled, + pid, orderBy = 'created_at', order = 'desc' } = params || {}; @@ -1182,6 +1183,19 @@ export async function getEvaluationPointGroups( if (name) queryParams.append('name', name); if (code) queryParams.append('code', code); if (is_enabled !== undefined) queryParams.append('is_enabled', String(is_enabled)); + // 🔑 添加 pid 参数过滤 + // pid=null 或 pid='0' 表示只查询一级分组,后端需要识别字符串 "null" + // 如果 pid 未定义,则不传该参数(默认查询所有分组) + if (pid !== undefined) { + if (pid === null || pid === '0') { + // 方案1:传递字符串 "null",后端需要识别并转换为 None/NULL + queryParams.append('pid', 'null'); + // 方案2:不传参数,后端默认查询一级分组(需要后端支持) + // 不添加 pid 参数 + } else { + queryParams.append('pid', String(pid)); + } + } const url = `/api/v3/evaluation-point-groups?${queryParams.toString()}`; diff --git a/app/routes/rule-groups._index.tsx b/app/routes/rule-groups._index.tsx index 90cb9f2..8774184 100644 --- a/app/routes/rule-groups._index.tsx +++ b/app/routes/rule-groups._index.tsx @@ -814,7 +814,7 @@ export default function RuleGroupsIndex() { record.isParent ? `parent-${record.id}` : `child-${record.id}`} emptyText="暂无分组数据" className="tree-table" /> diff --git a/docs/evaluation/evaluation_point_groups_backend_api_spec.md b/docs/evaluation/evaluation_point_groups_backend_api_spec.md new file mode 100644 index 0000000..a8093cb --- /dev/null +++ b/docs/evaluation/evaluation_point_groups_backend_api_spec.md @@ -0,0 +1,1568 @@ +# 评查点分组 FastAPI v3 后端接口规范 + +## 📚 目录 +- [数据模型定义](#数据模型定义) +- [接口列表](#接口列表) +- [详细接口说明](#详细接口说明) +- [错误处理规范](#错误处理规范) +- [验证规则](#验证规则) +- [完整测试用例](#完整测试用例) + +--- + +## 数据模型定义 + +### Python (FastAPI/Pydantic) 模型 + +```python +from pydantic import BaseModel, Field, validator +from typing import Optional, List +from datetime import datetime +from enum import Enum + +# ==================== 基础模型 ==================== + +class EvaluationPointGroupBase(BaseModel): + """评查点分组基础模型""" + id: int = Field(..., description="分组ID") + pid: Optional[int] = Field(None, description="父分组ID,null表示一级分组") + name: str = Field(..., min_length=1, max_length=100, description="分组名称") + code: str = Field(..., min_length=1, max_length=50, description="分组编码") + description: Optional[str] = Field(None, max_length=500, description="分组描述") + is_enabled: bool = Field(True, description="是否启用") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + rule_count: Optional[int] = Field(0, ge=0, description="关联的评查点数量(包含子分组)") + + class Config: + json_schema_extra = { + "example": { + "id": 1, + "pid": None, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25 + } + } + +class EvaluationPointGroupTree(EvaluationPointGroupBase): + """评查点分组树形结构模型""" + children: List['EvaluationPointGroupTree'] = Field(default_factory=list, description="子分组列表") + + class Config: + json_schema_extra = { + "example": { + "id": 1, + "pid": None, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25, + "children": [ + { + "id": 2, + "pid": 1, + "name": "合同审批", + "code": "CONTRACT_001_001", + "description": None, + "is_enabled": True, + "created_at": "2025-01-15T10:31:00Z", + "updated_at": "2025-01-15T10:31:00Z", + "rule_count": 10, + "children": [] + } + ] + } + } + +# 启用递归模型 +EvaluationPointGroupTree.model_rebuild() + +# ==================== 请求模型 ==================== + +class EvaluationPointGroupCreateRequest(BaseModel): + """创建分组请求模型""" + pid: Optional[int] = Field(None, description="父分组ID,null表示创建一级分组") + name: str = Field(..., min_length=1, max_length=100, description="分组名称") + code: str = Field(..., min_length=1, max_length=50, description="分组编码,必须唯一") + description: Optional[str] = Field(None, max_length=500, description="分组描述") + is_enabled: bool = Field(True, description="是否启用") + + @validator('name') + def name_must_not_be_empty(cls, v): + if not v or not v.strip(): + raise ValueError('分组名称不能为空') + return v.strip() + + @validator('code') + def code_must_be_valid(cls, v): + if not v or not v.strip(): + raise ValueError('分组编码不能为空') + # 可以添加更多编码格式验证 + return v.strip().upper() + + class Config: + json_schema_extra = { + "example": { + "pid": 1, + "name": "新分组名称", + "code": "NEW_GROUP_001", + "description": "分组描述", + "is_enabled": True + } + } + +class EvaluationPointGroupUpdateRequest(BaseModel): + """更新分组请求模型""" + pid: Optional[int] = Field(None, description="父分组ID") + name: str = Field(..., min_length=1, max_length=100, description="分组名称") + code: str = Field(..., min_length=1, max_length=50, description="分组编码") + description: Optional[str] = Field(None, max_length=500, description="分组描述") + is_enabled: bool = Field(..., description="是否启用") + + @validator('name') + def name_must_not_be_empty(cls, v): + if not v or not v.strip(): + raise ValueError('分组名称不能为空') + return v.strip() + + class Config: + json_schema_extra = { + "example": { + "pid": 1, + "name": "更新后的名称", + "code": "UPDATED_001", + "description": "更新后的描述", + "is_enabled": False + } + } + +class BatchUpdateStatusRequest(BaseModel): + """批量更新状态请求模型""" + ids: List[int] = Field(..., min_items=1, description="要更新的分组ID列表") + is_enabled: bool = Field(..., description="目标状态") + + class Config: + json_schema_extra = { + "example": { + "ids": [1, 2, 3], + "is_enabled": False + } + } + +class BatchDeleteRequest(BaseModel): + """批量删除请求模型""" + ids: List[int] = Field(..., min_items=1, description="要删除的分组ID列表") + + class Config: + json_schema_extra = { + "example": { + "ids": [10, 11, 12] + } + } + +# ==================== 响应模型 ==================== + +class EvaluationPointGroupListResponse(BaseModel): + """分组列表响应模型""" + data: List[EvaluationPointGroupBase] = Field(..., description="分组列表") + total: int = Field(..., ge=0, description="总记录数") + page: int = Field(..., ge=1, description="当前页码") + page_size: int = Field(..., ge=1, le=1000, description="每页记录数") + + class Config: + json_schema_extra = { + "example": { + "data": [ + { + "id": 1, + "pid": None, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25 + } + ], + "total": 100, + "page": 1, + "page_size": 20 + } + } + +class EvaluationPointGroupTreeResponse(BaseModel): + """树形结构响应模型""" + data: List[EvaluationPointGroupTree] = Field(..., description="树形结构数据") + + class Config: + json_schema_extra = { + "example": { + "data": [ + { + "id": 1, + "pid": None, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25, + "children": [ + { + "id": 2, + "pid": 1, + "name": "合同审批", + "code": "CONTRACT_001_001", + "description": None, + "is_enabled": True, + "created_at": "2025-01-15T10:31:00Z", + "updated_at": "2025-01-15T10:31:00Z", + "rule_count": 10, + "children": [] + } + ] + } + ] + } + } + +class EvaluationPointGroupDetailResponse(BaseModel): + """分组详情响应模型""" + data: EvaluationPointGroupTree = Field(..., description="分组详情") + + class Config: + json_schema_extra = { + "example": { + "data": { + "id": 1, + "pid": None, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": True, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25, + "children": [] + } + } + } + +class EvaluationPointGroupCreateResponse(BaseModel): + """创建分组响应模型""" + data: EvaluationPointGroupBase = Field(..., description="创建的分组信息") + message: str = Field(default="创建成功", description="操作消息") + + class Config: + json_schema_extra = { + "example": { + "data": { + "id": 100, + "pid": 1, + "name": "新分组名称", + "code": "NEW_GROUP_001", + "description": "分组描述", + "is_enabled": True, + "created_at": "2025-01-20T14:30:00Z", + "updated_at": "2025-01-20T14:30:00Z", + "rule_count": 0 + }, + "message": "创建成功" + } + } + +class EvaluationPointGroupUpdateResponse(BaseModel): + """更新分组响应模型""" + data: EvaluationPointGroupBase = Field(..., description="更新后的分组信息") + message: str = Field(default="更新成功", description="操作消息") + + class Config: + json_schema_extra = { + "example": { + "data": { + "id": 100, + "pid": 1, + "name": "更新后的名称", + "code": "UPDATED_001", + "description": "更新后的描述", + "is_enabled": False, + "created_at": "2025-01-20T14:30:00Z", + "updated_at": "2025-01-20T14:35:00Z", + "rule_count": 5 + }, + "message": "更新成功" + } + } + +class SimpleMessageResponse(BaseModel): + """简单消息响应模型""" + message: str = Field(..., description="操作消息") + + class Config: + json_schema_extra = { + "example": { + "message": "删除成功" + } + } + +class BatchOperationResponse(BaseModel): + """批量操作响应模型""" + message: str = Field(..., description="操作消息") + updated_count: Optional[int] = Field(None, description="成功更新的数量") + deleted_count: Optional[int] = Field(None, description="成功删除的数量") + failed_ids: Optional[List[int]] = Field(None, description="失败的ID列表") + errors: Optional[dict] = Field(None, description="错误详情") + + class Config: + json_schema_extra = { + "example": { + "message": "批量删除成功", + "deleted_count": 2, + "failed_ids": [11], + "errors": { + "11": "该分组下存在子分组,无法删除" + } + } + } + +class ErrorResponse(BaseModel): + """错误响应模型""" + detail: str = Field(..., description="错误详情") + code: Optional[int] = Field(None, description="错误代码") + + class Config: + json_schema_extra = { + "example": { + "detail": "该分组不存在", + "code": 404 + } + } +``` + +### TypeScript (前端) 接口定义 + +```typescript +/** + * 评查点分组基础接口 + */ +export interface EvaluationPointGroupResponse { + id: number; + pid: number | null; + name: string; + code: string; + description: string | null; + is_enabled: boolean; + created_at: string; // ISO 8601 format + updated_at: string; // ISO 8601 format + rule_count?: number | null; + children?: EvaluationPointGroupResponse[] | null; +} + +/** + * 列表响应接口 + */ +export interface EvaluationPointGroupListResponse { + data: EvaluationPointGroupResponse[]; + total: number; + page: number; + page_size: number; +} + +/** + * 树形结构响应接口 + */ +export interface EvaluationPointGroupTreeResponse { + data: EvaluationPointGroupResponse[]; +} + +/** + * 详情响应接口 + */ +export interface EvaluationPointGroupDetailResponse { + data: EvaluationPointGroupResponse; +} + +/** + * 创建/更新请求接口 + */ +export interface EvaluationPointGroupCreateUpdateDto { + pid?: number | null; + name: string; + code: string; + description?: string | null; + is_enabled: boolean; +} + +/** + * 创建/更新响应接口 + */ +export interface EvaluationPointGroupCreateUpdateResponse { + data: EvaluationPointGroupResponse; + message: string; +} + +/** + * 批量更新状态请求接口 + */ +export interface BatchUpdateStatusRequest { + ids: number[]; + is_enabled: boolean; +} + +/** + * 批量删除请求接口 + */ +export interface BatchDeleteRequest { + ids: number[]; +} + +/** + * 批量操作响应接口 + */ +export interface BatchOperationResponse { + message: string; + updated_count?: number; + deleted_count?: number; + failed_ids?: number[]; + errors?: Record; +} + +/** + * 错误响应接口 + */ +export interface ErrorResponse { + detail: string; + code?: number; +} +``` + +--- + +## 接口列表 + +| 序号 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 1 | GET | `/api/v3/evaluation-point-groups` | 获取一级分组列表(分页) | +| 2 | GET | `/api/v3/evaluation-point-groups/all` | 获取完整树形结构 | +| 3 | GET | `/api/v3/evaluation-point-groups/{id}` | 获取单个分组详情 | +| 4 | GET | `/api/v3/evaluation-point-groups/{parent_id}/children` | 获取子分组列表(分页) | +| 5 | POST | `/api/v3/evaluation-point-groups` | 创建分组 | +| 6 | PUT | `/api/v3/evaluation-point-groups/{id}` | 更新分组 | +| 7 | DELETE | `/api/v3/evaluation-point-groups/{id}` | 删除分组 | +| 8 | PATCH | `/api/v3/evaluation-point-groups/batch/status` | 批量更新状态 | +| 9 | DELETE | `/api/v3/evaluation-point-groups/batch` | 批量删除 | + +--- + +## 详细接口说明 + +### 1. 获取一级分组列表(分页) + +**请求** +```http +GET /api/v3/evaluation-point-groups?pid=null&page=1&page_size=20&name=合同&code=CONTRACT&is_enabled=true +Authorization: Bearer {jwt_token} +``` + +**查询参数** +| 参数 | 类型 | 必填 | 说明 | 默认值 | +|------|------|------|------|--------| +| pid | string | 否 | 父分组ID筛选
- `pid=null`:只查询一级分组(**推荐**)
- 不传参数:查询所有分组
- `pid=60`:查询指定父分组的子分组 | - | +| page | integer | 否 | 页码(从1开始) | 1 | +| page_size | integer | 否 | 每页记录数 | 20 | +| name | string | 否 | 分组名称模糊搜索 | - | +| code | string | 否 | 分组编码模糊搜索 | - | +| is_enabled | boolean | 否 | 是否启用筛选 | - | + +**⚠️ 重要说明:pid 参数处理** + +前端会传递字符串 `"null"` 来表示查询一级分组,后端需要识别并转换: + +```python +# FastAPI 后端处理示例 +@app.get("/api/v3/evaluation-point-groups") +async def get_evaluation_point_groups( + pid: Optional[str] = None, # 接收字符串类型 + page: int = 1, + page_size: int = 20, + name: Optional[str] = None, + code: Optional[str] = None, + is_enabled: Optional[bool] = None +): + # 🔑 将字符串 "null" 转换为 None + if pid == "null": + pid_value = None + elif pid is not None: + pid_value = int(pid) + else: + # 不传 pid 参数时,根据业务需求决定: + # 选项1: 查询所有分组 + # 选项2: 默认查询一级分组(推荐) + pid_value = None + + # 使用 pid_value 构建查询 + query = db.query(EvaluationPointGroup) + if pid_value is None: + # 查询一级分组: WHERE pid IS NULL + query = query.filter(EvaluationPointGroup.pid.is_(None)) + else: + # 查询指定父分组的子分组 + query = query.filter(EvaluationPointGroup.pid == pid_value) + + # ... 其他过滤条件 +``` + +**成功响应 (200)** +```json +{ + "data": [ + { + "id": 1, + "pid": null, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25 + }, + { + "id": 5, + "pid": null, + "name": "合同审核类", + "code": "CONTRACT_002", + "description": null, + "is_enabled": true, + "created_at": "2025-01-16T09:20:00Z", + "updated_at": "2025-01-16T09:20:00Z", + "rule_count": 18 + } + ], + "total": 2, + "page": 1, + "page_size": 20 +} +``` + +**错误响应 (401)** +```json +{ + "detail": "未授权,请先登录", + "code": 401 +} +``` + +--- + +### 2. 获取完整树形结构 + +**请求** +```http +GET /api/v3/evaluation-point-groups/all?flat=false&include_disabled=true +Authorization: Bearer {jwt_token} +``` + +**查询参数** +| 参数 | 类型 | 必填 | 说明 | 默认值 | +|------|------|------|------|--------| +| flat | boolean | 否 | 是否返回扁平结构(true:扁平数组,false:树形) | false | +| include_disabled | boolean | 否 | 是否包含已禁用的分组 | true | + +**成功响应 (200) - 树形结构 (flat=false)** +```json +{ + "data": [ + { + "id": 1, + "pid": null, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25, + "children": [ + { + "id": 2, + "pid": 1, + "name": "合同审批", + "code": "CONTRACT_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:31:00Z", + "updated_at": "2025-01-15T10:31:00Z", + "rule_count": 10, + "children": [ + { + "id": 3, + "pid": 2, + "name": "审批流程", + "code": "CONTRACT_001_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:32:00Z", + "updated_at": "2025-01-15T10:32:00Z", + "rule_count": 5, + "children": [] + } + ] + }, + { + "id": 4, + "pid": 1, + "name": "合同签署", + "code": "CONTRACT_001_002", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:33:00Z", + "updated_at": "2025-01-15T10:33:00Z", + "rule_count": 15, + "children": [] + } + ] + } + ] +} +``` + +**成功响应 (200) - 扁平结构 (flat=true)** +```json +{ + "data": [ + { + "id": 1, + "pid": null, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25 + }, + { + "id": 2, + "pid": 1, + "name": "合同审批", + "code": "CONTRACT_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:31:00Z", + "updated_at": "2025-01-15T10:31:00Z", + "rule_count": 10 + }, + { + "id": 3, + "pid": 2, + "name": "审批流程", + "code": "CONTRACT_001_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:32:00Z", + "updated_at": "2025-01-15T10:32:00Z", + "rule_count": 5 + } + ] +} +``` + +--- + +### 3. 获取单个分组详情 + +**请求** +```http +GET /api/v3/evaluation-point-groups/1?include_children=true +Authorization: Bearer {jwt_token} +``` + +**路径参数** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | integer | 是 | 分组ID | + +**查询参数** +| 参数 | 类型 | 必填 | 说明 | 默认值 | +|------|------|------|------|--------| +| include_children | boolean | 否 | 是否包含子分组 | false | + +**成功响应 (200) - include_children=true** +```json +{ + "data": { + "id": 1, + "pid": null, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25, + "children": [ + { + "id": 2, + "pid": 1, + "name": "合同审批", + "code": "CONTRACT_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:31:00Z", + "updated_at": "2025-01-15T10:31:00Z", + "rule_count": 10, + "children": [] + } + ] + } +} +``` + +**成功响应 (200) - include_children=false** +```json +{ + "data": { + "id": 1, + "pid": null, + "name": "合同管理类", + "code": "CONTRACT_001", + "description": "合同管理相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-15T10:30:00Z", + "updated_at": "2025-01-15T10:30:00Z", + "rule_count": 25 + } +} +``` + +**错误响应 (404)** +```json +{ + "detail": "分组不存在", + "code": 404 +} +``` + +--- + +### 4. 获取子分组列表(分页) + +**请求** +```http +GET /api/v3/evaluation-point-groups/1/children?page=1&page_size=20 +Authorization: Bearer {jwt_token} +``` + +**路径参数** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| parent_id | integer | 是 | 父分组ID | + +**查询参数** +| 参数 | 类型 | 必填 | 说明 | 默认值 | +|------|------|------|------|--------| +| page | integer | 否 | 页码(从1开始) | 1 | +| page_size | integer | 否 | 每页记录数 | 20 | + +**成功响应 (200)** +```json +{ + "data": [ + { + "id": 2, + "pid": 1, + "name": "合同审批", + "code": "CONTRACT_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:31:00Z", + "updated_at": "2025-01-15T10:31:00Z", + "rule_count": 10 + }, + { + "id": 4, + "pid": 1, + "name": "合同签署", + "code": "CONTRACT_001_002", + "description": null, + "is_enabled": true, + "created_at": "2025-01-15T10:33:00Z", + "updated_at": "2025-01-15T10:33:00Z", + "rule_count": 15 + } + ], + "total": 2, + "page": 1, + "page_size": 20 +} +``` + +**错误响应 (404)** +```json +{ + "detail": "父分组不存在", + "code": 404 +} +``` + +--- + +### 5. 创建分组 + +**请求** +```http +POST /api/v3/evaluation-point-groups +Authorization: Bearer {jwt_token} +Content-Type: application/json + +{ + "pid": 1, + "name": "新分组名称", + "code": "NEW_GROUP_001", + "description": "分组描述", + "is_enabled": true +} +``` + +**请求体** +| 字段 | 类型 | 必填 | 说明 | 限制 | +|------|------|------|------|------| +| pid | integer/null | 否 | 父分组ID,null表示创建一级分组 | 必须是存在的分组ID | +| name | string | 是 | 分组名称 | 1-100字符,不能为空 | +| code | string | 是 | 分组编码 | 1-50字符,必须唯一 | +| description | string/null | 否 | 分组描述 | 最多500字符 | +| is_enabled | boolean | 是 | 是否启用 | - | + +**成功响应 (201)** +```json +{ + "data": { + "id": 100, + "pid": 1, + "name": "新分组名称", + "code": "NEW_GROUP_001", + "description": "分组描述", + "is_enabled": true, + "created_at": "2025-01-20T14:30:00Z", + "updated_at": "2025-01-20T14:30:00Z", + "rule_count": 0 + }, + "message": "创建成功" +} +``` + +**错误响应 (400) - 编码重复** +```json +{ + "detail": "分组编码已存在", + "code": 400 +} +``` + +**错误响应 (404) - 父分组不存在** +```json +{ + "detail": "父分组不存在", + "code": 404 +} +``` + +**错误响应 (422) - 验证失败** +```json +{ + "detail": [ + { + "loc": ["body", "name"], + "msg": "分组名称不能为空", + "type": "value_error" + } + ] +} +``` + +--- + +### 6. 更新分组 + +**请求** +```http +PUT /api/v3/evaluation-point-groups/100 +Authorization: Bearer {jwt_token} +Content-Type: application/json + +{ + "pid": 1, + "name": "更新后的名称", + "code": "UPDATED_001", + "description": "更新后的描述", + "is_enabled": false +} +``` + +**路径参数** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | integer | 是 | 分组ID | + +**请求体** +| 字段 | 类型 | 必填 | 说明 | 限制 | +|------|------|------|------|------| +| pid | integer/null | 否 | 父分组ID | 不能设置为自己或自己的子孙节点 | +| name | string | 是 | 分组名称 | 1-100字符 | +| code | string | 是 | 分组编码 | 1-50字符,不能与其他分组重复 | +| description | string/null | 否 | 分组描述 | 最多500字符 | +| is_enabled | boolean | 是 | 是否启用 | - | + +**成功响应 (200)** +```json +{ + "data": { + "id": 100, + "pid": 1, + "name": "更新后的名称", + "code": "UPDATED_001", + "description": "更新后的描述", + "is_enabled": false, + "created_at": "2025-01-20T14:30:00Z", + "updated_at": "2025-01-20T14:35:00Z", + "rule_count": 0 + }, + "message": "更新成功" +} +``` + +**错误响应 (400) - 父分组设置错误** +```json +{ + "detail": "不能将分组设置为自己的子孙节点的父分组", + "code": 400 +} +``` + +**错误响应 (404)** +```json +{ + "detail": "分组不存在", + "code": 404 +} +``` + +--- + +### 7. 删除分组 + +**请求** +```http +DELETE /api/v3/evaluation-point-groups/100 +Authorization: Bearer {jwt_token} +``` + +**路径参数** +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | integer | 是 | 分组ID | + +**成功响应 (200)** +```json +{ + "message": "删除成功" +} +``` + +**错误响应 (400) - 存在子分组** +```json +{ + "detail": "该分组下存在子分组,无法删除", + "code": 400 +} +``` + +**错误响应 (400) - 存在关联评查点** +```json +{ + "detail": "该分组下存在评查点,无法删除", + "code": 400 +} +``` + +**错误响应 (404)** +```json +{ + "detail": "分组不存在", + "code": 404 +} +``` + +--- + +### 8. 批量更新状态 + +**请求** +```http +PATCH /api/v3/evaluation-point-groups/batch/status +Authorization: Bearer {jwt_token} +Content-Type: application/json + +{ + "ids": [1, 2, 3], + "is_enabled": false +} +``` + +**请求体** +| 字段 | 类型 | 必填 | 说明 | 限制 | +|------|------|------|------|------| +| ids | integer[] | 是 | 要更新的分组ID列表 | 至少包含1个ID | +| is_enabled | boolean | 是 | 目标状态 | - | + +**成功响应 (200)** +```json +{ + "message": "批量更新成功", + "updated_count": 3 +} +``` + +**部分成功响应 (200)** +```json +{ + "message": "部分更新成功", + "updated_count": 2, + "failed_ids": [999], + "errors": { + "999": "分组不存在" + } +} +``` + +**错误响应 (400)** +```json +{ + "detail": "ids 不能为空", + "code": 400 +} +``` + +--- + +### 9. 批量删除 + +**请求** +```http +DELETE /api/v3/evaluation-point-groups/batch +Authorization: Bearer {jwt_token} +Content-Type: application/json + +{ + "ids": [10, 11, 12] +} +``` + +**请求体** +| 字段 | 类型 | 必填 | 说明 | 限制 | +|------|------|------|------|------| +| ids | integer[] | 是 | 要删除的分组ID列表 | 至少包含1个ID | + +**成功响应 (200)** +```json +{ + "message": "批量删除成功", + "deleted_count": 3 +} +``` + +**部分成功响应 (200)** +```json +{ + "message": "部分删除成功", + "deleted_count": 2, + "failed_ids": [11], + "errors": { + "11": "该分组下存在子分组,无法删除" + } +} +``` + +**错误响应 (400)** +```json +{ + "detail": "ids 不能为空", + "code": 400 +} +``` + +--- + +## 错误处理规范 + +### HTTP 状态码 + +| 状态码 | 说明 | 使用场景 | +|--------|------|----------| +| 200 | OK | 请求成功 | +| 201 | Created | 资源创建成功 | +| 400 | Bad Request | 请求参数错误、业务逻辑错误 | +| 401 | Unauthorized | 未授权,JWT token无效或过期 | +| 403 | Forbidden | 无权限访问 | +| 404 | Not Found | 资源不存在 | +| 422 | Unprocessable Entity | 数据验证失败 | +| 500 | Internal Server Error | 服务器内部错误 | + +### 错误响应格式 + +**标准错误格式** +```json +{ + "detail": "错误描述信息", + "code": 400 +} +``` + +**验证错误格式 (422)** +```json +{ + "detail": [ + { + "loc": ["body", "name"], + "msg": "分组名称不能为空", + "type": "value_error" + }, + { + "loc": ["body", "code"], + "msg": "分组编码长度必须在1-50之间", + "type": "value_error" + } + ] +} +``` + +### 常见错误代码 + +| 错误代码 | 错误信息 | HTTP状态码 | +|---------|---------|-----------| +| 40001 | 分组编码已存在 | 400 | +| 40002 | 该分组下存在子分组,无法删除 | 400 | +| 40003 | 该分组下存在评查点,无法删除 | 400 | +| 40004 | 不能将分组设置为自己的子孙节点的父分组 | 400 | +| 40101 | 未授权,请先登录 | 401 | +| 40102 | Token已过期 | 401 | +| 40103 | Token无效 | 401 | +| 40401 | 分组不存在 | 404 | +| 40402 | 父分组不存在 | 404 | + +--- + +## 验证规则 + +### 字段验证规则 + +| 字段 | 验证规则 | +|------|----------| +| name | - 必填
- 长度:1-100字符
- 不能只包含空格
- 去除首尾空格后存储 | +| code | - 必填
- 长度:1-50字符
- 唯一性校验
- 自动转换为大写
- 建议格式:字母数字下划线 | +| description | - 可选
- 最大长度:500字符 | +| is_enabled | - 必填
- 布尔值 | +| pid | - 可选
- 必须是存在的分组ID
- 不能是自己
- 不能是自己的子孙节点 | + +### 业务规则 + +1. **创建分组** + - 编码必须唯一 + - 父分组必须存在(如果指定了pid) + - 创建后 rule_count 初始为 0 + +2. **更新分组** + - 不能将自己设置为自己的子孙节点的父分组(避免循环引用) + - 编码不能与其他分组重复(可以与自己原来的编码相同) + - 更新时自动更新 updated_at + +3. **删除分组** + - 不能删除有子分组的分组 + - 不能删除有关联评查点的分组 + - 建议:删除前检查并提示用户 + +4. **rule_count 计算** + - 包含该分组直接关联的评查点 + - 包含所有子分组(递归)关联的评查点 + - 在评查点创建/删除/移动时需要更新相关分组的 rule_count + +--- + +## 完整测试用例 + +### 测试用例 1: 创建一级分组 + +**请求** +```bash +curl -X POST http://10.79.97.17:8000/api/v3/evaluation-point-groups \ + -H "Authorization: Bearer eyJhbGc..." \ + -H "Content-Type: application/json" \ + -d '{ + "pid": null, + "name": "财务管理类", + "code": "FINANCE_001", + "description": "财务相关的评查点分组", + "is_enabled": true + }' +``` + +**预期响应 (201)** +```json +{ + "data": { + "id": 101, + "pid": null, + "name": "财务管理类", + "code": "FINANCE_001", + "description": "财务相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-20T15:30:00Z", + "updated_at": "2025-01-20T15:30:00Z", + "rule_count": 0 + }, + "message": "创建成功" +} +``` + +--- + +### 测试用例 2: 创建子分组 + +**请求** +```bash +curl -X POST http://10.79.97.17:8000/api/v3/evaluation-point-groups \ + -H "Authorization: Bearer eyJhbGc..." \ + -H "Content-Type: application/json" \ + -d '{ + "pid": 101, + "name": "预算管理", + "code": "FINANCE_001_001", + "description": null, + "is_enabled": true + }' +``` + +**预期响应 (201)** +```json +{ + "data": { + "id": 102, + "pid": 101, + "name": "预算管理", + "code": "FINANCE_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-20T15:31:00Z", + "updated_at": "2025-01-20T15:31:00Z", + "rule_count": 0 + }, + "message": "创建成功" +} +``` + +--- + +### 测试用例 3: 获取树形结构 + +**请求** +```bash +curl -X GET "http://10.79.97.17:8000/api/v3/evaluation-point-groups/all?flat=false&include_disabled=false" \ + -H "Authorization: Bearer eyJhbGc..." +``` + +**预期响应 (200)** +```json +{ + "data": [ + { + "id": 101, + "pid": null, + "name": "财务管理类", + "code": "FINANCE_001", + "description": "财务相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-20T15:30:00Z", + "updated_at": "2025-01-20T15:30:00Z", + "rule_count": 0, + "children": [ + { + "id": 102, + "pid": 101, + "name": "预算管理", + "code": "FINANCE_001_001", + "description": null, + "is_enabled": true, + "created_at": "2025-01-20T15:31:00Z", + "updated_at": "2025-01-20T15:31:00Z", + "rule_count": 0, + "children": [] + } + ] + } + ] +} +``` + +--- + +### 测试用例 4: 更新分组(禁用) + +**请求** +```bash +curl -X PUT http://10.79.97.17:8000/api/v3/evaluation-point-groups/102 \ + -H "Authorization: Bearer eyJhbGc..." \ + -H "Content-Type: application/json" \ + -d '{ + "pid": 101, + "name": "预算管理", + "code": "FINANCE_001_001", + "description": "预算编制与执行", + "is_enabled": false + }' +``` + +**预期响应 (200)** +```json +{ + "data": { + "id": 102, + "pid": 101, + "name": "预算管理", + "code": "FINANCE_001_001", + "description": "预算编制与执行", + "is_enabled": false, + "created_at": "2025-01-20T15:31:00Z", + "updated_at": "2025-01-20T15:35:00Z", + "rule_count": 0 + }, + "message": "更新成功" +} +``` + +--- + +### 测试用例 5: 批量更新状态 + +**请求** +```bash +curl -X PATCH http://10.79.97.17:8000/api/v3/evaluation-point-groups/batch/status \ + -H "Authorization: Bearer eyJhbGc..." \ + -H "Content-Type: application/json" \ + -d '{ + "ids": [101, 102], + "is_enabled": true + }' +``` + +**预期响应 (200)** +```json +{ + "message": "批量更新成功", + "updated_count": 2 +} +``` + +--- + +### 测试用例 6: 删除分组(有子分组,应该失败) + +**请求** +```bash +curl -X DELETE http://10.79.97.17:8000/api/v3/evaluation-point-groups/101 \ + -H "Authorization: Bearer eyJhbGc..." +``` + +**预期响应 (400)** +```json +{ + "detail": "该分组下存在子分组,无法删除", + "code": 400 +} +``` + +--- + +### 测试用例 7: 删除子分组(成功) + +**请求** +```bash +curl -X DELETE http://10.79.97.17:8000/api/v3/evaluation-point-groups/102 \ + -H "Authorization: Bearer eyJhbGc..." +``` + +**预期响应 (200)** +```json +{ + "message": "删除成功" +} +``` + +--- + +### 测试用例 8: 重复编码(应该失败) + +**请求** +```bash +curl -X POST http://10.79.97.17:8000/api/v3/evaluation-point-groups \ + -H "Authorization: Bearer eyJhbGc..." \ + -H "Content-Type: application/json" \ + -d '{ + "pid": null, + "name": "重复编码测试", + "code": "FINANCE_001", + "description": null, + "is_enabled": true + }' +``` + +**预期响应 (400)** +```json +{ + "detail": "分组编码已存在", + "code": 400 +} +``` + +--- + +### 测试用例 9: 获取分页列表(带搜索) + +**请求** +```bash +curl -X GET "http://10.79.97.17:8000/api/v3/evaluation-point-groups?page=1&page_size=10&name=财务&is_enabled=true" \ + -H "Authorization: Bearer eyJhbGc..." +``` + +**预期响应 (200)** +```json +{ + "data": [ + { + "id": 101, + "pid": null, + "name": "财务管理类", + "code": "FINANCE_001", + "description": "财务相关的评查点分组", + "is_enabled": true, + "created_at": "2025-01-20T15:30:00Z", + "updated_at": "2025-01-20T15:30:00Z", + "rule_count": 0 + } + ], + "total": 1, + "page": 1, + "page_size": 10 +} +``` + +--- + +### 测试用例 10: 批量删除 + +**请求** +```bash +curl -X DELETE http://10.79.97.17:8000/api/v3/evaluation-point-groups/batch \ + -H "Authorization: Bearer eyJhbGc..." \ + -H "Content-Type: application/json" \ + -d '{ + "ids": [101, 999] + }' +``` + +**预期响应 (200) - 部分成功** +```json +{ + "message": "部分删除成功", + "deleted_count": 1, + "failed_ids": [999], + "errors": { + "999": "分组不存在" + } +} +``` + +--- + +## 附录 + +### 数据库表结构建议 + +```sql +CREATE TABLE evaluation_point_groups ( + id SERIAL PRIMARY KEY, + pid INTEGER REFERENCES evaluation_point_groups(id) ON DELETE RESTRICT, + name VARCHAR(100) NOT NULL, + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + is_enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE INDEX idx_evaluation_point_groups_pid ON evaluation_point_groups(pid); +CREATE INDEX idx_evaluation_point_groups_code ON evaluation_point_groups(code); +CREATE INDEX idx_evaluation_point_groups_is_enabled ON evaluation_point_groups(is_enabled); +CREATE INDEX idx_evaluation_point_groups_name ON evaluation_point_groups USING gin(to_tsvector('simple', name)); + +-- 触发器:自动更新 updated_at +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_evaluation_point_groups_updated_at + BEFORE UPDATE ON evaluation_point_groups + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); +``` + +### rule_count 计算SQL示例 + +```sql +-- 计算某个分组的 rule_count(包含所有子孙分组) +WITH RECURSIVE group_tree AS ( + -- 基础查询:选择目标分组 + SELECT id + FROM evaluation_point_groups + WHERE id = :group_id + + UNION ALL + + -- 递归查询:选择所有子孙分组 + SELECT g.id + FROM evaluation_point_groups g + INNER JOIN group_tree gt ON g.pid = gt.id +) +SELECT COUNT(*) as rule_count +FROM evaluation_points ep +WHERE ep.group_id IN (SELECT id FROM group_tree); +``` + +--- + +## 版本历史 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0.0 | 2025-01-20 | 初始版本 | + +--- + +## 联系方式 + +如有疑问,请联系后端开发团队。