feat: add fission package download

This commit is contained in:
yangqianqian
2026-07-01 14:22:54 +08:00
parent 0894bba54e
commit 8c863efb7d
3 changed files with 227 additions and 4 deletions

View File

@@ -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,

View 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)

View File

@@ -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 && (