This commit is contained in:
fengxiangdebaozi committed 2026-06-05 18:58:37 +08:00
commit a642ba91fa
21 files changed
+1587 -47

No files matched your search

@@ -245,6 +245,8 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. |
| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. |
| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_<sha1>` → re-import is a no-op). **`view`**: a public, read-only result page — see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. |
| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3「结果绘制」report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/<code>`. Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
@@ -45,6 +45,7 @@ from app.gateway.routers import (
recommend,
recommended_questions,
roundtable_chains,
roundtable_draft_shares,
roundtable_drafts,
roundtable_jobs,
runs,
@@ -925,6 +926,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Public, unauthenticated read-only share snapshots (/api/public/shares/*).
app.include_router(thread_shares.public_router)
# 圆桌会商草稿分享(import 复制副本 / view 只读结果报告页)
app.include_router(roundtable_draft_shares.router)
# Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*).
app.include_router(roundtable_draft_shares.public_router)
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
@@ -167,6 +167,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_store = make_roundtable_draft_store(sf)
from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
@@ -330,6 +334,14 @@ def get_thread_share_store(request: Request):
return val
def get_roundtable_draft_share_store(request: Request):
"""Return the roundtable-draft share-link store."""
val = getattr(request.app.state, "roundtable_draft_share_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Roundtable draft share store not available")
return val
def get_embed_session_store(request: Request):
"""Return the iframe embed session → thread mapping store."""
val = getattr(request.app.state, "embed_session_store", None)
@@ -0,0 +1,277 @@
"""Roundtable-draft sharing API.
A user creates a share code for *one* of their roundtable-planning drafts (a full
会商 session). Two modes:
- ``import`` — redeeming the code copies the draft into the redeemer's account as
an independent copy they fully own (fresh draft id, deduped per recipient).
- ``view`` — a public, read-only page that renders the draft's Step 3 result
report (the self-contained 方案总览 HTML). Anyone with the link can open it
without logging in.
Mirrors ``app/gateway/routers/thread_shares.py`` but the shared resource is a
``roundtable_drafts`` row (read via ``app.state.roundtable_draft_store``) rather
than a conversation thread.
"""
from __future__ import annotations
import hashlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_roundtable_draft_share_store
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-draft-shares", tags=["roundtable-draft-shares"])
# Public, unauthenticated router. Its prefix is whitelisted in
# ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``) — only read-only,
# intentionally world-readable endpoints belong here.
public_router = APIRouter(prefix="/api/public/roundtable-shares", tags=["roundtable-draft-shares-public"])
_UNTITLED = "未命名会商"
_SHARE_MODES = ("import", "view")
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_draft_store(request: Request):
store = getattr(request.app.state, "roundtable_draft_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable draft store not available")
return store
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class CreateShareRequest(BaseModel):
draft_id: str = Field(..., description="The roundtable draft to share")
mode: str = Field(
default="import",
description='"import" — recipient copies the draft; "view" — public read-only result page',
)
class ShareResponse(BaseModel):
share_code: str = Field(..., description="Short code that recipients enter to import")
owner_user_id: str = Field(..., description="User who created the share")
draft_id: str = Field(..., description="The shared draft")
revoked: bool = Field(default=False, description="Whether the code has been revoked")
mode: str = Field(default="import", description='"import" or "view"')
created_at: str | None = Field(default=None, description="Creation timestamp (ISO-8601)")
class SharesListResponse(BaseModel):
shares: list[ShareResponse]
class SharePreviewResponse(BaseModel):
share_code: str
shared_by: str = Field(..., description="Owner user id of the shared draft")
title: str = Field(..., description="Title of the shared draft")
class ImportResultResponse(BaseModel):
imported: int = Field(..., description="1 when the draft was copied, else 0")
skipped: int = Field(..., description="1 when it was already imported, else 0")
draft_id: str | None = Field(default=None, description="The new local draft id (when imported)")
class PublicDraftShareSnapshotResponse(BaseModel):
"""Read-only roundtable-result snapshot served to anonymous viewers."""
share_code: str
title: str = Field(..., description="Draft title")
mode: str = Field(default="view")
# The Step 3「结果绘制」report — a self-contained HTML document. Empty when the
# shared draft never reached / generated a result report.
html: str = Field(default="", description="Self-contained Step 3 result report HTML")
summary: str | None = Field(default=None, description="The report's closing one-liner")
generated_at: str | None = Field(default=None, description="When the report was generated (ISO-8601)")
has_report: bool = Field(default=False, description="Whether a Step 3 report exists")
created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)")
# ---------------------------------------------------------------------------
# Owner side — create / list / revoke
# ---------------------------------------------------------------------------
@router.post("", response_model=ShareResponse, summary="Create a share code for one roundtable draft")
async def create_share(
body: CreateShareRequest,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> ShareResponse:
me = _current_user_id(request)
mode = body.mode if body.mode in _SHARE_MODES else "import"
# Only the draft's owner may share it.
draft = await _get_draft_store(request).get_draft(body.draft_id, me)
if draft is None:
raise HTTPException(status_code=404, detail="Draft not found")
# Reuse an existing active code (of the same mode) so re-sharing is idempotent.
existing = await share_store.get_active_for_draft(me, body.draft_id, mode=mode)
if existing is not None:
return ShareResponse(**existing)
record = await share_store.create(me, body.draft_id, mode=mode)
return ShareResponse(**record)
@router.get("", response_model=SharesListResponse, summary="List my roundtable-draft share codes")
async def list_shares(
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> SharesListResponse:
records = await share_store.list_for_owner(_current_user_id(request))
return SharesListResponse(shares=[ShareResponse(**r) for r in records])
@router.delete("/{share_code}", summary="Revoke a roundtable-draft share code")
async def revoke_share(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> dict[str, bool]:
ok = await share_store.revoke(share_code, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Share code not found")
return {"success": True}
# ---------------------------------------------------------------------------
# Recipient side — preview / import
# ---------------------------------------------------------------------------
async def _resolve_active_share(share_code: str, share_store: RoundtableDraftShareStore) -> dict:
share = await share_store.get(share_code)
if share is None:
raise HTTPException(status_code=404, detail="Share code not found")
if share.get("revoked"):
raise HTTPException(status_code=410, detail="This share code has been revoked")
return share
@router.get("/{share_code}/preview", response_model=SharePreviewResponse, summary="Preview a shared roundtable draft")
async def preview_share(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> SharePreviewResponse:
share = await _resolve_active_share(share_code, share_store)
owner = share["owner_user_id"]
draft = await _get_draft_store(request).get_draft(share["draft_id"], owner)
if draft is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
return SharePreviewResponse(
share_code=share_code,
shared_by=owner,
title=draft.get("title") or _UNTITLED,
)
def _imported_draft_id(share_code: str, recipient: str) -> str:
"""Deterministic per-(code, recipient) draft id so re-import is idempotent.
``roundtable_drafts.id`` is a global PK, so the id must be unique across
users — hence the recipient is folded into the hash rather than reusing a
code-only id (which would collide when two people import the same code).
"""
digest = hashlib.sha1(f"{share_code}:{recipient}".encode()).hexdigest()
return f"imp_{digest[:40]}"
@router.post("/{share_code}/import", response_model=ImportResultResponse, summary="Import a shared roundtable draft")
async def import_share(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> ImportResultResponse:
share = await _resolve_active_share(share_code, share_store)
if share.get("mode") == "view":
raise HTTPException(status_code=400, detail="This is a view-only share — open its link to read it instead of importing")
owner = share["owner_user_id"]
origin_id = share["draft_id"]
recipient = _current_user_id(request)
if owner == recipient:
raise HTTPException(status_code=400, detail="You cannot import your own share code")
draft_store = _get_draft_store(request)
src = await draft_store.get_draft(origin_id, owner)
if src is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
# Dedup: a deterministic id per (code, recipient) makes re-import a no-op.
new_id = _imported_draft_id(share_code, recipient)
if await draft_store.get_draft(new_id, recipient) is not None:
return ImportResultResponse(imported=0, skipped=1, draft_id=new_id)
await draft_store.create_draft(
recipient,
{
"id": new_id,
"title": src.get("title") or _UNTITLED,
"furthest_step": src.get("furthest_step") or 1,
"step1": src.get("step1"),
"step2": src.get("step2"),
"step3": src.get("step3"),
},
)
logger.info("Roundtable share %s imported by %s as draft %s", share_code, recipient, new_id)
return ImportResultResponse(imported=1, skipped=0, draft_id=new_id)
# ---------------------------------------------------------------------------
# Public read-only view — no authentication required
# ---------------------------------------------------------------------------
@public_router.get(
"/{share_code}",
response_model=PublicDraftShareSnapshotResponse,
summary="Public read-only snapshot of a view-shared roundtable draft",
)
async def get_public_draft_share_snapshot(
share_code: str,
request: Request,
share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store),
) -> PublicDraftShareSnapshotResponse:
"""Serve a frozen, read-only roundtable result. Anyone with the link can read it.
Only ``mode="view"`` shares are exposed; missing, revoked, or import-mode
codes all return 404 so the endpoint never reveals which case applies.
"""
share = await share_store.get(share_code)
if share is None or share.get("revoked") or share.get("mode") != "view":
raise HTTPException(status_code=404, detail="Share not found")
draft = await _get_draft_store(request).get_draft(share["draft_id"], share["owner_user_id"])
if draft is None:
raise HTTPException(status_code=404, detail="The shared draft no longer exists")
step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else None
html = (step3 or {}).get("html") or ""
return PublicDraftShareSnapshotResponse(
share_code=share_code,
title=draft.get("title") or _UNTITLED,
mode="view",
html=html,
summary=(step3 or {}).get("summary"),
generated_at=(step3 or {}).get("generatedAt"),
has_report=bool(html),
created_at=share.get("created_at"),
)
@@ -0,0 +1,57 @@
"""Create roundtable_draft_shares table.
Per-draft share links for the roundtable-planning page, mirroring
``thread_shares`` but pointing at a ``roundtable_drafts`` row. ``import`` codes
copy the draft into the recipient's account; ``view`` codes back a public
read-only result-report page. ``create_all`` makes the table for brand-new DBs;
this revision patches DBs that already exist.
Revision ID: 20260606_01
Revises: 20260605_02
Create Date: 2026-06-06
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260606_01"
down_revision = "20260605_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("roundtable_draft_shares"):
return
op.create_table(
"roundtable_draft_shares",
sa.Column("share_code", sa.String(length=32), primary_key=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=False),
sa.Column("draft_id", sa.String(length=64), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("mode", sa.String(length=16), nullable=False, server_default="import"),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_roundtable_draft_shares_owner_user_id",
"roundtable_draft_shares",
["owner_user_id"],
)
op.create_index(
"ix_roundtable_draft_shares_draft_id",
"roundtable_draft_shares",
["draft_id"],
)
def downgrade() -> None:
if _has_table("roundtable_draft_shares"):
op.drop_table("roundtable_draft_shares")
@@ -23,6 +23,7 @@ from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
from deerflow.persistence.roundtable_drafts.model import (
RoundtableDraftRow,
RoundtableRecommendHistoryRow,
@@ -54,6 +55,7 @@ __all__ = [
"RecommendedQuestionRow",
"RoundtableChainRow",
"RoundtableDraftRow",
"RoundtableDraftShareRow",
"RoundtableJobRow",
"RoundtableRecommendHistoryRow",
"RunEventRow",
@@ -0,0 +1,22 @@
"""Persistence layer for roundtable-draft share links."""
from deerflow.persistence.roundtable_draft_shares.base import (
RoundtableDraftShareStore,
generate_share_code,
)
from deerflow.persistence.roundtable_draft_shares.memory import MemoryRoundtableDraftShareStore
from deerflow.persistence.roundtable_draft_shares.sql import RoundtableDraftShareRepository
__all__ = [
"RoundtableDraftShareStore",
"MemoryRoundtableDraftShareStore",
"RoundtableDraftShareRepository",
"generate_share_code",
"make_roundtable_draft_share_store",
]
def make_roundtable_draft_share_store(session_factory):
if session_factory is None:
return MemoryRoundtableDraftShareStore()
return RoundtableDraftShareRepository(session_factory)
@@ -0,0 +1,56 @@
"""Abstract persistence API for roundtable-draft share links.
Mirrors ``thread_shares`` but shares one *roundtable-planning draft* (a full
会商 session: Step 1 intent + Step 2 roundtable + optional Step 3 report)
instead of a conversation thread. Redeeming an ``import`` code copies the draft
into the recipient's account; a ``view`` code is a public read-only page that
renders the draft's Step 3 result report.
The share-code alphabet / generator is reused from ``thread_shares.base`` so
codes are interchangeable in shape (unambiguous, human-readable).
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
# Reuse the same unambiguous-alphabet code generator as conversation shares.
from deerflow.persistence.thread_shares.base import generate_share_code
__all__ = ["RoundtableDraftShareStore", "generate_share_code"]
class RoundtableDraftShareStore(ABC):
@abstractmethod
async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
"""Create a new share link for one draft and return its record.
``mode`` is ``"import"`` (recipient copies the draft) or ``"view"``
(a public read-only result-report page).
"""
raise NotImplementedError
@abstractmethod
async def get(self, share_code: str) -> dict[str, Any] | None:
"""Look up a share by code regardless of owner (None if missing)."""
raise NotImplementedError
@abstractmethod
async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
"""Return the owner's non-revoked share of ``mode`` for ``draft_id``, if any.
Used so re-sharing the same draft in the same mode reuses its existing
code instead of minting a new one each time.
"""
raise NotImplementedError
@abstractmethod
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
"""List all share links created by ``owner_user_id`` (newest first)."""
raise NotImplementedError
@abstractmethod
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
"""Revoke a share link. Returns False if missing or not owned."""
raise NotImplementedError
@@ -0,0 +1,59 @@
"""In-memory ``RoundtableDraftShareStore`` for tests / memory backend."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code
class MemoryRoundtableDraftShareStore(RoundtableDraftShareStore):
def __init__(self) -> None:
self._shares: dict[str, dict[str, Any]] = {}
async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
code = generate_share_code()
while code in self._shares:
code = generate_share_code()
record = {
"share_code": code,
"owner_user_id": owner_user_id,
"draft_id": draft_id,
"revoked": False,
"mode": mode,
"created_at": datetime.now(UTC).isoformat(),
}
self._shares[code] = record
return copy.deepcopy(record)
async def get(self, share_code: str) -> dict[str, Any] | None:
record = self._shares.get(share_code)
return copy.deepcopy(record) if record is not None else None
async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
matches = [
r
for r in self._shares.values()
if r["owner_user_id"] == owner_user_id
and r["draft_id"] == draft_id
and r.get("mode", "import") == mode
and not r["revoked"]
]
if not matches:
return None
matches.sort(key=lambda r: r["created_at"], reverse=True)
return copy.deepcopy(matches[0])
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
rows = [r for r in self._shares.values() if r["owner_user_id"] == owner_user_id]
rows.sort(key=lambda r: r["created_at"], reverse=True)
return [copy.deepcopy(r) for r in rows]
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
record = self._shares.get(share_code)
if record is None or record["owner_user_id"] != owner_user_id:
return False
record["revoked"] = True
return True
@@ -0,0 +1,38 @@
"""ORM model for roundtable-draft share links.
A share link is a short, revocable code created by a user for *one* of their
roundtable-planning drafts. Redeeming it copies that draft into the redeemer's
account (``import``) or exposes its Step 3 result report on a public read-only
page (``view``). The table only stores the link — the draft snapshot is read
lazily at import / view time, so a revoked link instantly stops working.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class RoundtableDraftShareRow(Base):
__tablename__ = "roundtable_draft_shares"
# Short, URL-safe, unguessable code embedded in the share link.
share_code: Mapped[str] = mapped_column(String(32), primary_key=True)
# The user who created the share.
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# The single roundtable draft this link shares.
draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Soft-disable: a revoked link can no longer be previewed, imported, or viewed.
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Share mode:
# "import" — recipient (a logged-in user) copies the draft into their own
# account and can keep working on the copy.
# "view" — a public, read-only page anyone can open without logging in;
# it renders the draft's Step 3 result report (HTML).
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="import", server_default="import")
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,84 @@
"""SQLAlchemy-backed roundtable-draft share-link repository."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
def _row_to_dict(row: RoundtableDraftShareRow) -> dict[str, Any]:
data = row.to_dict()
created = data.get("created_at")
if isinstance(created, datetime):
data["created_at"] = created.isoformat()
return data
class RoundtableDraftShareRepository(RoundtableDraftShareStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]:
# Retry on the (astronomically unlikely) code collision.
async with self._sf() as session:
for _ in range(8):
code = generate_share_code()
if await session.get(RoundtableDraftShareRow, code) is not None:
continue
row = RoundtableDraftShareRow(
share_code=code,
owner_user_id=owner_user_id,
draft_id=draft_id,
revoked=False,
mode=mode,
)
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
raise RuntimeError("Failed to allocate a unique share code")
async def get(self, share_code: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftShareRow, share_code)
return _row_to_dict(row) if row is not None else None
async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None:
stmt = (
select(RoundtableDraftShareRow)
.where(
RoundtableDraftShareRow.owner_user_id == owner_user_id,
RoundtableDraftShareRow.draft_id == draft_id,
RoundtableDraftShareRow.mode == mode,
RoundtableDraftShareRow.revoked.is_(False),
)
.order_by(RoundtableDraftShareRow.created_at.desc())
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalars().first()
return _row_to_dict(row) if row is not None else None
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
stmt = (
select(RoundtableDraftShareRow)
.where(RoundtableDraftShareRow.owner_user_id == owner_user_id)
.order_by(RoundtableDraftShareRow.created_at.desc())
)
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RoundtableDraftShareRow, share_code)
if row is None or row.owner_user_id != owner_user_id:
return False
row.revoked = True
await session.commit()
return True