From 4849a765da54ef47ddf041685de98ec7ae7aba07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Mon, 27 Jul 2026 15:56:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(filelib):=20=E6=8E=88=E6=9D=83=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E4=BE=A7=E6=A0=8F=E5=B1=95=E7=A4=BA=20displayName=20?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=E8=A3=B8=20userId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrantDto 加 principalName:USER → User.displayName,GROUP → MemberGroup.name, 取不到行(用户/组已删)时回落为 principalId,与 /database/api/me 同一回落语义。 解析走批量 helper(两条 IN 查询,非 N+1),listGrants/putGrants/forceAdjustGrants 三个出口共用,保证 GET 与 PUT 响应同形状。组不按 archivedAt 过滤 —— 已归档组的 历史授权仍需显示名字,否则管理员无法辨认后收回。 principalName 是纯展示字段;写路径仍只认 principalId,不得据此做授权判断。 前端: - GrantsPanel 主体列由裸 id 改为展示名,id 移入 title 供排查;收回确认框同步。 - LibraryView 侧栏身份区改用 $me.displayName(/me 早已返回,此前未消费)。 - types.ts 去掉重复声明的 Grant 与无引用的 GroupSearchResult。 集成测试断言三种情形(displayName / 组名 / 已删主体回落)。 --- hub/filelib-web/src/lib/GrantsPanel.svelte | 5 ++- hub/filelib-web/src/lib/LibraryView.svelte | 5 ++- hub/filelib-web/src/lib/types.ts | 17 +------- hub/src/database/filelib/grantService.ts | 45 +++++++++++++++++++-- hub/test/integration/filelib-routes.test.ts | 36 +++++++++++++++++ 5 files changed, 85 insertions(+), 23 deletions(-) diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index 3199707..f2e6301 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -84,7 +84,7 @@ } async function revoke(g: Grant): Promise { - if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return; + if (!confirm(`收回「${g.principalName}」的 ${g.role} 授权?`)) return; try { await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, { method: "DELETE", @@ -133,7 +133,8 @@ - {g.principalId} + + {g.principalName} {#if g.isCreatorGrant}(创建者){/if} diff --git a/hub/filelib-web/src/lib/LibraryView.svelte b/hub/filelib-web/src/lib/LibraryView.svelte index 5a65257..8babc96 100644 --- a/hub/filelib-web/src/lib/LibraryView.svelte +++ b/hub/filelib-web/src/lib/LibraryView.svelte @@ -63,7 +63,7 @@ } } - const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase()); + const initial = $derived((($me?.displayName ?? $me?.userId) ?? "U").slice(0, 1).toUpperCase());
@@ -97,7 +97,8 @@ {#if showUserFooter}
{initial}
- {$me?.userId ?? ""} + + {$me?.displayName ?? ""}
{/if} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index f0bffd6..d39b00b 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -72,21 +72,6 @@ export interface ExportJob { readonly createdAt: string; } -export interface Grant { - readonly id: string; - readonly principalType: "USER" | "GROUP"; - readonly principalId: string; - readonly role: Role; - readonly isCreatorGrant: boolean; - readonly createdAt: string; -} - -export interface GroupSearchResult { - readonly id: string; - readonly name: string; - readonly breadcrumb: string; -} - /** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */ export interface MemberGroupNode { readonly id: string; @@ -114,6 +99,8 @@ export interface Grant { readonly id: string; readonly principalType: "USER" | "GROUP"; readonly principalId: string; + /** 后端解析好的展示名(USER→displayName / GROUP→组名);取不到行时回落为 principalId。 */ + readonly principalName: string; readonly role: Role; /** 创建者授权不可收回、不可改(契约 8.1)。 */ readonly isCreatorGrant: boolean; diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index cbb9f05..e443004 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -25,16 +25,24 @@ 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; readonly role: FileLibRole; readonly isCreatorGrant: boolean; readonly createdAt: Date; } -function toDto(grant: FileLibGrant): GrantDto { +function toDto(grant: FileLibGrant, principalName?: string): GrantDto { return { id: grant.id, principalType: grant.principalType, principalId: grant.principalId, + principalName: principalName ?? grant.principalId, role: grant.role, isCreatorGrant: grant.isCreatorGrant, createdAt: grant.createdAt, @@ -44,6 +52,35 @@ function toDto(grant: FileLibGrant): GrantDto { type Tx = Prisma.TransactionClient; type Deps = AccessDeps & { readonly prisma: PrismaClient }; +/** + * 批量解析 principal 展示名(两条 IN 查询,不做 N+1)。 + * 组不按 archivedAt 过滤:已归档组的历史授权仍要能显示出名字来给管理员收回。 + */ +async function resolvePrincipalNames( + tx: Tx | PrismaClient, + grants: readonly FileLibGrant[], +): Promise> { + 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, groups] = await Promise.all([ + userIds.length === 0 + ? Promise.resolve([]) + : tx.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }), + groupIds.length === 0 + ? Promise.resolve([]) + : tx.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }), + ]); + const names = new Map(); + for (const u of users) names.set(`USER:${u.id}`, u.displayName); + for (const g of groups) names.set(`GROUP:${g.id}`, g.name); + return names; +} + +async function toDtosWithNames(tx: Tx | PrismaClient, grants: readonly FileLibGrant[]): Promise { + const names = await resolvePrincipalNames(tx, grants); + return grants.map((g) => toDto(g, names.get(`${g.principalType}:${g.principalId}`))); +} + /** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */ async function requireManage( deps: Deps, @@ -66,7 +103,7 @@ export async function listGrants( where: { organizationId: deps.organizationId, nodeId, revokedAt: null }, orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], }); - return grants.map(toDto); + return toDtosWithNames(deps.prisma, grants); } export interface PutGrantsResult { @@ -137,7 +174,7 @@ export async function putGrants( where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], }); - return { granted, updated, grants: grants.map(toDto) }; + return { granted, updated, grants: await toDtosWithNames(tx, grants) }; }); } @@ -236,7 +273,7 @@ export async function forceAdjustGrants( where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], }); - return { granted, updated, grants: grants.map(toDto) }; + return { granted, updated, grants: await toDtosWithNames(tx, grants) }; }); } diff --git a/hub/test/integration/filelib-routes.test.ts b/hub/test/integration/filelib-routes.test.ts index b813cf9..ea2a2c5 100644 --- a/hub/test/integration/filelib-routes.test.ts +++ b/hub/test/integration/filelib-routes.test.ts @@ -150,6 +150,42 @@ describe("filelib http · 8.1 授权矩阵", () => { expect(revoke.statusCode).toBe(403); expect(revoke.json().error.code).toBe("cannot_touch_creator"); }); + + it("grants 返回 principalName:USER→displayName / GROUP→组名;取不到行时回落为 id", async () => { + const rootId = await createRoot(ADMIN_COOKIE()); + const group = await prisma.memberGroup.create({ data: { name: "物理组" } }); + + const put = await app.inject({ + method: "PUT", url: `/database/api/nodes/${rootId}/grants`, + headers: { cookie: ADMIN_COOKIE() }, + payload: { + grants: [ + { principalType: "USER", principalId: "u_alice", role: "EDIT" }, + { principalType: "GROUP", principalId: group.id, role: "VIEW" }, + // 数据库里没有对应 User 行(已删/脏数据)→ 回落为 principalId + { principalType: "USER", principalId: "u_ghost", role: "VIEW" }, + ], + }, + }); + expect(put.statusCode).toBe(200); + + const list = await app.inject({ + method: "GET", url: `/database/api/nodes/${rootId}/grants`, + headers: { cookie: ADMIN_COOKIE() }, + }); + expect(list.statusCode).toBe(200); + const byPrincipal = new Map( + list.json().grants.map((g: { principalId: string; principalName: string }) => [g.principalId, g.principalName]), + ); + expect(byPrincipal.get("u_alice")).toBe("Alice"); + expect(byPrincipal.get(group.id)).toBe("物理组"); + expect(byPrincipal.get("u_ghost")).toBe("u_ghost"); + // 创建者授权也要解析出名字 + const creator = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant); + expect(creator.principalName).toBe("Admin"); + // PUT 响应与 GET 同形状 + expect(put.json().grants.every((g: { principalName?: string }) => typeof g.principalName === "string")).toBe(true); + }); }); describe("filelib http · 文件冲突流", () => {