diff --git a/backend/app/api/v1/fission.py b/backend/app/api/v1/fission.py index 7e6d086..4c85ff5 100644 --- a/backend/app/api/v1/fission.py +++ b/backend/app/api/v1/fission.py @@ -11,11 +11,12 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy.orm import Session 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.models.fission import FissionTask, FissionNote @@ -117,6 +118,40 @@ def get_fission_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") def retry_fission_note_images_endpoint( fission_id: int, diff --git a/backend/app/services/fission_package_exporter.py b/backend/app/services/fission_package_exporter.py new file mode 100644 index 0000000..6ad98e5 --- /dev/null +++ b/backend/app/services/fission_package_exporter.py @@ -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) diff --git a/frontend/src/app/fission/[id]/notes/page.tsx b/frontend/src/app/fission/[id]/notes/page.tsx index f1bd3fd..30a2625 100644 --- a/frontend/src/app/fission/[id]/notes/page.tsx +++ b/frontend/src/app/fission/[id]/notes/page.tsx @@ -56,6 +56,7 @@ export default function FissionNotesPage() { const fissionId = Number(params?.id); const [data, setData] = useState(null); const [error, setError] = useState(null); + const [downloading, setDownloading] = useState(false); const fetchNotes = useCallback(async () => { setError(null); @@ -86,6 +87,27 @@ export default function FissionNotesPage() { setTimeout(tick, 8000); }, [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 (
@@ -97,9 +119,19 @@ export default function FissionNotesPage() { 一个爆款裂变出的多套差异化完整笔记,可分发给不同达人。

- - 返回裂变 - +
+ + + 返回裂变 + +
{error && (