feat: add fission package download
This commit is contained in:
@@ -11,11 +11,12 @@ import logging
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.response import ok, raise_not_found
|
from app.core.response import ok, raise_business, raise_not_found
|
||||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||||
from app.models.fission import FissionTask, FissionNote
|
from app.models.fission import FissionTask, FissionNote
|
||||||
|
|
||||||
@@ -117,6 +118,40 @@ def get_fission_notes(
|
|||||||
"fanout_count": ft.fanout_count, "notes": notes})
|
"fanout_count": ft.fanout_count, "notes": notes})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fission/{fission_id}/download")
|
||||||
|
def download_fission_package(
|
||||||
|
fission_id: int,
|
||||||
|
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""下载裂变结果:把 N 套裂变笔记打成 zip(文案+图片+发布清单)。"""
|
||||||
|
ft = db.query(FissionTask).filter(
|
||||||
|
FissionTask.id == fission_id,
|
||||||
|
FissionTask.workspace_id == current_user.workspace_id,
|
||||||
|
).first()
|
||||||
|
if not ft:
|
||||||
|
raise_not_found("裂变任务不存在")
|
||||||
|
|
||||||
|
rows = db.query(FissionNote).filter(
|
||||||
|
FissionNote.fission_id == fission_id,
|
||||||
|
FissionNote.workspace_id == current_user.workspace_id,
|
||||||
|
).order_by(FissionNote.seq.asc()).all()
|
||||||
|
if not rows:
|
||||||
|
raise_business("裂变结果为空,暂不能下载")
|
||||||
|
|
||||||
|
from app.services.fission_package_exporter import build_fission_package
|
||||||
|
try:
|
||||||
|
zip_path = build_fission_package(ft, rows)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise_business(str(exc))
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=zip_path,
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=f"fission-{fission_id}.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/fission/{fission_id}/notes/{seq}/retry-images")
|
@router.post("/fission/{fission_id}/notes/{seq}/retry-images")
|
||||||
def retry_fission_note_images_endpoint(
|
def retry_fission_note_images_endpoint(
|
||||||
fission_id: int,
|
fission_id: int,
|
||||||
|
|||||||
156
backend/app/services/fission_package_exporter.py
Normal file
156
backend/app/services/fission_package_exporter.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
fission_package_exporter.py — 裂变笔记包下载
|
||||||
|
|
||||||
|
把 FissionNote 表中的 N 套裂变结果打成 zip:
|
||||||
|
note_01/文案.txt + 生成图 + 发布清单 + 说明。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_name(value: str | None, fallback: str = "img") -> str:
|
||||||
|
raw = (value or fallback).strip() or fallback
|
||||||
|
return "".join(c if c.isalnum() or c in ("-", "_") else "_" for c in raw)[:40]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_upload_path(url: str, upload_root: Path) -> Path:
|
||||||
|
if url.startswith("/uploads/"):
|
||||||
|
return upload_root / url.removeprefix("/uploads/")
|
||||||
|
if url.startswith("uploads/"):
|
||||||
|
return upload_root / url.removeprefix("uploads/")
|
||||||
|
return Path(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _loads_json(value: str | None, fallback: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(value) if value else fallback
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _write_note_text(note_dir: Path, note: dict[str, Any]) -> None:
|
||||||
|
tags = note.get("tags") or []
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
tags = []
|
||||||
|
image_plan = note.get("imagePlan") or []
|
||||||
|
lines = [
|
||||||
|
f"【标题】{note.get('title', '')}",
|
||||||
|
"",
|
||||||
|
"【封面钩子】",
|
||||||
|
str(note.get("coverTitle") or ""),
|
||||||
|
"",
|
||||||
|
"【正文】",
|
||||||
|
str(note.get("content") or ""),
|
||||||
|
]
|
||||||
|
if tags:
|
||||||
|
lines += ["", "【标签】", " ".join(str(t) for t in tags)]
|
||||||
|
if image_plan:
|
||||||
|
lines += ["", "【图片排版说明】"]
|
||||||
|
for idx, plan in enumerate(image_plan, start=1):
|
||||||
|
if not isinstance(plan, dict):
|
||||||
|
continue
|
||||||
|
parts = [
|
||||||
|
f"{idx}. {plan.get('role') or f'图{idx}'}",
|
||||||
|
f"标题:{plan.get('title') or ''}",
|
||||||
|
f"叠字:{plan.get('overlayText') or ''}",
|
||||||
|
f"画面:{plan.get('text') or ''}",
|
||||||
|
]
|
||||||
|
lines.append(" / ".join(parts))
|
||||||
|
(note_dir / "文案.txt").write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def build_fission_package(ft, rows: list) -> str:
|
||||||
|
"""生成裂变下载 zip,返回 zip 本地路径。"""
|
||||||
|
upload_root = Path(get_settings().UPLOAD_ABS_ROOT).resolve()
|
||||||
|
package_dir = upload_root / "packages" / "fission" / str(ft.workspace_id) / str(ft.id)
|
||||||
|
if package_dir.exists():
|
||||||
|
shutil.rmtree(package_dir)
|
||||||
|
package_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
note_dirs: list[Path] = []
|
||||||
|
checklist: list[str] = [
|
||||||
|
"📋 裂变笔记发布清单",
|
||||||
|
f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
||||||
|
f"裂变ID:{ft.id}",
|
||||||
|
f"参考程度:{ft.reference_level}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
missing_images: list[str] = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(rows, start=1):
|
||||||
|
note = _loads_json(row.note_json, {})
|
||||||
|
images = _loads_json(row.images_json, [])
|
||||||
|
if not isinstance(note, dict):
|
||||||
|
note = {}
|
||||||
|
if not isinstance(images, list):
|
||||||
|
images = []
|
||||||
|
|
||||||
|
note_dir = package_dir / f"note_{idx:02d}"
|
||||||
|
note_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
note_dirs.append(note_dir)
|
||||||
|
_write_note_text(note_dir, note)
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
expected = 0
|
||||||
|
for img_idx, img in enumerate(images, start=1):
|
||||||
|
if not isinstance(img, dict) or img.get("error"):
|
||||||
|
continue
|
||||||
|
url = img.get("url") or img.get("image_url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
expected += 1
|
||||||
|
src = _resolve_upload_path(str(url), upload_root)
|
||||||
|
if not src.is_file():
|
||||||
|
missing_images.append(f"note_{idx:02d}: {url}")
|
||||||
|
continue
|
||||||
|
role = _safe_name(str(img.get("role") or f"img_{img_idx}"))
|
||||||
|
seq = img.get("seq") or img_idx
|
||||||
|
dst = note_dir / f"{int(seq):02d}_{role}.jpg"
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
copied += 1
|
||||||
|
|
||||||
|
if expected and copied == 0:
|
||||||
|
raise ValueError(f"第 {idx} 套裂变图片读取失败,拒绝生成无图下载包")
|
||||||
|
|
||||||
|
title = str(note.get("title") or f"第{idx}套")
|
||||||
|
checklist.append(
|
||||||
|
f"note_{idx:02d} 标题:{title[:32]} 图片数:{copied} 分数:{row.score}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not note_dirs:
|
||||||
|
raise ValueError("裂变结果为空,无法下载")
|
||||||
|
|
||||||
|
if missing_images:
|
||||||
|
checklist += ["", "图片读取提示:"]
|
||||||
|
checklist += [f"- {item}" for item in missing_images[:20]]
|
||||||
|
if len(missing_images) > 20:
|
||||||
|
checklist.append(f"- 另有 {len(missing_images) - 20} 张图片缺失")
|
||||||
|
checklist += [
|
||||||
|
"",
|
||||||
|
"使用说明:",
|
||||||
|
"- 每个 note_xx 文件夹是一套可独立发布的裂变笔记。",
|
||||||
|
"- 文案.txt 内含标题、封面钩子、正文、标签和图片排版说明。",
|
||||||
|
"- 图片按 01/02/03 顺序上传,避免传错序。",
|
||||||
|
]
|
||||||
|
(package_dir / "📋裂变发布清单.txt").write_text("\n".join(checklist), encoding="utf-8")
|
||||||
|
|
||||||
|
zip_path = package_dir / "fission_package.zip"
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for note_dir in note_dirs:
|
||||||
|
for fpath in sorted(note_dir.iterdir()):
|
||||||
|
zf.write(fpath, arcname=f"{note_dir.name}/{fpath.name}")
|
||||||
|
zf.write(package_dir / "📋裂变发布清单.txt", arcname="📋裂变发布清单.txt")
|
||||||
|
|
||||||
|
logger.info("fission package built: %s notes=%d", zip_path, len(note_dirs))
|
||||||
|
return str(zip_path)
|
||||||
@@ -56,6 +56,7 @@ export default function FissionNotesPage() {
|
|||||||
const fissionId = Number(params?.id);
|
const fissionId = Number(params?.id);
|
||||||
const [data, setData] = useState<NotesResponse | null>(null);
|
const [data, setData] = useState<NotesResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
const fetchNotes = useCallback(async () => {
|
const fetchNotes = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -86,6 +87,27 @@ export default function FissionNotesPage() {
|
|||||||
setTimeout(tick, 8000);
|
setTimeout(tick, 8000);
|
||||||
}, [fissionId, fetchNotes]);
|
}, [fissionId, fetchNotes]);
|
||||||
|
|
||||||
|
const downloadPackage = useCallback(async () => {
|
||||||
|
if (!fissionId || downloading) return;
|
||||||
|
setDownloading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const blob = await api.getBlob(`/api/v1/fission/${fissionId}/download`);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `fission-${fissionId}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
setError('下载裂变包失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
}, [fissionId, downloading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -97,9 +119,19 @@ export default function FissionNotesPage() {
|
|||||||
一个爆款裂变出的多套差异化完整笔记,可分发给不同达人。
|
一个爆款裂变出的多套差异化完整笔记,可分发给不同达人。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/fission" className="text-sm text-text-secondary underline">
|
<div className="flex items-center gap-3">
|
||||||
返回裂变
|
<button
|
||||||
</Link>
|
type="button"
|
||||||
|
onClick={downloadPackage}
|
||||||
|
disabled={!data || data.notes.length === 0 || downloading}
|
||||||
|
className="rounded-lg bg-brand-orange px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloading ? '下载中…' : '下载裂变包'}
|
||||||
|
</button>
|
||||||
|
<Link href="/fission" className="text-sm text-text-secondary underline">
|
||||||
|
返回裂变
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
Reference in New Issue
Block a user