fix: 图片生成失败后重试 前端一直显示生成中

This commit is contained in:
yangshilin
2026-03-13 16:23:51 +08:00
parent e751114354
commit 672abf5ec7
7 changed files with 105 additions and 43 deletions

View File

@@ -18,6 +18,7 @@ from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STA
from services.memoir_images.schema import ( from services.memoir_images.schema import (
completed_image_assets, completed_image_assets,
IMAGE_STATUS_COMPLETED, IMAGE_STATUS_COMPLETED,
IMAGE_STATUS_FAILED,
normalize_image_assets, normalize_image_assets,
) )
from services.memoir_images.serializers import memoir_image_to_dict from services.memoir_images.serializers import memoir_image_to_dict
@@ -74,6 +75,37 @@ def _normalize_image_assets(images: list[dict] | None) -> list[dict]:
return normalized_assets return normalized_assets
def _is_image_permanently_unavailable(rec) -> bool:
"""配图是否应清理:失败不可恢复,或 completed 但无 url/storage_key损坏数据"""
if not rec:
return False
status = getattr(rec, "status", None) or ""
retryable = getattr(rec, "retryable", None)
url = getattr(rec, "url", None)
storage_key = getattr(rec, "storage_key", None)
if status == IMAGE_STATUS_FAILED and retryable is False:
return True
if status == IMAGE_STATUS_COMPLETED and not url and not storage_key:
return True
return False
async def _cleanup_permanently_unavailable_images(ch: ChapterModel, db: AsyncSession) -> None:
"""清理章节中永久不可用的配图section.image_id 置空,删除 memoir_images 记录"""
sections = getattr(ch, "sections", None) or []
cleaned = False
for s in sections:
rec = getattr(s, "image_record", None)
if rec and _is_image_permanently_unavailable(rec):
logger.info("清理不可用配图: chapter=%s, section=%s", ch.id, s.id)
s.image_id = None
await db.delete(rec)
cleaned = True
if cleaned:
await db.commit()
await db.refresh(ch)
def _section_image_to_dict(section) -> dict | None: def _section_image_to_dict(section) -> dict | None:
"""从 section.image_id 关联的 memoir_imagesimage_record取配图。""" """从 section.image_id 关联的 memoir_imagesimage_record取配图。"""
if getattr(section, "image_record", None): if getattr(section, "image_record", None):
@@ -179,6 +211,7 @@ async def get_chapters(
for category in CHAPTER_ORDER: for category in CHAPTER_ORDER:
ch = chapter_by_category.pop(category, None) ch = chapter_by_category.pop(category, None)
if ch: if ch:
await _cleanup_permanently_unavailable_images(ch, db)
all_chapters.append(_chapter_to_dict(ch)) all_chapters.append(_chapter_to_dict(ch))
else: else:
if is_new is True: if is_new is True:
@@ -199,6 +232,7 @@ async def get_chapters(
}) })
for ch in chapter_by_category.values(): for ch in chapter_by_category.values():
await _cleanup_permanently_unavailable_images(ch, db)
all_chapters.append(_chapter_to_dict(ch)) all_chapters.append(_chapter_to_dict(ch))
return all_chapters return all_chapters
@@ -226,6 +260,7 @@ async def get_chapter(
raise HTTPException(status_code=404, detail="Chapter not found") raise HTTPException(status_code=404, detail="Chapter not found")
if chapter.user_id != current_user.id: if chapter.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权访问此章节") raise HTTPException(status_code=403, detail="无权访问此章节")
await _cleanup_permanently_unavailable_images(chapter, db)
return _chapter_to_dict(chapter) return _chapter_to_dict(chapter)

View File

@@ -55,10 +55,6 @@ logger = logging.getLogger(__name__)
_REDIS_CLIENTS: dict[bool, redis.Redis] = {} _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: def _get_redis_client(*, decode_responses: bool = False) -> redis.Redis:
client = _REDIS_CLIENTS.get(decode_responses) client = _REDIS_CLIENTS.get(decode_responses)
if client is None: if client is None:
@@ -930,35 +926,31 @@ def generate_chapter_images(self, chapter_id: str):
current_item["url"], current_item["url"],
) )
except Exception as exc: except Exception as exc:
current_item["status"] = IMAGE_STATUS_FAILED
current_item["error"] = str(exc)
failure_msg = f"section_index={sec_index}, error={exc}" failure_msg = f"section_index={sec_index}, error={exc}"
if isinstance(exc, CosUploadError) and not exc.retryable: if isinstance(exc, CosUploadError) and not exc.retryable:
current_item["retryable"] = False
permanent_failures.append(failure_msg) permanent_failures.append(failure_msg)
logger.error("图片上传不可重试: chapter=%s, %s", chapter_id, failure_msg) logger.error("图片上传不可重试,清理配图: chapter=%s, %s", chapter_id, failure_msg)
mi = section.image_record
section.image_id = None
if mi:
db.delete(mi)
db.commit()
else: else:
current_item["status"] = IMAGE_STATUS_FAILED
current_item["error"] = str(exc)
current_item["retryable"] = True current_item["retryable"] = True
retryable_failures.append(failure_msg) retryable_failures.append(failure_msg)
logger.warning("图片生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg) logger.warning("图片生成失败(可重试): chapter=%s, %s", chapter_id, failure_msg)
current_item["updated_at"] = datetime.now(timezone.utc).isoformat()
current_item["updated_at"] = datetime.now(timezone.utc).isoformat() _apply_item_to_memoir_image(section.image_record, current_item)
_apply_item_to_memoir_image(section.image_record, current_item) db.commit()
db.commit()
# 封面图先空着,不自动用首张完成图做封面 # 封面图先空着,不自动用首张完成图做封面
if retryable_failures: if retryable_failures:
raise RuntimeError( raise RuntimeError(
f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_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"} return {"status": "success"}
except PermanentImageGenerationError as exc:
logger.error("章节补图任务失败(不重试): chapter=%s, error=%s", chapter_id, exc)
raise
except Exception as exc: except Exception as exc:
logger.error("章节补图任务失败: chapter=%s, error=%s", chapter_id, exc) logger.error("章节补图任务失败: chapter=%s, error=%s", chapter_id, exc)
raise self.retry(exc=exc) raise self.retry(exc=exc)

View File

@@ -307,13 +307,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
"AccessDenied", retryable=False, request_id="req-403" "AccessDenied", retryable=False, request_id="req-403"
) )
task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock()) task_self = SimpleNamespace(request=SimpleNamespace(id="task-1"), retry=Mock())
img_rec = chapter.sections[0].image_record
with self.assertRaises(memoir_tasks.PermanentImageGenerationError) as ctx: result = generate_chapter_images.run.__func__(task_self, "chapter-1")
generate_chapter_images.run.__func__(task_self, "chapter-1")
self.assertIn("AccessDenied", str(ctx.exception)) self.assertEqual(result, {"status": "success"})
self.assertEqual(chapter.sections[0].image_record.status, "failed") self.assertIsNone(chapter.sections[0].image_id)
self.assertIn("AccessDenied", chapter.sections[0].image_record.error) db.delete.assert_called_with(img_rec)
task_self.retry.assert_not_called() task_self.retry.assert_not_called()
@patch("api.tasks.memoir_tasks.SessionLocal") @patch("api.tasks.memoir_tasks.SessionLocal")

