| """项目管理API""" |
| from fastapi import APIRouter, Depends, HTTPException |
| from fastapi.responses import Response |
| from sqlalchemy.ext.asyncio import AsyncSession |
| from sqlalchemy import select, func, delete |
| from typing import List |
| from app.database import get_db |
| from app.models.project import Project |
| from app.models.character import Character |
| from app.models.outline import Outline |
| from app.models.chapter import Chapter |
| from app.models.generation_history import GenerationHistory |
| from app.models.relationship import CharacterRelationship, Organization, OrganizationMember |
| from app.schemas.project import ( |
| ProjectCreate, |
| ProjectUpdate, |
| ProjectResponse, |
| ProjectListResponse |
| ) |
| from app.logger import get_logger |
| from app.utils.data_consistency import ( |
| run_full_data_consistency_check, |
| fix_missing_organization_records, |
| fix_organization_member_counts |
| ) |
|
|
| logger = get_logger(__name__) |
| router = APIRouter(prefix="/projects", tags=["项目管理"]) |
|
|
|
|
| @router.post("", response_model=ProjectResponse, summary="创建项目") |
| async def create_project( |
| project: ProjectCreate, |
| db: AsyncSession = Depends(get_db) |
| ): |
| try: |
| logger.info(f"创建新项目: {project.title}") |
| db_project = Project(**project.model_dump()) |
| db.add(db_project) |
| await db.commit() |
| await db.refresh(db_project) |
| logger.info(f"项目创建成功: {db_project.id}") |
| |
| return db_project |
| except Exception as e: |
| logger.error(f"创建项目失败: {str(e)}", exc_info=True) |
| raise |
|
|
|
|
| @router.get("", response_model=ProjectListResponse, summary="获取项目列表") |
| async def get_projects( |
| skip: int = 0, |
| limit: int = 100, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """获取所有项目列表""" |
| try: |
| logger.debug(f"获取项目列表: skip={skip}, limit={limit}") |
| count_result = await db.execute(select(func.count(Project.id))) |
| total = count_result.scalar_one() |
| |
| result = await db.execute( |
| select(Project) |
| .order_by(Project.updated_at.desc()) |
| .offset(skip) |
| .limit(limit) |
| ) |
| projects = result.scalars().all() |
| logger.info(f"获取项目列表成功: 共{total}个项目") |
| |
| return ProjectListResponse(total=total, items=projects) |
| except Exception as e: |
| logger.error(f"获取项目列表失败: {str(e)}", exc_info=True) |
| raise |
|
|
|
|
| @router.get("/{project_id}", response_model=ProjectResponse, summary="获取项目详情") |
| async def get_project( |
| project_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| try: |
| logger.debug(f"获取项目详情: {project_id}") |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| logger.info(f"获取项目详情成功: {project.title}") |
| return project |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"获取项目详情失败: {str(e)}", exc_info=True) |
| raise |
|
|
|
|
| @router.put("/{project_id}", response_model=ProjectResponse, summary="更新项目") |
| async def update_project( |
| project_id: str, |
| project_update: ProjectUpdate, |
| db: AsyncSession = Depends(get_db) |
| ): |
| try: |
| logger.info(f"更新项目: {project_id}") |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| update_data = project_update.model_dump(exclude_unset=True) |
| logger.debug(f"更新字段: {list(update_data.keys())}") |
| for field, value in update_data.items(): |
| setattr(project, field, value) |
| |
| await db.commit() |
| await db.refresh(project) |
| logger.info(f"项目更新成功: {project.title}") |
| return project |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"更新项目失败: {str(e)}", exc_info=True) |
| raise |
|
|
|
|
| @router.delete("/{project_id}", summary="删除项目") |
| async def delete_project( |
| project_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| try: |
| logger.info(f"删除项目: {project_id}") |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| project_title = project.title |
| |
| relationships_result = await db.execute( |
| delete(CharacterRelationship).where(CharacterRelationship.project_id == project_id) |
| ) |
| logger.debug(f"删除角色关系数: {relationships_result.rowcount}") |
| |
| orgs_result = await db.execute( |
| select(Organization).where(Organization.project_id == project_id) |
| ) |
| orgs = orgs_result.scalars().all() |
| org_member_count = 0 |
| for org in orgs: |
| members_result = await db.execute( |
| delete(OrganizationMember).where(OrganizationMember.organization_id == org.id) |
| ) |
| org_member_count += members_result.rowcount |
| logger.debug(f"删除组织成员数: {org_member_count}") |
| |
| organizations_result = await db.execute( |
| delete(Organization).where(Organization.project_id == project_id) |
| ) |
| logger.debug(f"删除组织数: {organizations_result.rowcount}") |
| |
| history_result = await db.execute( |
| delete(GenerationHistory).where(GenerationHistory.project_id == project_id) |
| ) |
| logger.debug(f"删除生成历史数: {history_result.rowcount}") |
| |
| chapters_result = await db.execute( |
| delete(Chapter).where(Chapter.project_id == project_id) |
| ) |
| logger.debug(f"删除章节数: {chapters_result.rowcount}") |
| |
| outlines_result = await db.execute( |
| delete(Outline).where(Outline.project_id == project_id) |
| ) |
| logger.debug(f"删除大纲数: {outlines_result.rowcount}") |
| |
| characters_result = await db.execute( |
| delete(Character).where(Character.project_id == project_id) |
| ) |
| logger.debug(f"删除角色数: {characters_result.rowcount}") |
| |
| await db.delete(project) |
| await db.commit() |
| |
| logger.info(f"项目删除成功: {project_title}") |
| return {"message": "项目及所有关联数据删除成功"} |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"删除项目失败: {str(e)}", exc_info=True) |
| raise |
|
|
|
|
| @router.get("/{project_id}/export", summary="导出项目章节为TXT") |
| async def export_project_chapters( |
| project_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """ |
| 导出项目的所有章节内容为TXT文本文件 |
| 按章节顺序组织,包含项目基本信息 |
| """ |
| try: |
| logger.info(f"开始导出项目: {project_id}") |
| |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| chapters_result = await db.execute( |
| select(Chapter) |
| .where(Chapter.project_id == project_id) |
| .order_by(Chapter.chapter_number) |
| ) |
| chapters = chapters_result.scalars().all() |
| |
| if not chapters: |
| logger.warning(f"项目没有章节: {project_id}") |
| raise HTTPException(status_code=404, detail="项目没有任何章节") |
| |
| txt_content = [] |
| |
| txt_content.append("=" * 80) |
| txt_content.append(f"项目标题: {project.title}") |
| txt_content.append("=" * 80) |
| |
| if project.description: |
| txt_content.append(f"\n简介: {project.description}\n") |
| |
| if project.theme: |
| txt_content.append(f"主题: {project.theme}") |
| |
| if project.genre: |
| txt_content.append(f"类型: {project.genre}") |
| |
| txt_content.append(f"总章节数: {len(chapters)}") |
| txt_content.append(f"总字数: {project.current_words}") |
| txt_content.append("\n" + "=" * 80 + "\n\n") |
| |
| for chapter in chapters: |
| txt_content.append(f"第 {chapter.chapter_number} 章 {chapter.title}") |
| txt_content.append("-" * 80) |
| txt_content.append("") |
| |
| if chapter.content: |
| txt_content.append(chapter.content) |
| else: |
| txt_content.append("(本章暂无内容)") |
| |
| txt_content.append("\n\n" + "=" * 80 + "\n\n") |
| |
| txt_content.append(f"--- 全文完 ---") |
| txt_content.append(f"\n导出时间: {func.now()}") |
| |
| final_content = "\n".join(txt_content) |
| |
| safe_title = "".join(c for c in project.title if c.isalnum() or c in (' ', '-', '_', ',', '。', '、')) |
| filename = f"{safe_title}.txt" |
| |
| from urllib.parse import quote |
| encoded_filename = quote(filename) |
| |
| logger.info(f"导出成功: {filename}, 共{len(chapters)}章, {len(final_content)}字符") |
| |
| return Response( |
| content=final_content.encode('utf-8'), |
| media_type="text/plain; charset=utf-8", |
| headers={ |
| "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", |
| "Content-Type": "text/plain; charset=utf-8" |
| } |
| ) |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"导出项目失败: {str(e)}", exc_info=True) |
| raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") |
|
|
|
|
| @router.post("/{project_id}/check-consistency", summary="检查数据一致性") |
| async def check_project_consistency( |
| project_id: str, |
| auto_fix: bool = True, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """ |
| 检查并修复项目的数据一致性问题 |
| |
| Args: |
| project_id: 项目ID |
| auto_fix: 是否自动修复问题(默认True) |
| |
| 返回检查报告,包含: |
| - organization_records: 检查并修复缺失的Organization记录 |
| - member_counts: 检查并修复组织成员计数 |
| - relationships: 验证关系数据完整性 |
| - organization_members: 验证组织成员数据完整性 |
| """ |
| try: |
| logger.info(f"开始数据一致性检查: {project_id}, auto_fix={auto_fix}") |
| |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| report = await run_full_data_consistency_check(project_id, db, auto_fix) |
| |
| logger.info(f"数据一致性检查完成: {project_id}") |
| return report |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"数据一致性检查失败: {str(e)}", exc_info=True) |
| raise HTTPException(status_code=500, detail=f"检查失败: {str(e)}") |
|
|
|
|
| @router.post("/{project_id}/fix-organizations", summary="修复组织记录") |
| async def fix_project_organizations( |
| project_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """ |
| 修复项目中缺失的Organization记录 |
| |
| 为所有is_organization=True但没有Organization记录的Character创建记录 |
| """ |
| try: |
| logger.info(f"开始修复组织记录: {project_id}") |
| |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| fixed_count, total_count = await fix_missing_organization_records(project_id, db) |
| |
| logger.info(f"组织记录修复完成: {project_id}, 修复{fixed_count}/{total_count}") |
| return { |
| "message": "组织记录修复完成", |
| "fixed": fixed_count, |
| "total": total_count |
| } |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"修复组织记录失败: {str(e)}", exc_info=True) |
| raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}") |
|
|
|
|
| @router.post("/{project_id}/fix-member-counts", summary="修复成员计数") |
| async def fix_project_member_counts( |
| project_id: str, |
| db: AsyncSession = Depends(get_db) |
| ): |
| """ |
| 修复项目中所有组织的成员计数 |
| |
| 从实际成员记录重新计算每个组织的member_count |
| """ |
| try: |
| logger.info(f"开始修复成员计数: {project_id}") |
| |
| result = await db.execute( |
| select(Project).where(Project.id == project_id) |
| ) |
| project = result.scalar_one_or_none() |
| |
| if not project: |
| logger.warning(f"项目不存在: {project_id}") |
| raise HTTPException(status_code=404, detail="项目不存在") |
| |
| fixed_count, total_count = await fix_organization_member_counts(project_id, db) |
| |
| logger.info(f"成员计数修复完成: {project_id}, 修复{fixed_count}/{total_count}") |
| return { |
| "message": "成员计数修复完成", |
| "fixed": fixed_count, |
| "total": total_count |
| } |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| logger.error(f"修复成员计数失败: {str(e)}", exc_info=True) |
| raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}") |