把“章节正文 + 图片”从 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:
Sully
2026-03-13 11:12:10 +08:00
committed by GitHub
parent 1cb804fa37
commit 2eb066dbec
19 changed files with 1280 additions and 624 deletions

View File

@@ -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)

View File

@@ -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_imagesimage_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}")