把“章节正文 + 图片”从 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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user