Fix memoir image delivery and Android rendering
This commit is contained in:
@@ -43,7 +43,6 @@ STAGE_TO_ORDER = {
|
||||
"summary": 7,
|
||||
}
|
||||
|
||||
|
||||
def get_system_prompt() -> str:
|
||||
"""获取整理 Agent 的系统提示词"""
|
||||
return """你是一位专业的传记作家和文字编辑,擅长将口语化的对话内容整理成优雅的书面语回忆录章节。
|
||||
@@ -323,4 +322,3 @@ def get_narrative_prompt(
|
||||
|
||||
只输出新对话内容的改写结果(包含图片占位符)。如果对话中没有值得记录的人生经历内容,输出空字符串。
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
章节相关 API 路由
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -12,11 +14,48 @@ from database.models import Chapter as ChapterModel
|
||||
from database.models import User as UserModel
|
||||
from middleware.auth import get_current_user
|
||||
from agents.prompts.memory_prompts import CHAPTER_CATEGORIES, CHAPTER_ORDER, STAGE_TO_ORDER
|
||||
from services.memoir_images.storage import (
|
||||
TencentCosStorageService,
|
||||
normalize_cos_url,
|
||||
resolve_image_storage_key,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/chapters", tags=["chapters"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_image_assets(images: list[dict] | None) -> list[dict]:
|
||||
bucket = os.getenv("TENCENT_COS_BUCKET", "")
|
||||
region = os.getenv("TENCENT_COS_REGION", "")
|
||||
base_url = os.getenv("TENCENT_COS_BASE_URL", "")
|
||||
storage = TencentCosStorageService.from_env()
|
||||
normalized_assets: list[dict] = []
|
||||
|
||||
for item in (images or []):
|
||||
asset = dict(item)
|
||||
normalized_url = normalize_cos_url(
|
||||
asset.get("url"),
|
||||
bucket=bucket,
|
||||
region=region,
|
||||
base_url=base_url,
|
||||
)
|
||||
storage_key = resolve_image_storage_key(asset)
|
||||
if asset.get("status") == "completed" and storage_key:
|
||||
try:
|
||||
asset["url"] = storage.get_download_url(storage_key)
|
||||
except Exception as exc:
|
||||
logger.warning("章节图片签名失败: key=%s, error=%s", storage_key, exc)
|
||||
asset["url"] = normalized_url
|
||||
else:
|
||||
asset["url"] = normalized_url
|
||||
asset.pop("storage_key", None)
|
||||
normalized_assets.append(asset)
|
||||
|
||||
return normalized_assets
|
||||
|
||||
|
||||
def _chapter_to_dict(ch: ChapterModel) -> dict:
|
||||
normalized_images = _normalize_image_assets(ch.images)
|
||||
return {
|
||||
"id": ch.id,
|
||||
"title": ch.title,
|
||||
@@ -24,7 +63,7 @@ def _chapter_to_dict(ch: ChapterModel) -> dict:
|
||||
"order_index": ch.order_index,
|
||||
"status": ch.status,
|
||||
"category": ch.category,
|
||||
"images": ch.images or [],
|
||||
"images": normalized_images,
|
||||
"updated_at": ch.updated_at.isoformat() if ch.updated_at else None,
|
||||
"is_new": ch.is_new,
|
||||
"source_segments": ch.source_segments or [],
|
||||
@@ -105,7 +144,7 @@ async def get_chapter(
|
||||
"order_index": chapter.order_index,
|
||||
"status": chapter.status,
|
||||
"category": chapter.category,
|
||||
"images": chapter.images or [],
|
||||
"images": _normalize_image_assets(chapter.images),
|
||||
"updated_at": chapter.updated_at.isoformat() if chapter.updated_at else None,
|
||||
"is_new": chapter.is_new,
|
||||
"source_segments": chapter.source_segments or [],
|
||||
@@ -151,4 +190,3 @@ async def regenerate_chapter(
|
||||
|
||||
# TODO: 实现重新整理逻辑
|
||||
return {"status": "ok", "message": "Chapter regeneration triggered"}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\{\{\{\{IMAGE:(.*?)\}\}\}\}")
|
||||
PLACEHOLDER_RE = re.compile(
|
||||
r"\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def parse_image_placeholders(content: str, max_images: int) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for match in PLACEHOLDER_RE.finditer(content or ""):
|
||||
description = match.group(1).strip()
|
||||
description = (match.group(1) or match.group(2) or "").strip()
|
||||
if not description:
|
||||
continue
|
||||
items.append(
|
||||
|
||||
@@ -20,7 +20,7 @@ class MemoirImageSettings:
|
||||
max_per_chapter=int(os.getenv("MEMOIR_IMAGE_MAX_PER_CHAPTER", "2")),
|
||||
provider=os.getenv("MEMOIR_IMAGE_PROVIDER", "liblib"),
|
||||
default_style=os.getenv("MEMOIR_IMAGE_STYLE_DEFAULT", "watercolor"),
|
||||
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1024x1024"),
|
||||
default_size=os.getenv("MEMOIR_IMAGE_SIZE_DEFAULT", "1280x720"),
|
||||
poll_interval_seconds=int(os.getenv("MEMOIR_IMAGE_POLL_INTERVAL", "3")),
|
||||
max_attempts=int(os.getenv("MEMOIR_IMAGE_MAX_ATTEMPTS", "60")),
|
||||
liblib_template_uuid=os.getenv(
|
||||
|
||||
@@ -1,12 +1,75 @@
|
||||
import os
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from qcloud_cos import CosConfig, CosS3Client
|
||||
|
||||
|
||||
def normalize_cos_base_url(base_url: str, bucket: str, region: str) -> str:
|
||||
candidate = (base_url or "").rstrip("/")
|
||||
if not candidate and bucket and region:
|
||||
candidate = f"https://{bucket}.cos.{region}.myqcloud.com"
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
parsed = urlparse(candidate)
|
||||
duplicated_appid_host = f"{bucket}-appid.cos." if bucket else ""
|
||||
if duplicated_appid_host and parsed.netloc.startswith(duplicated_appid_host):
|
||||
parsed = parsed._replace(
|
||||
netloc=parsed.netloc.replace(duplicated_appid_host, f"{bucket}.cos.", 1),
|
||||
path=parsed.path.rstrip("/"),
|
||||
)
|
||||
return urlunparse(parsed).rstrip("/")
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def normalize_cos_url(url: str | None, bucket: str, region: str, base_url: str | None = None) -> str | None:
|
||||
if not url:
|
||||
return url
|
||||
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return url
|
||||
|
||||
normalized_base = normalize_cos_base_url(
|
||||
base_url or f"{parsed.scheme}://{parsed.netloc}",
|
||||
bucket=bucket,
|
||||
region=region,
|
||||
)
|
||||
if not normalized_base:
|
||||
return url
|
||||
|
||||
normalized_parsed = urlparse(normalized_base)
|
||||
return urlunparse(parsed._replace(scheme=normalized_parsed.scheme, netloc=normalized_parsed.netloc))
|
||||
|
||||
|
||||
def resolve_image_storage_key(image: dict | None) -> str | None:
|
||||
if not image:
|
||||
return None
|
||||
|
||||
explicit_key = image.get("storage_key")
|
||||
if explicit_key:
|
||||
return explicit_key
|
||||
|
||||
url = image.get("url")
|
||||
if not url:
|
||||
return None
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
key = parsed.path.lstrip("/")
|
||||
return key or None
|
||||
|
||||
return str(url).lstrip("/") or None
|
||||
|
||||
|
||||
class TencentCosStorageService:
|
||||
def __init__(self, secret_id: str, secret_key: str, region: str, bucket: str, base_url: str):
|
||||
self.secret_id = secret_id
|
||||
self.secret_key = secret_key
|
||||
self.bucket = bucket
|
||||
self.base_url = base_url.rstrip("/")
|
||||
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)
|
||||
self.client = CosS3Client(config)
|
||||
|
||||
@@ -19,6 +82,13 @@ class TencentCosStorageService:
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "TencentCosStorageService":
|
||||
return cls(
|
||||
|
||||
@@ -13,6 +13,7 @@ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
||||
from io import BytesIO
|
||||
from services.memoir_images.storage import TencentCosStorageService, resolve_image_storage_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,6 +43,23 @@ def split_content_blocks(content: str, images: list[dict]) -> list[dict]:
|
||||
return blocks
|
||||
|
||||
|
||||
def _prepare_pdf_image_assets(images: list[dict]) -> list[dict]:
|
||||
storage = TencentCosStorageService.from_env()
|
||||
prepared_assets: list[dict] = []
|
||||
|
||||
for item in images or []:
|
||||
asset = dict(item)
|
||||
storage_key = resolve_image_storage_key(asset)
|
||||
if asset.get("status") == "completed" and storage_key:
|
||||
try:
|
||||
asset["url"] = storage.get_download_url(storage_key)
|
||||
except Exception as exc:
|
||||
logger.warning(f"PDF 图片签名失败: key={storage_key}, error={exc}")
|
||||
prepared_assets.append(asset)
|
||||
|
||||
return prepared_assets
|
||||
|
||||
|
||||
class PDFService:
|
||||
"""PDF 生成服务"""
|
||||
|
||||
@@ -108,7 +126,7 @@ class PDFService:
|
||||
story.append(Paragraph(chapter.title, heading_style))
|
||||
story.append(Spacer(1, 0.2 * inch))
|
||||
|
||||
images = getattr(chapter, "images", None) or []
|
||||
images = _prepare_pdf_image_assets(getattr(chapter, "images", None) or [])
|
||||
blocks = split_content_blocks(chapter.content, images)
|
||||
|
||||
for block in blocks:
|
||||
|
||||
@@ -106,7 +106,9 @@ def _merge_chapter_image_assets(
|
||||
merged_item["size"] = merged_item.get("size") or size
|
||||
merged_item["created_at"] = merged_item.get("created_at") or now_iso
|
||||
merged_item["updated_at"] = merged_item.get("updated_at") or now_iso
|
||||
if merged_item.get("status") == "completed" and not merged_item.get("url"):
|
||||
if merged_item.get("status") == "completed" and not (
|
||||
merged_item.get("storage_key") or merged_item.get("url")
|
||||
):
|
||||
merged_item["status"] = "failed"
|
||||
merged_item["error"] = merged_item.get("error") or "missing image url"
|
||||
else:
|
||||
@@ -131,6 +133,7 @@ def initialize_chapter_images(chapter) -> list[dict]:
|
||||
settings = MemoirImageSettings.from_env()
|
||||
if not settings.enabled:
|
||||
chapter.images = []
|
||||
logger.info(f"章节图片初始化跳过: chapter={chapter.id}, enabled=false")
|
||||
return chapter.images
|
||||
|
||||
prompt_service = MemoirImagePromptService(llm=None, settings=settings)
|
||||
@@ -144,6 +147,13 @@ def initialize_chapter_images(chapter) -> list[dict]:
|
||||
size=settings.default_size,
|
||||
now_iso=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
logger.info(
|
||||
"章节图片初始化完成: chapter=%s, placeholders=%d, images=%d, statuses=%s",
|
||||
chapter.id,
|
||||
len(placeholders),
|
||||
len(chapter.images or []),
|
||||
[item.get("status") for item in (chapter.images or [])],
|
||||
)
|
||||
return chapter.images
|
||||
|
||||
|
||||
@@ -479,6 +489,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
|
||||
|
||||
for chapter_id in sorted(chapters_to_enqueue):
|
||||
try:
|
||||
logger.info(f"派发章节补图任务: chapter={chapter_id}")
|
||||
generate_chapter_images.delay(chapter_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"补图任务派发失败: chapter={chapter_id}, error={exc}")
|
||||
@@ -599,6 +610,7 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
try:
|
||||
chapter = db.get(Chapter, chapter_id)
|
||||
if not chapter or not chapter.images:
|
||||
logger.info(f"章节补图跳过: chapter={chapter_id}, reason=no_images")
|
||||
return {"status": "no_images"}
|
||||
|
||||
settings = MemoirImageSettings.from_env()
|
||||
@@ -606,9 +618,16 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
provider = LiblibImageProvider(template_uuid=settings.liblib_template_uuid)
|
||||
storage = TencentCosStorageService.from_env()
|
||||
images = [dict(item) for item in (chapter.images or [])]
|
||||
pending_count = sum(1 for item in images if item.get("status") in {"pending", "failed"})
|
||||
logger.info(
|
||||
"章节补图开始: chapter=%s, total_images=%d, pending_images=%d",
|
||||
chapter_id,
|
||||
len(images),
|
||||
pending_count,
|
||||
)
|
||||
|
||||
for index, item in enumerate(images):
|
||||
if item.get("status") == "completed" and item.get("url"):
|
||||
if item.get("status") == "completed" and (item.get("storage_key") or item.get("url")):
|
||||
continue
|
||||
if item.get("status") not in {"pending", "failed"}:
|
||||
continue
|
||||
@@ -643,12 +662,19 @@ def generate_chapter_images(self, chapter_id: str):
|
||||
)
|
||||
image_bytes = provider.download_image(job)
|
||||
key = build_cos_key(chapter.user_id, chapter.id, current_item["index"], prompt_data["prompt"])
|
||||
current_item["storage_key"] = key
|
||||
current_item["url"] = storage.upload_bytes(image_bytes, key, "image/png")
|
||||
current_item["prompt"] = prompt_data["prompt"]
|
||||
current_item["style"] = prompt_data["style"]
|
||||
current_item["size"] = prompt_data["size"]
|
||||
current_item["status"] = "completed"
|
||||
current_item["error"] = None
|
||||
logger.info(
|
||||
"章节补图成功: chapter=%s, index=%s, url=%s",
|
||||
chapter_id,
|
||||
current_item.get("index"),
|
||||
current_item["url"],
|
||||
)
|
||||
except Exception as exc:
|
||||
current_item["status"] = "failed"
|
||||
current_item["error"] = str(exc)
|
||||
|
||||
58
api/tests/test_chapters_router_images.py
Normal file
58
api/tests/test_chapters_router_images.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from api.routers.chapters import _chapter_to_dict
|
||||
|
||||
|
||||
class ChaptersRouterImagesTest(unittest.TestCase):
|
||||
@patch("api.routers.chapters.TencentCosStorageService")
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"TENCENT_COS_BUCKET": "life-echo-dev-1319381411",
|
||||
"TENCENT_COS_REGION": "ap-shanghai",
|
||||
"TENCENT_COS_BASE_URL": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com",
|
||||
},
|
||||
clear=False,
|
||||
)
|
||||
def test_chapter_to_dict_returns_signed_image_urls_for_response(self, storage_cls):
|
||||
storage = Mock()
|
||||
storage.get_download_url.return_value = "https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123"
|
||||
storage_cls.from_env.return_value = storage
|
||||
|
||||
chapter = type(
|
||||
"ChapterStub",
|
||||
(),
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "童年的夏天",
|
||||
"content": "{{IMAGE:南方小镇的青石板路}}",
|
||||
"order_index": 0,
|
||||
"status": "completed",
|
||||
"category": "childhood",
|
||||
"images": [
|
||||
{
|
||||
"index": 0,
|
||||
"placeholder": "{{IMAGE:南方小镇的青石板路}}",
|
||||
"description": "南方小镇的青石板路",
|
||||
"status": "completed",
|
||||
"prompt": "A serene southern China town",
|
||||
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
|
||||
"storage_key": "memoirs/u1/c1/0-demo.png",
|
||||
}
|
||||
],
|
||||
"updated_at": None,
|
||||
"is_new": False,
|
||||
"source_segments": [],
|
||||
},
|
||||
)()
|
||||
|
||||
payload = _chapter_to_dict(chapter)
|
||||
|
||||
self.assertEqual(
|
||||
payload["images"][0]["url"],
|
||||
"https://signed.example.com/memoirs/u1/c1/0-demo.png?sig=123",
|
||||
)
|
||||
self.assertEqual(payload["images"][0]["prompt"], "A serene southern China town")
|
||||
self.assertNotIn("storage_key", payload["images"][0])
|
||||
@@ -58,6 +58,7 @@ class GenerateChapterImagesTaskTest(unittest.TestCase):
|
||||
generate_chapter_images.run("chapter-1")
|
||||
|
||||
self.assertEqual(chapter.images[0]["status"], "completed")
|
||||
self.assertEqual(chapter.images[0]["storage_key"], "memoirs/user-1/chapter-1/0-7e1f860790.png")
|
||||
self.assertEqual(chapter.images[0]["url"], "https://cos.example.com/memoirs/u1/c1/0.png")
|
||||
self.assertEqual(chapter.images[0]["prompt"], "A serene southern China town")
|
||||
db.commit.assert_called()
|
||||
|
||||
@@ -66,3 +66,23 @@ class MemoirImageBootstrapTest(unittest.TestCase):
|
||||
self.assertEqual(assets[0]["url"], "https://cos.example.com/existing.png")
|
||||
self.assertEqual(assets[1]["status"], "pending")
|
||||
self.assertEqual(assets[1]["description"], "奶奶坐在院子里的藤椅上")
|
||||
|
||||
def test_initialize_chapter_images_accepts_double_brace_placeholders(self):
|
||||
chapter = type(
|
||||
"ChapterStub",
|
||||
(),
|
||||
{
|
||||
"id": "chapter-1",
|
||||
"title": "童年的夏天",
|
||||
"category": "childhood",
|
||||
"content": "开头。\n\n{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}\n\n结尾。",
|
||||
"images": [],
|
||||
},
|
||||
)()
|
||||
|
||||
with unittest.mock.patch.dict(os.environ, {"MEMOIR_IMAGE_ENABLED": "true"}, clear=False):
|
||||
assets = initialize_chapter_images(chapter)
|
||||
|
||||
self.assertEqual(len(assets), 1)
|
||||
self.assertEqual(assets[0]["status"], "pending")
|
||||
self.assertEqual(assets[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}")
|
||||
|
||||
@@ -43,3 +43,12 @@ class MemoirImageParserTest(unittest.TestCase):
|
||||
self.assertEqual(assets[0]["status"], "pending")
|
||||
self.assertEqual(assets[0]["provider"], "liblib")
|
||||
self.assertEqual(assets[0]["url"], None)
|
||||
|
||||
def test_parse_image_placeholders_accepts_double_brace_variant(self):
|
||||
content = "开头。\n\n{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}\n\n结尾。"
|
||||
|
||||
items = parse_image_placeholders(content, max_images=2)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["placeholder"], "{{IMAGE:1938年初的上海弄堂口,冬日萧瑟}}")
|
||||
self.assertEqual(items[0]["description"], "1938年初的上海弄堂口,冬日萧瑟")
|
||||
|
||||
@@ -15,6 +15,7 @@ class MemoirImagePromptingTest(unittest.TestCase):
|
||||
default_size="1024x1024",
|
||||
poll_interval_seconds=3,
|
||||
max_attempts=20,
|
||||
liblib_template_uuid="tpl-uuid",
|
||||
)
|
||||
service = MemoirImagePromptService(llm=None, settings=settings)
|
||||
|
||||
@@ -39,6 +40,7 @@ class MemoirImagePromptingTest(unittest.TestCase):
|
||||
default_size="1024x1024",
|
||||
poll_interval_seconds=3,
|
||||
max_attempts=20,
|
||||
liblib_template_uuid="tpl-uuid",
|
||||
)
|
||||
llm = Mock()
|
||||
llm.invoke.return_value.content = (
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from api.services.memoir_images.storage import TencentCosStorageService
|
||||
from api.services.memoir_images.storage import (
|
||||
TencentCosStorageService,
|
||||
normalize_cos_url,
|
||||
resolve_image_storage_key,
|
||||
)
|
||||
|
||||
|
||||
class MemoirImageStorageTest(unittest.TestCase):
|
||||
@@ -28,3 +32,80 @@ class MemoirImageStorageTest(unittest.TestCase):
|
||||
"https://memoir-1250000000.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
|
||||
)
|
||||
client.put_object.assert_called_once()
|
||||
|
||||
@patch("api.services.memoir_images.storage.CosS3Client")
|
||||
def test_upload_bytes_normalizes_duplicate_appid_suffix_in_base_url(self, client_cls):
|
||||
client = Mock()
|
||||
client_cls.return_value = client
|
||||
storage = TencentCosStorageService(
|
||||
secret_id="id",
|
||||
secret_key="key",
|
||||
region="ap-shanghai",
|
||||
bucket="life-echo-dev-1319381411",
|
||||
base_url="https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com",
|
||||
)
|
||||
|
||||
url = storage.upload_bytes(
|
||||
image_bytes=b"png-bytes",
|
||||
key="memoirs/u1/c1/0-demo.png",
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
url,
|
||||
"https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
|
||||
)
|
||||
client.put_object.assert_called_once()
|
||||
|
||||
def test_normalize_cos_url_repairs_existing_duplicate_appid_host(self):
|
||||
normalized = normalize_cos_url(
|
||||
"https://life-echo-dev-1319381411-appid.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
|
||||
bucket="life-echo-dev-1319381411",
|
||||
region="ap-shanghai",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
normalized,
|
||||
"https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png",
|
||||
)
|
||||
|
||||
@patch("api.services.memoir_images.storage.CosS3Client")
|
||||
def test_get_download_url_returns_presigned_download_url(self, client_cls):
|
||||
client = Mock()
|
||||
client.get_presigned_download_url.return_value = "https://cos.example.com/0.png?q-sign-algorithm=sha1"
|
||||
client_cls.return_value = client
|
||||
storage = TencentCosStorageService(
|
||||
secret_id="id",
|
||||
secret_key="key",
|
||||
region="ap-shanghai",
|
||||
bucket="life-echo-dev-1319381411",
|
||||
base_url="https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com",
|
||||
)
|
||||
|
||||
url = storage.get_download_url("memoirs/u1/c1/0-demo.png", expires=1800)
|
||||
|
||||
self.assertEqual(url, "https://cos.example.com/0.png?q-sign-algorithm=sha1")
|
||||
client.get_presigned_download_url.assert_called_once_with(
|
||||
Bucket="life-echo-dev-1319381411",
|
||||
Key="memoirs/u1/c1/0-demo.png",
|
||||
Expired=1800,
|
||||
)
|
||||
|
||||
def test_resolve_image_storage_key_prefers_explicit_storage_key(self):
|
||||
key = resolve_image_storage_key(
|
||||
{
|
||||
"storage_key": "memoirs/u1/c1/0-demo.png",
|
||||
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/other.png",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(key, "memoirs/u1/c1/0-demo.png")
|
||||
|
||||
def test_resolve_image_storage_key_derives_key_from_existing_url(self):
|
||||
key = resolve_image_storage_key(
|
||||
{
|
||||
"url": "https://life-echo-dev-1319381411.cos.ap-shanghai.myqcloud.com/memoirs/u1/c1/0-demo.png?q-sign-algorithm=sha1"
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(key, "memoirs/u1/c1/0-demo.png")
|
||||
|
||||
@@ -6,7 +6,12 @@ from api.services.pdf_service import PDFService
|
||||
|
||||
class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
|
||||
@patch("api.services.pdf_service.httpx.AsyncClient")
|
||||
async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(self, async_client_cls):
|
||||
@patch("api.services.pdf_service.TencentCosStorageService")
|
||||
async def test_generate_pdf_embeds_completed_images_and_removes_placeholders(
|
||||
self,
|
||||
storage_cls,
|
||||
async_client_cls,
|
||||
):
|
||||
png_bytes = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00"
|
||||
@@ -20,6 +25,9 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
|
||||
mock_client.get.return_value = mock_response
|
||||
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.return_value = "https://signed.example.com/0.png?sig=123"
|
||||
storage_cls.from_env.return_value = storage
|
||||
|
||||
service = PDFService()
|
||||
book = type("BookStub", (), {"title": "我的回忆录"})()
|
||||
@@ -33,7 +41,8 @@ class PDFServiceImagesTest(unittest.IsolatedAsyncioTestCase):
|
||||
{
|
||||
"index": 0,
|
||||
"placeholder": "{{{{IMAGE:南方小镇的青石板路}}}}",
|
||||
"url": "https://cos.example.com/0.png",
|
||||
"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",
|
||||
}
|
||||
],
|
||||
@@ -44,3 +53,4 @@ 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")
|
||||
|
||||
@@ -35,6 +35,7 @@ class ProcessMemoirSegmentsImageEnqueueTest(unittest.TestCase):
|
||||
default_size="1024x1024",
|
||||
poll_interval_seconds=3,
|
||||
max_attempts=20,
|
||||
liblib_template_uuid="tpl-uuid",
|
||||
)
|
||||
get_state_mock.return_value = SimpleNamespace(current_stage="childhood", slots={})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user