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

项目元素查询接口支持子项目ID自动转换

日期: 2026-02-11
类型: 功能增强
影响范围: 后端 API - 项目元素所有接口(创建、查询、更新、删除)

变更概述

为项目角色、场景、道具的所有接口(创建、查询列表、查询详情、更新、删除)添加了子项目ID自动转换为父项目ID的逻辑,并在更新/删除接口中添加了项目归属验证,确保数据安全性。

背景

项目元素(角色、场景、道具)存储在父项目级别,所有子项目共享这些元素。但前端在调用接口时,可能传入子项目ID,导致:

  1. 查询接口:查询不到数据
  2. 创建接口:元素被错误创建在子项目下
  3. 更新/删除接口:缺少项目归属验证,存在安全隐患

变更内容

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创建

  1. POST /projects/{project_id}/characters
  2. POST /projects/{project_id}/locations
  3. 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查询

  1. GET /projects/{project_id}/characters
  2. GET /projects/{project_id}/locations
  3. 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查询

  1. GET /projects/{project_id}/characters/{character_id}
  2. GET /projects/{project_id}/locations/{location_id}
  3. 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个)- 添加归属验证

  1. PUT /projects/{project_id}/characters/{character_id}
  2. PUT /projects/{project_id}/locations/{location_id}
  3. 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个)- 添加归属验证

  1. DELETE /projects/{project_id}/characters/{character_id}
  2. DELETE /projects/{project_id}/locations/{location_id}
  3. 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进行操作

安全增强

更新和删除接口新增归属验证:

  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

测试用例

# 假设父项目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

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_idcharacterId, is_offscreenisOffscreen

影响分析

⚠️ 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 - 项目元素 Schema
  • server/app/schemas/file_checksum.py - 文件校验和 Schema