feat(roundtable): 业务链条「发布为公共」+ 我的/公共筛选
后端:roundtable_chains 加 is_public 列;list_chains 支持 scope=mine|public(public=所有已发布,任何账号可查看/选入研讨);get_chain 放开公共可见;响应加 is_public/is_mine;update 支持 is_public(发布/取消,owner 限定,编辑/删除仍仅 owner)。迁移 20260608_03 接在合并 head 20260608_02 之后,保证单一 alembic head(多 head 会让启动自动迁移失败、静默降级 create_all → 列缺失 500)。 前端:listChains(scope) + setChainPublic;BusinessChain 加 isPublic/isMine;列表弹窗与研讨选择器加「我的/公共」筛选 tab;列表卡片发布/取消发布 + 公共徽标(别人的只读);配置页头部加发布按钮(已保存且本人)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
05d14bb364
commit
7affaaf7c0
10 files changed
+373
-44
No files matched your search
@@ -49,6 +49,10 @@ class ChainResponse(BaseModel):
|
||||
# Optional layered orchestration: list of stages, each a list of agent_ids
|
||||
# run in parallel (stages run serially). None/absent = linear chain.
|
||||
stages: list[list[str]] | None = None
|
||||
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||
is_public: bool = False
|
||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||
is_mine: bool = True
|
||||
created_at: datetime | str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
@@ -73,6 +77,8 @@ class ChainUpdateRequest(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=2048)
|
||||
seats: list[ChainSeatSchema] | None = None
|
||||
stages: list[list[str]] | None = None
|
||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
@@ -149,10 +155,18 @@ def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | Non
|
||||
|
||||
|
||||
@router.get("", response_model=ChainListResponse)
|
||||
async def list_chains(request: Request) -> ChainListResponse:
|
||||
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
|
||||
"""列出业务链条。
|
||||
|
||||
``scope`` 查询参数(筛选):
|
||||
- ``mine``(默认):仅当前用户自己的链条;
|
||||
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
|
||||
其余值按 ``mine`` 处理。
|
||||
"""
|
||||
normalized_scope = scope if scope in ("mine", "public") else "mine"
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_chains(user_id)
|
||||
rows = await store.list_chains(user_id, scope=normalized_scope)
|
||||
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
||||
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""Merge chain_stages and admin_leaderboard_daily_stats migration heads.
|
||||
|
||||
Two independent migrations diverged from ``20260607_02``: ``20260607_03``
|
||||
(roundtable_chains.stages) and ``20260608_01`` (admin_leaderboard_daily_stats +
|
||||
analytics indexes). This no-op merge revision joins both lineages so
|
||||
``upgrade head`` succeeds.
|
||||
|
||||
Revision ID: 20260608_02
|
||||
Revises: 20260607_03, 20260608_01
|
||||
Create Date: 2026-06-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
revision = "20260608_02"
|
||||
down_revision = ("20260607_03", "20260608_01")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"""Add is_public column to roundtable_chains.
|
||||
|
||||
「发布」business chains: ``is_public=True`` makes a chain visible to **all
|
||||
accounts** under the "公共" filter (read + select-into-roundtable only; edit /
|
||||
delete stay owner-only). Defaults to ``0`` (private). ``create_all`` adds the
|
||||
column for brand-new DBs; this revision patches DBs that already have the table.
|
||||
|
||||
Chained after the merge head ``20260608_02`` (chain_stages + leaderboard) so the
|
||||
revision graph stays single-headed — otherwise ``alembic upgrade head`` aborts
|
||||
with "multiple heads" and the auto-migrate on startup silently falls back to the
|
||||
create_all schema (which lacks this column → 500 on list).
|
||||
|
||||
Revision ID: 20260608_03
|
||||
Revises: 20260608_02
|
||||
Create Date: 2026-06-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260608_03"
|
||||
down_revision = "20260608_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 _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "is_public"):
|
||||
op.add_column(
|
||||
"roundtable_chains",
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_roundtable_chains_is_public", "roundtable_chains", ["is_public"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_chains", "is_public"):
|
||||
with op.batch_alter_table("roundtable_chains") as batch:
|
||||
batch.drop_index("ix_roundtable_chains_is_public")
|
||||
op.drop_column("roundtable_chains", "is_public")
|
||||
+6
-1
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
@@ -39,6 +39,11 @@ class RoundtableChainRow(Base):
|
||||
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
|
||||
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
|
||||
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
|
||||
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0", index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
|
||||
+31
-8
@@ -44,7 +44,7 @@ class RoundtableChainRepository:
|
||||
self._sf = session_factory
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(row: RoundtableChainRow) -> dict[str, Any]:
|
||||
def _to_dict(row: RoundtableChainRow, current_user_id: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"title": row.title,
|
||||
@@ -52,27 +52,46 @@ class RoundtableChainRepository:
|
||||
"seats": _loads(row.seats) or [],
|
||||
# None when absent → frontend reads it as "linear chain" (no layering).
|
||||
"stages": _loads(row.stages),
|
||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||
}
|
||||
|
||||
async def list_chains(self, user_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
async def list_chains(
|
||||
self, user_id: str, *, scope: str = "mine", limit: int = 200
|
||||
) -> list[dict[str, Any]]:
|
||||
"""列出链条。
|
||||
|
||||
``scope``:
|
||||
- ``"mine"``(默认):仅当前用户自己的链条;
|
||||
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
|
||||
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
|
||||
"""
|
||||
if scope == "public":
|
||||
cond = RoundtableChainRow.is_public.is_(True)
|
||||
else:
|
||||
cond = RoundtableChainRow.user_id == user_id
|
||||
stmt = (
|
||||
select(RoundtableChainRow)
|
||||
.where(RoundtableChainRow.user_id == user_id)
|
||||
.where(cond)
|
||||
.order_by(RoundtableChainRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [self._to_dict(row) for row in result.scalars()]
|
||||
return [self._to_dict(row, user_id) for row in result.scalars()]
|
||||
|
||||
async def get_chain(self, chain_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。"""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
if row is None:
|
||||
return None
|
||||
return self._to_dict(row)
|
||||
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
|
||||
return None
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def create_chain(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
@@ -83,6 +102,7 @@ class RoundtableChainRepository:
|
||||
description=(data.get("description") or None),
|
||||
seats=_dumps(data.get("seats") or []),
|
||||
stages=_dumps(data.get("stages")),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -90,7 +110,7 @@ class RoundtableChainRepository:
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def update_chain(
|
||||
self,
|
||||
@@ -101,6 +121,7 @@ class RoundtableChainRepository:
|
||||
description=_UNSET,
|
||||
seats=_UNSET,
|
||||
stages=_UNSET,
|
||||
is_public=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
@@ -114,10 +135,12 @@ class RoundtableChainRepository:
|
||||
row.seats = _dumps(seats)
|
||||
if stages is not _UNSET:
|
||||
row.stages = _dumps(stages)
|
||||
if is_public is not _UNSET and is_public is not None:
|
||||
row.is_public = bool(is_public)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row)
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
||||
async with self._sf() as session:
|
||||
|
||||
Reference in new issue
Block a user