From 672abf5ec7e3632777598e32323d6a369581f5b2 Mon Sep 17 00:00:00 2001 From: yangshilin <2157598560@qq.com> Date: Fri, 13 Mar 2026 16:23:51 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=BE=E7=89=87=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=90=8E=E9=87=8D=E8=AF=95=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=80=E7=9B=B4=E6=98=BE=E7=A4=BA=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/routers/chapters.py | 35 ++++++++++++++ api/tasks/memoir_tasks.py | 30 +++++------- .../test_generate_chapter_images_task.py | 10 ++-- .../memoir/ChapterReadingImageBlocksTest.kt | 47 +++++++++++++++++-- .../components/memoir/MemoirContentBlocks.kt | 6 +-- .../ui/components/memoir/MemoirInlineImage.kt | 4 +- .../memoir/MemoirContentBlocksTest.kt | 16 +++---- 7 files changed, 105 insertions(+), 43 deletions(-) diff --git a/api/routers/chapters.py b/api/routers/chapters.py index 6e13688..7f2bb58 100644 --- a/api/routers/chapters.py +++ b/api/routers/chapters.py @@ -18,6 +18,7 @@ from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STA from services.memoir_images.schema import ( completed_image_assets, IMAGE_STATUS_COMPLETED, + IMAGE_STATUS_FAILED, normalize_image_assets, ) 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 +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: """从 section.image_id 关联的 memoir_images(image_record)取配图。""" if getattr(section, "image_record", None): @@ -179,6 +211,7 @@ async def get_chapters( for category in CHAPTER_ORDER: ch = chapter_by_category.pop(category, None) if ch: + await _cleanup_permanently_unavailable_images(ch, db) all_chapters.append(_chapter_to_dict(ch)) else: if is_new is True: @@ -199,6 +232,7 @@ async def get_chapters( }) for ch in chapter_by_category.values(): + await _cleanup_permanently_unavailable_images(ch, db) all_chapters.append(_chapter_to_dict(ch)) return all_chapters @@ -226,6 +260,7 @@ async def get_chapter( raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != current_user.id: raise HTTPException(status_code=403, detail="无权访问此章节") + await _cleanup_permanently_unavailable_images(chapter, db) return _chapter_to_dict(chapter) diff --git a/api/tasks/memoir_tasks.py b/api/tasks/memoir_tasks.py index 3a9915f..2a734f9 100644 --- a/api/tasks/memoir_tasks.py +++ b/api/tasks/memoir_tasks.py @@ -55,10 +55,6 @@ 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: @@ -930,35 +926,31 @@ def generate_chapter_images(self, chapter_id: str): current_item["url"], ) except Exception as exc: - current_item["status"] = IMAGE_STATUS_FAILED - current_item["error"] = str(exc) failure_msg = f"section_index={sec_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) + 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: + current_item["status"] = IMAGE_STATUS_FAILED + current_item["error"] = str(exc) 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() - _apply_item_to_memoir_image(section.image_record, current_item) - db.commit() + current_item["updated_at"] = datetime.now(timezone.utc).isoformat() + _apply_item_to_memoir_image(section.image_record, current_item) + db.commit() # 封面图先空着,不自动用首张完成图做封面 if retryable_failures: raise RuntimeError( f"章节补图存在可重试失败项: chapter={chapter_id}, failures={'; '.join(retryable_failures)}" ) - if permanent_failures: - raise PermanentImageGenerationError( - f"章节补图存在不可重试失败项: chapter={chapter_id}, failures={'; '.join(permanent_failures)}" - ) return {"status": "success"} - except PermanentImageGenerationError as exc: - logger.error("章节补图任务失败(不重试): chapter=%s, error=%s", chapter_id, exc) - raise except Exception as exc: logger.error("章节补图任务失败: chapter=%s, error=%s", chapter_id, exc) raise self.retry(exc=exc) diff --git a/api/tests/test_generate_chapter_images_task.py b/api/tests/test_generate_chapter_images_task.py index 5a29ba5..46f2ccf 100644 --- a/api/tests/test_generate_chapter_images_task.py +++ b/api/tests/test_generate_chapter_images_task.py @@ -307,13 +307,13 @@ class GenerateChapterImagesTaskTest(unittest.TestCase): "AccessDenied", retryable=False, request_id="req-403" ) 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: - generate_chapter_images.run.__func__(task_self, "chapter-1") + result = generate_chapter_images.run.__func__(task_self, "chapter-1") - self.assertIn("AccessDenied", str(ctx.exception)) - self.assertEqual(chapter.sections[0].image_record.status, "failed") - self.assertIn("AccessDenied", chapter.sections[0].image_record.error) + self.assertEqual(result, {"status": "success"}) + self.assertIsNone(chapter.sections[0].image_id) + db.delete.assert_called_with(img_rec) task_self.retry.assert_not_called() @patch("api.tasks.memoir_tasks.SessionLocal") diff --git a/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt b/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt index 3659a19..3af1f56 100644 --- a/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt +++ b/app-android/app/src/androidTest/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingImageBlocksTest.kt @@ -83,12 +83,12 @@ class ChapterReadingImageBlocksTest { composeRule.setContent { ChapterReadingView(chapter = chapter) } - composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed() - composeRule.onNodeWithText("图片生成失败,暂不可恢复").assertIsDisplayed() + composeRule.onNodeWithTag("memoir-image-error-0").assertDoesNotExist() + composeRule.onNodeWithText("图片生成失败,暂不可恢复").assertDoesNotExist() } @Test - fun chapterReadingView_showsUnavailablePlaceholder_forCompletedImageWithoutUrl() { + fun chapterReadingView_hidesCompletedImageWithoutUrl() { val chapter = ChapterContentDto( id = "chapter-1", title = "童年的夏天", @@ -120,7 +120,44 @@ class ChapterReadingImageBlocksTest { composeRule.setContent { ChapterReadingView(chapter = chapter) } - composeRule.onNodeWithTag("memoir-image-error-0").assertIsDisplayed() - composeRule.onNodeWithText("图片暂不可用").assertIsDisplayed() + composeRule.onNodeWithTag("memoir-image-error-0").assertDoesNotExist() + 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() } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt index 330078d..fce4b53 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocks.kt @@ -40,10 +40,10 @@ fun splitMemoirContent(content: String, images: List): List !image.url.isNullOrBlank() MEMOIR_IMAGE_STATUS_PENDING, - MEMOIR_IMAGE_STATUS_PROCESSING, - MEMOIR_IMAGE_STATUS_FAILED -> true + MEMOIR_IMAGE_STATUS_PROCESSING -> true + MEMOIR_IMAGE_STATUS_FAILED -> image.retryable == true else -> false } } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt index ed8a802..aaa8478 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirInlineImage.kt @@ -246,10 +246,10 @@ fun MemoirInlineImage( modifier = modifier, text = "图片生成中…", ) - MEMOIR_IMAGE_STATUS_FAILED -> MemoirImageStatusPlaceholder( + MEMOIR_IMAGE_STATUS_FAILED -> MemoirImageLoadingPlaceholder( image = image, - text = memoirImageFailureText(image), modifier = modifier, + text = "图片生成中…", ) else -> Unit } diff --git a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt index e2d0d2a..703192e 100644 --- a/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt +++ b/app-android/app/src/test/java/com/huaga/life_echo/ui/components/memoir/MemoirContentBlocksTest.kt @@ -81,14 +81,14 @@ class MemoirContentBlocksTest { ) ) - assertTrue(blocks.any { it is MemoirContentBlock.Image }) - val imageBlock = blocks[1] as MemoirContentBlock.Image - assertEquals("failed", imageBlock.image.status) - assertEquals(false, imageBlock.image.retryable) + assertFalse(blocks.any { it is MemoirContentBlock.Image }) + assertEquals(2, blocks.size) + assertTrue((blocks[0] as MemoirContentBlock.Text).content.contains("开头")) + assertTrue((blocks[1] as MemoirContentBlock.Text).content.contains("结尾")) } @Test - fun splitMemoirContent_keepsCompletedImageBlock_whenSignedUrlIsUnavailable() { + fun splitMemoirContent_skipsCompletedImageBlock_whenSignedUrlIsUnavailable() { val blocks = splitMemoirContent( content = "开头。\n\n{{{{IMAGE:签名失败的图}}}}\n\n结尾。", images = listOf( @@ -110,9 +110,7 @@ class MemoirContentBlocksTest { ) ) - assertTrue(blocks.any { it is MemoirContentBlock.Image }) - val imageBlock = blocks[1] as MemoirContentBlock.Image - assertEquals("completed", imageBlock.image.status) - assertEquals(null, imageBlock.image.url) + assertFalse(blocks.any { it is MemoirContentBlock.Image }) + assertEquals(2, blocks.size) } }