+
最近打开
+
+ {#if error !== null}
+
{error}
+ {:else if entries === null}
+
加载中…
+ {:else if entries.length === 0}
+
还没有访问记录 · 去文件库逛逛
+ {:else}
+
+ {#each entries as e (e.nodeId + "/" + e.filePath)}
+ onopen({ nodeId: e.nodeId, ...(e.filePath !== "" ? { filePath: e.filePath } : {}) })}
+ >
+
+
+ {e.name}
+ {#if e.filePath !== ""}
+ {e.filePath}
+ {/if}
+
+ {fmt(e.openedAt)}
+
+ {/each}
+
+ {/if}
+
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts
index 74f472b..29a0e8f 100644
--- a/hub/filelib-web/src/lib/types.ts
+++ b/hub/filelib-web/src/lib/types.ts
@@ -18,6 +18,8 @@ export interface BreadcrumbEntry {
readonly id: string | null;
readonly name: string | null;
readonly kind: NodeKind;
+ /** 该节点对调用者的 effective role;无 View 为 null。 */
+ readonly role: Role | null;
}
export interface NodeDetail {
@@ -130,9 +132,27 @@ export interface UserSearchResult {
readonly avatarUrl: string | null;
}
+/** 最近打开条目(GET /database/api/recent)。 */
+export interface RecentEntry {
+ readonly nodeId: string;
+ readonly kind: NodeKind;
+ readonly name: string;
+ /** "" = 节点本身;非空 = 项目内文件路径。 */
+ readonly filePath: string;
+ readonly openedAt: string;
+}
+
+/** 回收站条目(GET /database/api/bin)。 */
+export interface BinEntry {
+ readonly id: string;
+ readonly parentId: string | null;
+ readonly kind: NodeKind;
+ readonly name: string;
+ readonly deletedAt: string;
+}
+
/** 管理后台概览统计(GET /database/api/stats)。 */
-export interface DashboardStats {
- readonly folders: number;
+export interface DashboardStats { readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte
index 30bb9bf..ac106b0 100644
--- a/hub/filelib-web/src/routes/app/+page.svelte
+++ b/hub/filelib-web/src/routes/app/+page.svelte
@@ -1,12 +1,30 @@
-
+
+
+
+
+ {#if view === "library"}
+ (navTarget = null)} />
+ {:else if view === "recent"}
+
+ {:else}
+
+ {/if}
{:else}
diff --git a/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql b/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql
new file mode 100644
index 0000000..124ca09
--- /dev/null
+++ b/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql
@@ -0,0 +1,65 @@
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx";
+
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedCode_trgm_idx";
+
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedName_trgm_idx";
+
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx";
+
+-- DropIndex
+DROP INDEX "Team_archivedAt_idx";
+
+-- AlterTable
+ALTER TABLE "ExternalDirectoryConnection" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "Organization" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "ProjectSearchDocument" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- CreateTable
+CREATE TABLE "FileLibRecentVisit" (
+ "id" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "nodeId" TEXT NOT NULL,
+ "filePath" TEXT NOT NULL DEFAULT '',
+ "openedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "FileLibRecentVisit_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "FileLibRecentVisit_organizationId_userId_openedAt_idx" ON "FileLibRecentVisit"("organizationId", "userId", "openedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "FileLibRecentVisit_organizationId_userId_nodeId_filePath_key" ON "FileLibRecentVisit"("organizationId", "userId", "nodeId", "filePath");
+
+-- RenameForeignKey
+ALTER TABLE "OrganizationFeishuApplicationConnection" RENAME CONSTRAINT "OrganizationFeishuApplicationConnection_activeSecretVersionId_f" TO "OrganizationFeishuApplicationConnection_activeSecretVersio_fkey";
+
+-- RenameIndex
+ALTER INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt" RENAME TO "ExternalPrincipalMembership_principalType_principalId_revok_idx";
+
+-- RenameIndex
+ALTER INDEX "ExternalPrincipalMembership_userId_principalType_principalId_co" RENAME TO "ExternalPrincipalMembership_userId_principalType_principalI_key";
+
+-- RenameIndex
+ALTER INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder" RENAME TO "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortO_idx";
+
+-- RenameIndex
+ALTER INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_ke" RENAME TO "OrganizationCapabilityConnection_organizationId_capabilityI_key";
+
+-- RenameIndex
+ALTER INDEX "OrganizationFeishuApplicationConnection_activeSecretVersionId_k" RENAME TO "OrganizationFeishuApplicationConnection_activeSecretVersion_key";
+
+-- RenameIndex
+ALTER INDEX "OrganizationFeishuApplicationConnection_appIdentityFingerprint_" RENAME TO "OrganizationFeishuApplicationConnection_appIdentityFingerpr_key";
+
+-- RenameIndex
+ALTER INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_" RENAME TO "TeamExternalBinding_teamId_principalType_principalId_revoke_key";
diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma
index 0779fa9..ffd6828 100644
--- a/hub/prisma/schema.prisma
+++ b/hub/prisma/schema.prisma
@@ -1110,3 +1110,18 @@ model FileLibExportJob {
@@index([organizationId, status])
}
+
+/// ADR-0031:最近打开。客户端在成功打开后上报;filePath="" 表示节点本身
+/// (文件夹/项目),非空表示项目内文件预览(PG 唯一索引视 NULL 互不相同,
+/// 故用空串而非 null)。名称读取时 join FileLibNode 实时取,不做冗余。
+model FileLibRecentVisit {
+ id String @id @default(cuid())
+ organizationId String
+ userId String
+ nodeId String
+ filePath String @default("")
+ openedAt DateTime
+
+ @@unique([organizationId, userId, nodeId, filePath])
+ @@index([organizationId, userId, openedAt])
+}
diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts
index fe01d09..56f1267 100644
--- a/hub/src/database/filelib/audit.ts
+++ b/hub/src/database/filelib/audit.ts
@@ -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",
diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts
new file mode 100644
index 0000000..2cb20cc
--- /dev/null
+++ b/hub/src/database/filelib/binService.ts
@@ -0,0 +1,181 @@
+/**
+ * 回收站(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 } 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
,
+ deps: BinDeps,
+ actor: FileLibActor,
+ groupIds: readonly string[],
+ nodeId: string,
+): Promise {
+ 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 {
+ 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();
+ 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 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, kind: node.kind, name: node.name, pathIds: node.pathIds };
+}
+
+/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */
+export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise {
+ const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
+ await deps.prisma.$transaction(async (tx) => {
+ const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
+ 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 },
+ });
+ });
+}
+
+/**
+ * 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举,
+ * 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
+ * 父行必须晚于全部子孙行删除。
+ */
+export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
+ if (!actor.isWebsiteAdmin) {
+ throw new FileLibError(404, "node_not_found", "node not found");
+ }
+ return deps.prisma.$transaction(async (tx) => {
+ 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");
+
+ 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 };
+ });
+}
diff --git a/hub/src/database/filelib/recentService.ts b/hub/src/database/filelib/recentService.ts
new file mode 100644
index 0000000..c494d94
--- /dev/null
+++ b/hub/src/database/filelib/recentService.ts
@@ -0,0 +1,106 @@
+/**
+ * 最近打开(ADR-0031)。
+ *
+ * 记录:客户端在成功打开后上报;node 需 VIEW(D8,无权即 404 不泄露);
+ * upsert 语义 —— 重复打开只刷新 openedAt。不写审计(按用户的读模型,
+ * 非权限敏感写)。
+ * 列表:本人最近 20 条,openedAt 倒序;节点已删或**任一祖先已删**的条目
+ * 过滤掉(D8/D15 可见性在每个面都成立);名称 join FileLibNode 实时取。
+ */
+
+import type { PrismaClient } from "@prisma/client";
+import { requireAccessInTx, type AccessDeps, type FileLibActor } from "./treeService.js";
+
+export type RecentDeps = AccessDeps & { readonly prisma: PrismaClient };
+
+export interface RecentEntryDto {
+ readonly nodeId: string;
+ readonly kind: "FOLDER" | "PROJECT";
+ readonly name: string;
+ /** "" = 节点本身;非空 = 项目内文件路径。 */
+ readonly filePath: string;
+ readonly openedAt: Date;
+}
+
+const RECENT_LIMIT = 20;
+
+export async function recordVisit(
+ deps: RecentDeps,
+ actor: FileLibActor,
+ nodeId: string,
+ filePath: string | undefined,
+): Promise {
+ const path = filePath ?? "";
+ await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, nodeId, "VIEW"));
+ await deps.prisma.fileLibRecentVisit.upsert({
+ where: {
+ organizationId_userId_nodeId_filePath: {
+ organizationId: deps.organizationId,
+ userId: actor.userId,
+ nodeId,
+ filePath: path,
+ },
+ },
+ update: { openedAt: new Date() },
+ create: {
+ organizationId: deps.organizationId,
+ userId: actor.userId,
+ nodeId,
+ filePath: path,
+ openedAt: new Date(),
+ },
+ });
+}
+
+export async function listRecent(deps: RecentDeps, actor: FileLibActor): Promise {
+ // 可见性过滤会丢弃一部分,超取再截断。
+ const rows = await deps.prisma.fileLibRecentVisit.findMany({
+ where: { organizationId: deps.organizationId, userId: actor.userId },
+ orderBy: { openedAt: "desc" },
+ take: RECENT_LIMIT * 3,
+ });
+ if (rows.length === 0) return [];
+
+ const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
+ const nodes = await deps.prisma.fileLibNode.findMany({
+ where: { id: { in: nodeIds } },
+ select: { id: true, kind: true, name: true, pathIds: true, deletedAt: true },
+ });
+ const byId = new Map(nodes.map((n) => [n.id, n]));
+
+ // 祖先活跃性:收集所有节点的祖先段,查已删集合。
+ const ancestorIds = new Set();
+ for (const n of nodes) {
+ for (const s of n.pathIds.split("/").filter((x) => x !== "" && x !== n.id)) ancestorIds.add(s);
+ }
+ const deletedAncestorIds = new Set(
+ ancestorIds.size === 0
+ ? []
+ : (
+ await deps.prisma.fileLibNode.findMany({
+ where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
+ select: { id: true },
+ })
+ ).map((r) => r.id),
+ );
+
+ const out: RecentEntryDto[] = [];
+ for (const row of rows) {
+ if (out.length >= RECENT_LIMIT) break;
+ const node = byId.get(row.nodeId);
+ if (node === undefined || node.deletedAt !== null) continue;
+ const hidden = node.pathIds
+ .split("/")
+ .filter((s) => s !== "" && s !== node.id)
+ .some((s) => deletedAncestorIds.has(s));
+ if (hidden) continue;
+ out.push({
+ nodeId: node.id,
+ kind: node.kind,
+ name: node.name,
+ filePath: row.filePath,
+ openedAt: row.openedAt,
+ });
+ }
+ return out;
+}
diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts
index 10322b4..3827cd1 100644
--- a/hub/src/database/filelib/treeService.ts
+++ b/hub/src/database/filelib/treeService.ts
@@ -443,10 +443,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 只留占位。 */
@@ -484,6 +486,7 @@ export async function breadcrumb(
id: visible ? current.id : null,
name: visible ? current.name : null,
kind: current.kind,
+ role,
};
});
});
diff --git a/hub/src/database/routes/binRoutes.ts b/hub/src/database/routes/binRoutes.ts
new file mode 100644
index 0000000..402503a
--- /dev/null
+++ b/hub/src/database/routes/binRoutes.ts
@@ -0,0 +1,45 @@
+/**
+ * /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 {
+ 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 };
+ await restoreBinEntry(svc, actor, id);
+ return reply.status(204).send();
+ } 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);
+ }
+ });
+}
diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts
index 9cfaf35..11b1ccb 100644
--- a/hub/src/database/routes/databaseRoutes.ts
+++ b/hub/src/database/routes/databaseRoutes.ts
@@ -28,6 +28,8 @@ 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 { registerRecentRoutes } from "./recentRoutes.js";
import { registerTeacherApp } from "./teacherApp.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
@@ -161,6 +163,8 @@ export async function registerDatabaseRoutes(
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
+ await registerBinRoutes(app, filelibDeps);
+ await registerRecentRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
diff --git a/hub/src/database/routes/recentRoutes.ts b/hub/src/database/routes/recentRoutes.ts
new file mode 100644
index 0000000..ebd6479
--- /dev/null
+++ b/hub/src/database/routes/recentRoutes.ts
@@ -0,0 +1,47 @@
+/**
+ * /database/api/recent 最近打开端点(ADR-0031)。
+ * 约定:绝对路径;actorOrNull 前置;业务全走 recentService;错误统一 sendRouteError。
+ */
+
+import type { FastifyInstance } from "fastify";
+import { listRecent, recordVisit } from "../filelib/recentService.js";
+import { FileLibError } from "../filelib/model.js";
+import {
+ actorOrNull,
+ bodyObject,
+ optionalString,
+ requireString,
+ sendRouteError,
+ type FileLibRouteDeps,
+} from "../filelib/routeShared.js";
+
+export async function registerRecentRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise {
+ const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
+
+ app.get("/database/api/recent", async (request, reply) => {
+ const actor = await actorOrNull(request, reply, deps);
+ if (actor === null) return reply;
+ try {
+ return { entries: await listRecent(svc, actor) };
+ } catch (error) {
+ return sendRouteError(reply, error);
+ }
+ });
+
+ app.post("/database/api/recent", async (request, reply) => {
+ const actor = await actorOrNull(request, reply, deps);
+ if (actor === null) return reply;
+ try {
+ const body = bodyObject(request.body);
+ const nodeId = requireString(body, "nodeId");
+ const filePath = optionalString(body, "filePath");
+ if (filePath !== undefined && filePath.trim() === "") {
+ throw new FileLibError(400, "invalid_request", "filePath must be non-empty when present");
+ }
+ await recordVisit(svc, actor, nodeId, filePath);
+ return reply.status(204).send();
+ } catch (error) {
+ return sendRouteError(reply, error);
+ }
+ });
+}
diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts
new file mode 100644
index 0000000..926da4e
--- /dev/null
+++ b/hub/test/integration/filelib-nav.test.ts
@@ -0,0 +1,163 @@
+/**
+ * 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。
+ * 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
+ * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 /
+ * upsert 刷新 / 删除与祖先删除的可见性过滤。
+ * 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
+import {
+ createNode,
+ softDeleteNode,
+ listChildren,
+ getEffectiveRole,
+ type FileLibActor,
+ type TreeServiceDeps,
+} from "../../src/database/filelib/treeService.js";
+import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js";
+import { listRecent, recordVisit, type RecentDeps } from "../../src/database/filelib/recentService.js";
+import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
+import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
+import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js";
+
+const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
+const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
+const BOB: FileLibActor = { userId: "u_bob", isWebsiteAdmin: false };
+
+function treeDeps(): TreeServiceDeps {
+ return {
+ prisma,
+ groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
+ versionStore: createInMemoryVersionStore(),
+ organizationId: DEFAULT_ORG_ID,
+ storageRoot: "/tmp/filelib-test",
+ };
+}
+
+function binDeps(): BinDeps {
+ return {
+ prisma,
+ organizationId: DEFAULT_ORG_ID,
+ groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
+ };
+}
+
+function recentDeps(): RecentDeps {
+ return {
+ prisma,
+ organizationId: DEFAULT_ORG_ID,
+ groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
+ };
+}
+
+beforeEach(async () => {
+ await resetDb();
+ for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
+ await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
+ }
+});
+
+describe("binService · 列出与可见性", () => {
+ it("只露每支已删子树的顶;管理员全见,直连 MANAGE 可见,无关者不见", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
+ await createNode(treeDeps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" });
+ // alice 在 child 上直连 MANAGE。
+ const own = await createNode(treeDeps(), ADMIN, {
+ parentId: null, kind: "PROJECT", name: "alice 项目",
+ grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
+ });
+
+ await softDeleteNode(treeDeps(), ADMIN, child.id); // 删中间层:child 是顶,孙项目不单列
+ await softDeleteNode(treeDeps(), ADMIN, own.id);
+
+ const adminBin = await listBin(binDeps(), ADMIN);
+ expect(adminBin.map((e) => e.name).sort()).toEqual(["alice 项目", "必修一"]);
+
+ const aliceBin = await listBin(binDeps(), ALICE);
+ expect(aliceBin.map((e) => e.name)).toEqual(["alice 项目"]); // 只见自己 MANAGE 的
+
+ const bobBin = await listBin(binDeps(), BOB);
+ expect(bobBin).toEqual([]);
+
+ void root;
+ });
+});
+
+describe("binService · 恢复", () => {
+ it("restore 只清本节点:整支立即可见,落 folder.restore 审计;无权者 404", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
+ await softDeleteNode(treeDeps(), ADMIN, child.id);
+
+ await expect(restoreBinEntry(binDeps(), BOB, child.id)).rejects.toMatchObject({ statusCode: 404 });
+ await restoreBinEntry(binDeps(), ADMIN, child.id);
+
+ const visible = await listChildren(treeDeps(), ADMIN, root.id);
+ expect(visible.map((c) => c.name)).toContain("必修一");
+
+ const audits = await prisma.auditEntry.findMany({
+ where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore },
+ });
+ expect(audits).toHaveLength(1);
+ });
+});
+
+describe("binService · 彻底删除", () => {
+ it("仅管理员;整支硬删(含子孙/授权),落 node.purge 审计", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, {
+ parentId: root.id, kind: "PROJECT", name: "TH-141",
+ grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
+ });
+ await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶
+
+ await expect(purgeBinEntry(binDeps(), ALICE, root.id)).rejects.toMatchObject({ statusCode: 404 });
+ const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
+ expect(removed).toBe(2);
+
+ expect(await prisma.fileLibNode.count({ where: { id: { in: [root.id, child.id] } } })).toBe(0);
+ expect(await prisma.fileLibGrant.count({ where: { nodeId: child.id } })).toBe(0);
+
+ const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
+ expect(audits).toHaveLength(1);
+ });
+});
+
+describe("recentService · 最近打开", () => {
+ it("上报需 VIEW(404);upsert 刷新 openedAt;删除/祖先删除的条目被过滤", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const proj = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141" });
+ const secret = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "秘密" });
+
+ // bob 对 secret 无 VIEW → 404
+ await expect(recordVisit(recentDeps(), BOB, secret.id, undefined)).rejects.toMatchObject({ statusCode: 404 });
+
+ // admin 上报:root、proj、proj 内文件
+ await recordVisit(recentDeps(), ADMIN, root.id, undefined);
+ await recordVisit(recentDeps(), ADMIN, proj.id, undefined);
+ await recordVisit(recentDeps(), ADMIN, proj.id, "讲义/第一章.md");
+
+ let entries = await listRecent(recentDeps(), ADMIN);
+ expect(entries).toHaveLength(3);
+ expect(entries.map((e) => e.filePath)).toContain("讲义/第一章.md");
+
+ // upsert:重复打开 root 只刷新,不新增
+ await recordVisit(recentDeps(), ADMIN, root.id, undefined);
+ entries = await listRecent(recentDeps(), ADMIN);
+ expect(entries).toHaveLength(3);
+ expect(entries[0]!.nodeId).toBe(root.id); // 最新在前
+
+ // 删祖先 → 整支条目消失
+ await softDeleteNode(treeDeps(), ADMIN, root.id);
+ entries = await listRecent(recentDeps(), ADMIN);
+ expect(entries).toEqual([]);
+
+ // 恢复后重新可见
+ await restoreBinEntry(binDeps(), ADMIN, root.id);
+ entries = await listRecent(recentDeps(), ADMIN);
+ expect(entries).toHaveLength(3);
+ expect(await getEffectiveRole(treeDeps(), ADMIN, proj.id)).toBe("MANAGE");
+ });
+});