把“章节正文 + 图片”从 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

@@ -9,6 +9,58 @@ from api.tasks import memoir_tasks
from api.tasks.memoir_tasks import generate_chapter_images
def _section_image_record(img_dict):
"""把图片 dict 转成 image_record 用的 SimpleNamespace可被任务更新属性"""
d = dict(img_dict or {})
return SimpleNamespace(
placeholder=d.get("placeholder"),
description=d.get("description"),
status=d.get("status"),
prompt=d.get("prompt"),
url=d.get("url"),
storage_key=d.get("storage_key"),
error=d.get("error"),
retryable=d.get("retryable"),
)
def _chapter_with_sections(sections_data):
"""构造带 sections 的 chapter stub供 generate_chapter_images 使用(任务从 section.image_record 读/写)。"""
sections = []
for i, d in enumerate(sections_data):
img = d.get("image")
if img:
rec = _section_image_record(img)
sec = SimpleNamespace(
content=d.get("content", ""),
image_id="img-%s-%s" % (i, id(rec)),
image_record=rec,
order_index=d.get("order_index", i),
)
else:
sec = SimpleNamespace(
content=d.get("content", ""),
image_id=None,
image_record=None,
order_index=d.get("order_index", i),
)
sections.append(sec)
return SimpleNamespace(
id="chapter-1",
user_id="user-1",
title="童年的夏天",
category="childhood",
cover_image=None,
images=[],
sections=sections,
)
def _bind_db_execute_to_chapter(db_mock, chapter):
"""让 db.execute(select(...)).unique().scalar_one_or_none() 返回 chapter。"""
db_mock.execute.return_value.unique.return_value.scalar_one_or_none.return_value = chapter
class GenerateChapterImagesTaskTest(unittest.TestCase):
def setUp(self):
memoir_tasks._REDIS_CLIENTS.clear()
@@ -26,29 +78,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
session_local_cls,
redis_from_url,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "pending",
"url": None,
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
])
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
redis_from_url.return_value.set.return_value = False
@@ -74,29 +108,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
storage_cls,
session_local_cls,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "pending",
"url": None,
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
])
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
prompt_service_cls.return_value.build_prompt.return_value = {
"prompt": "A serene southern China town",
@@ -113,8 +129,8 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
generate_chapter_images.run.__func__(task_self, "chapter-1")
self.assertIs(ctx.exception, retry_error)
self.assertEqual(chapter.images[0]["status"], "failed")
self.assertEqual(chapter.images[0]["error"], "transient provider error")
self.assertEqual(chapter.sections[0].image_record.status, "failed")
self.assertEqual(chapter.sections[0].image_record.error, "transient provider error")
task_self.retry.assert_called_once()
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
@@ -133,29 +149,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
storage_cls,
session_local_cls,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "pending",
"url": None,
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
])
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
prompt_service_cls.return_value.build_prompt.return_value = {
"prompt": "A serene southern China town",
@@ -176,10 +174,10 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
generate_chapter_images.run("chapter-1")
self.assertEqual(chapter.images[0]["status"], "completed")
self.assertEqual(chapter.images[0]["storage_key"], "memoirs/user-1/chapter-1/0-7e1f860790.png")
self.assertEqual(chapter.images[0]["url"], "https://cos.example.com/memoirs/u1/c1/0.png")
self.assertEqual(chapter.images[0]["prompt"], "A serene southern China town")
self.assertEqual(chapter.sections[0].image_record.status, "completed")
self.assertEqual(chapter.sections[0].image_record.storage_key, "memoirs/user-1/chapter-1/0-7e1f860790.png")
self.assertEqual(chapter.sections[0].image_record.url, "https://cos.example.com/memoirs/u1/c1/0.png")
self.assertEqual(chapter.sections[0].image_record.prompt, "A serene southern China town")
provider_inst.close.assert_called_once()
db.commit.assert_called()
@@ -196,27 +194,9 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
storage_cls,
session_local_cls,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "pending",
"url": None,
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
])
settings_from_env.return_value = SimpleNamespace(
enabled=False,
max_per_chapter=2,
@@ -227,15 +207,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
result = generate_chapter_images.run("chapter-1")
self.assertEqual(result, {"status": "disabled"})
self.assertEqual(chapter.images, [])
prompt_service_cls.assert_not_called()
provider_cls.assert_not_called()
storage_cls.from_env.return_value.upload_bytes.assert_not_called()
@@ -256,33 +234,15 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
storage_cls,
session_local_cls,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "pending",
"url": None,
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
])
image_buffer = BytesIO()
Image.new("RGB", (2, 1), color="white").save(image_buffer, format="JPEG")
jpeg_bytes = image_buffer.getvalue()
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
prompt_service_cls.return_value.build_prompt.return_value = {
"prompt": "A serene southern China town",
@@ -320,33 +280,15 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
storage_cls,
session_local_cls,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "pending",
"url": None,
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "pending", "url": None}},
])
image_buffer = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_buffer, format="PNG")
png_bytes = image_buffer.getvalue()
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
prompt_service_cls.return_value.build_prompt.return_value = {
"prompt": "A serene southern China town",
@@ -370,8 +312,8 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
generate_chapter_images.run.__func__(task_self, "chapter-1")
self.assertIn("AccessDenied", str(ctx.exception))
self.assertEqual(chapter.images[0]["status"], "failed")
self.assertIn("AccessDenied", chapter.images[0]["error"])
self.assertEqual(chapter.sections[0].image_record.status, "failed")
self.assertIn("AccessDenied", chapter.sections[0].image_record.error)
task_self.retry.assert_not_called()
@patch("api.tasks.memoir_tasks.SessionLocal")
@@ -389,29 +331,11 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
storage_cls,
session_local_cls,
):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"user_id": "user-1",
"title": "童年的夏天",
"category": "childhood",
"content": "那条路我一直记得。",
"images": [
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"description": "南方小镇的青石板路",
"status": "completed",
"url": "https://cos.example.com/already-there.png",
}
],
},
)()
chapter = _chapter_with_sections([
{"content": "那条路我一直记得。", "image": {"index": 0, "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", "description": "南方小镇的青石板路", "status": "completed", "url": "https://cos.example.com/already-there.png"}},
])
db = Mock()
db.get.return_value = chapter
_bind_db_execute_to_chapter(db, chapter)
session_local_cls.return_value = db
generate_chapter_images.run("chapter-1")