# 项目元素查询接口支持子项目ID自动转换 **日期**: 2026-02-11 **类型**: 功能增强 **影响范围**: 后端 API - 项目元素所有接口(创建、查询、更新、删除) ## 变更概述 为项目角色、场景、道具的所有接口(创建、查询列表、查询详情、更新、删除)添加了子项目ID自动转换为父项目ID的逻辑,并在更新/删除接口中添加了项目归属验证,确保数据安全性。 ## 背景 项目元素(角色、场景、道具)存储在父项目级别,所有子项目共享这些元素。但前端在调用接口时,可能传入子项目ID,导致: 1. 查询接口:查询不到数据 2. 创建接口:元素被错误创建在子项目下 3. 更新/删除接口:缺少项目归属验证,存在安全隐患 ## 变更内容 ### 1. 新增公共方法 **文件**: `server/app/services/project_service.py` ```python async def get_parent_project_id(self, project_id: UUID) -> UUID: """获取父级项目ID 如果项目是子项目(parent_project_id != NULL),返回其父项目ID 如果项目是父项目(parent_project_id == NULL),返回自身ID Args: project_id: 项目ID Returns: UUID: 父级项目ID Raises: NotFoundError: 项目不存在 """ ``` ### 2. 修改接口(共12个) **文件**: `server/app/api/v1/project_elements.py` #### A. 创建接口(3个)- 转换为父项目ID创建 1. **POST /projects/{project_id}/characters** 2. **POST /projects/{project_id}/locations** 3. **POST /projects/{project_id}/props** ```python # 如果是子项目ID,转换为父项目ID parent_project_id = await project_service.get_parent_project_id(project_id) # 使用父项目ID创建元素 character = await service.create_character( project_id=parent_project_id, # 使用转换后的ID ... ) ``` #### B. 查询列表接口(3个)- 转换为父项目ID查询 1. **GET /projects/{project_id}/characters** 2. **GET /projects/{project_id}/locations** 3. **GET /projects/{project_id}/props** ```python # 如果是子项目ID,转换为父项目ID parent_project_id = await project_service.get_parent_project_id(project_id) # 使用父项目ID查询列表 characters = await character_repo.get_by_project_id(parent_project_id, skip, limit) ``` #### C. 查询详情接口(3个)- 转换为父项目ID查询 1. **GET /projects/{project_id}/characters/{character_id}** 2. **GET /projects/{project_id}/locations/{location_id}** 3. **GET /projects/{project_id}/props/{prop_id}** ```python # 如果是子项目ID,转换为父项目ID parent_project_id = await project_service.get_parent_project_id(project_id) # 使用父项目ID查询详情 detail = await service.get_character_detail( user_id=str(current_user.user_id), project_id=str(parent_project_id), # 使用转换后的ID character_id=str(character_id) ) ``` #### D. 更新接口(3个)- 添加归属验证 1. **PUT /projects/{project_id}/characters/{character_id}** 2. **PUT /projects/{project_id}/locations/{location_id}** 3. **PUT /projects/{project_id}/props/{prop_id}** ```python # 如果是子项目ID,转换为父项目ID parent_project_id = await project_service.get_parent_project_id(project_id) # 验证元素是否属于该项目 character = await character_repo.get_by_id(character_id) if not character: raise HTTPException(status_code=404, detail="角色不存在") if character.project_id != parent_project_id: raise HTTPException(status_code=403, detail="该角色不属于此项目") # 验证通过后执行更新 character = await service.update_character(character_id, update_data) ``` #### E. 删除接口(3个)- 添加归属验证 1. **DELETE /projects/{project_id}/characters/{character_id}** 2. **DELETE /projects/{project_id}/locations/{location_id}** 3. **DELETE /projects/{project_id}/props/{prop_id}** ```python # 如果是子项目ID,转换为父项目ID parent_project_id = await project_service.get_parent_project_id(project_id) # 验证元素是否属于该项目 character = await character_repo.get_by_id(character_id) if not character: raise HTTPException(status_code=404, detail="角色不存在") if character.project_id != parent_project_id: raise HTTPException(status_code=403, detail="该角色不属于此项目") # 验证通过后执行删除 await service.delete_character(character_id) ``` ## 技术细节 ### 实现方式 - 在 API 层统一处理(而非 Repository 层),保持逻辑透明 - 参考 `ScreenplayService._get_parent_project_id` 的实现模式 - 不增加 Repository 层耦合 ### 转换逻辑 ``` 输入: project_id (可能是父项目ID或子项目ID) ↓ 查询 projects 表 ↓ 判断: parent_project_id 是否为 NULL? ├─ 是 → 返回 project_id (自身是父项目) └─ 否 → 返回 parent_project_id (转换为父项目ID) ↓ 使用转换后的ID进行操作 ``` ### 安全增强 更新和删除接口新增归属验证: 1. 查询元素是否存在 2. 验证元素的 `project_id` 是否与传入的 `project_id`(转换后)一致 3. 不一致则返回 403 Forbidden 错误 ## 影响分析 ### 兼容性 - ✅ 向后兼容:传入父项目ID的调用不受影响 - ✅ 新增支持:传入子项目ID现在也能正确处理 - ✅ 安全增强:更新/删除接口增加了归属验证 ### 性能 - 每次请求增加一次 `projects` 表查询(通过主键,性能影响可忽略) - 更新/删除接口增加一次元素查询(用于验证归属) - 可考虑后续添加缓存优化 ## 测试建议 ### 测试场景 #### 1. 创建接口 - 传入父项目ID → 元素创建在父项目下 - 传入子项目ID → 元素自动创建在父项目下(而非子项目) #### 2. 查询接口 - 传入父项目ID → 返回所有元素 - 传入子项目ID → 返回父项目的所有元素(与场景1结果一致) #### 3. 更新接口 - 传入正确的项目ID + 元素ID → 更新成功 - 传入错误的项目ID + 元素ID → 返回 403 Forbidden - 传入不存在的元素ID → 返回 404 Not Found #### 4. 删除接口 - 传入正确的项目ID + 元素ID → 删除成功 - 传入错误的项目ID + 元素ID → 返回 403 Forbidden - 传入不存在的元素ID → 返回 404 Not Found ### 测试用例 ```bash # 假设父项目ID: parent-uuid, 子项目ID: child-uuid # ========== 创建接口测试 ========== # 使用子项目ID创建,应该创建在父项目下 POST /api/v1/projects/child-uuid/characters { "name": "测试角色", "role_type": 1 } # 验证:查询 project_characters 表,project_id 应该是 parent-uuid # ========== 查询列表接口测试 ========== # 父项目ID查询 GET /api/v1/projects/parent-uuid/characters # 子项目ID查询(应返回相同结果) GET /api/v1/projects/child-uuid/characters # ========== 查询详情接口测试 ========== GET /api/v1/projects/child-uuid/characters/{character_id} # ========== 更新接口测试 ========== # 正确的项目ID PUT /api/v1/projects/parent-uuid/characters/{character_id} # 或 PUT /api/v1/projects/child-uuid/characters/{character_id} # 错误的项目ID(应返回 403) PUT /api/v1/projects/other-project-uuid/characters/{character_id} # ========== 删除接口测试 ========== # 正确的项目ID DELETE /api/v1/projects/parent-uuid/characters/{character_id} # 错误的项目ID(应返回 403) DELETE /api/v1/projects/other-project-uuid/characters/{character_id} ``` ## 相关文件 - `server/app/services/project_service.py` - 新增 `get_parent_project_id` 方法 - `server/app/api/v1/project_elements.py` - 修改12个接口 - `server/app/repositories/project_repository.py` - 依赖的 `get_by_id` 方法 - `server/app/repositories/project_character_repository.py` - 依赖的 `get_by_id` 方法 - `server/app/repositories/project_location_repository.py` - 依赖的 `get_by_id` 方法 - `server/app/repositories/project_prop_repository.py` - 依赖的 `get_by_id` 方法 ## 后续优化 1. **缓存优化**: 考虑缓存项目的父子关系,减少数据库查询 2. **批量查询**: 如果有批量查询需求,可优化为一次性转换多个ID 3. **中间件**: 如果更多接口需要此逻辑,可考虑提取为中间件 4. **权限验证**: 可以在更新/删除前增加用户权限验证 ## 参考 - 参考实现: `server/app/services/screenplay_service.py:_get_parent_project_id` - 相关表: `projects` (parent_project_id 字段) - 相关表: `project_characters`, `project_locations`, `project_props` (project_id 字段) --- ## 补充变更:API 响应字段规范化 **日期**: 2026-02-11 **类型**: Bug 修复 **影响范围**: API 响应格式 ### 问题描述 API 响应字段使用 snake_case(如 `character_id`),不符合项目 API 设计规范(应使用 camelCase,如 `characterId`)。 ### 修复内容 **修改文件**: - `server/app/schemas/project_element.py` - `server/app/schemas/file_checksum.py` **修复的 Schema**: 1. `ElementTag` 2. `ProjectCharacterResponse` 3. `ProjectCharacterDetailResponse` 4. `ProjectLocationResponse` 5. `ProjectLocationDetailResponse` 6. `ProjectPropResponse` 7. `ProjectPropDetailResponse` 8. `FileChecksumBase` 9. `FileChecksumResponse` 10. `FileChecksumUpdate` 11. `FileMetadata` ### 技术实现 为所有 Response Schema 配置 `alias_generator=to_camel`: ```python def to_camel(string: str) -> str: """将 snake_case 转换为 camelCase""" components = string.split('_') return components[0] + ''.join(x.title() for x in components[1:]) class ProjectCharacterResponse(BaseModel): character_id: str # Python 字段名(snake_case) project_id: str # ... model_config = ConfigDict( from_attributes=True, populate_by_name=True, alias_generator=to_camel # 自动生成 camelCase 别名 ) ``` ### 工作原理 1. **输入验证**: `populate_by_name=True` 允许使用 snake_case 或 camelCase 输入 2. **输出序列化**: FastAPI 的 `jsonable_encoder` 自动使用 `alias_generator` 生成的别名 3. **字段映射**: `character_id` → `characterId`, `is_offscreen` → `isOffscreen` ### 影响分析 ⚠️ **Breaking Change** - API 响应字段从 snake_case 改为 camelCase - 前端需要同步更新字段名 **示例对比**: ```json // 修复前 { "character_id": "xxx", "project_id": "yyy", "is_offscreen": false, "default_tag_id": null } // 修复后 { "characterId": "xxx", "projectId": "yyy", "isOffscreen": false, "defaultTagId": null } ``` ### 测试验证 ```bash # 测试角色详情接口 curl -X GET "http://localhost:6160/api/v1/projects/{project_id}/characters/{character_id}" \ -H "Authorization: Bearer {token}" # 验证响应字段为 camelCase # ✅ characterId, projectId, isOffscreen, defaultTagId # ❌ character_id, project_id, is_offscreen, default_tag_id ``` ### 相关文件 - `server/app/schemas/project_element.py` - 项目元素 Schema - `server/app/schemas/file_checksum.py` - 文件校验和 Schema