fix: fix various issues before merging

This commit is contained in:
Kevin
2026-03-11 11:27:32 +08:00
parent bd5f0905ba
commit 1f98b8bfd6
15 changed files with 297 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
},

View File

@@ -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
}
},

View File

@@ -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<ChapterImageDto>): List<Mem
val parts = remaining.split(placeholder, limit = 2)
val before = stripImagePlaceholders(parts.first())
if (before.isNotBlank()) blocks += MemoirContentBlock.Text(before)
if (image.status == "completed" && !image.url.isNullOrBlank()) {
if (image.status == MEMOIR_IMAGE_STATUS_COMPLETED && !image.url.isNullOrBlank()) {
blocks += MemoirContentBlock.Image(image)
} else if (image.status == "pending" || image.status == "processing") {
} else if (
image.status == MEMOIR_IMAGE_STATUS_PENDING ||
image.status == MEMOIR_IMAGE_STATUS_PROCESSING
) {
blocks += MemoirContentBlock.Image(image)
}
remaining = parts.getOrElse(1) { "" }

View File

@@ -22,6 +22,9 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
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
import com.huaga.life_echo.ui.theme.AppTypography
import com.huaga.life_echo.ui.theme.LightPurple
import com.huaga.life_echo.ui.theme.SlatePurple
@@ -74,7 +77,7 @@ fun MemoirInlineImage(
modifier: Modifier = Modifier,
) {
when (image.status) {
"completed" -> 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 = "图片生成中…",