feat(roundtable): 业务链条配置增加管理员权限,管理员可配所有链条
- 后端 store/路由:scope=all(仅管理员)列全量;get/update/delete 加 is_admin 旁路,管理员可操作任意 owner 链条且不转移归属;非管理员请求 all 退化为 mine - 前端列表页/会商弹窗:管理员多「全部链条」筛选 tab,canManage=isAdmin||isMine 放开发布/设置/删除 - 编辑器:管理员可见发布按钮;按 chainId 找不到本地列表时直接 getChain 拉取(修复管理员从「全部链条」进编辑器空白),并修掉自身无链条时加载卡死的边界 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
48744441ba
commit
f13a1bb7fe
6 files changed
+113
-45
No files matched your search
@@ -6,11 +6,15 @@ following this order. Bound to the requesting user (not the browser), so it
|
||||
follows the account across devices/sessions — same model as roundtable drafts.
|
||||
|
||||
Routes (prefix ``/api/roundtable-chains``):
|
||||
GET "" list current user's chains (full records, incl. seats)
|
||||
GET "" list chains (``scope=mine``默认 / ``public`` / ``all`` 仅管理员)
|
||||
POST "" create a chain
|
||||
GET "/{chain_id}" one chain
|
||||
PUT "/{chain_id}" partial update (title / description / seats)
|
||||
DELETE "/{chain_id}" delete
|
||||
|
||||
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
|
||||
(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
|
||||
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -131,6 +135,18 @@ def _current_user_id(request: Request) -> str:
|
||||
return get_effective_user_id()
|
||||
|
||||
|
||||
def _is_admin(request: Request) -> bool:
|
||||
"""当前请求是否为管理员。
|
||||
|
||||
认证关闭时 ``request.state.user`` 为 None(单一 default 用户,mine 即 all),
|
||||
按管理员处理(与 menu_overrides 等放行约定一致,无越权风险)。
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
if user is None:
|
||||
return True
|
||||
return getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
async def _enrich_owner_names(rows: list[dict]) -> None:
|
||||
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
|
||||
|
||||
@@ -269,13 +285,18 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
|
||||
|
||||
``scope`` 查询参数(筛选):
|
||||
- ``mine``(默认):仅当前用户自己的链条;
|
||||
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
|
||||
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨);
|
||||
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
|
||||
其余值按 ``mine`` 处理。
|
||||
"""
|
||||
normalized_scope = scope if scope in ("mine", "public") else "mine"
|
||||
is_admin = _is_admin(request)
|
||||
normalized_scope = scope if scope in ("mine", "public", "all") else "mine"
|
||||
# 非管理员请求 all → 退化为 mine,绝不越权。
|
||||
if normalized_scope == "all" and not is_admin:
|
||||
normalized_scope = "mine"
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
rows = await store.list_chains(user_id, scope=normalized_scope)
|
||||
rows = await store.list_chains(user_id, scope=normalized_scope, is_admin=is_admin)
|
||||
await _enrich_owner_names(rows)
|
||||
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
|
||||
|
||||
@@ -310,7 +331,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
||||
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.get_chain(chain_id, user_id)
|
||||
row = await store.get_chain(chain_id, user_id, is_admin=_is_admin(request))
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Chain not found")
|
||||
await _enrich_owner_names([row])
|
||||
@@ -334,7 +355,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
||||
user_id = _current_user_id(request)
|
||||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
row = await store.update_chain(chain_id, user_id, **fields)
|
||||
row = await store.update_chain(chain_id, user_id, is_admin=_is_admin(request), **fields)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Chain not found")
|
||||
await _enrich_owner_names([row])
|
||||
@@ -345,7 +366,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
||||
async def delete_chain(request: Request, chain_id: str) -> None:
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
deleted = await store.delete_chain(chain_id, user_id)
|
||||
deleted = await store.delete_chain(chain_id, user_id, is_admin=_is_admin(request))
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Chain not found")
|
||||
|
||||
|
||||
+22
-14
@@ -70,36 +70,41 @@ class RoundtableChainRepository:
|
||||
}
|
||||
|
||||
async def list_chains(
|
||||
self, user_id: str, *, scope: str = "mine", limit: int = 200
|
||||
self, user_id: str, *, scope: str = "mine", limit: int = 200, is_admin: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""列出链条。
|
||||
|
||||
``scope``:
|
||||
- ``"mine"``(默认):仅当前用户自己的链条;
|
||||
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。
|
||||
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的;
|
||||
- ``"all"``:所有账号的全部链条(**仅管理员**,由路由层校验后传 ``is_admin=True``;
|
||||
非管理员误传则按 ``"mine"`` 退化,绝不越权)。
|
||||
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
|
||||
"""
|
||||
if scope == "public":
|
||||
cond = RoundtableChainRow.is_public.is_(True)
|
||||
elif scope == "all" and is_admin:
|
||||
cond = None
|
||||
else:
|
||||
cond = RoundtableChainRow.user_id == user_id
|
||||
stmt = (
|
||||
select(RoundtableChainRow)
|
||||
.where(cond)
|
||||
.order_by(RoundtableChainRow.updated_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
stmt = select(RoundtableChainRow)
|
||||
if cond is not None:
|
||||
stmt = stmt.where(cond)
|
||||
stmt = stmt.order_by(RoundtableChainRow.updated_at.desc()).limit(limit)
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
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 def get_chain(
|
||||
self, chain_id: str, user_id: str, *, is_admin: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨);
|
||||
**管理员可见任意链条**(``is_admin=True``,供后台管理配置)。"""
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.user_id != user_id and not bool(getattr(row, "is_public", False)):
|
||||
if not is_admin and row.user_id != user_id and not bool(getattr(row, "is_public", False)):
|
||||
return None
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
@@ -140,10 +145,12 @@ class RoundtableChainRepository:
|
||||
seat_mode=_UNSET,
|
||||
gather_first=_UNSET,
|
||||
is_public=_UNSET,
|
||||
is_admin: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
# 管理员可改任意链条;否则仅 owner 本人。不变更 user_id(不转移归属)。
|
||||
if row is None or (not is_admin and row.user_id != user_id):
|
||||
return None
|
||||
if title is not _UNSET and title is not None:
|
||||
row.title = str(title).strip()
|
||||
@@ -168,10 +175,11 @@ class RoundtableChainRepository:
|
||||
await session.refresh(row)
|
||||
return self._to_dict(row, user_id)
|
||||
|
||||
async def delete_chain(self, chain_id: str, user_id: str) -> bool:
|
||||
async def delete_chain(self, chain_id: str, user_id: str, *, is_admin: bool = False) -> bool:
|
||||
async with self._sf() as session:
|
||||
row = await session.get(RoundtableChainRow, chain_id)
|
||||
if row is None or row.user_id != user_id:
|
||||
# 管理员可删任意链条;否则仅 owner 本人。
|
||||
if row is None or (not is_admin and row.user_id != user_id):
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
|
||||
Reference in new issue
Block a user