fix: 修复 Liblib provider 认证和多个图片生成关键缺陷

- 重写 LiblibImageProvider:Bearer token 改为 HMAC-SHA1 签名认证,
  适配 Liblib 真实 API(Star-3 Alpha 文生图端点)
- 修复 chapter.images JSON 列原地修改不持久化(深拷贝+整列重赋值)
- 修复 generate_chapter_images 在事务提交前派发(改为 commit 后统一 delay)
- 修复 initialize_chapter_images 覆盖已完成图片(新增 merge 去重逻辑)
- 修复 Android failed 图片渲染为错误卡片(改为隐藏,保持正文连续)
- 模型模板 UUID 改为环境变量配置(LIBLIB_TEMPLATE_UUID)
- 更新 .env 凭证格式为 ACCESS_KEY/SECRET_KEY
- 补充 test_memoir_image_bootstrap 缺失的 unittest.mock 导入

Made-with: Cursor
This commit is contained in:
Kevin
2026-03-10 17:02:50 +08:00
parent 830b6efc39
commit 0970cb7408
11 changed files with 484 additions and 111 deletions

View File

@@ -1,58 +1,144 @@
import unittest
from unittest.mock import Mock
from unittest.mock import Mock, patch
from api.services.memoir_images.provider import LiblibImageProvider
class MemoirImageProviderTest(unittest.TestCase):
def test_submit_generation_handles_sync_provider_response(self):
def _make_provider(http_client=None):
return LiblibImageProvider(
http_client=http_client or Mock(),
access_key="test-ak",
secret_key="test-sk",
base_url="https://openapi.liblibai.cloud",
template_uuid="tpl-uuid",
)
class LiblibSignatureTest(unittest.TestCase):
def test_sign_returns_url_with_auth_params(self):
provider = _make_provider()
url = provider._sign("/api/generate/webui/text2img/ultra")
self.assertIn("AccessKey=test-ak", url)
self.assertIn("Signature=", url)
self.assertIn("Timestamp=", url)
self.assertIn("SignatureNonce=", url)
self.assertTrue(url.startswith("https://openapi.liblibai.cloud/api/generate/webui/text2img/ultra?"))
class SubmitGenerationTest(unittest.TestCase):
def test_submit_returns_processing_with_job_id(self):
http_client = Mock()
http_client.post.return_value.json.return_value = {
"status": "succeeded",
"image_url": "https://provider.example.com/1.png",
resp = Mock()
resp.json.return_value = {
"code": 0,
"data": {"generateUuid": "uuid-abc"},
}
provider = LiblibImageProvider(http_client=http_client, api_key="test-key", base_url="https://example.com")
resp.raise_for_status = Mock()
http_client.post.return_value = resp
job = provider.submit_generation(prompt="foo", size="1024x1024", style="watercolor")
self.assertEqual(job["status"], "completed")
self.assertEqual(job["image_url"], "https://provider.example.com/1.png")
def test_submit_generation_handles_async_provider_response(self):
http_client = Mock()
http_client.post.return_value.json.return_value = {
"task_id": "job-123",
"status": "queued",
}
provider = LiblibImageProvider(http_client=http_client, api_key="test-key", base_url="https://example.com")
job = provider.submit_generation(prompt="foo", size="1024x1024", style="watercolor")
provider = _make_provider(http_client)
job = provider.submit_generation(prompt="a cat", size="1024x1024", style="watercolor")
self.assertEqual(job["status"], "processing")
self.assertEqual(job["job_id"], "job-123")
self.assertEqual(job["job_id"], "uuid-abc")
self.assertIsNone(job["image_url"])
def test_poll_until_complete_returns_completed_job(self):
call_kwargs = http_client.post.call_args
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
self.assertEqual(body["templateUuid"], "tpl-uuid")
self.assertEqual(body["generateParams"]["prompt"], "a cat")
self.assertEqual(body["generateParams"]["aspectRatio"], "square")
def test_submit_raises_on_error_code(self):
http_client = Mock()
http_client.get.return_value.json.side_effect = [
{"status": "queued"},
{"status": "succeeded", "image_url": "https://provider.example.com/1.png"},
]
provider = LiblibImageProvider(http_client=http_client, api_key="test-key", base_url="https://example.com")
resp = Mock()
resp.json.return_value = {"code": 100000, "msg": "param error"}
resp.raise_for_status = Mock()
http_client.post.return_value = resp
provider = _make_provider(http_client)
with self.assertRaises(RuntimeError):
provider.submit_generation(prompt="a cat", size="1024x1024", style="watercolor")
class PollUntilCompleteTest(unittest.TestCase):
def test_returns_completed_on_status_5(self):
http_client = Mock()
pending_resp = Mock()
pending_resp.json.return_value = {
"code": 0,
"data": {"generateStatus": 2, "images": []},
}
pending_resp.raise_for_status = Mock()
success_resp = Mock()
success_resp.json.return_value = {
"code": 0,
"data": {
"generateStatus": 5,
"images": [{"imageUrl": "https://cdn.example.com/1.png", "auditStatus": 3}],
},
}
success_resp.raise_for_status = Mock()
http_client.post.side_effect = [pending_resp, success_resp]
provider = _make_provider(http_client)
job = provider.poll_until_complete(
{"status": "processing", "job_id": "job-123"},
{"status": "processing", "job_id": "uuid-abc"},
poll_interval_seconds=0,
max_attempts=2,
max_attempts=3,
)
self.assertEqual(job["status"], "completed")
self.assertEqual(job["image_url"], "https://provider.example.com/1.png")
self.assertEqual(job["image_url"], "https://cdn.example.com/1.png")
self.assertEqual(job["job_id"], "uuid-abc")
def test_download_image_fetches_binary_payload(self):
def test_raises_on_status_6_failure(self):
http_client = Mock()
http_client.get.return_value.content = b"png-bytes"
provider = LiblibImageProvider(http_client=http_client, api_key="test-key", base_url="https://example.com")
resp = Mock()
resp.json.return_value = {
"code": 0,
"data": {"generateStatus": 6, "generateMsg": "content violation"},
}
resp.raise_for_status = Mock()
http_client.post.return_value = resp
payload = provider.download_image({"image_url": "https://provider.example.com/1.png"})
provider = _make_provider(http_client)
with self.assertRaises(RuntimeError, msg="content violation"):
provider.poll_until_complete(
{"status": "processing", "job_id": "uuid-abc"},
poll_interval_seconds=0,
max_attempts=2,
)
def test_raises_timeout_after_max_attempts(self):
http_client = Mock()
resp = Mock()
resp.json.return_value = {
"code": 0,
"data": {"generateStatus": 2, "images": []},
}
resp.raise_for_status = Mock()
http_client.post.return_value = resp
provider = _make_provider(http_client)
with self.assertRaises(TimeoutError):
provider.poll_until_complete(
{"status": "processing", "job_id": "uuid-abc"},
poll_interval_seconds=0,
max_attempts=2,
)
class DownloadImageTest(unittest.TestCase):
def test_download_fetches_binary_payload(self):
http_client = Mock()
resp = Mock()
resp.content = b"png-bytes"
resp.raise_for_status = Mock()
http_client.get.return_value = resp
provider = _make_provider(http_client)
payload = provider.download_image({"image_url": "https://cdn.example.com/1.png"})
self.assertEqual(payload, b"png-bytes")