fix: 图片生成失败后重试 前端一直显示生成中
This commit is contained in:
@@ -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_images(image_record)取配图。"""
|
"""从 section.image_id 关联的 memoir_images(image_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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user