fix(wcd): verify imported stickers and remap production assets
Build, Push and Deploy / build (push) Failing after 45m28s
Build, Push and Deploy / deploy (push) Skipped

This commit is contained in:
2026-09-11 18:21:01 +08:00
parent ca5df31d54
commit 469145edd6
2 changed files with 179 additions and 10 deletions
+10 -10
View File
@@ -438,7 +438,7 @@ async def _create_wcd_job(wcd_file: UploadFile, user_params: dict) -> JobCreateR
meta = _register_import_asset(str(item.get("name") or pkg_id), asset_bytes, mime)
real_id = str(meta["asset_id"])
remap[pkg_id] = real_id
file_map[pkg_id] = {
file_map[real_id] = {
"path": str(_asset_dir(real_id) / f"asset{_asset_extension_for_mime(mime)}"),
"mime": mime,
}
@@ -1323,15 +1323,15 @@ def _asset_meta_items() -> list[dict]:
def _find_asset_by_sha256(digest: str) -> dict | None:
for meta in _asset_meta_items():
if meta.get("sha256") == digest:
return meta
for meta in _asset_meta_items():
try:
ext = _asset_extension_for_mime(str(meta.get("mime_type") or ""))
path = _asset_dir(str(meta["asset_id"])) / f"asset{ext}"
if path.exists() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
meta["sha256"] = digest
if not path.exists():
continue
actual_digest = hashlib.sha256(path.read_bytes()).hexdigest()
if actual_digest == digest:
meta["sha256"] = actual_digest
_write_asset_meta(_asset_dir(str(meta["asset_id"])), meta)
return meta
except Exception:
@@ -1528,7 +1528,7 @@ async def update_design_template(
return DesignTemplate(**data)
@app.delete("/api/design-templates/{template_id}", status_code=204)
@app.delete("/api/design-templates/{template_id}", status_code=204, response_model=None)
def delete_design_template(template_id: str) -> None:
template_dir = _design_template_dir(template_id)
if not (template_dir / "template.json").exists():
@@ -1790,7 +1790,7 @@ def analyze_asset_line_spacing(
return LineSpacingAnalysisSummary(**result.as_dict())
@app.delete("/api/assets/{asset_id}", status_code=204)
@app.delete("/api/assets/{asset_id}", status_code=204, response_model=None)
def delete_asset(asset_id: str) -> None:
d = _asset_dir(asset_id)
meta = _read_asset_meta(d)
@@ -1916,7 +1916,7 @@ async def update_project(
return Project(**data)
@app.delete("/api/projects/{project_id}", status_code=204)
@app.delete("/api/projects/{project_id}", status_code=204, response_model=None)
def delete_project(project_id: str) -> None:
pdir = _project_dir(project_id)
path = pdir / "project.json"
@@ -2011,7 +2011,7 @@ async def upload_font(
return Font(**meta)
@app.delete("/api/fonts/{font_id}", status_code=204)
@app.delete("/api/fonts/{font_id}", status_code=204, response_model=None)
def delete_font(font_id: str) -> None:
"""删除已上传字体(内置默认字体不可删除)。"""
if font_id == "__default__":
+169
View File
@@ -0,0 +1,169 @@
import asyncio
import json
import hashlib
import sys
import zipfile
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from fastapi import UploadFile
from PIL import Image
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from service import app # noqa: E402
SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10">'
'<rect width="20" height="10" fill="#3366ff"/></svg>'
)
def png_bytes(color=(51, 102, 255)):
buffer = BytesIO()
Image.new("RGB", (1, 1), color).save(buffer, format="PNG")
return buffer.getvalue()
@pytest.fixture
def isolated_asset_store(tmp_path, monkeypatch):
assets_dir = tmp_path / "service_assets"
assets_dir.mkdir()
monkeypatch.setattr(app, "ASSETS_DIR", assets_dir)
design_templates_dir = tmp_path / "service_design_templates"
design_templates_dir.mkdir()
monkeypatch.setattr(app, "DESIGN_TEMPLATES_DIR", design_templates_dir)
return assets_dir
def asset_dirs(assets_dir):
return sorted(path for path in assets_dir.glob("*/*") if path.is_dir())
def test_duplicate_content_is_reused(isolated_asset_store):
first = app._register_import_asset("first", SVG.encode(), "image/svg+xml")
second = app._register_import_asset("second", SVG.encode(), "image/svg+xml")
assert first["asset_id"] == second["asset_id"]
assert second["name"] == "first"
assert len(asset_dirs(isolated_asset_store)) == 1
def test_same_name_different_content_gets_unique_assets(isolated_asset_store):
first = app._register_import_asset("same-name", SVG.encode(), "image/svg+xml")
second = app._register_import_asset(
"same-name", SVG.replace("#3366ff", "#ff3366").encode(), "image/svg+xml"
)
assert first["asset_id"] != second["asset_id"]
assert len(asset_dirs(isolated_asset_store)) == 2
assert first["sha256"] != second["sha256"]
def test_stale_sha256_meta_does_not_reuse_mismatched_file(isolated_asset_store):
content = SVG.encode()
existing_id = "asset_stale00000000000000000000000000"
existing_dir = isolated_asset_store / existing_id[:2] / existing_id
existing_dir.mkdir(parents=True)
(existing_dir / "asset.svg").write_text('<svg xmlns="x" width="1" height="1"/>')
(existing_dir / "meta.json").write_text(
json.dumps(
{
"asset_id": existing_id,
"name": "old",
"type": "sticker",
"mime_type": "image/svg+xml",
"sha256": hashlib.sha256(content).hexdigest(),
}
)
)
imported = app._register_import_asset("new", content, "image/svg+xml")
assert imported["asset_id"] != existing_id
assert len(asset_dirs(isolated_asset_store)) == 2
def test_wcd_production_uses_real_asset_ids(tmp_path, isolated_asset_store, monkeypatch):
manifest = {
"format": "wordcloud-canvas",
"version": 1,
"name": "test-design",
"assets": [{"id": "pkg-1", "name": "sticker", "type": "svg", "mimeType": "image/svg+xml"}],
}
document_json = {
"width": 40,
"height": 20,
"background": "#ffffff",
"layers": [],
"layerFolders": [],
"elements": [
{
"id": "element-1",
"type": "sticker",
"name": "sticker",
"assetId": "pkg-1",
"x": 5,
"y": 5,
"width": 10,
"height": 10,
"rotation": 0,
"opacity": 1,
}
],
}
package = BytesIO()
with zipfile.ZipFile(package, "w") as archive:
archive.writestr("manifest.json", json.dumps(manifest))
archive.writestr("document.json", json.dumps(document_json))
archive.writestr("assets/pkg-1.png", png_bytes())
class Paths:
input_dir = tmp_path / "input"
output_dir = tmp_path / "output"
def prepare_job_dirs(job_id):
Paths.input_dir.mkdir(parents=True)
Paths.output_dir.mkdir(parents=True)
return Paths
manager = SimpleNamespace(
create_job=lambda: "job-test",
set_artifacts=lambda *args, **kwargs: None,
set_status=lambda *args, **kwargs: None,
)
original_compose = app._compose_design_png
captured = {}
def compose(document, file_map, output_path):
captured["document"] = document
captured["file_map"] = dict(file_map)
original_compose(document, file_map, output_path)
monkeypatch.setattr(app, "manager", manager)
monkeypatch.setattr(app, "storage", SimpleNamespace(prepare_job_dirs=prepare_job_dirs))
monkeypatch.setattr(app, "_write_order", lambda *args, **kwargs: None)
monkeypatch.setattr(app, "_compose_design_png", compose)
monkeypatch.setattr(app.threading, "Thread", lambda target, **kwargs: SimpleNamespace(start=target))
upload = UploadFile(filename="test.wcd", file=BytesIO(package.getvalue()))
asyncio.run(app._create_wcd_job(upload, {}))
document = captured["document"]
file_map = captured["file_map"]
real_ids = [element["assetId"] for element in document["elements"]]
assert real_ids
assert all(asset_id in file_map for asset_id in real_ids)
assert all(Path(asset_id).is_absolute() is False for asset_id in real_ids)
png_path = isolated_asset_store.parent / "composed.png"
original_compose(document, file_map, png_path)
with Image.open(png_path) as image:
assert image.getpixel((10, 10)) == (51, 102, 255)