Fix memoir image delivery and Android rendering

This commit is contained in:
Kevin
2026-03-11 10:06:12 +08:00
parent 0970cb7408
commit a76cf8da18
23 changed files with 537 additions and 51 deletions

View File

@@ -0,0 +1,58 @@
import os
import unittest
from unittest.mock import Mock, patch
from api.routers.chapters import _chapter_to_dict
class ChaptersRouterImagesTest(unittest.TestCase):
@patch("api.routers.chapters.TencentCosStorageService")
@patch.dict(
os.environ,
{
"TENCENT_COS_BUCKET": "life-echo-dev-1319381411",
"TENCENT_COS_REGION": "ap-shanghai",
"TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com",
},
clear=False,
)
def test_chapter_to_dict_returns_signed_image_urls_for_response(self, storage_cls):
storage = Mock()
storage.get_download_url.return_value = "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123"
storage_cls.from_env.return_value = storage
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"content": "{{IMAGE:南方小镇的青石板路}}",
"order_index": 0,
"status": "completed",
"category": "childhood",
"images": [
{
"index": 0,
"placeholder": "{{IMAGE:南方小镇的青石板路}}",
"description": "南方小镇的青石板路",
"status": "completed",
"prompt": "A serene southern China town",
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
"storage_key": "memoirs/u1/c1/0-demo.png",
}
],
"updated_at": None,
"is_new": False,
"source_segments": [],
},
)()
payload = _chapter_to_dict(chapter)
self.assertEqual(
payload["images"][0]["url"],
"https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123",
)
self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town")
self.assertNotIn("storage_key", payload["images"][0])

View File

@@ -58,6 +58,7 @@ 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")
db.commit.assert_called()

View File

@@ -66,3 +66,23 @@ class MemoirImageBootstrapTest(unittest.TestCase):
self.assertEqual(assets[0]["url"], "https://cos.example.com/existing.png")
self.assertEqual(assets[1]["status"], "pending")
self.assertEqual(assets[1]["description"], "奶奶坐在院子里的藤椅上")
def test_initialize_chapter_images_accepts_double_brace_placeholders(self):
chapter = type(
"ChapterStub",
(),
{
"id": "chapter-1",
"title": "童年的夏天",
"category": "childhood",
"content": "开头。\n\n{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}\n\n结尾。",
"images": [],
},
)()
with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False):
assets = initialize_chapter_images(chapter)
self.assertEqual(len(assets), 1)
self.assertEqual(assets[0]["status"], "pending")
self.assertEqual(assets[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}")

View File

@@ -43,3 +43,12 @@ class MemoirImageParserTest(unittest.TestCase):
self.assertEqual(assets[0]["status"], "pending")
self.assertEqual(assets[0]["provider"], "liblib")
self.assertEqual(assets[0]["url"], None)
def test_parse_image_placeholders_accepts_double_brace_variant(self):
content = "开头。\n\n{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}\n\n结尾。"
items = parse_image_placeholders(content, max_images=2)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口冬日萧瑟}}")
self.assertEqual(items[0]["description"], "1938年初的上海弄堂口冬日萧瑟")

View File

@@ -15,6 +15,7 @@ class MemoirImagePromptingTest(unittest.TestCase):
default_size="1024x1024",
poll_interval_seconds=3,
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
service = MemoirImagePromptService(llm=None, settings=settings)
@@ -39,6 +40,7 @@ class MemoirImagePromptingTest(unittest.TestCase):
default_size="1024x1024",
poll_interval_seconds=3,
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
llm = Mock()
llm.invoke.return_value.content = (

View File

@@ -1,7 +1,11 @@
import unittest
from unittest.mock import Mock, patch
from api.services.memoir_images.storage import TencentCosStorageService
from api.services.memoir_images.storage import (
TencentCosStorageService,
normalize_cos_url,
resolve_image_storage_key,
)
class MemoirImageStorageTest(unittest.TestCase):
@@ -28,3 +32,80 @@ class MemoirImageStorageTest(unittest.TestCase):
"https://memoir-1250000000.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
)
client.put_object.assert_called_once()
@patch("api.services.memoir_images.storage.CosS3Client")
def test_upload_bytes_normalizes_duplicate_appid_suffix_in_base_url(self, client_cls):
client = Mock()
client_cls.return_value = client
storage = TencentCosStorageService(
secret_id="id",
secret_key="key",
region="ap-shanghai",
bucket="life-echo-dev-1319381411",
base_url="https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com",
)
url = storage.upload_bytes(
image_bytes=b"png-bytes",
key="memoirs/u1/c1/0-demo.png",
content_type="image/png",
)
self.assertEqual(
url,
"https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
)
client.put_object.assert_called_once()
def test_normalize_cos_url_repairs_existing_duplicate_appid_host(self):
normalized = normalize_cos_url(
"https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
bucket="life-echo-dev-1319381411",
region="ap-shanghai",
)
self.assertEqual(
normalized,
"https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
)
@patch("api.services.memoir_images.storage.CosS3Client")
def test_get_download_url_returns_presigned_download_url(self, client_cls):
client = Mock()
client.get_presigned_download_url.return_value = "https://cos.example.com/0.png?q-sign-algorithm=sha1"
client_cls.return_value = client
storage = TencentCosStorageService(
secret_id="id",
secret_key="key",
region="ap-shanghai",
bucket="life-echo-dev-1319381411",
base_url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com",
)
url = storage.get_download_url("memoirs/u1/c1/0-demo.png", expires=1800)
self.assertEqual(url, "https://cos.example.com/0.png?q-sign-algorithm=sha1")
client.get_presigned_download_url.assert_called_once_with(
Bucket="life-echo-dev-1319381411",
Key="memoirs/u1/c1/0-demo.png",
Expired=1800,
)
def test_resolve_image_storage_key_prefers_explicit_storage_key(self):
key = resolve_image_storage_key(
{
"storage_key": "memoirs/u1/c1/0-demo.png",
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/other.png",
}
)
self.assertEqual(key, "memoirs/u1/c1/0-demo.png")
def test_resolve_image_storage_key_derives_key_from_existing_url(self):
key = resolve_image_storage_key(
{
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1"
}
)
self.assertEqual(key, "memoirs/u1/c1/0-demo.png")

View File

@@ -6,7 +6,12 @@ from api.services.pdf_service import PDFService
class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
@patch("api.services.pdf_service.httpx.AsyncClient")
async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(self, async_client_cls):
@patch("api.services.pdf_service.TencentCosStorageService")
async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(
self,
storage_cls,
async_client_cls,
):
png_bytes = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00"
@@ -20,6 +25,9 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
mock_client.get.return_value = mock_response
async_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
async_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
storage = MagicMock()
storage.get_download_url.return_value = "https://signed.example.com/0.png?sig=123"
storage_cls.from_env.return_value = storage
service = PDFService()
book = type("BookStub", (), {"title": "我的回忆录"})()
@@ -33,7 +41,8 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
{
"index": 0,
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
"url": "https://cos.example.com/0.png",
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0.png",
"storage_key": "memoirs/u1/c1/0.png",
"status": "completed",
}
],
@@ -44,3 +53,4 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
self.assertGreater(len(pdf_bytes), 100)
self.assertNotIn(b"IMAGE:", pdf_bytes)
mock_client.get.assert_called_once_with("https://signed.example.com/0.png?sig=123")

View File

@@ -35,6 +35,7 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase):
default_size="1024x1024",
poll_interval_seconds=3,
max_attempts=20,
liblib_template_uuid="tpl-uuid",
)
get_state_mock.return_value = SimpleNamespace(current_stage="childhood", slots={})