""" 章节相关 API 路由 """ import logging import os from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import joinedload from database import get_async_db from database.models import Chapter as ChapterModel, ChapterSection from database.models import User as UserModel from middleware.auth import get_current_user from app.agents.memoir.prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER from services.memoir_images.schema import ( completed_image_assets, IMAGE_STATUS_COMPLETED, IMAGE_STATUS_FAILED, normalize_image_assets, ) from services.memoir_images.serializers import memoir_image_to_dict from services.memoir_images.settings import MemoirImageSettings from services.memoir_images.storage import ( CosDownloadUrlError, TencentCosStorageService, mark_image_delivery_unavailable, normalize_cos_url, resolve_image_storage_key, ) router = APIRouter(prefix="/api/chapters", tags=["chapters"]) logger = logging.getLogger(__name__) def _normalize_image_assets(images: list[dict] | None) -> list[dict]: bucket = os.getenv("TENCENT_COS_BUCKET", "") region = os.getenv("TENCENT_COS_REGION", "") base_url = os.getenv("TENCENT_COS_BASE_URL", "") storage = TencentCosStorageService.from_env() settings = MemoirImageSettings.from_env() source_assets = normalize_image_assets(images) if not settings.enabled: source_assets = completed_image_assets(source_assets) normalized_assets: list[dict] = [] for item in source_assets: asset = dict(item) normalized_url = normalize_cos_url( asset.get("url"), bucket=bucket, region=region, base_url=base_url, ) storage_key = resolve_image_storage_key(asset) if asset.get("status") == IMAGE_STATUS_COMPLETED and storage_key: try: asset["url"] = storage.get_download_url(storage_key) except CosDownloadUrlError as exc: logger.warning( "章节图片签名失败: key=%s, retryable=%s, request_id=%s, error=%s", storage_key, exc.retryable, exc.request_id, exc, ) asset = mark_image_delivery_unavailable(asset) except Exception as exc: logger.warning("章节图片签名失败: key=%s, error=%s", storage_key, exc) asset = mark_image_delivery_unavailable(asset) else: asset["url"] = normalized_url asset.pop("storage_key", None) normalized_assets.append(asset) return normalized_assets def _is_image_permanently_unavailable(rec) -> bool: """配图是否应清理:失败不可恢复,或 completed 但无 url/storage_key(损坏数据)""" if not rec: return False status = getattr(rec, "status", None) or "" retryable = getattr(rec, "retryable", None) url = getattr(rec, "url", None) storage_key = getattr(rec, "storage_key", None) if status == IMAGE_STATUS_FAILED and retryable is False: return True if status == IMAGE_STATUS_COMPLETED and not url and not storage_key: return True return False async def _cleanup_permanently_unavailable_images(ch: ChapterModel, db: AsyncSession) -> None: """清理章节中永久不可用的配图:section.image_id 置空,删除 memoir_images 记录""" sections = getattr(ch, "sections", None) or [] cleaned = False for s in sections: rec = getattr(s, "image_record", None) if rec and _is_image_permanently_unavailable(rec): logger.info("清理不可用配图: chapter=%s, section=%s", ch.id, s.id) s.image_id = None await db.delete(rec) cleaned = True if cleaned: await db.commit() await db.refresh(ch) def _section_image_to_dict(section) -> dict | None: """从 section.image_id 关联的 memoir_images(image_record)取配图。""" if getattr(section, "image_record", None): return memoir_image_to_dict(section.image_record) return None def _chapter_cover_to_dict(ch) -> dict | None: """优先从 memoir_images 表(section_id 为空的一条)取封面,否则回退到 chapter.cover_image JSON。""" images = getattr(ch, "images", None) or [] for m in images: if getattr(m, "section_id", None) is None: return memoir_image_to_dict(m) if getattr(ch, "cover_image", None) and isinstance(ch.cover_image, dict): return ch.cover_image return None def _sections_to_content_and_images(ch): """ 从 chapter.sections 按 order_index 顺序拼出 content 与 images,保证每段文字与配图一一对应。 客户端依赖 content 中的占位符(与 images 中每项的 placeholder 一致)来切分正文并插入图片。 """ sections = getattr(ch, "sections", None) or [] ordered = sorted(sections, key=lambda s: getattr(s, "order_index", 0)) parts = [] images = [] for s in ordered: text = (s.content or "").strip() if text: parts.append(text) img = _section_image_to_dict(s) if img: images.append(img) placeholder = (img.get("placeholder") or "").strip() if placeholder: parts.append(placeholder) content = "\n\n".join(parts) if parts else "" return content, images def _chapter_to_dict(ch: ChapterModel) -> dict: content, images_list = _sections_to_content_and_images(ch) normalized_images = _normalize_image_assets(images_list) cover = _chapter_cover_to_dict(ch) cover_normalized = _normalize_image_assets([cover] if cover else [])[0] if cover else None sections_data = [] if getattr(ch, "sections", None): for s in sorted(ch.sections, key=lambda x: getattr(x, "order_index", 0)): sec_img = _section_image_to_dict(s) sec_img = _normalize_image_assets([sec_img] if sec_img else [])[0] if sec_img else None sections_data.append({"content": (s.content or "").strip(), "image": sec_img}) return { "id": ch.id, "title": ch.title, "content": content, "order_index": ch.order_index, "status": ch.status, "category": ch.category, "images": normalized_images, "cover_image": cover_normalized, "sections": sections_data, "updated_at": ch.updated_at.isoformat() if ch.updated_at else None, "is_new": ch.is_new, "source_segments": ch.source_segments or [], } @router.get("", response_model=List[dict]) async def get_chapters( current_user: UserModel = Depends(get_current_user), is_new: Optional[bool] = Query(None, description="仅返回未读章节"), db: AsyncSession = Depends(get_async_db) ): """ 获取用户所有章节(需要认证,仅返回 active 章节)。 始终返回全部 8 个预定义类别,没有内容的类别用占位符返回。 """ stmt = ( select(ChapterModel) .where( ChapterModel.user_id == current_user.id, ChapterModel.is_active == True, ) .options( joinedload(ChapterModel.sections), joinedload(ChapterModel.images), joinedload(ChapterModel.sections).joinedload(ChapterSection.image_record), ) .order_by(ChapterModel.order_index) ) if is_new is True: stmt = stmt.where(ChapterModel.is_new == True) result = await db.execute(stmt) chapters = result.unique().scalars().all() chapter_by_category: dict[str, ChapterModel] = {} for ch in chapters: if ch.category and ch.category not in chapter_by_category: chapter_by_category[ch.category] = ch all_chapters: List[dict] = [] for category in CHAPTER_ORDER: ch = chapter_by_category.pop(category, None) if ch: await _cleanup_permanently_unavailable_images(ch, db) all_chapters.append(_chapter_to_dict(ch)) else: if is_new is True: continue all_chapters.append({ "id": f"placeholder_{category}", "title": CHAPTER_CATEGORIES[category], "content": "", "order_index": STAGE_TO_ORDER.get(category, 999), "status": "empty", "category": category, "images": [], "cover_image": None, "sections": [], "updated_at": None, "is_new": False, "source_segments": [], }) for ch in chapter_by_category.values(): await _cleanup_permanently_unavailable_images(ch, db) all_chapters.append(_chapter_to_dict(ch)) return all_chapters @router.get("/{chapter_id}", response_model=dict) async def get_chapter( chapter_id: str, current_user: UserModel = Depends(get_current_user), db: AsyncSession = Depends(get_async_db) ): """获取章节详情(需要认证,只能访问自己的章节)""" stmt = ( select(ChapterModel) .where(ChapterModel.id == chapter_id) .options( joinedload(ChapterModel.sections), joinedload(ChapterModel.images), joinedload(ChapterModel.sections).joinedload(ChapterSection.image_record), ) ) result = await db.execute(stmt) chapter = result.unique().scalar_one_or_none() if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权访问此章节") await _cleanup_permanently_unavailable_images(chapter, db) return _chapter_to_dict(chapter) @router.delete("/{chapter_id}") async def disable_chapter( chapter_id: str, current_user: UserModel = Depends(get_current_user), db: AsyncSession = Depends(get_async_db) ): """清除章节(将章节标记为 disabled,需要认证,只能操作自己的章节)""" chapter = await db.get(ChapterModel, chapter_id) if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") # 验证用户权限 if chapter.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权操作此章节") # 将章节标记为 disabled(不物理删除) chapter.is_active = False await db.commit() return {"status": "ok", "message": "章节已清除"} @router.post("/{chapter_id}/regenerate") async def regenerate_chapter( chapter_id: str, current_user: UserModel = Depends(get_current_user), db: AsyncSession = Depends(get_async_db) ): """重新整理章节(需要认证,只能操作自己的章节)""" chapter = await db.get(ChapterModel, chapter_id) if not chapter: raise HTTPException(status_code=404, detail="Chapter not found") # 验证用户权限 if chapter.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权操作此章节") # TODO: 实现重新整理逻辑 return {"status": "ok", "message": "Chapter regeneration triggered"}