把“章节正文 + 图片”从 chapters 单表/JSON 结构,重构为“章节 chapter + 段落 section + 图片 memoir_images 独立表”的新数据模型,同时联动修改接口、PDF 导出、异步任务、迁移脚本、测试,以及修复 Android 端聊天列表显示问题。 (#9)
* refactor: 表结构重构,新增段落section和图片image新表 * fix: fix android app import error * refactor: 重构文件名 * fix: 优化提示词 * fix: 消息气泡显示位置异常问题 --------- Co-authored-by: yangshilin <2157598560@qq.com>
This commit is contained in:
@@ -112,14 +112,20 @@ async def export_pdf(
|
||||
if book.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权导出此回忆录")
|
||||
|
||||
# 获取所有 active 章节
|
||||
# 获取所有 active 章节并预加载 sections(供 PDF 按段渲染)
|
||||
from database.models import Chapter
|
||||
stmt = select(Chapter).where(
|
||||
Chapter.user_id == current_user.id,
|
||||
Chapter.is_active == True
|
||||
).order_by(Chapter.order_index)
|
||||
from sqlalchemy.orm import joinedload
|
||||
stmt = (
|
||||
select(Chapter)
|
||||
.where(
|
||||
Chapter.user_id == current_user.id,
|
||||
Chapter.is_active == True,
|
||||
)
|
||||
.options(joinedload(Chapter.sections))
|
||||
.order_by(Chapter.order_index)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
chapters = result.scalars().all()
|
||||
chapters = result.unique().scalars().all()
|
||||
|
||||
# 生成 PDF
|
||||
pdf_bytes = await pdf_service.generate_pdf(book, chapters)
|
||||
|
||||
@@ -8,9 +8,10 @@ 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
|
||||
from database.models import Chapter as ChapterModel, ChapterSection
|
||||
from database.models import User as UserModel
|
||||
from middleware.auth import get_current_user
|
||||
from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER
|
||||
@@ -19,6 +20,7 @@ from services.memoir_images.schema import (
|
||||
IMAGE_STATUS_COMPLETED,
|
||||
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,
|
||||
@@ -72,16 +74,68 @@ def _normalize_image_assets(images: list[dict] | None) -> list[dict]:
|
||||
return normalized_assets
|
||||
|
||||
|
||||
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:
|
||||
normalized_images = _normalize_image_assets(ch.images)
|
||||
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": ch.content,
|
||||
"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 [],
|
||||
@@ -98,15 +152,23 @@ async def get_chapters(
|
||||
获取用户所有章节(需要认证,仅返回 active 章节)。
|
||||
始终返回全部 8 个预定义类别,没有内容的类别用占位符返回。
|
||||
"""
|
||||
stmt = select(ChapterModel).where(
|
||||
ChapterModel.user_id == current_user.id,
|
||||
ChapterModel.is_active == True
|
||||
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)
|
||||
stmt = stmt.order_by(ChapterModel.order_index)
|
||||
result = await db.execute(stmt)
|
||||
chapters = result.scalars().all()
|
||||
chapters = result.unique().scalars().all()
|
||||
|
||||
chapter_by_category: dict[str, ChapterModel] = {}
|
||||
for ch in chapters:
|
||||
@@ -129,6 +191,8 @@ async def get_chapters(
|
||||
"status": "empty",
|
||||
"category": category,
|
||||
"images": [],
|
||||
"cover_image": None,
|
||||
"sections": [],
|
||||
"updated_at": None,
|
||||
"is_new": False,
|
||||
"source_segments": [],
|
||||
@@ -147,26 +211,22 @@ async def get_chapter(
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
"""获取章节详情(需要认证,只能访问自己的章节)"""
|
||||
chapter = await db.get(ChapterModel, chapter_id)
|
||||
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="无权访问此章节")
|
||||
|
||||
return {
|
||||
"id": chapter.id,
|
||||
"title": chapter.title,
|
||||
"content": chapter.content,
|
||||
"order_index": chapter.order_index,
|
||||
"status": chapter.status,
|
||||
"category": chapter.category,
|
||||
"images": _normalize_image_assets(chapter.images),
|
||||
"updated_at": chapter.updated_at.isoformat() if chapter.updated_at else None,
|
||||
"is_new": chapter.is_new,
|
||||
"source_segments": chapter.source_segments or [],
|
||||
}
|
||||
return _chapter_to_dict(chapter)
|
||||
|
||||
|
||||
@router.delete("/{chapter_id}")
|
||||
|
||||
Reference in New Issue
Block a user