feat: add unified asset retry endpoint
Some checks failed
Build and Push Docker Images / changes (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-admin-frontend (push) Has been cancelled

This commit is contained in:
2026-04-18 11:40:10 +08:00
parent b8d3cb4644
commit 0613238a37
9 changed files with 316 additions and 91 deletions

View File

@@ -10,12 +10,12 @@ from sqlalchemy.orm import joinedload
from app.core.logging import get_logger
from app.db.models import ChildProfile, Story, StoryUniverse
from app.schemas.story_schemas import (
GenerateRequest,
StorybookRequest,
FullStoryResponse,
StorybookResponse,
StorybookPageResponse,
AchievementItem,
FullStoryResponse,
GenerateRequest,
StorybookPageResponse,
StorybookRequest,
StorybookResponse,
)
from app.services.audio_storage import (
audio_cache_exists,
@@ -24,8 +24,8 @@ from app.services.audio_storage import (
)
from app.services.memory_service import build_enhanced_memory_context
from app.services.provider_router import (
generate_story_content,
generate_image,
generate_story_content,
generate_storybook,
)
from app.services.story_status import (
@@ -140,7 +140,7 @@ async def generate_and_save_story(
profile_id, universe_id = await validate_profile_and_universe(
request.child_profile_id, request.universe_id, user_id, db
)
# 2. Build Context
memory_context = await build_enhanced_memory_context(profile_id, universe_id, db)
@@ -153,8 +153,11 @@ async def generate_and_save_story(
memory_context=memory_context,
db=db,
)
except Exception as exc:
raise HTTPException(status_code=502, detail="Story generation failed, please try again.") from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail="Story generation failed, please try again.",
) from exc
# 4. Save
story = Story(
@@ -247,7 +250,7 @@ async def generate_storybook_service(
profile_id, universe_id = await validate_profile_and_universe(
request.child_profile_id, request.universe_id, user_id, db
)
logger.info(
"storybook_request",
user_id=user_id,
@@ -418,11 +421,11 @@ async def get_story_detail(
return story
async def delete_story(
story_id: int,
user_id: str,
db: AsyncSession,
) -> None:
async def delete_story(
story_id: int,
user_id: str,
db: AsyncSession,
) -> None:
"""Delete a story."""
story = await get_story_detail(story_id, user_id, db)
await db.delete(story)
@@ -456,12 +459,131 @@ async def create_story_from_result(
await db.commit()
await db.refresh(story)
if universe_id:
extract_story_achievements.delay(story.id, universe_id)
return story
if universe_id:
extract_story_achievements.delay(story.id, universe_id)
return story
async def _retry_cover_image_asset(story: Story, db: AsyncSession) -> None:
"""Retry cover generation for a text story."""
if not story.cover_prompt:
raise HTTPException(status_code=400, detail="Story has no cover prompt")
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
try:
story.image_url = await generate_image(story.cover_prompt, db=db)
sync_story_status(story, image_status=StoryAssetStatus.READY)
except Exception as exc:
sync_story_status(
story,
image_status=StoryAssetStatus.FAILED,
last_error=f"封面生成失败: {exc}",
)
logger.error("cover_asset_retry_failed", story_id=story.id, error=str(exc))
await db.commit()
async def _retry_storybook_image_assets(story: Story, db: AsyncSession) -> None:
"""Retry missing storybook cover/page images."""
pages_data = [dict(page) for page in story.pages or [] if isinstance(page, dict)]
has_image_prompt = bool(story.cover_prompt) or any(
page.get("image_prompt") for page in pages_data
)
if not has_image_prompt:
raise HTTPException(status_code=400, detail="Storybook has no image prompts")
sync_story_status(story, image_status=StoryAssetStatus.GENERATING)
await db.commit()
cover_failed = False
failed_pages: list[int] = []
if story.cover_prompt and not story.image_url:
try:
story.image_url = await generate_image(story.cover_prompt, db=db)
except Exception as exc:
cover_failed = True
logger.warning(
"storybook_cover_asset_retry_failed",
story_id=story.id,
error=str(exc),
)
for page in pages_data:
if not page.get("image_prompt") or page.get("image_url"):
continue
try:
page["image_url"] = await generate_image(page["image_prompt"], db=db)
except Exception as exc:
page_number = page.get("page_number")
if isinstance(page_number, int):
failed_pages.append(page_number)
logger.warning(
"storybook_page_asset_retry_failed",
story_id=story.id,
page=page_number,
error=str(exc),
)
story.pages = pages_data
sync_story_status(
story,
image_status=_resolve_storybook_image_status(
generate_images=True,
cover_prompt=story.cover_prompt,
cover_url=story.image_url,
pages_data=pages_data,
),
last_error=_build_storybook_error_message(
cover_failed=cover_failed,
failed_pages=failed_pages,
),
)
await db.commit()
async def _retry_audio_asset(story_id: int, user_id: str, db: AsyncSession) -> None:
"""Retry audio generation while preserving persisted status on provider failure."""
try:
await generate_story_audio(story_id, user_id, db)
except HTTPException as exc:
if exc.status_code >= 500:
logger.warning("audio_asset_retry_failed", story_id=story_id, error=exc.detail)
return
raise
async def retry_story_assets(
story_id: int,
user_id: str,
assets: list[str],
db: AsyncSession,
) -> Story:
"""Retry selected assets through one workflow-level endpoint."""
story = await get_story_detail(story_id, user_id, db)
requested_assets = list(dict.fromkeys(assets))
if "image" in requested_assets:
if story.mode == "storybook":
await _retry_storybook_image_assets(story, db)
else:
await _retry_cover_image_asset(story, db)
if "audio" in requested_assets:
await _retry_audio_asset(story_id, user_id, db)
return await get_story_detail(story_id, user_id, db)
async def generate_story_cover(
story_id: int,
user_id: str,
@@ -469,7 +591,7 @@ async def generate_story_cover(
) -> str:
"""Generate cover image for an existing story."""
story = await get_story_detail(story_id, user_id, db)
if not story.cover_prompt:
raise HTTPException(status_code=400, detail="Story has no cover prompt")
@@ -503,7 +625,7 @@ async def generate_story_audio(
) -> bytes:
"""Generate audio for a story."""
story = await get_story_detail(story_id, user_id, db)
if not story.story_text:
raise HTTPException(status_code=400, detail="Story has no text")