fix(filelib): 授权表与侧栏展示 displayName 而非裸 userId

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 / 组名 / 已删主体回落)。
This commit is contained in:
2026-07-27 15:56:07 +08:00
parent 82241afb56
commit 4849a765da
5 changed files with 85 additions and 23 deletions
+41 -4
View File
@@ -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<ReadonlyMap<string, string>> {
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<string, string>();
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<readonly GrantDto[]> {
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) };
});
}