修复回忆录图片重试状态透传与前端展示
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
45
docs/memoir-image-status-contract.md
Normal file
45
docs/memoir-image-status-contract.md
Normal 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` 自动延长轮询窗口
|
||||
Reference in New Issue
Block a user