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

项目素材引用计数维护实现

日期:2026-02-04
类型:功能实现
影响范围:后端服务层


变更概述

StoryboardService 中实现项目素材引用计数(usage_count)的自动维护逻辑,确保素材被分镜使用时计数准确更新。


变更详情

1. 修改 StoryboardService

文件server/app/services/storyboard_service.py

1.1 添加导入

from sqlalchemy import update
from app.models.project_resource import ProjectResource

1.2 修改 add_element_to_storyboard() 方法

在添加项目素材到分镜时,自动增加引用计数:

async def add_element_to_storyboard(self, ...):
    # ... 创建关联记录 ...
    
    created_item = await self.storyboard_repo.create_item(item)
    
    # 如果是项目素材,增加引用计数
    if item_type == ItemType.RESOURCE and resource_id:
        await self.session.execute(
            update(ProjectResource)
            .where(ProjectResource.project_resource_id == resource_id)
            .values(usage_count=ProjectResource.usage_count + 1)
        )
        logger.debug(
            "项目素材引用计数 +1: resource_id=%s",
            resource_id
        )
    
    await self.session.commit()
    # ...

1.3 修改 remove_element_from_storyboard() 方法

在从分镜移除项目素材时,自动减少引用计数:

async def remove_element_from_storyboard(self, ...):
    # ... 获取元素信息 ...
    
    # 如果是项目素材,减少引用计数
    if item.item_type == ItemType.RESOURCE and item.resource_id:
        await self.session.execute(
            update(ProjectResource)
            .where(ProjectResource.project_resource_id == item.resource_id)
            .values(usage_count=ProjectResource.usage_count - 1)
        )
        logger.debug(
            "项目素材引用计数 -1: resource_id=%s",
            item.resource_id
        )
    
    await self.storyboard_repo.delete_item(item_id)
    await self.session.commit()
    # ...

实现特点

1. 事务安全

引用计数更新和关联记录的创建/删除在同一事务中完成:

# 创建关联记录
created_item = await self.storyboard_repo.create_item(item)

# 更新引用计数
await self.session.execute(update(...))

# 一起提交
await self.session.commit()

优点

  • 确保数据一致性
  • 失败时自动回滚
  • 避免计数不准确

2. 仅对项目素材生效

只有当元素类型为 ItemType.RESOURCE 时才更新计数:

if item_type == ItemType.RESOURCE and resource_id:
    # 更新引用计数

原因

  • ItemType.ELEMENT_TAG(剧本元素标签)不需要引用计数
  • 避免不必要的数据库操作

3. 日志记录

使用 DEBUG 级别记录引用计数变化:

logger.debug(
    "项目素材引用计数 +1: resource_id=%s",
    resource_id
)

用途

  • 便于调试
  • 审计追踪
  • 问题排查

数据库字段

project_resources.usage_count

属性
类型 INTEGER
默认值 0
约束 NOT NULL, CHECK (usage_count >= 0)
索引 idx_project_resources_usage_count
说明 被多少个分镜使用

维护规则

  • 添加素材到分镜:usage_count +1
  • 从分镜移除素材:usage_count -1
  • 软删除分镜:不影响 usage_count
  • 硬删除分镜:批量减少所有关联素材的 usage_count

使用场景

场景 1:添加素材到分镜

POST /api/v1/storyboards/{storyboard_id}/items
Content-Type: application/json

{
  "item_type": 2,
  "resource_id": "019d1234-5678-7abc-def0-123456789abc"
}

执行流程

  1. 验证分镜存在
  2. 验证项目权限
  3. 创建 storyboard_items 记录
  4. 执行 UPDATE project_resources SET usage_count = usage_count + 1
  5. 提交事务

结果

  • 素材成功关联到分镜
  • usage_count 从 0 变为 1

场景 2:从分镜移除素材

DELETE /api/v1/storyboards/{storyboard_id}/items/{item_id}

执行流程

  1. 获取元素信息
  2. 验证项目权限
  3. 执行 UPDATE project_resources SET usage_count = usage_count - 1
  4. 删除 storyboard_items 记录
  5. 提交事务

结果

  • 素材从分镜移除
  • usage_count 从 1 变为 0

场景 3:删除保护(待实现)

usage_count > 0 时,禁止删除素材:

# ProjectResourceService.delete_resource()
if resource.usage_count > 0 and not force:
    raise ValidationError(
        "该素材正在被 %d 个分镜使用,无法删除。" % resource.usage_count
    )

技术栈符合性

完全符合 jointo-tech-stack 规范

  1. 事务管理

    • 使用 async with self.session.begin() 或手动 commit()
    • 关联操作和计数更新在同一事务
  2. 日志系统

    • 使用标准库 logging
    • 使用 %-formatting 格式化
    • DEBUG 级别记录计数变化
  3. 异步编程

    • 所有数据库操作使用 async/await
    • 使用 AsyncSession
  4. 应用层约束

    • 无物理外键
    • 应用层保证引用完整性

测试建议

单元测试

