You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
11 KiB
11 KiB
项目元素查询接口支持子项目ID自动转换
日期: 2026-02-11
类型: 功能增强
影响范围: 后端 API - 项目元素所有接口(创建、查询、更新、删除)
变更概述
为项目角色、场景、道具的所有接口(创建、查询列表、查询详情、更新、删除)添加了子项目ID自动转换为父项目ID的逻辑,并在更新/删除接口中添加了项目归属验证,确保数据安全性。
背景
项目元素(角色、场景、道具)存储在父项目级别,所有子项目共享这些元素。但前端在调用接口时,可能传入子项目ID,导致:
- 查询接口:查询不到数据
- 创建接口:元素被错误创建在子项目下
- 更新/删除接口:缺少项目归属验证,存在安全隐患
变更内容
1. 新增公共方法
文件: server/app/services/project_service.py
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创建
- POST /projects/{project_id}/characters
- POST /projects/{project_id}/locations
- POST /projects/{project_id}/props
# 如果是子项目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查询
- GET /projects/{project_id}/characters
- GET /projects/{project_id}/locations
- GET /projects/{project_id}/props
# 如果是子项目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查询
- GET /projects/{project_id}/characters/{character_id}
- GET /projects/{project_id}/locations/{location_id}
- GET /projects/{project_id}/props/{prop_id}
# 如果是子项目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个)- 添加归属验证
- PUT /projects/{project_id}/characters/{character_id}
- PUT /projects/{project_id}/locations/{location_id}
- PUT /projects/{project_id}/props/{prop_id}
# 如果是子项目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个)- 添加归属验证
- DELETE /projects/{project_id}/characters/{character_id}
- DELETE /projects/{project_id}/locations/{location_id}
- DELETE /projects/{project_id}/props/{prop_id}
# 如果是子项目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进行操作
安全增强
更新和删除接口新增归属验证:
- 查询元素是否存在
- 验证元素的
project_id是否与传入的project_id(转换后)一致 - 不一致则返回 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
测试用例
# 假设父项目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方法
后续优化
- 缓存优化: 考虑缓存项目的父子关系,减少数据库查询
- 批量查询: 如果有批量查询需求,可优化为一次性转换多个ID
- 中间件: 如果更多接口需要此逻辑,可考虑提取为中间件
- 权限验证: 可以在更新/删除前增加用户权限验证
参考
- 参考实现:
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.pyserver/app/schemas/file_checksum.py
修复的 Schema:
ElementTagProjectCharacterResponseProjectCharacterDetailResponseProjectLocationResponseProjectLocationDetailResponseProjectPropResponseProjectPropDetailResponseFileChecksumBaseFileChecksumResponseFileChecksumUpdateFileMetadata
技术实现
为所有 Response Schema 配置 alias_generator=to_camel:
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 别名
)
工作原理
- 输入验证:
populate_by_name=True允许使用 snake_case 或 camelCase 输入 - 输出序列化: FastAPI 的
jsonable_encoder自动使用alias_generator生成的别名 - 字段映射:
character_id→characterId,is_offscreen→isOffscreen
影响分析
⚠️ Breaking Change
- API 响应字段从 snake_case 改为 camelCase
- 前端需要同步更新字段名
示例对比:
// 修复前
{
"character_id": "xxx",
"project_id": "yyy",
"is_offscreen": false,
"default_tag_id": null
}
// 修复后
{
"characterId": "xxx",
"projectId": "yyy",
"isOffscreen": false,
"defaultTagId": null
}
测试验证
# 测试角色详情接口
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- 项目元素 Schemaserver/app/schemas/file_checksum.py- 文件校验和 Schema