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:
董宏凯andClaude Opus 4.8 committed 2026-06-21 17:17:19 +08:00
1 parent 48744441ba
commit f13a1bb7fe
6 files changed
+113 -45

No files matched your search

@@ -54,8 +54,13 @@ export interface ChainUpdatePayload {
isPublic?: boolean; isPublic?: boolean;
} }
/** 列表筛选范围:`mine` = 我的;`public` = 所有已发布的公共链条。 */ /**
export type ChainScope = "mine" | "public"; * 列表筛选范围:
* - `mine` = 我的;
* - `public` = 所有已发布的公共链条;
* - `all` = 所有账号的全部链条(**仅管理员**;后端对非管理员请求 `all` 退化为 `mine`)。
*/
export type ChainScope = "mine" | "public" | "all";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`; const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
@@ -146,7 +151,7 @@ function fromUpdate(p: ChainUpdatePayload) {
* - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。 * - `"public"`:所有已发布的公共链条(任何账号可见 / 可选入研讨)。
*/ */
export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> { export async function listChains(scope: ChainScope = "mine"): Promise<BusinessChain[]> {
const url = scope === "public" ? `${BASE()}?scope=public` : BASE(); const url = scope === "mine" ? BASE() : `${BASE()}?scope=${scope}`;
const res = await apiFetch(url, { method: "GET" }); const res = await apiFetch(url, { method: "GET" });
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains"); const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
return (data.chains ?? []).map(toChain); return (data.chains ?? []).map(toChain);
@@ -31,6 +31,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { getStoredAuth } from "@/core/auth";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { listChains, setChainPublic, type ChainScope } from "../api/chains"; import { listChains, setChainPublic, type ChainScope } from "../api/chains";
@@ -71,7 +72,9 @@ export function BusinessChainListBody({
const [chains, setChains] = useState<BusinessChain[]>([]); const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 筛选:我的 / 公共(已发布)。 // 管理员:额外多一个「全部」筛选,且可编辑 / 发布任意账号的链条。
const isAdmin = getStoredAuth()?.system_role === "admin";
// 筛选:我的 / 公共(已发布)/(管理员)全部。
const [scope, setScope] = useState<ChainScope>("mine"); const [scope, setScope] = useState<ChainScope>("mine");
const [publishingId, setPublishingId] = useState<string | null>(null); const [publishingId, setPublishingId] = useState<string | null>(null);
// 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。 // 实时名称表(id 小写 → 最新 name):用于把席位的落库快照名刷新成最新(agent 改名同步)。
@@ -147,9 +150,9 @@ export function BusinessChainListBody({
return ( return (
<> <>
{/* 筛选:我的 / 公共 */} {/* 筛选:我的 / 公共 /(管理员)全部 */}
<div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start"> <div className="shrink-0 flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5 self-start">
{(["mine", "public"] as const).map((s) => ( {((isAdmin ? ["mine", "public", "all"] : ["mine", "public"]) as ChainScope[]).map((s) => (
<button <button
key={s} key={s}
type="button" type="button"
@@ -161,7 +164,7 @@ export function BusinessChainListBody({
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{s === "mine" ? "我的" : "公共"} {s === "mine" ? "我的" : s === "public" ? "公共" : "全部"}
</button> </button>
))} ))}
</div> </div>
@@ -195,6 +198,7 @@ export function BusinessChainListBody({
<ChainRow <ChainRow
key={chain.id} key={chain.id}
chain={chain} chain={chain}
canManage={isAdmin || chain.isMine !== false}
nameById={nameById} nameById={nameById}
publishing={publishingId === chain.id} publishing={publishingId === chain.id}
onSettings={() => onConfigure(chain.id)} onSettings={() => onConfigure(chain.id)}
@@ -245,18 +249,20 @@ export function BusinessChainListDialog({
function ChainRow({ function ChainRow({
chain, chain,
canManage,
nameById, nameById,
publishing, publishing,
onSettings, onSettings,
onTogglePublic, onTogglePublic,
}: { }: {
chain: BusinessChain; chain: BusinessChain;
/** 能否编辑 / 发布(本人链条 或 管理员)。 */
canManage: boolean;
nameById: Map<string, string>; nameById: Map<string, string>;
publishing: boolean; publishing: boolean;
onSettings: () => void; onSettings: () => void;
onTogglePublic: () => void; onTogglePublic: () => void;
}) { }) {
const isMine = chain.isMine !== false;
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。 // 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : ""; const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return ( return (
@@ -274,8 +280,8 @@ function ChainRow({
</span> </span>
)} )}
<div className="ml-auto flex shrink-0 items-center gap-1.5"> <div className="ml-auto flex shrink-0 items-center gap-1.5">
{/* 发布 / 取消发布:仅本人的链条可操作。 */} {/* 发布 / 取消发布:本人的链条或管理员可操作。 */}
{isMine && ( {canManage && (
<Button <Button
variant={chain.isPublic ? "secondary" : "outline"} variant={chain.isPublic ? "secondary" : "outline"}
size="sm" size="sm"
@@ -291,8 +297,8 @@ function ChainRow({
{chain.isPublic ? "取消发布" : "发布"} {chain.isPublic ? "取消发布" : "发布"}
</Button> </Button>
)} )}
{/* 设置(编辑):仅本人的链条;公共里别人的链条只读浏览。 */} {/* 设置(编辑):本人的链条或管理员;普通用户看别人的链条只读浏览。 */}
{isMine && ( {canManage && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -78,6 +78,7 @@ import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { ModelNameSelect } from "@/components/workspace/model-name-select"; import { ModelNameSelect } from "@/components/workspace/model-name-select";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { import {
filterRecommendCandidates, filterRecommendCandidates,
@@ -86,6 +87,7 @@ import {
import { import {
createChain, createChain,
deleteChain, deleteChain,
getChain,
listChains, listChains,
optimizeChainPrompt, optimizeChainPrompt,
setChainPublic, setChainPublic,
@@ -171,6 +173,8 @@ export default function BusinessChainEditorPage() {
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。 // 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
const [currentChainPublic, setCurrentChainPublic] = useState(false); const [currentChainPublic, setCurrentChainPublic] = useState(false);
const [currentChainMine, setCurrentChainMine] = useState(true); const [currentChainMine, setCurrentChainMine] = useState(true);
// 管理员:可编辑 / 发布 / 删除任意账号的链条(与「全部链条」筛选配套)。
const isAdmin = getStoredAuth()?.system_role === "admin";
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -330,10 +334,23 @@ export default function BusinessChainEditorPage() {
if (appliedChainParamRef.current) return; if (appliedChainParamRef.current) return;
const chainId = searchParams.get("chainId"); const chainId = searchParams.get("chainId");
if (!chainId) return; if (!chainId) return;
if (chains.length === 0) return; // 等 refreshChains 拉回列表 appliedChainParamRef.current = true; // 只应用一次,之后不覆盖用户手动切换
const chain = chains.find((c) => c.id === chainId); // 优先用「我的链条」列表里已拉到的对象(省一次请求);不在列表里(管理员从「全部链条」
if (chain) loadChain(chain); // 点设置 / 公共链条 / 自己没有任何链条)则直接按 id 拉取。后端对管理员放开任意链条、
appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试 // 对所有人放开已发布公共链条;拉不到则静默保持空白新建态。
const local = chains.find((c) => c.id === chainId);
if (local) {
loadChain(local);
return;
}
void (async () => {
try {
const chain = await getChain(chainId);
if (chain) loadChain(chain);
} catch {
// 静默:取不到就保持当前(空白)状态。
}
})();
}, [chains, searchParams, loadChain]); }, [chains, searchParams, loadChain]);
// ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)──────────────────── // ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)────────────────────
@@ -987,8 +1004,8 @@ export default function BusinessChainEditorPage() {
{/* 右侧操作 */} {/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */} {/* 发布 / 取消发布:已保存且本人所有(或管理员)的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
{currentChainId && currentChainMine && ( {currentChainId && (currentChainMine || isAdmin) && (
<button <button
type="button" type="button"
onClick={handleTogglePublish} onClick={handleTogglePublish}
@@ -61,6 +61,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { useRouteNavigate } from "@/core/route-base"; import { useRouteNavigate } from "@/core/route-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree"; import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
@@ -83,6 +84,8 @@ export default function BusinessChainListPage() {
const navigate = useRouteNavigate(); const navigate = useRouteNavigate();
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。 // 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>(); const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// 管理员:额外多一个「全部链条」筛选,且可编辑 / 发布 / 删除任意账号的链条。
const isAdmin = getStoredAuth()?.system_role === "admin";
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。 // 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。 // 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
@@ -314,13 +317,14 @@ export default function BusinessChainListPage() {
{/* 筛选工具栏 */} {/* 筛选工具栏 */}
<div className="mb-6 flex flex-col gap-4 rounded-2xl border border-zinc-100 bg-white p-4 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347] lg:flex-row lg:items-center lg:justify-between"> <div className="mb-6 flex flex-col gap-4 rounded-2xl border border-zinc-100 bg-white p-4 shadow-sm dark:border-[#1A2F5A] dark:bg-[#122347] lg:flex-row lg:items-center lg:justify-between">
{/* 我的 / 公共 */} {/* 我的 / 公共 /(管理员)全部 */}
<div className="flex self-start rounded-xl bg-zinc-100 p-1 dark:bg-[#0C1836]"> <div className="flex self-start rounded-xl bg-zinc-100 p-1 dark:bg-[#0C1836]">
{( {(
[ [
["mine", "我的链条"], ["mine", "我的链条"],
["public", "公共列表"], ["public", "公共列表"],
] as const ...(isAdmin ? ([["all", "全部链条"]] as const) : []),
] as [ChainScope, string][]
).map(([key, label]) => ( ).map(([key, label]) => (
<button <button
key={key} key={key}
@@ -439,6 +443,7 @@ export default function BusinessChainListPage() {
<ChainCard <ChainCard
key={chain.id} key={chain.id}
chain={chain} chain={chain}
canManage={isAdmin || chain.isMine !== false}
publishing={publishingId === chain.id} publishing={publishingId === chain.id}
onSettings={() => goConfigure(chain.id)} onSettings={() => goConfigure(chain.id)}
onTogglePublic={() => handleTogglePublic(chain)} onTogglePublic={() => handleTogglePublic(chain)}
@@ -450,6 +455,7 @@ export default function BusinessChainListPage() {
) : ( ) : (
<ChainTable <ChainTable
chains={visibleChains} chains={visibleChains}
canManage={isAdmin}
publishingId={publishingId} publishingId={publishingId}
onSettings={(c) => goConfigure(c.id)} onSettings={(c) => goConfigure(c.id)}
onTogglePublic={handleTogglePublic} onTogglePublic={handleTogglePublic}
@@ -620,18 +626,20 @@ function EmptyState({
function ChainCard({ function ChainCard({
chain, chain,
canManage,
publishing, publishing,
onSettings, onSettings,
onTogglePublic, onTogglePublic,
onDelete, onDelete,
}: { }: {
chain: BusinessChain; chain: BusinessChain;
/** 能否编辑 / 发布 / 删除(本人链条 或 管理员)。 */
canManage: boolean;
publishing: boolean; publishing: boolean;
onSettings: () => void; onSettings: () => void;
onTogglePublic: () => void; onTogglePublic: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const isMine = chain.isMine !== false;
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : ""; const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return ( return (
@@ -685,8 +693,8 @@ function ChainCard({
</div> </div>
</div> </div>
{/* 操作按钮:仅本人链条可发布 / 设置 / 删除(公共里别人的链条只读浏览)。 */} {/* 操作按钮:本人链条或管理员可发布 / 设置 / 删除(普通用户看别人的链条只读浏览)。 */}
{isMine && ( {canManage && (
<div className="flex shrink-0 items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100"> <div className="flex shrink-0 items-center gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<button <button
type="button" type="button"
@@ -892,12 +900,15 @@ function dotClass(avatarType: string): string {
function ChainTable({ function ChainTable({
chains, chains,
canManage,
publishingId, publishingId,
onSettings, onSettings,
onTogglePublic, onTogglePublic,
onDelete, onDelete,
}: { }: {
chains: BusinessChain[]; chains: BusinessChain[];
/** 管理员:可管理任意链条(即使非本人)。 */
canManage: boolean;
publishingId: string | null; publishingId: string | null;
onSettings: (c: BusinessChain) => void; onSettings: (c: BusinessChain) => void;
onTogglePublic: (c: BusinessChain) => void; onTogglePublic: (c: BusinessChain) => void;
@@ -919,7 +930,7 @@ function ChainTable({
</thead> </thead>
<tbody className="divide-y divide-zinc-100 text-xs text-zinc-800 dark:divide-[#1A2F5A] dark:text-[#C5CFE0]"> <tbody className="divide-y divide-zinc-100 text-xs text-zinc-800 dark:divide-[#1A2F5A] dark:text-[#C5CFE0]">
{chains.map((chain) => { {chains.map((chain) => {
const isMine = chain.isMine !== false; const isMine = canManage || chain.isMine !== false;
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "—"; const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "—";
return ( return (
<tr <tr
@@ -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. follows the account across devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/roundtable-chains``): 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 POST "" create a chain
GET "/{chain_id}" one chain GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats) PUT "/{chain_id}" partial update (title / description / seats)
DELETE "/{chain_id}" delete DELETE "/{chain_id}" delete
管理员权限:``system_role == "admin"`` 的用户(``_is_admin``)可 list ``scope=all``
(所有账号的全部链条),并可 get / 编辑 / 发布 / 删除**任意** owner 的链条;非管理员
仍仅限本人链条(``scope=all`` 退化为 ``mine``)。编辑/删除不转移链条归属(``user_id`` 不变)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -131,6 +135,18 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id() 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: async def _enrich_owner_names(rows: list[dict]) -> None:
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。 """把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
@@ -269,13 +285,18 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
``scope`` 查询参数(筛选): ``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条; - ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。 - ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨);
- ``all``:所有账号的全部链条(**仅管理员**;非管理员请求 ``all`` 退化为 ``mine``)。
其余值按 ``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) store = _get_store(request)
user_id = _current_user_id(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) await _enrich_owner_names(rows)
return ChainListResponse(chains=[ChainResponse(**r) for r in 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: async def get_chain(request: Request, chain_id: str) -> ChainResponse:
store = _get_store(request) store = _get_store(request)
user_id = _current_user_id(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: if row is None:
raise HTTPException(status_code=404, detail="Chain not found") raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row]) 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) user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields. # Distinguish "omitted" from "explicitly sent"; only forward provided fields.
fields = body.model_dump(exclude_unset=True) 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: if row is None:
raise HTTPException(status_code=404, detail="Chain not found") raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row]) 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: async def delete_chain(request: Request, chain_id: str) -> None:
store = _get_store(request) store = _get_store(request)
user_id = _current_user_id(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: if not deleted:
raise HTTPException(status_code=404, detail="Chain not found") raise HTTPException(status_code=404, detail="Chain not found")
@@ -70,36 +70,41 @@ class RoundtableChainRepository:
} }
async def list_chains( 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]]: ) -> list[dict[str, Any]]:
"""列出链条。 """列出链条。
``scope``: ``scope``:
- ``"mine"``(默认):仅当前用户自己的链条; - ``"mine"``(默认):仅当前用户自己的链条;
- ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的。 - ``"public"``:所有**已发布**(``is_public=True``)链条,含本人发布的;
- ``"all"``:所有账号的全部链条(**仅管理员**,由路由层校验后传 ``is_admin=True``;
非管理员误传则按 ``"mine"`` 退化,绝不越权)。
其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。 其余值按 ``"mine"`` 处理。``is_mine`` 标记每条是否本人所有,供前端判断可否编辑。
""" """
if scope == "public": if scope == "public":
cond = RoundtableChainRow.is_public.is_(True) cond = RoundtableChainRow.is_public.is_(True)
elif scope == "all" and is_admin:
cond = None
else: else:
cond = RoundtableChainRow.user_id == user_id cond = RoundtableChainRow.user_id == user_id
stmt = ( stmt = select(RoundtableChainRow)
select(RoundtableChainRow) if cond is not None:
.where(cond) stmt = stmt.where(cond)
.order_by(RoundtableChainRow.updated_at.desc()) stmt = stmt.order_by(RoundtableChainRow.updated_at.desc()).limit(limit)
.limit(limit)
)
async with self._sf() as session: async with self._sf() as session:
result = await session.execute(stmt) result = await session.execute(stmt)
return [self._to_dict(row, user_id) 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: async def get_chain(
"""取一条链条:owner 可见,**已发布的公共链条任何人可见**(供选入研讨)。""" 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: async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id) row = await session.get(RoundtableChainRow, chain_id)
if row is None: if row is None:
return 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 None
return self._to_dict(row, user_id) return self._to_dict(row, user_id)
@@ -140,10 +145,12 @@ class RoundtableChainRepository:
seat_mode=_UNSET, seat_mode=_UNSET,
gather_first=_UNSET, gather_first=_UNSET,
is_public=_UNSET, is_public=_UNSET,
is_admin: bool = False,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id) 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 return None
if title is not _UNSET and title is not None: if title is not _UNSET and title is not None:
row.title = str(title).strip() row.title = str(title).strip()
@@ -168,10 +175,11 @@ class RoundtableChainRepository:
await session.refresh(row) await session.refresh(row)
return self._to_dict(row, user_id) 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: async with self._sf() as session:
row = await session.get(RoundtableChainRow, chain_id) 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 return False
await session.delete(row) await session.delete(row)
await session.commit() await session.commit()