View File

@@ -83,12 +83,12 @@ class ChapterReadingImageBlocksTest {
composeRule.setContent { ChapterReadingView(chapter = chapter) } composeRule.setContent { ChapterReadingView(chapter = chapter) }
composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed() composeRule.onNodeWithTag("memoir-image-error-0").assertDoesNotExist()
composeRule.onNodeWithText("图片生成失败,暂不可恢复").assertIsDisplayed() composeRule.onNodeWithText("图片生成失败,暂不可恢复").assertDoesNotExist()
} }
@Test @Test
fun chapterReadingView_showsUnavailablePlaceholder_forCompletedImageWithoutUrl() { fun chapterReadingView_hidesCompletedImageWithoutUrl() {
val chapter = ChapterContentDto( val chapter = ChapterContentDto(
id = "chapter-1", id = "chapter-1",
title = "童年的夏天", title = "童年的夏天",
@@ -120,7 +120,44 @@ class ChapterReadingImageBlocksTest {
composeRule.setContent { ChapterReadingView(chapter = chapter) } composeRule.setContent { ChapterReadingView(chapter = chapter) }
composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed() composeRule.onNodeWithTag("memoir-image-error-0").assertDoesNotExist()
composeRule.onNodeWithText("图片暂不可用").assertIsDisplayed() composeRule.onNodeWithText("图片暂不可用").assertDoesNotExist()
}
@Test
fun chapterReadingView_showsLoadingForRetryableFailedImage() {
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 = "failed",
retryable = true,
provider = "liblib",
style = "watercolor",
size = "1024x1024",
error = "provider timeout",
created_at = null,
updated_at = null,
)
),
)
composeRule.setContent { ChapterReadingView(chapter = chapter) }
composeRule.onNodeWithTag("memoir-image-loading-0").assertIsDisplayed()
composeRule.onNodeWithText("图片生成中…").assertIsDisplayed()
} }
} }

