diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 129b298..6e6e4f9 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -21,7 +21,9 @@ from services.memoir_images.schema import ( ) from services.memoir_images.settings import MemoirImageSettings from services.memoir_images.storage import ( + CosDownloadUrlError, TencentCosStorageService, + mark_image_delivery_unavailable, normalize_cos_url, resolve_image_storage_key, ) @@ -53,10 +55,15 @@ def _normalize_image_assets(images: list[dict] | None) -> list[dict]: if asset.get("status") == IMAGE_STATUS_COMPLETED and storage_key: try: asset["url"] = storage.get_download_url(storage_key) + except CosDownloadUrlError as exc: + logger.warning( + "章节图片签名失败: key=%s, retryable=%s, request_id=%s, error=%s", + storage_key, exc.retryable, exc.request_id, exc, + ) + asset = mark_image_delivery_unavailable(asset) except Exception as exc: logger.warning("章节图片签名失败: key=%s, error=%s", storage_key, exc) - asset["url"] = normalized_url - asset["error"] = asset.get("error") or "image delivery unavailable" + asset = mark_image_delivery_unavailable(asset) else: asset["url"] = normalized_url asset.pop("storage_key", None) diff --git a/api/services/memoir_images/schema.py b/api/services/memoir_images/schema.py index c2fe592..6d4ea8f 100644 --- a/api/services/memoir_images/schema.py +++ b/api/services/memoir_images/schema.py @@ -48,6 +48,7 @@ def normalize_image_asset(asset: dict[str, Any] | None) -> dict[str, Any] | None normalized["error"] = _as_optional_string(asset.get("error")) normalized["created_at"] = _as_optional_string(asset.get("created_at")) normalized["updated_at"] = _as_optional_string(asset.get("updated_at")) + normalized["retryable"] = _as_optional_bool(asset.get("retryable")) if normalized["status"] == IMAGE_STATUS_COMPLETED and not ( normalized["url"] or normalized["storage_key"] @@ -55,6 +56,9 @@ def normalize_image_asset(asset: dict[str, Any] | None) -> dict[str, Any] | None normalized["status"] = IMAGE_STATUS_FAILED normalized["error"] = normalized["error"] or "missing image url" + if normalized["status"] != IMAGE_STATUS_FAILED: + normalized["retryable"] = None + return normalized @@ -91,6 +95,20 @@ def _as_optional_string(value: Any) -> str | None: return str(value) +def _as_optional_bool(value: Any) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes"}: + return True + if lowered in {"false", "0", "no"}: + return False + return None + + def _coerce_int(value: Any, default: int) -> int: try: return int(value) diff --git a/api/services/memoir_images/storage.py b/api/services/memoir_images/storage.py index 824fb98..c127244 100644 --- a/api/services/memoir_images/storage.py +++ b/api/services/memoir_images/storage.py @@ -1,7 +1,11 @@ +import logging import os from urllib.parse import urlparse, urlunparse from qcloud_cos import CosConfig, CosS3Client +from qcloud_cos.cos_exception import CosClientError, CosServiceError + +logger = logging.getLogger(__name__) def normalize_cos_base_url(base_url: str, bucket: str, region: str) -> str: @@ -63,34 +67,114 @@ def resolve_image_storage_key(image: dict | None) -> str | None: return str(url).lstrip("/") or None +def mark_image_delivery_unavailable( + image: dict | None, + *, + error_message: str = "image delivery unavailable", +) -> dict: + normalized = dict(image or {}) + normalized["url"] = None + normalized["error"] = normalized.get("error") or error_message + return normalized + + +class CosStorageError(Exception): + """Base exception for COS storage operations.""" + + def __init__(self, message: str, *, retryable: bool = True, request_id: str = ""): + super().__init__(message) + self.retryable = retryable + self.request_id = request_id + + +class CosUploadError(CosStorageError): + """Raised when put_object fails.""" + + +class CosDownloadUrlError(CosStorageError): + """Raised when generating a presigned download URL fails.""" + + +def _is_retryable_cos_error(exc: Exception) -> bool: + if isinstance(exc, CosClientError): + return True + if isinstance(exc, CosServiceError): + try: + return exc.get_status_code() >= 500 + except Exception: + return True + return True + + class TencentCosStorageService: _instance: "TencentCosStorageService | None" = None - _instance_config: tuple[str, str, str, str, str] | None = None + _instance_config: tuple[str, str, str, str, str, str] | None = None - def __init__(self, secret_id: str, secret_key: str, region: str, bucket: str, base_url: str): + def __init__( + self, + secret_id: str, + secret_key: str, + region: str, + bucket: str, + base_url: str, + token: str = "", + ): self.secret_id = secret_id self.secret_key = secret_key self.bucket = bucket self.region = region self.base_url = normalize_cos_base_url(base_url, bucket=bucket, region=region) - config = CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key) + config = CosConfig( + Region=region, + SecretId=secret_id, + SecretKey=secret_key, + Token=token, + Scheme="https", + ) self.client = CosS3Client(config) def upload_bytes(self, image_bytes: bytes, key: str, content_type: str) -> str: - self.client.put_object( - Bucket=self.bucket, - Body=image_bytes, - Key=key, - ContentType=content_type, - ) + try: + response = self.client.put_object( + Bucket=self.bucket, + Body=image_bytes, + Key=key, + ContentType=content_type, + ) + etag = response.get("ETag", "") + request_id = response.get("x-cos-request-id", "") + logger.info( + "COS upload ok: key=%s, ETag=%s, request_id=%s", key, etag, request_id + ) + except (CosClientError, CosServiceError) as exc: + retryable = _is_retryable_cos_error(exc) + request_id = ( + exc.get_request_id() if isinstance(exc, CosServiceError) else "" + ) + raise CosUploadError( + f"COS upload failed: key={key}, error={exc}", + retryable=retryable, + request_id=request_id, + ) from exc return f"{self.base_url}/{key}" def get_download_url(self, key: str, expires: int = 3600) -> str: - return self.client.get_presigned_download_url( - Bucket=self.bucket, - Key=key, - Expired=expires, - ) + try: + return self.client.get_presigned_download_url( + Bucket=self.bucket, + Key=key, + Expired=expires, + ) + except (CosClientError, CosServiceError) as exc: + retryable = _is_retryable_cos_error(exc) + request_id = ( + exc.get_request_id() if isinstance(exc, CosServiceError) else "" + ) + raise CosDownloadUrlError( + f"COS presigned URL failed: key={key}, error={exc}", + retryable=retryable, + request_id=request_id, + ) from exc @classmethod def from_env(cls) -> "TencentCosStorageService": @@ -100,6 +184,7 @@ class TencentCosStorageService: os.getenv("TENCENT_COS_REGION", ""), os.getenv("TENCENT_COS_BUCKET", ""), os.getenv("TENCENT_COS_BASE_URL", ""), + os.getenv("TENCENT_COS_TOKEN", ""), ) if cls._instance is None or cls._instance_config != config: cls._instance = cls( @@ -108,6 +193,7 @@ class TencentCosStorageService: region=config[2], bucket=config[3], base_url=config[4], + token=config[5], ) cls._instance_config = config return cls._instance diff --git a/api/services/pdf_service.py b/api/services/pdf_service.py index 3ba51ae..8c8623c 100644 --- a/api/services/pdf_service.py +++ b/api/services/pdf_service.py @@ -15,7 +15,12 @@ from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont 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 +from services.memoir_images.storage import ( + CosDownloadUrlError, + TencentCosStorageService, + mark_image_delivery_unavailable, + resolve_image_storage_key, +) logger = logging.getLogger(__name__) @@ -53,8 +58,15 @@ def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]: if asset.get("status") == IMAGE_STATUS_COMPLETED and storage_key: try: asset["url"] = storage.get_download_url(storage_key) + except CosDownloadUrlError as exc: + logger.warning( + "PDF 图片签名失败: key=%s, retryable=%s, request_id=%s, error=%s", + storage_key, exc.retryable, exc.request_id, exc, + ) + asset = mark_image_delivery_unavailable(asset) except Exception as exc: - logger.warning(f"PDF 图片签名失败: key={storage_key}, error={exc}") + logger.warning("PDF 图片签名失败: key=%s, error=%s", storage_key, exc) + asset = mark_image_delivery_unavailable(asset) prepared_assets.append(asset) return prepared_assets diff --git a/api/tasks/memoir_tasks.py b/api/tasks/memoir_tasks.py index 553d9c4..4d9447b 100644 --- a/api/tasks/memoir_tasks.py +++ b/api/tasks/memoir_tasks.py @@ -44,12 +44,16 @@ from services.memoir_images.schema import ( normalize_image_assets, ) from services.memoir_images.settings import MemoirImageSettings -from services.memoir_images.storage import TencentCosStorageService +from services.memoir_images.storage import TencentCosStorageService, CosUploadError logger = logging.getLogger(__name__) _REDIS_CLIENTS: dict[bool, redis.Redis] = {} +class PermanentImageGenerationError(RuntimeError): + """Raised when chapter image generation hits a non-retryable failure.""" + + def _get_redis_client(*, decode_responses: bool = False) -> redis.Redis: client = _REDIS_CLIENTS.get(decode_responses) if client is None: @@ -736,11 +740,14 @@ def generate_chapter_images(self, chapter_id: str): len(images), pending_count, ) - failures: list[str] = [] + retryable_failures: list[str] = [] + permanent_failures: list[str] = [] for index, item in enumerate(images): if item.get("status") == IMAGE_STATUS_COMPLETED and (item.get("storage_key") or item.get("url")): continue + if item.get("status") == IMAGE_STATUS_FAILED and item.get("retryable") is False: + continue if item.get("status") not in {IMAGE_STATUS_PENDING, IMAGE_STATUS_FAILED}: continue @@ -781,6 +788,7 @@ def generate_chapter_images(self, chapter_id: str): current_item["size"] = prompt_data["size"] current_item["status"] = IMAGE_STATUS_COMPLETED current_item["error"] = None + current_item["retryable"] = None logger.info( "章节补图成功: chapter=%s, index=%s, url=%s", chapter_id, @@ -790,20 +798,34 @@ def generate_chapter_images(self, chapter_id: str): except Exception as exc: current_item["status"] = IMAGE_STATUS_FAILED current_item["error"] = str(exc) - failures.append(f"index={current_item.get('index')}, error={exc}") - logger.warning(f"图片生成失败: chapter={chapter_id}, index={current_item.get('index')}, error={exc}") + failure_msg = f"index={current_item.get('index')}, error={exc}" + if isinstance(exc, CosUploadError) and not exc.retryable: + current_item["retryable"] = False + permanent_failures.append(failure_msg) + logger.error("图片上传不可重试: chapter=%s, %s", chapter_id, failure_msg) + else: + current_item["retryable"] = True + retryable_failures.append(failure_msg) + logger.warning("图片生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg) current_item["updated_at"] = datetime.now(timezone.utc).isoformat() images[index] = current_item chapter.images = images db.commit() - if failures: + if retryable_failures: raise RuntimeError( - f"章节补图存在失败项: chapter={chapter_id}, failures={'; '.join(failures)}" + f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}" + ) + if permanent_failures: + raise PermanentImageGenerationError( + f"章节补图存在不可重试失败项: chapter={chapter_id}, failures={'; '.join(permanent_failures)}" ) return {"status": "success"} + except PermanentImageGenerationError as exc: + logger.error("章节补图任务失败(不重试): chapter=%s, error=%s", chapter_id, exc) + raise except Exception as exc: logger.error("章节补图任务失败: chapter=%s, error=%s", chapter_id, exc) raise self.retry(exc=exc) diff --git a/api/tests/test_chapters_router_images.py b/api/tests/test_chapters_router_images.py index cbc12c6..76a32a6 100644 --- a/api/tests/test_chapters_router_images.py +++ b/api/tests/test_chapters_router_images.py @@ -2,6 +2,7 @@ import os import unittest from unittest.mock import Mock, patch +from api.routers import chapters as chapters_module from api.routers.chapters import _chapter_to_dict @@ -69,7 +70,9 @@ class ChaptersRouterImagesTest(unittest.TestCase): ) def test_chapter_to_dict_preserves_completed_asset_when_signing_fails(self, storage_cls): storage = Mock() - storage.get_download_url.side_effect = RuntimeError("cos unavailable") + storage.get_download_url.side_effect = chapters_module.CosDownloadUrlError( + "cos unavailable", retryable=True, request_id="req-err" + ) storage_cls.from_env.return_value = storage chapter = type( @@ -102,11 +105,9 @@ class ChaptersRouterImagesTest(unittest.TestCase): payload = _chapter_to_dict(chapter) self.assertEqual(payload["images"][0]["status"], "completed") - self.assertEqual( - payload["images"][0]["url"], - "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png", - ) + self.assertIsNone(payload["images"][0]["url"]) self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town") + self.assertEqual(payload["images"][0]["error"], "image delivery unavailable") self.assertNotIn("storage_key", payload["images"][0]) @patch("api.routers.chapters.TencentCosStorageService") @@ -183,3 +184,40 @@ class ChaptersRouterImagesTest(unittest.TestCase): self.assertEqual(len(payload["images"]), 1) self.assertEqual(payload["images"][0]["status"], "completed") + + @patch("api.routers.chapters.TencentCosStorageService") + @patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False) + def test_chapter_to_dict_preserves_retryable_flag_for_failed_assets(self, storage_cls): + storage_cls.from_env.return_value = Mock() + + chapter = type( + "ChapterStub", + (), + { + "id": "chapter-1", + "title": "童年的夏天", + "content": "{{IMAGE:南方小镇的青石板路}}", + "order_index": 0, + "status": "completed", + "category": "childhood", + "images": [ + { + "index": 0, + "placeholder": "{{IMAGE:南方小镇的青石板路}}", + "description": "南方小镇的青石板路", + "status": "failed", + "url": None, + "error": "upload denied", + "retryable": False, + } + ], + "updated_at": None, + "is_new": False, + "source_segments": [], + }, + )() + + payload = _chapter_to_dict(chapter) + + self.assertEqual(payload["images"][0]["status"], "failed") + self.assertFalse(payload["images"][0]["retryable"]) diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py index e4d957a..607195f 100644 --- a/api/tests/test_generate_chapter_images_task.py +++ b/api/tests/test_generate_chapter_images_task.py @@ -305,6 +305,75 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): self.assertTrue(upload_args[0].startswith(b"\x89PNG\r\n\x1a\n")) self.assertEqual(upload_args[2], "image/png") + @patch("api.tasks.memoir_tasks.SessionLocal") + @patch("api.tasks.memoir_tasks.TencentCosStorageService") + @patch("api.tasks.memoir_tasks.LiblibImageProvider") + @patch("api.tasks.memoir_tasks.MemoirImagePromptService") + @patch("api.tasks.memoir_tasks._release_chapter_image_lock") + @patch("api.tasks.memoir_tasks._acquire_chapter_image_lock", return_value=True) + def test_generate_chapter_images_fails_without_retry_on_permanent_cos_error( + self, + _acquire_lock_mock, + _release_lock_mock, + prompt_service_cls, + provider_cls, + 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, + } + ], + }, + )() + + 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 + session_local_cls.return_value = db + prompt_service_cls.return_value.build_prompt.return_value = { + "prompt": "A serene southern China town", + "style": "watercolor", + "size": "1024x1024", + "prompt_context": "childhood: 童年的夏天", + } + provider_inst = provider_cls.return_value + provider_inst.submit_generation.return_value = { + "status": "completed", + "image_url": "https://provider.example.com/1.png", + } + provider_inst.download_image.return_value = png_bytes + storage_inst = storage_cls.from_env.return_value + storage_inst.upload_bytes.side_effect = memoir_tasks.CosUploadError( + "AccessDenied", retryable=False, request_id="req-403" + ) + task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock()) + + with self.assertRaises(memoir_tasks.PermanentImageGenerationError) as ctx: + 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"]) + task_self.retry.assert_not_called() + @patch("api.tasks.memoir_tasks.SessionLocal") @patch("api.tasks.memoir_tasks.TencentCosStorageService") @patch("api.tasks.memoir_tasks.LiblibImageProvider") diff --git a/api/tests/test_memoir_image_schema.py b/api/tests/test_memoir_image_schema.py index 89a5c94..d21b3e1 100644 --- a/api/tests/test_memoir_image_schema.py +++ b/api/tests/test_memoir_image_schema.py @@ -31,3 +31,32 @@ class MemoirImageSchemaTest(unittest.TestCase): ) self.assertIsNone(asset) + + def test_normalize_image_asset_preserves_retryable_for_failed_assets(self): + asset = normalize_image_asset( + { + "index": 0, + "placeholder": "{{IMAGE:南方小镇的青石板路}}", + "description": "南方小镇的青石板路", + "status": IMAGE_STATUS_FAILED, + "error": "upload denied", + "retryable": False, + } + ) + + self.assertEqual(asset["status"], IMAGE_STATUS_FAILED) + self.assertFalse(asset["retryable"]) + + def test_normalize_image_asset_clears_retryable_for_non_failed_assets(self): + asset = normalize_image_asset( + { + "index": 0, + "placeholder": "{{IMAGE:南方小镇的青石板路}}", + "description": "南方小镇的青石板路", + "status": IMAGE_STATUS_PENDING, + "retryable": True, + } + ) + + self.assertEqual(asset["status"], IMAGE_STATUS_PENDING) + self.assertIsNone(asset["retryable"]) diff --git a/api/tests/test_memoir_image_storage.py b/api/tests/test_memoir_image_storage.py index e3bc0cb..981d222 100644 --- a/api/tests/test_memoir_image_storage.py +++ b/api/tests/test_memoir_image_storage.py @@ -2,8 +2,13 @@ import os import unittest from unittest.mock import Mock, patch +from qcloud_cos.cos_exception import CosClientError, CosServiceError + from api.services.memoir_images.storage import ( + CosDownloadUrlError, + CosUploadError, TencentCosStorageService, + _is_retryable_cos_error, normalize_cos_url, resolve_image_storage_key, ) @@ -36,6 +41,10 @@ class MemoirImageStorageTest(unittest.TestCase): @patch("api.services.memoir_images.storage.CosS3Client") def test_upload_bytes_returns_persistent_cos_url(self, client_cls): client = Mock() + client.put_object.return_value = { + "ETag": '"abc123"', + "x-cos-request-id": "req-001", + } client_cls.return_value = client storage = TencentCosStorageService( secret_id="id", @@ -133,3 +142,90 @@ class MemoirImageStorageTest(unittest.TestCase): ) self.assertEqual(key, "memoirs/u1/c1/0-demo.png") + + @patch("api.services.memoir_images.storage.CosS3Client") + def test_upload_bytes_raises_cos_upload_error_on_client_error(self, client_cls): + client = Mock() + client.put_object.side_effect = CosClientError("network timeout") + client_cls.return_value = client + storage = TencentCosStorageService( + secret_id="id", + secret_key="key", + region="ap-shanghai", + bucket="memoir-1250000000", + base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", + ) + + with self.assertRaises(CosUploadError) as ctx: + storage.upload_bytes(b"data", "key.png", "image/png") + + self.assertTrue(ctx.exception.retryable) + self.assertIsInstance(ctx.exception.__cause__, CosClientError) + + @patch("api.services.memoir_images.storage.CosS3Client") + def test_upload_bytes_raises_non_retryable_on_403(self, client_cls): + client = Mock() + svc_error = CosServiceError("PUT", "AccessDenied", 403) + client.put_object.side_effect = svc_error + client_cls.return_value = client + storage = TencentCosStorageService( + secret_id="id", + secret_key="key", + region="ap-shanghai", + bucket="memoir-1250000000", + base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", + ) + + with self.assertRaises(CosUploadError) as ctx: + storage.upload_bytes(b"data", "key.png", "image/png") + + self.assertFalse(ctx.exception.retryable) + + @patch("api.services.memoir_images.storage.CosS3Client") + def test_get_download_url_raises_cos_download_url_error(self, client_cls): + client = Mock() + client.get_presigned_download_url.side_effect = CosClientError("dns failure") + client_cls.return_value = client + storage = TencentCosStorageService( + secret_id="id", + secret_key="key", + region="ap-shanghai", + bucket="memoir-1250000000", + base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", + ) + + with self.assertRaises(CosDownloadUrlError) as ctx: + storage.get_download_url("key.png") + + self.assertTrue(ctx.exception.retryable) + + def test_is_retryable_returns_true_for_client_error(self): + self.assertTrue(_is_retryable_cos_error(CosClientError("timeout"))) + + def test_is_retryable_returns_false_for_4xx_service_error(self): + self.assertFalse(_is_retryable_cos_error(CosServiceError("GET", "Forbidden", 403))) + + def test_is_retryable_returns_true_for_5xx_service_error(self): + self.assertTrue(_is_retryable_cos_error(CosServiceError("GET", "Internal", 500))) + + @patch("api.services.memoir_images.storage.CosS3Client") + def test_cos_config_includes_scheme_and_token(self, client_cls): + client_cls.return_value = Mock() + + with patch("api.services.memoir_images.storage.CosConfig") as config_cls: + TencentCosStorageService( + secret_id="id", + secret_key="key", + region="ap-shanghai", + bucket="memoir-1250000000", + base_url="https://memoir-1250000000.cos.ap-shanghai.myqcloud.com", + token="tmp-token", + ) + + config_cls.assert_called_once_with( + Region="ap-shanghai", + SecretId="id", + SecretKey="key", + Token="tmp-token", + Scheme="https", + ) diff --git a/api/tests/test_pdf_service_images.py b/api/tests/test_pdf_service_images.py index 22bb84a..80c5eab 100644 --- a/api/tests/test_pdf_service_images.py +++ b/api/tests/test_pdf_service_images.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch, MagicMock from PIL import Image from api.services.pdf_service import PDFService +from api.services import pdf_service as pdf_service_module class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase): @@ -108,3 +109,44 @@ 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") + + @patch("api.services.pdf_service.httpx.AsyncClient") + @patch("api.services.pdf_service.TencentCosStorageService") + async def test_generate_pdf_skips_private_cos_url_when_signing_fails( + self, + storage_cls, + async_client_cls, + ): + mock_client = AsyncMock() + 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.side_effect = pdf_service_module.CosDownloadUrlError( + "cos unavailable", retryable=True, request_id="req-err" + ) + storage_cls.from_env.return_value = storage + + service = PDFService() + book = type("BookStub", (), {"title": "我的回忆录"})() + chapter = type( + "ChapterStub", + (), + { + "title": "童年的夏天", + "content": "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}\n\n奶奶常坐在那里。", + "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", + } + ], + }, + )() + + pdf_bytes = await service.generate_pdf(book, [chapter]) + + self.assertGreater(len(pdf_bytes), 100) + mock_client.get.assert_not_called() diff --git a/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt b/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt index 39a40c4..fd7f86c 100644 --- a/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt +++ b/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt @@ -1,8 +1,8 @@ package com.huaga.life_echo.ui.components.memoir import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertDoesNotExist import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import com.huaga.life_echo.network.models.ChapterContentDto @@ -70,6 +70,7 @@ class ChapterReadingImageBlocksTest { prompt = null, url = null, status = "failed", + retryable = false, provider = "liblib", style = "watercolor", size = "1024x1024", @@ -82,6 +83,44 @@ class ChapterReadingImageBlocksTest { composeRule.setContent { ChapterReadingView(chapter = chapter) } - composeRule.onNodeWithTag("memoir-image-error-0").assertDoesNotExist() + composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed() + composeRule.onNodeWithText("图片生成失败,暂不可恢复").assertIsDisplayed() + } + + @Test + fun chapterReadingView_showsUnavailablePlaceholder_forCompletedImageWithoutUrl() { + val chapter = ChapterContentDto( + id = "chapter-1", + title = "童年的夏天", + content = "那条路我一直记得。\n\n{{{{IMAGE:南方小镇的青石板路}}}}", + orderIndex = 0, + status = "completed", + category = "childhood", + pageCount = null, + updatedAt = 0L, + quotes = emptyList(), + images = listOf( + ChapterImageDto( + index = 0, + placeholder = "{{{{IMAGE:南方小镇的青石板路}}}}", + description = "南方小镇的青石板路", + prompt = null, + url = null, + status = "completed", + retryable = null, + provider = "liblib", + style = "watercolor", + size = "1024x1024", + error = "image delivery unavailable", + created_at = null, + updated_at = null, + ) + ), + ) + + composeRule.setContent { ChapterReadingView(chapter = chapter) } + + composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed() + composeRule.onNodeWithText("图片暂不可用").assertIsDisplayed() } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/models/MemoirModels.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/models/MemoirModels.kt index 34f86d9..ac66f9f 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/network/models/MemoirModels.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/models/MemoirModels.kt @@ -27,6 +27,7 @@ data class ChapterImageDto( val prompt: String? = null, val url: String? = null, val status: String, + val retryable: Boolean? = null, val provider: String? = null, val style: String? = null, val size: String? = null, 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 2f86eaf..40d0043 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 @@ -2,6 +2,7 @@ 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_FAILED import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PENDING import com.huaga.life_echo.network.models.MEMOIR_IMAGE_STATUS_PROCESSING @@ -27,12 +28,7 @@ fun splitMemoirContent(content: String, images: List): List): List true + else -> false + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt index 0e1856e..57e8ef3 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt @@ -23,6 +23,7 @@ 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_FAILED 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 @@ -70,6 +71,39 @@ private fun MemoirImageLoadingPlaceholder( } } +@Composable +private fun MemoirImageStatusPlaceholder( + image: ChapterImageDto, + text: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(memoirImageAspectRatio(image.size)) + .clip(RoundedCornerShape(16.dp)) + .background(LightPurple.copy(alpha = 0.16f)) + .testTag("memoir-image-error-${image.index}"), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + fontSize = AppTypography.bodyMedium, + color = SlatePurple.copy(alpha = 0.72f), + ) + } +} + +internal fun memoirImageFailureText(image: ChapterImageDto): String { + return when { + image.status == MEMOIR_IMAGE_STATUS_FAILED && image.retryable == true -> + "图片生成失败,稍后重试" + image.status == MEMOIR_IMAGE_STATUS_FAILED -> + "图片生成失败,暂不可恢复" + else -> "图片暂不可用" + } +} + @Composable fun MemoirInlineImage( image: ChapterImageDto, @@ -77,36 +111,51 @@ fun MemoirInlineImage( modifier: Modifier = Modifier, ) { when (image.status) { - MEMOIR_IMAGE_STATUS_COMPLETED -> SubcomposeAsyncImage( - model = image.url, - contentDescription = image.description, - contentScale = ContentScale.FillWidth, - loading = { - MemoirImageLoadingPlaceholder( + MEMOIR_IMAGE_STATUS_COMPLETED -> { + if (image.url.isNullOrBlank()) { + MemoirImageStatusPlaceholder( image = image, + text = memoirImageFailureText(image), modifier = modifier, - text = "图片加载中…", ) - }, - error = { - MemoirImageLoadingPlaceholder( - image = image, - modifier = modifier, - text = "图片暂不可用", + } else { + SubcomposeAsyncImage( + model = image.url, + contentDescription = image.description, + contentScale = ContentScale.FillWidth, + loading = { + MemoirImageLoadingPlaceholder( + image = image, + modifier = modifier, + text = "图片加载中…", + ) + }, + error = { + MemoirImageStatusPlaceholder( + image = image, + text = "图片暂不可用", + modifier = modifier, + ) + }, + modifier = modifier + .fillMaxWidth() + .aspectRatio(memoirImageAspectRatio(image.size)) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick) + .testTag("memoir-image-${image.index}") ) - }, - modifier = modifier - .fillMaxWidth() - .aspectRatio(memoirImageAspectRatio(image.size)) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick) - .testTag("memoir-image-${image.index}") - ) + } + } MEMOIR_IMAGE_STATUS_PENDING, MEMOIR_IMAGE_STATUS_PROCESSING -> MemoirImageLoadingPlaceholder( image = image, modifier = modifier, text = "图片生成中…", ) + MEMOIR_IMAGE_STATUS_FAILED -> MemoirImageStatusPlaceholder( + image = image, + text = memoirImageFailureText(image), + modifier = modifier, + ) else -> Unit } } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt index d5c5b34..e57a083 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt @@ -70,6 +70,7 @@ class MemoirContentBlocksTest { prompt = null, url = null, status = "failed", + retryable = false, provider = "liblib", style = "watercolor", size = "1024x1024", @@ -80,10 +81,38 @@ class MemoirContentBlocksTest { ) ) - assertFalse(blocks.any { it is MemoirContentBlock.Image }) - val combinedText = blocks.filterIsInstance().joinToString("\n") { it.content } - assertFalse(combinedText.contains("IMAGE:")) - assertTrue(combinedText.contains("开头")) - assertTrue(combinedText.contains("结尾")) + assertTrue(blocks.any { it is MemoirContentBlock.Image }) + val imageBlock = blocks[1] as MemoirContentBlock.Image + assertEquals("failed", imageBlock.image.status) + assertEquals(false, imageBlock.image.retryable) + } + + @Test + fun splitMemoirContent_keepsCompletedImageBlock_whenSignedUrlIsUnavailable() { + val blocks = splitMemoirContent( + content = "开头。\n\n{{{{IMAGE:签名失败的图}}}}\n\n结尾。", + images = listOf( + ChapterImageDto( + index = 0, + placeholder = "{{{{IMAGE:签名失败的图}}}}", + description = "签名失败的图", + prompt = null, + url = null, + status = "completed", + retryable = null, + provider = "liblib", + style = "watercolor", + size = "1024x1024", + error = "image delivery unavailable", + created_at = null, + updated_at = null, + ) + ) + ) + + assertTrue(blocks.any { it is MemoirContentBlock.Image }) + val imageBlock = blocks[1] as MemoirContentBlock.Image + assertEquals("completed", imageBlock.image.status) + assertEquals(null, imageBlock.image.url) } } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt new file mode 100644 index 0000000..b637c84 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImageStateTest.kt @@ -0,0 +1,51 @@ +package com.huaga.life_echo.ui.components.memoir + +import com.huaga.life_echo.network.models.ChapterImageDto +import org.junit.Assert.assertEquals +import org.junit.Test + +class MemoirInlineImageStateTest { + + @Test + fun memoirImageFailureText_returnsRetryHint_forRetryableFailures() { + val image = image(status = "failed", retryable = true) + + assertEquals("图片生成失败,稍后重试", memoirImageFailureText(image)) + } + + @Test + fun memoirImageFailureText_returnsPermanentHint_forNonRetryableFailures() { + val image = image(status = "failed", retryable = false) + + assertEquals("图片生成失败,暂不可恢复", memoirImageFailureText(image)) + } + + @Test + fun memoirImageFailureText_returnsUnavailableHint_forCompletedImageWithoutUrl() { + val image = image(status = "completed", retryable = null, url = null) + + assertEquals("图片暂不可用", memoirImageFailureText(image)) + } + + private fun image( + status: String, + retryable: Boolean?, + url: String? = null, + ): ChapterImageDto { + return ChapterImageDto( + index = 0, + placeholder = "{{IMAGE:南方小镇的青石板路}}", + description = "南方小镇的青石板路", + prompt = null, + url = url, + status = status, + retryable = retryable, + provider = "liblib", + style = "watercolor", + size = "1024x1024", + error = null, + created_at = null, + updated_at = null, + ) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt index e42f7fc..d7a4069 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/screens/MemoirImagePollingTest.kt @@ -107,7 +107,27 @@ class MemoirImagePollingTest { ) } - private fun chapterWithImages(status: String, imageCount: Int = 1): ChapterDto { + @Test + fun shouldContinueMemoirImagePolling_returnsFalse_forRetryableFailedImages() { + val chapters = listOf( + chapterWithImages("failed", imageCount = 1, retryable = true), + ) + + assertFalse( + shouldContinueMemoirImagePolling( + chapters = chapters, + pollStartedAtMs = 0L, + nowMs = 1L, + maxObservedPendingImages = 1L, + ) + ) + } + + private fun chapterWithImages( + status: String, + imageCount: Int = 1, + retryable: Boolean? = null, + ): ChapterDto { return ChapterDto( id = "chapter-$status", title = "title-$status", @@ -123,6 +143,7 @@ class MemoirImagePollingTest { prompt = null, url = if (status == "completed") "https://cos.example.com/$status-$index.png" else null, status = status, + retryable = retryable, provider = "liblib", style = "memoir", size = "1024x1024", diff --git a/docs/memoir-image-status-contract.md b/docs/memoir-image-status-contract.md new file mode 100644 index 0000000..222a1bd --- /dev/null +++ b/docs/memoir-image-status-contract.md @@ -0,0 +1,45 @@ +# 回忆录图片状态契约 + +本文档描述章节图片资产在接口层的字段语义,供后端、Android 客户端和后续其他前端实现统一使用。 + +## 字段说明 + +- `status` + - `pending`: 等待生成 + - `processing`: 正在生成 + - `completed`: 生成完成 + - `failed`: 生成失败 +- `url` + - 仅当图片当前可直接展示时才应为非空 + - 私有 COS 桶签名失败时,必须置空,不能回退为未签名的原始 COS URL +- `retryable` + - `null`: 当前不是失败态,或该字段不适用 + - `true`: 失败但仍可重试 + - `false`: 失败且不可重试,属于永久失败 +- `error` + - 面向调试和展示的错误信息 + +## 推荐组合语义 + +| status | url | retryable | 语义 | +| --- | --- | --- | --- | +| `pending` | `null` | `null` | 任务尚未开始或等待处理 | +| `processing` | `null` | `null` | 图片正在生成 | +| `completed` | 非空 | `null` | 图片可直接展示 | +| `completed` | `null` | `null` | 图片已完成但当前不可投递,例如私有桶签名失败 | +| `failed` | `null` | `true` | 本次失败可重试 | +| `failed` | `null` | `false` | 永久失败,应明确展示终态 | + +## 客户端处理建议 + +- `pending` / `processing`: 显示“图片生成中” +- `completed` 且 `url` 非空: 正常展示图片 +- `completed` 且 `url` 为空: 显示“图片暂不可用” +- `failed` 且 `retryable=true`: 显示“图片生成失败,稍后重试” +- `failed` 且 `retryable=false`: 显示“图片生成失败,暂不可恢复” + +## 约束 + +- 后端在规范化图片资产时必须保留 `retryable` +- 非失败态不应输出 `retryable=true/false` +- 客户端轮询是否继续,仍以工作态 `pending/processing` 为准,不因 `failed + retryable=true` 自动延长轮询窗口