feat(insta-lab): 슬레이트 zip 패키지 다운로드 API (10 PNG + caption.txt)
GET /api/insta/slates/{slate_id}/package 엔드포인트 추가.
렌더된 card_assets PNG들 + suggested_caption + hashtags를
단일 zip으로 번들해 StreamingResponse 반환.
hashtags JSON 문자열/리스트 방어 파싱 포함.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
|||||||
"""FastAPI entrypoint for insta-lab."""
|
"""FastAPI entrypoint for insta-lab."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import zipfile
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Body, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from _shared.access_log import install as install_access_log
|
from _shared.access_log import install as install_access_log
|
||||||
|
|
||||||
@@ -247,6 +249,35 @@ def get_asset(slate_id: int, page: int):
|
|||||||
return FileResponse(match["file_path"], media_type="image/png")
|
return FileResponse(match["file_path"], media_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/insta/slates/{slate_id}/package")
|
||||||
|
def download_package(slate_id: int):
|
||||||
|
slate = db.get_card_slate(slate_id)
|
||||||
|
if not slate:
|
||||||
|
raise HTTPException(404, "slate not found")
|
||||||
|
assets = sorted(db.list_card_assets(slate_id), key=lambda a: a["page_index"])
|
||||||
|
if not assets:
|
||||||
|
raise HTTPException(409, "아직 렌더된 카드가 없습니다")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for a in assets:
|
||||||
|
fp = a["file_path"]
|
||||||
|
if os.path.exists(fp):
|
||||||
|
z.write(fp, arcname=f"{a['page_index']:02d}.png")
|
||||||
|
caption = (slate.get("suggested_caption") or "").strip()
|
||||||
|
tags = slate.get("hashtags") or []
|
||||||
|
if isinstance(tags, str):
|
||||||
|
try:
|
||||||
|
tags = json.loads(tags)
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
caption_full = caption + ("\n\n" + " ".join(tags) if tags else "")
|
||||||
|
z.writestr("caption.txt", caption_full)
|
||||||
|
buf.seek(0)
|
||||||
|
return StreamingResponse(buf, media_type="application/zip", headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="insta_slate_{slate_id}.zip"'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/insta/slates/{slate_id}")
|
@app.delete("/api/insta/slates/{slate_id}")
|
||||||
def delete_slate(slate_id: int):
|
def delete_slate(slate_id: int):
|
||||||
if not db.get_card_slate(slate_id):
|
if not db.get_card_slate(slate_id):
|
||||||
|
|||||||
45
insta-lab/app/test_package_api.py
Normal file
45
insta-lab/app/test_package_api.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import io, os, tempfile, zipfile, sys
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _client(monkeypatch):
|
||||||
|
# Insert web-backend root (3 levels up from this file) so _shared is importable
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
from app import config, db
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
monkeypatch.setattr(config, "INSTA_DATA_PATH", tmp, raising=False)
|
||||||
|
monkeypatch.setattr(db, "DB_PATH", os.path.join(tmp, "insta.db"), raising=False)
|
||||||
|
db.init_db()
|
||||||
|
from app.main import app
|
||||||
|
return TestClient(app), db, tmp
|
||||||
|
|
||||||
|
|
||||||
|
def test_package_zip_contains_pngs_and_caption(monkeypatch):
|
||||||
|
client, db, tmp = _client(monkeypatch)
|
||||||
|
# 슬레이트 + 2개 asset(실제 PNG 파일) 시드
|
||||||
|
sid = db.add_card_slate({
|
||||||
|
"keyword": "k",
|
||||||
|
"category": "economy",
|
||||||
|
"status": "rendered",
|
||||||
|
"cover_copy": {"headline": "h"},
|
||||||
|
"body_copies": [{"headline": "b", "body": "x"}] * 8,
|
||||||
|
"cta_copy": {},
|
||||||
|
"suggested_caption": "캡션입니다",
|
||||||
|
"hashtags": ["#a", "#b"],
|
||||||
|
})
|
||||||
|
cards_dir = os.path.join(tmp, "insta_cards", str(sid))
|
||||||
|
os.makedirs(cards_dir, exist_ok=True)
|
||||||
|
for pg in (1, 2):
|
||||||
|
fp = os.path.join(cards_dir, f"{pg:02d}.png")
|
||||||
|
with open(fp, "wb") as f:
|
||||||
|
f.write(b"\x89PNG\r\n" + b"0" * 2000)
|
||||||
|
db.add_card_asset(slate_id=sid, page_index=pg, file_path=fp)
|
||||||
|
r = client.get(f"/api/insta/slates/{sid}/package")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"] == "application/zip"
|
||||||
|
z = zipfile.ZipFile(io.BytesIO(r.content))
|
||||||
|
names = z.namelist()
|
||||||
|
assert any(n.endswith(".png") for n in names)
|
||||||
|
assert "caption.txt" in names
|
||||||
|
cap = z.read("caption.txt").decode("utf-8")
|
||||||
|
assert "캡션입니다" in cap and "#a" in cap
|
||||||
Reference in New Issue
Block a user