diff --git a/api/services/memoir_images/parser.py b/api/services/memoir_images/parser.py index 18f511e..62eca3a 100644 --- a/api/services/memoir_images/parser.py +++ b/api/services/memoir_images/parser.py @@ -1,6 +1,8 @@ import re from typing import Any +from .schema import IMAGE_STATUS_PENDING + PLACEHOLDER_RE = re.compile( r"\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}", re.DOTALL, @@ -40,7 +42,7 @@ def build_initial_image_assets( "description": item["description"], "prompt": None, "url": None, - "status": "pending", + "status": IMAGE_STATUS_PENDING, "provider": provider, "style": style, "size": size, diff --git a/api/services/memoir_images/settings.py b/api/services/memoir_images/settings.py index e820e70..feb347b 100644 --- a/api/services/memoir_images/settings.py +++ b/api/services/memoir_images/settings.py @@ -1,6 +1,8 @@ import os from dataclasses import dataclass +DEFAULT_LIBLIB_TEMPLATE_UUID = "5d7e67009b344550bc1aa6ccbfa1d7f4" + @dataclass(frozen=True) class MemoirImageSettings: @@ -17,13 +19,19 @@ class MemoirImageSettings: def from_env(cls) -> "MemoirImageSettings": return cls( enabled=os.getenv("MEMOIR_IMAGE_ENABLED", "").lower() in {"1", "true", "yes"}, - max_per_chapter=int(os.getenv("MEMOIR_IMAGE_MAX_PER_CHAPTER", "2")), + max_per_chapter=_get_int_env("MEMOIR_IMAGE_MAX_PER_CHAPTER", 2), provider=os.getenv("MEMOIR_IMAGE_PROVIDER", "liblib"), default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", "watercolor"), default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1280x720"), - poll_interval_seconds=int(os.getenv("MEMOIR_IMAGE_POLL_INTERVAL", "3")), - max_attempts=int(os.getenv("MEMOIR_IMAGE_MAX_ATTEMPTS", "60")), - liblib_template_uuid=os.getenv( - "LIBLIB_TEMPLATE_UUID", "5d7e67009b344550bc1aa6ccbfa1d7f4" - ), + poll_interval_seconds=_get_int_env("MEMOIR_IMAGE_POLL_INTERVAL", 3), + max_attempts=_get_int_env("MEMOIR_IMAGE_MAX_ATTEMPTS", 60), + liblib_template_uuid=os.getenv("LIBLIB_TEMPLATE_UUID") or DEFAULT_LIBLIB_TEMPLATE_UUID, ) + + +def _get_int_env(name: str, default: int) -> int: + value = os.getenv(name, str(default)) + try: + return int(value) + except (TypeError, ValueError): + return default diff --git a/api/services/memoir_images/storage.py b/api/services/memoir_images/storage.py index 72bc783..824fb98 100644 --- a/api/services/memoir_images/storage.py +++ b/api/services/memoir_images/storage.py @@ -64,6 +64,9 @@ def resolve_image_storage_key(image: dict | None) -> str | None: class TencentCosStorageService: + _instance: "TencentCosStorageService | None" = None + _instance_config: tuple[str, str, str, str, str] | None = None + def __init__(self, secret_id: str, secret_key: str, region: str, bucket: str, base_url: str): self.secret_id = secret_id self.secret_key = secret_key @@ -91,10 +94,20 @@ class TencentCosStorageService: @classmethod def from_env(cls) -> "TencentCosStorageService": - return cls( - secret_id=os.getenv("TENCENT_COS_SECRET_ID", ""), - secret_key=os.getenv("TENCENT_COS_SECRET_KEY", ""), - region=os.getenv("TENCENT_COS_REGION", ""), - bucket=os.getenv("TENCENT_COS_BUCKET", ""), - base_url=os.getenv("TENCENT_COS_BASE_URL", ""), + config = ( + os.getenv("TENCENT_COS_SECRET_ID", ""), + os.getenv("TENCENT_COS_SECRET_KEY", ""), + os.getenv("TENCENT_COS_REGION", ""), + os.getenv("TENCENT_COS_BUCKET", ""), + os.getenv("TENCENT_COS_BASE_URL", ""), ) + if cls._instance is None or cls._instance_config != config: + cls._instance = cls( + secret_id=config[0], + secret_key=config[1], + region=config[2], + bucket=config[3], + base_url=config[4], + ) + cls._instance_config = config + return cls._instance diff --git a/api/services/pdf_service.py b/api/services/pdf_service.py index 77a91dd..3ba51ae 100644 --- a/api/services/pdf_service.py +++ b/api/services/pdf_service.py @@ -2,23 +2,23 @@ PDF 生成服务 """ import logging -import re +from io import BytesIO from typing import List import httpx +from PIL import Image from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak, Image as ReportLabImage from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont -from io import BytesIO +from services.memoir_images.parser import PLACEHOLDER_RE +from services.memoir_images.schema import IMAGE_STATUS_COMPLETED, normalize_image_assets from services.memoir_images.storage import TencentCosStorageService, resolve_image_storage_key logger = logging.getLogger(__name__) -PLACEHOLDER_RE = re.compile(r"\{\{\{\{IMAGE:.*?\}\}\}\}|\{\{IMAGE:.*?\}\}", re.DOTALL) - def strip_image_placeholders(text: str) -> str: return PLACEHOLDER_RE.sub("", text or "").strip() @@ -35,7 +35,7 @@ def split_content_blocks(content: str, images: list[dict]) -> list[dict]: cleaned_before = strip_image_placeholders(before) if cleaned_before: blocks.append({"type": "text", "value": cleaned_before}) - if image.get("status") == "completed" and image.get("url"): + if image.get("status") == IMAGE_STATUS_COMPLETED and image.get("url"): blocks.append({"type": "image", "url": image["url"]}) cleaned_remaining = strip_image_placeholders(remaining) if cleaned_remaining: @@ -47,10 +47,10 @@ def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]: storage = TencentCosStorageService.from_env() prepared_assets: list[dict] = [] - for item in images or []: + for item in normalize_image_assets(images): asset = dict(item) storage_key = resolve_image_storage_key(asset) - if asset.get("status") == "completed" and storage_key: + if asset.get("status") == IMAGE_STATUS_COMPLETED and storage_key: try: asset["url"] = storage.get_download_url(storage_key) except Exception as exc: @@ -60,6 +60,16 @@ def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]: return prepared_assets +def _fit_image_size(image_bytes: bytes, max_width: float, max_height: float) -> tuple[float, float]: + with Image.open(BytesIO(image_bytes)) as image: + width, height = image.size + if width <= 0 or height <= 0: + return max_width, max_height + + scale = min(max_width / width, max_height / height) + return width * scale, height * scale + + class PDFService: """PDF 生成服务""" @@ -140,7 +150,12 @@ class PDFService: image_bytes = await self._fetch_image_bytes(block["url"]) if image_bytes: try: - img = ReportLabImage(BytesIO(image_bytes), width=5 * inch, height=3.75 * inch) + width, height = _fit_image_size( + image_bytes, + max_width=5 * inch, + max_height=3.75 * inch, + ) + img = ReportLabImage(BytesIO(image_bytes), width=width, height=height) story.append(img) story.append(Spacer(1, 0.2 * inch)) except Exception as exc: diff --git a/api/tests/test_memoir_image_prompting.py b/api/tests/test_memoir_image_prompting.py index e745b74..1bb40ce 100644 --- a/api/tests/test_memoir_image_prompting.py +++ b/api/tests/test_memoir_image_prompting.py @@ -1,12 +1,12 @@ import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch from api.services.memoir_images.prompting import MemoirImagePromptService from api.services.memoir_images.settings import MemoirImageSettings class MemoirImagePromptingTest(unittest.TestCase): - def test_prompt_service_uses_category_style_and_plain_fallback_without_llm(self): + def test_prompt_service_uses_english_fallback_without_llm(self): settings = MemoirImageSettings( enabled=True, max_per_chapter=2, @@ -28,7 +28,9 @@ class MemoirImagePromptingTest(unittest.TestCase): self.assertEqual(result["style"], "watercolor") self.assertEqual(result["size"], "1024x1024") - self.assertIn("奶奶坐在院子里的藤椅上", result["prompt"]) + self.assertIn("childhood memory", result["prompt"]) + self.assertIn("watercolor", result["prompt"]) + self.assertNotIn("奶奶坐在院子里的藤椅上", result["prompt"]) self.assertIn("childhood", result["prompt_context"]) def test_prompt_service_parses_structured_llm_response(self): @@ -59,3 +61,32 @@ class MemoirImagePromptingTest(unittest.TestCase): self.assertEqual(result["prompt"], "A grandmother in a quiet courtyard, summer cicadas, soft watercolor") self.assertEqual(result["style"], "watercolor") self.assertEqual(result["size"], "1024x1024") + + @patch("api.services.memoir_images.prompting.logger") + def test_prompt_service_logs_warning_and_falls_back_when_llm_response_is_invalid( + self, logger_mock + ): + settings = MemoirImageSettings( + enabled=True, + max_per_chapter=2, + provider="liblib", + default_style="watercolor", + default_size="1024x1024", + poll_interval_seconds=3, + max_attempts=20, + liblib_template_uuid="tpl-uuid", + ) + llm = Mock() + llm.invoke.return_value.content = "not-json" + service = MemoirImagePromptService(llm=llm, settings=settings) + + result = service.build_prompt( + chapter_title="童年的夏天", + chapter_category="childhood", + description="奶奶坐在院子里的藤椅上", + context_excerpt="梧桐树下很安静,夏天总有蝉鸣。", + ) + + self.assertIn("childhood memory", result["prompt"]) + self.assertNotIn("奶奶坐在院子里的藤椅上", result["prompt"]) + logger_mock.warning.assert_called_once() diff --git a/api/tests/test_memoir_image_schema.py b/api/tests/test_memoir_image_schema.py new file mode 100644 index 0000000..89a5c94 --- /dev/null +++ b/api/tests/test_memoir_image_schema.py @@ -0,0 +1,33 @@ +import unittest + +from api.services.memoir_images.schema import ( + IMAGE_STATUS_FAILED, + IMAGE_STATUS_PENDING, + normalize_image_asset, +) + + +class MemoirImageSchemaTest(unittest.TestCase): + def test_normalize_image_asset_coerces_invalid_status_to_failed(self): + asset = normalize_image_asset( + { + "index": 0, + "placeholder": "{{IMAGE:南方小镇的青石板路}}", + "description": "南方小镇的青石板路", + "status": "mystery", + "url": "https://cos.example.com/0.png", + } + ) + + self.assertEqual(asset["status"], IMAGE_STATUS_FAILED) + self.assertEqual(asset["error"], "invalid image status: mystery") + + def test_normalize_image_asset_requires_placeholder_and_description(self): + asset = normalize_image_asset( + { + "index": 0, + "status": IMAGE_STATUS_PENDING, + } + ) + + self.assertIsNone(asset) diff --git a/api/tests/test_memoir_image_settings.py b/api/tests/test_memoir_image_settings.py new file mode 100644 index 0000000..3b78165 --- /dev/null +++ b/api/tests/test_memoir_image_settings.py @@ -0,0 +1,33 @@ +import os +import unittest +from unittest.mock import patch + +from api.services.memoir_images.settings import ( + DEFAULT_LIBLIB_TEMPLATE_UUID, + MemoirImageSettings, +) + + +class MemoirImageSettingsTest(unittest.TestCase): + @patch.dict( + os.environ, + { + "MEMOIR_IMAGE_MAX_PER_CHAPTER": "not-an-int", + "MEMOIR_IMAGE_POLL_INTERVAL": "bad", + "MEMOIR_IMAGE_MAX_ATTEMPTS": "oops", + }, + clear=False, + ) + def test_from_env_falls_back_to_defaults_for_invalid_integers(self): + settings = MemoirImageSettings.from_env() + + self.assertEqual(settings.max_per_chapter, 2) + self.assertEqual(settings.poll_interval_seconds, 3) + self.assertEqual(settings.max_attempts, 60) + + @patch.dict(os.environ, {}, clear=False) + def test_from_env_uses_shared_template_uuid_default(self): + with patch.dict(os.environ, {"LIBLIB_TEMPLATE_UUID": ""}, clear=False): + settings = MemoirImageSettings.from_env() + + self.assertEqual(settings.liblib_template_uuid, DEFAULT_LIBLIB_TEMPLATE_UUID) diff --git a/api/tests/test_memoir_image_storage.py b/api/tests/test_memoir_image_storage.py index 3f8262e..e3bc0cb 100644 --- a/api/tests/test_memoir_image_storage.py +++ b/api/tests/test_memoir_image_storage.py @@ -1,3 +1,4 @@ +import os import unittest from unittest.mock import Mock, patch @@ -9,6 +10,29 @@ from api.services.memoir_images.storage import ( class MemoirImageStorageTest(unittest.TestCase): + @patch.dict( + os.environ, + { + "TENCENT_COS_SECRET_ID": "id", + "TENCENT_COS_SECRET_KEY": "key", + "TENCENT_COS_REGION": "ap-shanghai", + "TENCENT_COS_BUCKET": "memoir-1250000000", + "TENCENT_COS_BASE_URL": "https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", + }, + clear=False, + ) + @patch("api.services.memoir_images.storage.CosS3Client") + def test_from_env_reuses_singleton_for_same_config(self, client_cls): + TencentCosStorageService._instance = None + TencentCosStorageService._instance_config = None + client_cls.return_value = Mock() + + first = TencentCosStorageService.from_env() + second = TencentCosStorageService.from_env() + + self.assertIs(first, second) + client_cls.assert_called_once() + @patch("api.services.memoir_images.storage.CosS3Client") def test_upload_bytes_returns_persistent_cos_url(self, client_cls): client = Mock() diff --git a/api/tests/test_memoir_tasks_redis.py b/api/tests/test_memoir_tasks_redis.py new file mode 100644 index 0000000..a515a40 --- /dev/null +++ b/api/tests/test_memoir_tasks_redis.py @@ -0,0 +1,39 @@ +import unittest +from unittest.mock import Mock, patch + +from api.tasks import memoir_tasks +from api.tasks.memoir_tasks import ( + _acquire_chapter_lock, + _release_chapter_lock, + _update_task_status_sync, +) + + +class MemoirTasksRedisReuseTest(unittest.TestCase): + def setUp(self): + memoir_tasks._REDIS_CLIENTS.clear() + + @patch("api.tasks.memoir_tasks.redis.from_url") + def test_chapter_lock_helpers_reuse_same_redis_client(self, from_url_mock): + client = Mock() + client.set.return_value = True + from_url_mock.return_value = client + + self.assertTrue(_acquire_chapter_lock("user-1", "childhood")) + _release_chapter_lock("user-1", "childhood") + + self.assertEqual(from_url_mock.call_count, 1) + client.set.assert_called_once() + client.delete.assert_called_once() + + @patch("api.tasks.memoir_tasks.redis.from_url") + def test_task_status_updates_reuse_decode_response_client(self, from_url_mock): + client = Mock() + client.hget.return_value = None + from_url_mock.return_value = client + + _update_task_status_sync("user-1", "task-1", "running") + _update_task_status_sync("user-1", "task-1", "success", {"processed": 1}) + + self.assertEqual(from_url_mock.call_count, 1) + client.hset.assert_called() diff --git a/api/tests/test_pdf_service_images.py b/api/tests/test_pdf_service_images.py index f77cc94..22bb84a 100644 --- a/api/tests/test_pdf_service_images.py +++ b/api/tests/test_pdf_service_images.py @@ -1,10 +1,64 @@ +from io import BytesIO import unittest from unittest.mock import AsyncMock, patch, MagicMock +from PIL import Image + from api.services.pdf_service import PDFService class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): + @patch("api.services.pdf_service.ReportLabImage") + @patch("api.services.pdf_service.httpx.AsyncClient") + @patch("api.services.pdf_service.TencentCosStorageService") + async def test_generate_pdf_preserves_image_aspect_ratio( + self, + storage_cls, + async_client_cls, + reportlab_image_cls, + ): + image_buffer = BytesIO() + Image.new("RGB", (2, 1), color="white").save(image_buffer, format="PNG") + png_bytes = image_buffer.getvalue() + mock_response = MagicMock() + mock_response.content = png_bytes + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + 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 + reportlab_image_cls.return_value = MagicMock() + + service = PDFService() + book = type("BookStub", (), {"title": "我的回忆录"})() + chapter = type( + "ChapterStub", + (), + { + "title": "童年的夏天", + "content": "{{{{IMAGE:南方小镇的青石板路}}}}", + "images": [ + { + "index": 0, + "placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}", + "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", + } + ], + }, + )() + + await service.generate_pdf(book, [chapter]) + + _, kwargs = reportlab_image_cls.call_args + self.assertAlmostEqual(kwargs["width"], 5 * 72) + self.assertAlmostEqual(kwargs["height"], 2.5 * 72) + @patch("api.services.pdf_service.httpx.AsyncClient") @patch("api.services.pdf_service.TencentCosStorageService") async def test_generate_pdf_embeds_completed_images_and_removes_placeholders( diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt index 42b3cf2..3ce2861 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.huaga.life_echo.network.models.ChapterDto +import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_COMPLETED import com.huaga.life_echo.ui.components.common.MarkdownText import com.huaga.life_echo.ui.icons.AppIcons import com.huaga.life_echo.ui.theme.* @@ -173,7 +174,9 @@ private fun FilledChapterCard( ) { val processedContent = TextUtils.removeImagePlaceholders( chapter.content, - hasImages = chapter.images.any { it.status == "completed" && !it.url.isNullOrBlank() } + hasImages = chapter.images.any { + it.status == MEMOIR_IMAGE_STATUS_COMPLETED && !it.url.isNullOrBlank() + } ) MarkdownText( diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt index d47e77f..afebf7d 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.huaga.life_echo.network.models.ChapterContentDto +import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_COMPLETED import com.huaga.life_echo.network.models.ChapterImageDto import com.huaga.life_echo.ui.components.common.MarkdownText import com.huaga.life_echo.ui.theme.AppTypography @@ -77,7 +78,7 @@ fun ChapterReadingView( MemoirInlineImage( image = block.image, onClick = { - if (block.image.status == "completed" && !block.image.url.isNullOrBlank()) { + if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) { viewerImage = block.image } }, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt index 9fe5675..233f184 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.huaga.life_echo.network.models.ChapterContentDto +import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_COMPLETED import com.huaga.life_echo.network.models.ChapterImageDto import com.huaga.life_echo.ui.components.common.MarkdownText import com.huaga.life_echo.ui.icons.AppIcons @@ -91,7 +92,7 @@ fun FullTextReadingView( MemoirInlineImage( image = block.image, onClick = { - if (block.image.status == "completed" && !block.image.url.isNullOrBlank()) { + if (block.image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !block.image.url.isNullOrBlank()) { viewerImage = block.image } }, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt index 17746bd..2f86eaf 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt @@ -1,6 +1,9 @@ package com.huaga.life_echo.ui.components.memoir import com.huaga.life_echo.network.models.ChapterImageDto +import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_COMPLETED +import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PENDING +import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PROCESSING sealed interface MemoirContentBlock { data class Text(val content: String) : MemoirContentBlock @@ -24,9 +27,12 @@ fun splitMemoirContent(content: String, images: List): List SubcomposeAsyncImage( + MEMOIR_IMAGE_STATUS_COMPLETED -> SubcomposeAsyncImage( model = image.url, contentDescription = image.description, contentScale = ContentScale.FillWidth, @@ -99,7 +102,7 @@ fun MemoirInlineImage( .clickable(onClick = onClick) .testTag("memoir-image-${image.index}") ) - "pending", "processing" -> MemoirImageLoadingPlaceholder( + MEMOIR_IMAGE_STATUS_PENDING, MEMOIR_IMAGE_STATUS_PROCESSING -> MemoirImageLoadingPlaceholder( image = image, modifier = modifier, text = "图片生成中…",