View File

@@ -40,10 +40,10 @@ fun splitMemoirContent(content: String, images: List<ChapterImageDto>): List<Mem
internal fun shouldRenderMemoirImageBlock(image: ChapterImageDto): Boolean { internal fun shouldRenderMemoirImageBlock(image: ChapterImageDto): Boolean {
return when (image.status) { return when (image.status) {
MEMOIR_IMAGE_STATUS_COMPLETED, MEMOIR_IMAGE_STATUS_COMPLETED -> !image.url.isNullOrBlank()
MEMOIR_IMAGE_STATUS_PENDING, MEMOIR_IMAGE_STATUS_PENDING,
MEMOIR_IMAGE_STATUS_PROCESSING, MEMOIR_IMAGE_STATUS_PROCESSING -> true
MEMOIR_IMAGE_STATUS_FAILED -> true MEMOIR_IMAGE_STATUS_FAILED -> image.retryable == true
else -> false else -> false
} }
} }

View File

@@ -246,10 +246,10 @@ fun MemoirInlineImage(
modifier = modifier, modifier = modifier,
text = "图片生成中…", text = "图片生成中…",
) )
MEMOIR_IMAGE_STATUS_FAILED -> MemoirImageStatusPlaceholder( MEMOIR_IMAGE_STATUS_FAILED -> MemoirImageLoadingPlaceholder(
image = image, image = image,
text = memoirImageFailureText(image),
modifier = modifier, modifier = modifier,
text = "图片生成中…",
) )
else -> Unit else -> Unit
} }

View File

@@ -81,14 +81,14 @@ class MemoirContentBlocksTest {
) )
) )
assertTrue(blocks.any { it is MemoirContentBlock.Image }) assertFalse(blocks.any { it is MemoirContentBlock.Image })
val imageBlock = blocks[1] as MemoirContentBlock.Image assertEquals(2, blocks.size)
assertEquals("failed", imageBlock.image.status) assertTrue((blocks[0] as MemoirContentBlock.Text).content.contains("开头"))
assertEquals(false, imageBlock.image.retryable) assertTrue((blocks[1] as MemoirContentBlock.Text).content.contains("结尾"))
} }
@Test @Test
fun splitMemoirContent_keepsCompletedImageBlock_whenSignedUrlIsUnavailable() { fun splitMemoirContent_skipsCompletedImageBlock_whenSignedUrlIsUnavailable() {
val blocks = splitMemoirContent( val blocks = splitMemoirContent(
content = "开头。\n\n{{{{IMAGE:签名失败的图}}}}\n\n结尾。", content = "开头。\n\n{{{{IMAGE:签名失败的图}}}}\n\n结尾。",
images = listOf( images = listOf(
@@ -110,9 +110,7 @@ class MemoirContentBlocksTest {
) )
) )
assertTrue(blocks.any { it is MemoirContentBlock.Image }) assertFalse(blocks.any { it is MemoirContentBlock.Image })
val imageBlock = blocks[1] as MemoirContentBlock.Image assertEquals(2, blocks.size)
assertEquals("completed", imageBlock.image.status)
assertEquals(null, imageBlock.image.url)
} }
} }