# 项目素材引用计数维护实现 **日期**:2026-02-04 **类型**:功能实现 **影响范围**:后端服务层 --- ## 变更概述 在 `StoryboardService` 中实现项目素材引用计数(`usage_count`)的自动维护逻辑,确保素材被分镜使用时计数准确更新。 --- ## 变更详情 ### 1. 修改 StoryboardService **文件**:`server/app/services/storyboard_service.py` #### 1.1 添加导入 ```python from sqlalchemy import update from app.models.project_resource import ProjectResource ``` #### 1.2 修改 add_element_to_storyboard() 方法 在添加项目素材到分镜时,自动增加引用计数: ```python 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() 方法 在从分镜移除项目素材时,自动减少引用计数: ```python 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. 事务安全 引用计数更新和关联记录的创建/删除在同一事务中完成: ```python # 创建关联记录 created_item = await self.storyboard_repo.create_item(item) # 更新引用计数 await self.session.execute(update(...)) # 一起提交 await self.session.commit() ``` **优点**: - 确保数据一致性 - 失败时自动回滚 - 避免计数不准确 ### 2. 仅对项目素材生效 只有当元素类型为 `ItemType.RESOURCE` 时才更新计数: ```python if item_type == ItemType.RESOURCE and resource_id: # 更新引用计数 ``` **原因**: - `ItemType.ELEMENT_TAG`(剧本元素标签)不需要引用计数 - 避免不必要的数据库操作 ### 3. 日志记录 使用 DEBUG 级别记录引用计数变化: ```python 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:添加素材到分镜 ```http 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:从分镜移除素材 ```http 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` 时,禁止删除素材: ```python # 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. **应用层约束** - 无物理外键 - 应用层保证引用完整性 --- ## 测试建议 ### 单元测试 ```python 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 ``` ### 集成测试 ```python 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 语句更新: ```python # 批量增加引用计数 await self.session.execute( update(ProjectResource) .where(ProjectResource.project_resource_id.in_(resource_ids)) .values(usage_count=ProjectResource.usage_count + 1) ) ``` ### 2. 引用计数修复工具 创建脚本修复不一致的引用计数: ```python # 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` 中添加: ```python 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() ``` --- ## 相关文档 - [分镜服务完整实现](./2026-02-04-storyboard-complete-implementation.md) - [需求文档:分镜-项目素材关联](../../requirements/backend/04-services/project/storyboard-project-resource-association.md) --- ## 总结 项目素材引用计数维护功能已完整实现: - ✅ 添加素材到分镜时自动 +1 - ✅ 从分镜移除素材时自动 -1 - ✅ 事务安全保证 - ✅ 日志记录 - ✅ 符合技术栈规范 **待实现**: - ⏸️ 删除保护逻辑 - ⏸️ 批量操作优化 - ⏸️ 引用计数修复工具 引用计数功能可以准确追踪素材的使用情况,为素材管理和删除保护提供数据支持。 --- **变更作者**:Kiro AI **审核状态**:待审核 **部署状态**:待部署