Merge branch 'maoyuanyang-main'

# Conflicts:
#	hub/filelib-web/src/lib/GrantsPanel.svelte
#	hub/filelib-web/src/lib/OverviewPanel.svelte
#	hub/filelib-web/src/lib/types.ts
#	hub/src/database/filelib/grantService.ts
This commit is contained in:
2026-08-03 15:35:44 +08:00
37 changed files with 1926 additions and 273 deletions
+4
View File
@@ -19,6 +19,10 @@ export const FILE_LIB_AUDIT_ACTIONS = {
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
// ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
folderRestore: "folder.restore",
projectRestore: "project.restore",
nodePurge: "node.purge",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
+201
View File
@@ -0,0 +1,201 @@
/**
* 回收站(ADR-0031)。
*
* 列出:deletedAt != null 且**祖先全活跃**的节点(每支已删子树只露顶)。
* 可见性:网站管理员,或在该已删节点上持活跃 MANAGE grant(直连 grant,
* 不走继承 —— 回收站是管理面,不是浏览面)。
* 恢复:只清本节点 deletedAt(与 D15 删除对称),整支立即可见,落审计。
* 彻底删除:仅网站管理员;按 pathIds 物化路径枚举子树,**自最深一层逐批
* 向上删**(self-FK 是 ON DELETE RESTRICT,一次 deleteMany 不保证顺序),
* 同事务一条 node.purge 审计。
*/
import type { PrismaClient } from "@prisma/client";
import { FileLibError, nameKey } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { GroupResolver } from "./groupResolver.js";
import type { FileLibActor } from "./treeService.js";
export interface BinDeps {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly groupResolver: GroupResolver;
}
export interface BinEntryDto {
readonly id: string;
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly deletedAt: Date;
}
/** actor 对 node 是否可见(管理员,或节点上的直连 MANAGE —— USER 或其已解析组)。 */
async function canSeeEntry(
tx: Pick<PrismaClient, "fileLibGrant">,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<boolean> {
if (actor.isWebsiteAdmin) return true;
const grant = await tx.fileLibGrant.findFirst({
where: {
organizationId: deps.organizationId,
nodeId,
revokedAt: null,
role: "MANAGE",
OR: [
{ principalType: "USER", principalId: actor.userId },
...(groupIds.length > 0
? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
: []),
],
},
select: { id: true },
});
return grant !== null;
}
/** 列出回收站(祖先全活跃的已删节点顶)。 */
export async function listBin(deps: BinDeps, actor: FileLibActor): Promise<readonly BinEntryDto[]> {
const deleted = await deps.prisma.fileLibNode.findMany({
where: { organizationId: deps.organizationId, deletedAt: { not: null } },
orderBy: { deletedAt: "desc" },
});
if (deleted.length === 0) return [];
// 祖先活跃性:收集所有 pathIds 里的祖先段,查哪些已删,做集合判定。
const ancestorIds = new Set<string>();
for (const n of deleted) {
const segments = n.pathIds.split("/").filter((s) => s !== "" && s !== n.id);
for (const s of segments) ancestorIds.add(s);
}
const deletedAncestorIds = new Set(
(
await deps.prisma.fileLibNode.findMany({
where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
select: { id: true },
})
).map((r) => r.id),
);
const tops = deleted.filter(
(n) => !n.pathIds.split("/").filter((s) => s !== "" && s !== n.id).some((s) => deletedAncestorIds.has(s)),
);
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const out: BinEntryDto[] = [];
for (const n of tops) {
if (await canSeeEntry(deps.prisma, deps, actor, groupIds, n.id)) {
out.push({ id: n.id, parentId: n.parentId, kind: n.kind, name: n.name, deletedAt: n.deletedAt! });
}
}
return out;
}
/** 取回收站条目并做可见性门禁(D8:不可见即 404)。 */
async function requireBinEntry(
tx: PrismaClient,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<{ readonly id: string; readonly parentId: string | null; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) {
throw new FileLibError(404, "node_not_found", "node not found");
}
return { id: node.id, parentId: node.parentId, kind: node.kind, name: node.name, pathIds: node.pathIds };
}
export interface RestoreResult {
readonly name: string;
}
/**
* 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。
* ADR-0033:与活跃兄弟撞名时不失败,自动改成「原名(已恢复[/ N])」——
* 恢复的意义就是找回,撞名死锁不是保护;审计 detail 记 renamedFrom。
*/
export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<RestoreResult> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return deps.prisma.$transaction(async (tx) => {
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
const clash = await tx.fileLibNode.findFirst({
where: {
organizationId: deps.organizationId,
parentId: node.parentId,
deletedAt: null,
id: { not: node.id },
nameLower: nameKey(node.name),
},
select: { id: true },
});
if (clash !== null) {
throw new FileLibError(409, "name_conflict_on_restore", "name conflict on restore");
}
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } });
await writeFileLibAudit(tx, {
action: node.kind === "PROJECT"
? FILE_LIB_AUDIT_ACTIONS.projectRestore
: FILE_LIB_AUDIT_ACTIONS.folderRestore,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name },
});
return { name: node.name };
});
}
/**
* 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE;
* 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举,
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
* 父行必须晚于全部子孙行删除。
*/
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return deps.prisma.$transaction(async (tx) => {
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
const subtree = await tx.fileLibNode.findMany({
where: {
organizationId: deps.organizationId,
OR: [{ id: node.id }, { pathIds: { startsWith: `${node.pathIds}/` } }],
},
select: { id: true, pathIds: true },
});
const depthOf = (p: string): number => p.split("/").filter((s) => s !== "").length;
const byDepthDesc = [...subtree].sort((a, b) => depthOf(b.pathIds) - depthOf(a.pathIds));
let removed = 0;
let cursor = 0;
while (cursor < byDepthDesc.length) {
const depth = depthOf(byDepthDesc[cursor]!.pathIds);
const batch: string[] = [];
while (cursor < byDepthDesc.length && depthOf(byDepthDesc[cursor]!.pathIds) === depth) {
batch.push(byDepthDesc[cursor]!.id);
cursor += 1;
}
removed += (await tx.fileLibNode.deleteMany({ where: { id: { in: batch } } })).count;
}
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name, removed },
});
return { removed };
});
}
+37 -50
View File
@@ -25,13 +25,10 @@ export interface GrantDto {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
/**
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
*/
readonly principalName: string;
/** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */
readonly principalName: string | null;
/** USER 主体的飞书 openId;GROUP 或主体已删时为 null。 */
readonly principalOpenId: string | null;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
readonly createdAt: Date;
@@ -42,13 +39,42 @@ function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
id: grant.id,
principalType: grant.principalType,
principalId: grant.principalId,
principalName: principalName ?? grant.principalId,
principalName: null,
principalOpenId: null,
role: grant.role,
isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt,
};
}
/** 批量回填主体显示名与飞书 openId(两次查询,不做 per-row 往返)。可在事务内调用。 */
async function withPrincipalNames(
prisma: Pick<PrismaClient, "user" | "memberGroup">,
grants: readonly GrantDto[],
): Promise<readonly GrantDto[]> {
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
const users = userIds.length === 0
? []
: await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, displayName: true, feishuOpenId: true },
});
const groups = groupIds.length === 0
? []
: await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } });
const nameById = new Map<string, string>([
...users.map((u) => [u.id, u.displayName] as const),
...groups.map((g) => [g.id, g.name] as const),
]);
const openIdById = new Map<string, string>(users.map((u) => [u.id, u.feishuOpenId] as const));
return grants.map((g) => ({
...g,
principalName: nameById.get(g.principalId) ?? null,
principalOpenId: g.principalType === "USER" ? openIdById.get(g.principalId) ?? null : null,
}));
}
type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient };
@@ -103,7 +129,7 @@ export async function listGrants(
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return toDtosWithNames(deps.prisma, grants);
return withPrincipalNames(deps.prisma, grants.map(toDto));
}
export interface PutGrantsResult {
@@ -174,7 +200,7 @@ export async function putGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
});
}
@@ -273,46 +299,7 @@ export async function forceAdjustGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
});
}
/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
export async function setIndependentPermission(
deps: Deps,
actor: FileLibActor,
nodeId: string,
enabled: boolean,
): Promise<{ readonly enabled: boolean }> {
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
}
const current = await tx.fileLibProjectSettings.findUnique({
where: { nodeId: node.id },
select: { independentPermissionsEnabled: true },
});
if ((current?.independentPermissionsEnabled ?? false) === enabled) {
return { enabled }; // 状态未变:空操作,不产生审计
}
await tx.fileLibProjectSettings.upsert({
where: { nodeId: node.id },
update: { independentPermissionsEnabled: enabled },
create: { nodeId: node.id, independentPermissionsEnabled: enabled },
});
await writeFileLibAudit(tx, {
action: enabled
? FILE_LIB_AUDIT_ACTIONS.independentEnable
: FILE_LIB_AUDIT_ACTIONS.independentDisable,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "project",
objectId: node.id,
objectPath: node.pathIds,
detail: { enabled },
});
return { enabled };
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
});
}
+3 -9
View File
@@ -1,5 +1,5 @@
/**
* 纯权限 reducer(契约 P6 / D11 / D8)。
* 纯权限 reducer(契约 P6 / D8)。
*
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
@@ -23,8 +23,6 @@ export interface EffectiveRoleInput {
readonly nodeKind: "FOLDER" | "PROJECT";
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
readonly ancestorIds: readonly string[];
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
readonly independentPermissionsEnabled: boolean;
readonly userId: string;
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
readonly groupIds: readonly string[];
@@ -37,20 +35,16 @@ export interface EffectiveRoleInput {
* r ∈ {R} ancestors(R) };无匹配 → null(无任何权限)。
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
*
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
* grant 不受开关影响。
* 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除,
* FileLibProjectSettings 不再被读取。
*/
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
const groups = new Set(input.groupIds);
const freezeProjectGrants =
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
let best: FileLibRole | null = null;
for (const grant of input.grants) {
if (!onChain.has(grant.nodeId)) continue;
if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
+5 -33
View File
@@ -99,7 +99,7 @@ async function loadVisibleChain(
return { node, ancestors: ordered };
}
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */
async function resolveRole(
tx: Tx,
deps: AccessDeps,
@@ -111,20 +111,11 @@ async function resolveRole(
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
let independentPermissionsEnabled = false;
if (chain.node.kind === "PROJECT") {
const settings = await tx.fileLibProjectSettings.findUnique({
where: { nodeId: chain.node.id },
select: { independentPermissionsEnabled: true },
});
independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
}
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return effectiveRole({
nodeId: chain.node.id,
nodeKind: chain.node.kind,
ancestorIds: chain.ancestors.map((a) => a.id),
independentPermissionsEnabled,
userId: actor.userId,
groupIds,
grants,
@@ -285,11 +276,6 @@ export async function createNode(
},
});
}
if (input.kind === "PROJECT") {
await tx.fileLibProjectSettings.create({
data: { nodeId: id, independentPermissionsEnabled: false },
});
}
await writeFileLibAudit(tx, {
action: nodeAction(input.kind, "Create"),
@@ -465,10 +451,12 @@ export async function getEffectiveRole(
export interface BreadcrumbEntry {
readonly depth: number;
/** D17:无 View 的祖先 id/name 为 null(不泄露)。 */
/** D17:无 View 的祖先 id/name 为 null(不泄露)。 */
readonly id: string | null;
readonly name: string | null;
readonly kind: "FOLDER" | "PROJECT";
/** 该节点对调用者的 effective role;无 View 为 null。 */
readonly role: FileLibRole | null;
}
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
@@ -490,20 +478,12 @@ export async function breadcrumb(
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
const settings = chain.node.kind === "PROJECT"
? await tx.fileLibProjectSettings.findUnique({
where: { nodeId: chain.node.id },
select: { independentPermissionsEnabled: true },
})
: null;
return chainNodes.map((current, depth) => {
const role = effectiveRole({
nodeId: current.id,
nodeKind: current.kind,
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
independentPermissionsEnabled:
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
userId: actor.userId,
groupIds,
grants: allGrants,
@@ -514,6 +494,7 @@ export async function breadcrumb(
id: visible ? current.id : null,
name: visible ? current.name : null,
kind: current.kind,
role,
};
});
});
@@ -553,14 +534,6 @@ export async function listChildren(
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
const settingsRows = projectIds.length === 0
? []
: await tx.fileLibProjectSettings.findMany({
where: { nodeId: { in: projectIds } },
select: { nodeId: true, independentPermissionsEnabled: true },
});
const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const out: ChildNodeDto[] = [];
@@ -569,7 +542,6 @@ export async function listChildren(
nodeId: child.id,
nodeKind: child.kind,
ancestorIds: parentAncestorIds,
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
userId: actor.userId,
groupIds,
grants: allGrants,
+44
View File
@@ -0,0 +1,44 @@
/**
* /database/api/bin/* 回收站端点(ADR-0031)。
* 约定:绝对路径;actorOrNull 前置;业务全走 binService;错误统一 sendRouteError。
*/
import type { FastifyInstance } from "fastify";
import { listBin, purgeBinEntry, restoreBinEntry } from "../filelib/binService.js";
import { actorOrNull, sendRouteError, type FileLibRouteDeps } from "../filelib/routeShared.js";
export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
app.get("/database/api/bin", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
return { entries: await listBin(svc, actor) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/bin/:id/restore", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return await restoreBinEntry(svc, actor, id);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/bin/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return await purgeBinEntry(svc, actor, id);
} catch (error) {
return sendRouteError(reply, error);
}
});
}
@@ -28,6 +28,7 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerBinRoutes } from "./binRoutes.js";
import { registerTeacherApp } from "./teacherApp.js";
import { createGitVersionStore } from "../filelib/gitVersionStore.js";
import { resolveMaxFileBytes } from "../filelib/fileService.js";
@@ -166,6 +167,7 @@ export async function registerDatabaseRoutes(
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerBinRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
-20
View File
@@ -21,7 +21,6 @@ import {
listGrants,
putGrants,
revokeGrant,
setIndependentPermission,
} from "../filelib/grantService.js";
import { FileLibError } from "../filelib/model.js";
import {
@@ -114,9 +113,6 @@ export async function registerFileLibRoutes(
where: { id, organizationId: deps.organizationId },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
const settings = node.kind === "PROJECT"
? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } })
: null;
return {
node: {
id: node.id,
@@ -126,7 +122,6 @@ export async function registerFileLibRoutes(
description: node.description,
role,
provisionStatus: node.provisionStatus,
independentPermission: settings?.independentPermissionsEnabled ?? false,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
},
@@ -258,21 +253,6 @@ export async function registerFileLibRoutes(
}
});
app.put("/database/api/projects/:id/independent-permission", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
if (typeof body["enabled"] !== "boolean") {
throw new FileLibError(400, "invalid_request", "enabled must be a boolean");
}
return await setIndependentPermission(grantDeps, actor, id, body["enabled"]);
} catch (error) {
return sendRouteError(reply, error);
}
});
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
}