修复回忆录图片重试状态透传与前端展示

This commit is contained in:
Kevin
2026-03-11 15:20:59 +08:00
parent 4b4dea8a45
commit 305e5dcde9
18 changed files with 724 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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 == MEMOIR_IMAGE_STATUS_COMPLETED && !image.url.isNullOrBlank()) {
blocks += MemoirContentBlock.Image(image)
} else if (
image.status == MEMOIR_IMAGE_STATUS_PENDING ||
image.status == MEMOIR_IMAGE_STATUS_PROCESSING
) {
if (shouldRenderMemoirImageBlock(image)) {
blocks += MemoirContentBlock.Image(image)
}
remaining = parts.getOrElse(1) { "" }
@@ -41,3 +37,13 @@ fun splitMemoirContent(content: String, images: List<ChapterImageDto>): List<Mem
if (trailingText.isNotBlank()) blocks += MemoirContentBlock.Text(trailingText)
return blocks
}
internal fun shouldRenderMemoirImageBlock(image: ChapterImageDto): Boolean {
return when (image.status) {
MEMOIR_IMAGE_STATUS_COMPLETED,
MEMOIR_IMAGE_STATUS_PENDING,
MEMOIR_IMAGE_STATUS_PROCESSING,
MEMOIR_IMAGE_STATUS_FAILED -> true
else -> false
}
}

View File

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

View File

@@ -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<MemoirContentBlock.Text>().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)
}
}

View File

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

View File

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

View File

@@ -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` 自动延长轮询窗口