async def test_add_element_increases_usage_count():
    """测试添加素材到分镜时增加引用计数"""
    # 创建项目素材
    resource = await create_project_resource(usage_count=0)
    
    # 添加到分镜
    await service.add_element_to_storyboard(
        user_id=user_id,
        storyboard_id=storyboard_id,
        item_type=ItemType.RESOURCE,
        resource_id=resource.project_resource_id
    )
    
    # 验证引用计数
    updated_resource = await repo.get_by_id(resource.project_resource_id)
    assert updated_resource.usage_count == 1

async def test_remove_element_decreases_usage_count():
    """测试从分镜移除素材时减少引用计数"""
    # 创建项目素材并添加到分镜
    resource = await create_project_resource(usage_count=1)
    item = await create_storyboard_item(resource_id=resource.project_resource_id)
    
    # 从分镜移除
    await service.remove_element_from_storyboard(
        user_id=user_id,
        item_id=item.item_id
    )
    
    # 验证引用计数
    updated_resource = await repo.get_by_id(resource.project_resource_id)
    assert updated_resource.usage_count == 0

async def test_usage_count_transaction_rollback():
    """测试事务回滚时引用计数不变"""
    resource = await create_project_resource(usage_count=0)
    
    # 模拟添加失败
    with pytest.raises(Exception):
        await service.add_element_to_storyboard(
            user_id=user_id,
            storyboard_id=invalid_storyboard_id,  # 不存在的分镜
            item_type=ItemType.RESOURCE,
            resource_id=resource.project_resource_id
        )
    
    # 验证引用计数未变化
    resource = await repo.get_by_id(resource.project_resource_id)
    assert resource.usage_count == 0

集成测试

async def test_usage_count_api_integration(client):
    """测试通过 API 操作时引用计数正确更新"""
    # 创建项目素材
    resource = await create_project_resource()
    
    # 通过 API 添加到分镜
    response = await client.post(
        f"/api/v1/storyboards/{storyboard_id}/items",
        json={
            "item_type": 2,
            "resource_id": str(resource.project_resource_id)
        }
    )
    assert response.status_code == 200
    
    # 验证引用计数
    resource = await repo.get_by_id(resource.project_resource_id)
    assert resource.usage_count == 1
    
    # 通过 API 移除
    item_id = response.json()["data"]["item_id"]
    response = await client.delete(
        f"/api/v1/storyboards/{storyboard_id}/items/{item_id}"
    )
    assert response.status_code == 200
    
    # 验证引用计数
    resource = await repo.get_by_id(resource.project_resource_id)
    assert resource.usage_count == 0

后续优化

1. 批量操作优化

当批量添加/移除素材时,使用单个 SQL 语句更新:

# 批量增加引用计数
await self.session.execute(
    update(ProjectResource)
    .where(ProjectResource.project_resource_id.in_(resource_ids))
    .values(usage_count=ProjectResource.usage_count + 1)
)

2. 引用计数修复工具

创建脚本修复不一致的引用计数:

# scripts/fix_usage_count.py
async def fix_usage_count():
    """修复项目素材的引用计数"""
    resources = await session.execute(select(ProjectResource))
    
    for resource in resources.scalars():
        # 统计实际引用次数
        actual_count = await session.execute(
            select(func.count(StoryboardItem.item_id))
            .where(
                StoryboardItem.resource_id == resource.project_resource_id,
                StoryboardItem.item_type == ItemType.RESOURCE
            )
        )
        actual_count = actual_count.scalar_one()
        
        # 更新引用计数
        if resource.usage_count != actual_count:
            await session.execute(
                update(ProjectResource)
                .where(ProjectResource.project_resource_id == resource.project_resource_id)
                .values(usage_count=actual_count)
            )
            logger.info(
                "修复引用计数: resource_id=%s, old=%d, new=%d",
                resource.project_resource_id,
                resource.usage_count,
                actual_count
            )

3. 删除保护实现

ProjectResourceService 中添加:

async def delete_resource(
    self,
    user_id: UUID,
    resource_id: UUID,
    force: bool = False
) -> None:
    """删除项目素材"""
    resource = await self.repo.get_by_id(resource_id)
    if not resource:
        raise NotFoundError("素材不存在")
    
    # 检查引用计数
    if resource.usage_count > 0 and not force:
        raise ValidationError(
            "该素材正在被 %d 个分镜使用,无法删除。"
            "请先从分镜中移除该素材,或使用强制删除。" % resource.usage_count
        )
    
    # 删除素材
    await self.repo.delete(resource_id)
    await self.session.commit()

相关文档


总结

项目素材引用计数维护功能已完整实现:

  • 添加素材到分镜时自动 +1
  • 从分镜移除素材时自动 -1
  • 事务安全保证
  • 日志记录
  • 符合技术栈规范

待实现

  • ⏸️ 删除保护逻辑
  • ⏸️ 批量操作优化
  • ⏸️ 引用计数修复工具

引用计数功能可以准确追踪素材的使用情况,为素材管理和删除保护提供数据支持。


变更作者:Kiro AI
审核状态:待审核
部署状态:待部署