Compare commits

..

15 Commits

Author SHA1 Message Date
8a13e455fb chore: 删除 .omo/ 与文件库-接口契约.md
.omo/ 下 12 个 run-continuation/ses_*.json 是 agent 会话续跑状态,
机器生成,本不该进版本库;文件库-开工计划.md 一并删除。

《文件库-接口契约.md》(C/D 编号)同时删除。两份文档的内容都可从 git
历史取回。

代码注释里的 C/D 编号(契约 8.1、C2、C4、D11–D19 等)因此不再有在库
文档可查,分布在 filelib 的 model / grantService / treeService /
guards、prisma schema 与迁移、以及 ADR-0028。README 原先按路径引用
这两份文档,现改为说明出处与取回方式。
2026-07-26 20:32:38 +08:00
6990082247 build(deploy): 部署与限流配置切换到 filelib-web,并加共存回归测试
三处引用旧工程名/旧资源路径的地方一并更新,它们必须同时改 —— 少改一处
就是静默故障,而不是构建期报错:

1. 部署脚本(deploy_platform.sh / deploy_fleet_release.sh):npm ci 的
   prefix、rsync 排除项、构建产物存在性检查从 database-admin 换成
   filelib-web。最后一项是真门禁:static.ts 缺产物时只 warn 不注册路由,
   漏改会让 /app 与 /database 静默 404 —— 恰是 database-admin 长期处于
   禁用状态的原因。

2. silo 限流豁免:资源路径随 appDir 改名而变(/database/_app/* 已不存在,
   现为 /_filelib/*);/app/* 此前不在豁免列表,它现在也是 SPA 外壳,
   客户端路由无法预先枚举。
   注:/database/* 是整体豁免,filelib 的 JSON API 也绕过限流预算。这是
   迁移前就有的行为,原样保留,但覆盖面因多了 /app/* 而变宽。

3. 回归测试:把 registerStaticSpa 与 registerDatabaseSpa 挂到同一个
   Fastify 实例,断言 ready() 不因重复路由抛错 —— appDir 若用回默认的
   _app,这里会红(ADR-0029 的承重约束)。另断言 /app 与
   /database/dashboard/users 返回同一份字节(SPA 回退不读请求)、body 含
   /_filelib/。构建产物缺失时不 skip 而是直接失败:那说明该先跑
   filelib-web 的 build,不是测试不适用。
2026-07-26 20:23:15 +08:00
cdeb29ccf2 fix(database): 补回文件库的「授权」tab 与 /me 的显示名
迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用:
  GET/PUT/DELETE /nodes/:id/grants
  PUT /projects/:id/independent-permission
权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。

tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、
授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍
挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。

GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者
可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走
/groups/search 下拉选,不手敲 id。

/database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。
旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后
(ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的
无关细节。

概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、
节点标题旁的角色 tag,以及整块缺失的独立权限开关。
2026-07-26 20:19:47 +08:00
eeb8f56742 fix(filelib-web): 补齐 Group 管理面板,与旧后端面板逐条对齐
迁移时误把分支上一个早先存在的简易 GroupAdmin(281 行)当成迁移产物,
它与旧 renderGroupsPanel(747 行)从来不是同一个东西,于是后端 8 个
group 端点前端只调了 5 个。

补上的功能(端点一直可用,只是没有入口):
  PATCH /groups/:id          重命名 / 改描述
  GET  /groups?includeArchived=1  列出已归档组
  POST /groups/:id/restore   恢复(连带恢复已归档祖先链,子树仍归档)
  GET  /users/search         成员选择器,不再手敲 userId
影响最实际的是恢复:软删的组此前在界面上无法恢复。

补上的交互:折叠树、组名过滤(命中项保留整条祖先链,过滤态强制展开)、
右键菜单(归档组只给「恢复」)、面包屑、统计条、树底部计数、成员表的
头像/openId/加入时间三列。

types.ts 之前也是截断的:MemberGroupNode 少 archivedAt,
MemberGroupMember 少 feishuOpenId/avatarUrl/joinedAt —— 类型里没有,
UI 自然渲染不出来。

一处实现偏离:折叠状态用数组而非 Set。Svelte 5 的 $state 深层代理不
跟踪 Set 变更,用 Set 会点了没反应。

groups tab 外框补 padding:20px/overflow:hidden,对齐旧 #tab-groups,
否则面板贴着侧边栏。
2026-07-26 20:19:30 +08:00
325b4fc137 fix(filelib-web): 补回迁移丢失的共享组件样式层与图标集
第一版迁移只把 uiTheme.ts 的 @theme 颜色令牌搬了过来,155 行里约 90
行的组件类(.btn/.panel/.input/.select/table.list/.tag/.switch/
.link-danger/.quiet 等)被丢掉,于是每个组件各自内联重述按钮、输入框、
面板的样式 —— 正是旧代码的重复问题被原样复刻,后台观感明显退化。

现在 app.css 是设计系统的唯一去处:@theme 管令牌,@layer components
管组件类。组件只带布局工具类,不重述组件样式。

图标集同样是丢的:旧面板有 13 个内联 SVG,新版一个不剩,只有纯文字的
「+」「删除」—— 这是"简陋"最直接的来源。提成 Icon.svelte 共享。
Group 节点沿用两人剪影而非文件夹图标:MemberGroup 与文件库的
FOLDER/PROJECT 是两套无关层级,图标不应混淆(ADR-0028/0021)。

顺手修 FilesPanel 的 uploadInput:bind:this 的目标要用 $state,
否则 Svelte 5 下不保证更新。
2026-07-26 20:19:10 +08:00
a7f90f387d chore(database-admin): 删除该前端工程,已被 filelib-web 取代
12628c9 引入它意在替换后端渲染的 /database 页面,但从未接通:具体
路由 /database/dashboard 比 SPA 通配 /database/* 更具体,服务端
handler 永远胜出,SPA 的 dashboard 不可达。工程头注释声称 SPA 已
接管 dashboard、且 /database/config 存在,两者当时都不成立。

hub 的 build 脚本也从未构建它,于是 static.ts 里的 existsSync 守卫
每次部署都失败,这个外壳实际长期处于禁用状态 —— 它没服务过一个请求。

与 filelib-web 合并而非并存的理由:两者共用文件库浏览器、会话层、
toast 宿主与设计令牌,拆开就要把这些全复制一遍(ADR-0029)。

内容可从 git 历史取回。
2026-07-26 20:18:15 +08:00
d159e372d2 refactor(database)!: 后端不再渲染任何 HTML,只出 JSON
删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

新增两个端点承接原先在 page handler 里 inline 算的东西:
  GET /database/config      免鉴权 bootstrap(org slug + dev 开关);
                            注册位置刻意早于 silo org 的提前返回,
                            org 未就绪时登录页仍要能渲染。
  GET /database/api/stats   概览统计,要求 silo org OWNER/ADMIN ——
                            它聚合的是 org 级计数与审计流,不是
                            单节点权限视图。

静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。

/database/library 改为 302 到 /database/dashboard/library。

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
2026-07-26 20:17:59 +08:00
3d0f4e5c2d feat(filelib-web): 把 /database 各页从后端 HTML 拼接迁到 SvelteKit 路由
登录页、后台外壳与六个 tab 全部成为客户端路由:
  /database/admin              登录(迁自 renderLoginPage)
  /database/dashboard          概览(迁自 renderDashboard)
  .../library .../users .../groups .../search .../settings

六个 tab 是真 URL,不再是 location.hash + display:none —— 刷新不丢
位置,链接可分享。

BrowserShell 拆成 LibraryView,加 showUserFooter:老师端 /app 显示
身份/登出页脚,后台的文件库 tab 不显示(外层已有身份区)。

bootstrap 走 /database/config 而非 /database/api/login-info:后者由
teacherApp 在 silo org 查找成功后才注册,前者无条件注册,登录页在
org 未就绪时也必须能拿到配置。

后端拥有的链接(OAuth、DEV 一键登录)标 data-sveltekit-reload,
否则被客户端路由拦下。
2026-07-26 20:17:43 +08:00
de9f846fd0 build(filelib-web): 从 Svelte+Vite 改为 SvelteKit(adapter-static)
纯 SPA:adapter-static + fallback index.html,不做 SSR/预渲染。
index.html / main.ts / App.svelte 由 app.html + src/routes/ 取代。

两项配置是承重的(ADR-0029),不是风格选择:
  appDir: '_filelib'   默认 _app 会与 admin-web 在根上注册的 /_app/*
                       撞成 Fastify 重复路由,启动即抛错。
  paths.relative: false 同一份 index.html 会在 /app 和
                       /database/dashboard/users 等不同深度送出,
                       相对资源路径会解析到错的 base。

dev 代理表列出后端拥有的全部路径:JSON API、免鉴权 bootstrap
(/database/config)、OAuth、以及 DEV 一键登录端点 —— 后者不代理会被
SPA 回退吃掉。
2026-07-26 20:17:27 +08:00
683e97ca53 docs(adr): 0029 web 界面一律静态 SPA,hub 只出 JSON
记录本次迁移的语义决策:没有 HTTP handler 渲染 HTML;/app 与
/database 是同一个前端工程 filelib-web,构建一次挂两个前缀;
客户端导航用真 URL 路由而非 hash 片段。

两条承重配置约束一并写明:appDir 必须改名(默认 _app 与 admin-web
在根上的 /_app/* 撞重复路由,Fastify 启动即失败),以及
paths.relative=false(同一份 index.html 在不同 URL 深度被送出)。
2026-07-26 20:17:09 +08:00
11a7ec8004 feat(database): 后台成员组(MemberGroup)管理与嵌套解析 2026-07-26 18:06:18 +08:00
2f2ece1a3a fix(database): expose submitCreate on window.__lib in library browser
新建根目录/子节点弹窗的「创建」按钮 onclick 调 window.__lib.submitCreate,
但该函数虽已定义却漏挂到 __lib 导出表,导致点击报 "submitCreate is not
a function"。补挂即可。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:39:38 +08:00
58c81d4379 Merge remote-tracking branch 'origin/main' into feat/member-group-hierarchy 2026-07-24 00:00:50 +08:00
bai d16bd4899d Merge pull request 'feat(database): init database folder frontend and permission' (#1) from maoyuanyang/curriculum-project-hub:main into main
Reviewed-on: #1
2026-07-23 23:44:17 +08:00
ymy 4021e58d5d feat(database): init database folder frontend and permission 2026-07-23 23:41:11 +08:00
103 changed files with 10650 additions and 3482 deletions
+4
View File
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**:
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
_Avoid_: Organization deletion, service restart
**Member Group**:
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
_Avoid_: Team (the org-scoped flat grouping), Feishu department
@@ -0,0 +1,153 @@
# ADR 0028: Member Group Management And Resolution
## Status
Accepted.
## Context
ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the
principal-set permission model. The file library (《文件库-接口契约.md》) computes
effective permission over two principal kinds — `USER` and `GROUP` — and consumes
the group side through a single read-only port, `GroupResolver`
(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2).
The contract's v0.1 proposal framed the Group system as a *separate HTTP service*
owned by another team, consumed read-only. In practice the schema now carries the
group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`,
`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and
the product requirement is to build **group management in the backend admin**, not
to integrate a foreign service. Until this ADR, nothing read or wrote those tables:
the live `GroupResolver` was a transitional implementation reading flat hub `Team`
membership, and the admin "Group 管理" panel actually managed `Team`.
This ADR settles the semantics needed to make the `MemberGroup` tables the real,
in-hub group system.
## Decision
### Group system is in-hub, not a foreign service
`MemberGroup` is the platform's global member-group principal. It lives in the hub
database and is managed through the `/database` backend. The contract's "separate
service" framing was an unfrozen v0.1 proposal; the implementation aligns to the
tables that were actually built. The `GroupResolver` port stays — an external
`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the
default implementation reads the in-hub `MemberGroup` closure.
### Authority: website administrator only
Group create/delete and member add/remove are restricted to the **website
administrator**, defined (consistently with the rest of the file library, D19/C4
adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in
`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform
control plane; the file library uniformly uses org OWNER/ADMIN today and this
feature stays consistent with that. Reading groups for the authorization selector
(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage
holder's ability, not an administrator's.
### Resolution semantics (the crux)
`resolveMemberGroupIds(user)` returns the user's **active direct groups the
active ancestors of those groups**, deduplicated (the closure's depth-0 self row
makes each direct group its own ancestor). This is the single query the permission
engine relies on; equivalently: a grant placed on group G applies to members of G
and of every descendant of G (requirement 3.2 — permission flows down the tree, so
resolution collects up the tree). It is computed **live, never cached** (contract
D4/G4): a membership change is visible on the very next protected request.
MemberGroup is global (no `organizationId`), so resolution is not org-scoped.
### Soft delete via `archivedAt`, cascading the subtree
Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group
cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where
`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application
operation, not a DB constraint. Closure and membership rows are **retained**;
resolution and listing filter by `archivedAt`, so an archived group and everything
under it stop contributing to permission at once.
### Closure maintenance
The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P`
insert `(a.ancestorId, G, a.depth + 1)` for every `a` in
`closure where descendantId = P`. v1 does **not** support reparenting a group
(moving it under a new parent). The schema reserves reparent (closure rebuild plus
the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.
### Rename and description edits are in scope; reparent stays out
A group's `name` and `description` are mutable by the website administrator
(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately
separated from reparent: renaming touches **no** closure row and cannot create a
cycle, so it carries none of the invariant risk that keeps reparent out of v1. The
endpoint therefore **rejects** a `parentId` field outright rather than ignoring it,
so a future reparent cannot arrive silently through this route. Passing an empty
`description` clears it; omitting a field leaves it unchanged.
### Restore is deliberately asymmetric with delete
Archived groups stay visible to the administrator (`GET
/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the
console tags and greys them) and can be restored (`POST
/database/api/groups/:id/restore`, audited as `group.restore`).
Restore is **not** the mirror image of delete. Delete cascades down the whole
subtree; restore un-archives **the group plus every archived ancestor of it, and
nothing below it**:
- Restoring the ancestor chain is **mandatory**, not a convenience. An active group
whose parent is archived has no path in the tree, and the `depth` derivation
(closure row count) presumes "an active group's ancestors are active" — the
invariant that cascade-delete establishes. Restoring a node alone would break it.
- The subtree is deliberately **left archived**. A group's descendants may have been
archived for reasons of their own, and one click should not silently re-grant
permission across a whole historical branch. Descendants remain visible in their
archived state and are each restored explicitly.
Restore takes effect immediately, like every other membership change (D4/G4): the
group resumes contributing permission on the next resolution.
An archived group is **readable but not writable**. Its membership rows are never
revoked by archiving, so `listMembers` succeeds on an archived group — the console
must be able to show *who was in it* before deciding whether to restore it. Every
mutation, by contrast, still requires an active group (`requireActiveGroup` → 404):
rename, child creation, and member add/remove all reject. The group is inert for
permission purposes and frozen for editing, but not hidden and not forgotten.
### Member picker reads global users, admin-only
`GET /database/api/users/search` backs the "add member" picker: it matches `User`
by display name or Feishu open id and is gated to the website administrator, the
same authority that may add members. It widens no existing capability — adding a
member already accepts **any** global user (`resolveUser` does not require an org
membership), so the endpoint only replaces blind id entry with search. It is
deliberately **not** opened to the non-admin authorization-selector audience that
`/groups/search` serves: choosing a group to grant is a Manage-holder action,
whereas enumerating people is not. `excludeGroupId` filters out the target group's
active members so the picker cannot surface a candidate that must 409.
### Audit is written in-hub
The contract (C3 §6.3) originally deferred group actions to the foreign Group
service's own audit. With the group system in-hub, group mutations are audited
through the existing file-library sink (`filelib/audit.ts`, same-transaction
`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`,
so the audit row is attributed to the silo org. New actions: `group.create`,
`group.update`, `group.delete`, `group.restore`, `group.member_add`,
`group.member_remove`; new audit object type `group`.
## Consequences
- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader.
`createTeamGroupResolver` is retained but deprecated (no longer wired); existing
flat-Team group grants no longer resolve for the file library.
- Group grants take effect in real time through the existing `effectiveRole`
reducer (P6) with no change to the permission algebra — only the set of group ids
fed to it changes.
- v1 omits reparent; the closure invariants above must hold whenever reparent is
added later (rebuild descendants' ancestor rows, reject cycles).
- Group management is an admin-only surface; the authorization selector is not.
- Numeric limits (max depth, max members) and a hard-delete/restore path remain
follow-on operational decisions; they must not weaken the archived-filter,
admin-authority, or live-resolution invariants fixed here.
@@ -0,0 +1,132 @@
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
## Status
Accepted.
## Context
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
teacher-facing file library (`/app`), and the database admin back office
(`/database`). They arrived at different times and diverged in how HTML reached
the browser.
`/admin` and `/app` were already separated: the backend serves a prebuilt static
`index.html` and never inspects the request; all data flows through JSON
endpoints. `/database` was not. Roughly 1770 lines across four modules
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
assembled HTML template strings server-side, reading the session cookie and
querying Prisma inside the page handler, with layout expressed as inline
`style="…"` attributes and behavior as `<script>` text.
A prior migration (`12628c9`) introduced a fourth frontend project,
`hub/database-admin/`, intended to replace those pages. It was never wired up:
the concrete route `/database/dashboard` is more specific than the SPA wildcard
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
was unreachable. That project's file header claimed the SPA served the dashboard
and that `/database/config` existed; neither was true. The `npm run build` script
also never built it, so the `existsSync` guard in `database/static.ts` failed on
every deploy and the shell was permanently disabled.
Duplicated visual rules were the practical cost: card padding and type sizes were
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
were genuinely shared.
## Decision
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
request; all per-user and per-request data is fetched by the client from JSON
endpoints under `/api/*` or `/database/api/*`.
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
and mounted at two prefixes.** They share the file library browser, the session
layer, the toast host, and the design tokens; splitting them would duplicate all
of it. `hub/database-admin` is deleted — superseded before it ever served a
request.
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
Fastify instance, and are load-bearing:
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
with the root `/_app/*` asset route that `admin-web` owns
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
collision is a boot failure, not a silent misroute.
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
paths would resolve against the wrong base.
**Client-side navigation uses real URL routes, not hash fragments or hidden
sections.** The six back-office tabs are `/database/dashboard`,
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
Refresh preserves position and links are shareable — the previous
`location.hash` + `display:none` scheme lost both.
Concrete routes must be registered before the SPA wildcards. This is an ordering
obligation on `database/plugin.ts`, not an incidental detail: the earlier
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
route outranks the fallback.
## Consequences
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
carries no security weight; every endpoint keeps its own `fail closed` guard.
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
org-wide counts and the audit stream rather than a per-node permission view.
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
handler read from Prisma to render chrome has to become part of a JSON payload
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
until these were added. When migrating a server-rendered surface, the data the
template closed over is part of the contract being ported, not an incidental
detail of the old implementation.
- Editing a page no longer requires a Hub restart in development; `vite dev`
serves the frontend and proxies data requests to the Hub. In production the
`index.html` is cached in memory at startup, so a frontend rebuild does require
a restart.
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
extending that list.
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
tokens plus an `@layer components` block for the shared component classes
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
`routes/uiTheme.ts` is deleted; both halves live there now.
The first cut of this migration kept only the tokens and restated button,
input, and panel styling inline in every component. That reproduced the
duplication the old code had — the admin panels visibly regressed — so the
component layer was ported too. Components carry layout utilities; they do not
restate component styling. The one admitted exception is a data-derived value
(tree indent computed from `depth`), which cannot be a static class.
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
- **A migrated surface is only done when its endpoint coverage matches.** Two
panels were rebuilt from a superficially similar component that predated the
migration rather than from the server module they replaced, and the mismatch
was invisible in the rendered page:
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
a soft-deleted group could not be restored through the UI at all even though
the backend fully supported it.
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
.../grants` and `PUT .../independent-permission` had no caller. Permission
editing is the point of the back office, and it was unreachable.
Diffing the route table against the frontend's `api()` call sites catches this;
reading the new page does not.
## Deferred
- `/admin` (admin-web) stays a separate project. It has its own design language
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
merging it is not motivated by shared code.
- The `search` and `settings` tabs remain placeholders, as they were server-side.
- Serving `/admin` and `/database` from a single SPA, which would remove the
`appDir` collision constraint entirely.
+4
View File
@@ -8,6 +8,10 @@ dist/
.dev-keyring.json
.dev-workspaces/
.dev-skills/
.filelib-repos/
admin-web/node_modules/
admin-web/build/
admin-web/.svelte-kit/
filelib-web/node_modules/
filelib-web/build/
filelib-web/.svelte-kit/
-23
View File
@@ -1,23 +0,0 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-4
View File
@@ -1,4 +0,0 @@
build
.svelte-kit
node_modules
package-lock.json
-9
View File
@@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 120,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
{
"name": "database-admin",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"bits-ui": "^2.18.1",
"prettier": "^3.9.5",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
}
}
-29
View File
@@ -1,29 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Curriculum Project Hub — 数据库管理后台" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<style>
/* Fallback before CSS bundle */
html {
font-family: 'Inter', system-ui, 'Noto Sans SC', 'PingFang SC', sans-serif;
}
</style>
<title>Database Admin</title>
%sveltekit.head%
</head>
<body
data-sveltekit-preload-data="hover"
class="relative min-h-screen overflow-x-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700"
>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
-76
View File
@@ -1,76 +0,0 @@
/**
* Thin API client for the database-admin backend. Same-origin cookie auth,
* reusing the platform session (`cph_session`) and the admin plane's /api/me.
*/
export class ApiError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status: number) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = status;
}
}
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
const init: RequestInit = {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
};
const res = await fetch(url, init);
const text = await res.text();
let data: unknown = null;
if (text !== '') {
try {
data = JSON.parse(text);
} catch {
data = text;
}
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
}
return data;
}
const get = (u: string) => request('GET', u);
const post = (u: string, b?: unknown) => request('POST', u, b);
// --- Types ---
export interface OrgMembership {
id: string;
slug: string;
name: string;
status: string;
role: 'OWNER' | 'ADMIN' | 'MEMBER';
}
export interface MeResponse {
user: {
id: string;
feishuOpenId: string;
displayName: string;
avatarUrl: string | null;
};
organizations: OrgMembership[];
}
/** Unauthenticated bootstrap the login page needs: which org to OAuth against + dev toggle. */
export interface DatabaseConfig {
siloOrganizationSlug: string;
devLoginEnabled: boolean;
}
// --- API ---
export const api = {
me: () => get('/api/me') as Promise<MeResponse>,
logout: () => post('/auth/logout'),
databaseConfig: () => get('/database/config') as Promise<DatabaseConfig>,
};
@@ -1,6 +0,0 @@
<!-- Animated aurora background blobs, shared by both pages (soft pastels on light). -->
<div class="pointer-events-none fixed inset-0 overflow-hidden">
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
</div>
-7
View File
@@ -1,7 +0,0 @@
import type { OrgMembership } from './api';
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
if (!org) return false;
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
-58
View File
@@ -1,58 +0,0 @@
import { writable } from 'svelte/store';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api, type MeResponse } from './api';
interface SessionState {
loading: boolean;
me: MeResponse | null;
error: string | null;
}
export const session = writable<SessionState>({
loading: true,
me: null,
error: null,
});
export async function loadSession(): Promise<void> {
session.update((s) => ({ ...s, loading: true, error: null }));
try {
const me = await api.me();
session.set({ loading: false, me, error: null });
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 401) {
session.set({ loading: false, me: null, error: null });
void redirectToLogin();
return;
}
session.set({
loading: false,
me: null,
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Send the browser to the login page (`/database/admin`). Unlike admin-web we
* don't jump straight to Feishu OAuth: the login page reads /database/config
* to build the org-scoped link and to show the dev bypass when enabled.
*/
export async function redirectToLogin(): Promise<void> {
const loginPath = `${base}/admin`;
if (window.location.pathname === loginPath) return;
await goto(loginPath, { replaceState: true });
}
/** Build the org-scoped Feishu OAuth entry point (a backend route, not under base). */
export function feishuLoginHref(orgSlug: string, returnTo: string = `${base}/dashboard`): string {
return `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
}
export async function logout(): Promise<void> {
await api.logout();
session.set({ loading: false, me: null, error: null });
await redirectToLogin();
}
@@ -1,7 +0,0 @@
<script lang="ts">
import './app.css';
let { children } = $props();
</script>
{@render children()}
@@ -1,30 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api } from '$lib/api';
onMount(async () => {
// /database entry — route to dashboard if signed in, else the login page.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
} catch {
await goto(`${base}/admin`, { replaceState: true });
}
});
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-3 text-slate-400">
<svg class="h-7 w-7 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">正在进入…</p>
</div>
</div>
@@ -1,78 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api, type DatabaseConfig } from '$lib/api';
import { feishuLoginHref } from '$lib/session';
import Aurora from '$lib/components/Aurora.svelte';
let config = $state<DatabaseConfig | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
// Already signed in → straight to the dashboard.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
return;
} catch {
// Not signed in (401) or backend unreachable — show the login card.
}
try {
config = await api.databaseConfig();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
});
const feishuHref = $derived(config ? feishuLoginHref(config.siloOrganizationSlug) : '#');
</script>
<Aurora />
<main class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
<div class="mb-7 text-center">
<div
class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn"
>
D
</div>
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
</div>
{#if error}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
无法连接后端:{error}
</div>
{/if}
<a
href={feishuHref}
aria-disabled={config ? 'false' : 'true'}
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400 aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z" />
</svg>
使用飞书登录
</a>
{#if config?.devLoginEnabled}
<div class="relative my-6 rise-3">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center">
<span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span>
</div>
</div>
<a
href="/database/dev-login"
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100"
>
<span></span> 一键登录管理员
</a>
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
</div>
</main>
-96
View File
@@ -1,96 +0,0 @@
@import 'tailwindcss';
@source './**/*.{html,js,svelte,ts}';
@source '../lib/**/*.{html,js,svelte,ts}';
/*
* Design system for the database-admin SPA. Migrated verbatim (in spirit) from
* the previous server-rendered pages in hub/src/database/routes/databaseRoutes.ts:
* a light aurora / glassmorphism look with violet→cyan gradients and soft rise-in
* entrances. Distinct from admin-web's flat industrial theme on purpose.
*/
@theme {
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', sans-serif;
}
@keyframes aurora {
0% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(6%, -8%) scale(1.15);
}
66% {
transform: translate(-8%, 6%) scale(0.9);
}
100% {
transform: translate(0, 0) scale(1);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@layer base {
:root {
font-family: var(--font-sans);
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer components {
.aurora {
filter: blur(80px);
animation: aurora 18s ease-in-out infinite;
}
.rise {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.rise-1 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.rise-2 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
}
.rise-3 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
}
.grad-text {
background: linear-gradient(120deg, #7c3aed, #0891b2, #4f46e5);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer 6s linear infinite;
}
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.glow-btn {
box-shadow: 0 12px 32px -10px rgba(124, 58, 237, 0.45);
}
}
@@ -1,160 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { session, loadSession, logout } from '$lib/session';
import { isOrgAdmin } from '$lib/org';
import Aurora from '$lib/components/Aurora.svelte';
onMount(() => {
loadSession();
});
// The session is scoped to the silo org, so /api/me returns exactly that org's
// membership. Admin gate: only OWNER/ADMIN may use the database console.
const me = $derived($session.me);
const org = $derived(me?.organizations[0] ?? null);
const allowed = $derived(isOrgAdmin(org));
const displayName = $derived(me?.user.displayName ?? '');
const initial = $derived(displayName.slice(0, 1) || 'U');
const navItems = [
{ label: '概览', href: `${base}/dashboard`, active: true, icon: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z' },
{ label: '数据表', href: '#', active: false, icon: 'M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z' },
{ label: '查询', href: '#', active: false, icon: 'm21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z' },
{
label: '设置',
href: '#',
active: false,
icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z',
},
];
const stats = [
{ label: '数据表', value: '—', accent: 'from-violet-200/60 to-transparent' },
{ label: '记录数', value: '—', accent: 'from-cyan-200/60 to-transparent' },
{ label: '最近查询', value: '—', accent: 'from-indigo-200/60 to-transparent' },
];
</script>
<Aurora />
{#if $session.loading}
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-3 text-slate-400">
<svg class="h-8 w-8 animate-spin text-violet-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass w-full max-w-sm rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
<h2 class="mb-2 text-lg font-bold text-slate-900">无法连接后端</h2>
<p class="mb-5 text-sm text-slate-500">{$session.error}</p>
<button
class="rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white"
onclick={() => loadSession()}>重试</button
>
</div>
</div>
{:else if !allowed}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass w-full max-w-md rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
<h2 class="mb-2 text-lg font-bold text-slate-900">无权访问</h2>
<p class="mb-5 text-sm text-slate-500">数据库管理台仅向组织所有者与管理员开放。</p>
<button
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
onclick={() => logout()}>退出登录</button
>
</div>
</div>
{:else}
<div class="relative flex min-h-screen">
<!-- 左侧菜单栏 -->
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
<div class="flex items-center gap-3 px-5 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn"
>
D
</div>
<span class="text-base font-bold grad-text">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
{#each navItems as item}
<a
href={item.href}
class={item.active
? 'group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50'
: 'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900'}
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"><path d={item.icon} /></svg
>
{item.label}
</a>
{/each}
</nav>
<div class="m-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white"
>
{initial}
</div>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
<p class="truncate text-sm font-medium text-slate-700">{displayName}</p>
</div>
</div>
</aside>
<!-- 右侧内容 -->
<div class="flex flex-1 flex-col">
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
<div>
<h1 class="text-lg font-bold text-slate-900">概览</h1>
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
</div>
<button
onclick={() => logout()}
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
退出登录
</button>
</header>
<main class="flex-1 p-8">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
{#each stats as s, i}
<div
class="rise-{i +
1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50"
>
<div class="absolute inset-0 bg-gradient-to-br {s.accent} opacity-0 transition group-hover:opacity-100"></div>
<p class="relative text-sm text-slate-500">{s.label}</p>
<p class="relative mt-2 text-3xl font-bold text-slate-900">{s.value}</p>
</div>
{/each}
</div>
<div
class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400"
>
内容区占位 · 后续数据端点挂在 /database/* 并加 guard
</div>
</main>
</div>
</div>
{/if}
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
</svg>

Before

Width:  |  Height:  |  Size: 353 B

-3
View File
@@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:
-22
View File
@@ -1,22 +0,0 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
// This SPA is served under /database by the Hub. admin-web owns the
// root asset paths (/_app, /favicon.svg); a base path moves this app's
// assets to /database/_app/* so the two builds never collide.
paths: { base: '/database' },
},
};
export default config;
-20
View File
@@ -1,20 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
-17
View File
@@ -1,17 +0,0 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8788',
'/auth': 'http://127.0.0.1:8788',
// Backend owns the /database/* HTTP surface (login page, dashboard,
// data routes). Proxy it in dev so those paths hit the real server
// instead of the SPA fallback.
'/database': 'http://127.0.0.1:8788',
},
},
});
+4 -4
View File
@@ -82,11 +82,11 @@ REMOTE
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
--exclude database-admin/node_modules --exclude database-admin/build --exclude database-admin/.svelte-kit \
--exclude filelib-web/node_modules --exclude filelib-web/build --exclude filelib-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & database-admin SPAs)"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -98,11 +98,11 @@ flock /var/lock/cph-hub-release-publish bash -c '
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
npm ci --prefix database-admin
npm ci --prefix filelib-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
test -f database-admin/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
+3 -3
View File
@@ -61,10 +61,10 @@ if [ "$release_ready" = false ]; then
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
# `npm run build` → tsc then admin:build + database:build → admin-web/build and
# database-admin/build for registerStaticSpa / registerDatabaseSpa.
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix database-admin && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
+1758
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "filelib-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.7.0",
"vite": "^8.0.16"
}
}
+263
View File
@@ -0,0 +1,263 @@
@import "tailwindcss";
/* 全局 UI 主题令牌(与 hub 端 uiTheme.ts 同源) */
@theme {
--color-bg: #fcfcfb;
--color-panel: #ffffff;
--color-sidebar: #f7f7f5;
--color-ink: #1a1a18;
--color-ink-2: #6b6a66;
--color-ink-3: #9c9b96;
--color-line: #ecece8;
--color-line-soft: #f1f1ee;
--color-hover: #f4f4f1;
--color-selected: #ebebe7;
--color-accent: #1a1a18;
--color-accent-hover: #333330;
--color-danger: #a13a33;
--color-guide: #e9e9e5;
--color-diff-add-bg: #f3f6f2;
--color-diff-add-text: #4a6741;
--color-diff-del-bg: #f8f2f1;
--color-diff-del-text: #a13a33;
}
html,
body {
height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-ink);
font-family:
"Inter",
-apple-system,
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
sans-serif;
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* 共享组件层(ADR-0029)。
*
* 从已删除的 hub/src/database/routes/uiTheme.ts 原样搬来。组件只带布局工具类,
* 不重述这里的组件样式 —— 第一版迁移只搬了上面的 @theme 令牌,把按钮/输入框/
* 面板样式在每个组件里内联重写了一遍,后台随即明显退化。 */
@layer components {
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
color: var(--color-ink);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 120ms ease;
white-space: nowrap;
}
.btn:hover {
background: var(--color-hover);
}
.btn-sm {
padding: 4px 9px;
font-size: 11.5px;
}
.btn-primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.btn-danger {
border-color: transparent;
background: transparent;
color: var(--color-danger);
}
.btn-danger:hover {
background: color-mix(in srgb, var(--color-danger) 7%, transparent);
}
.panel {
background: var(--color-panel);
border: 1px solid var(--color-line-soft);
border-radius: 10px;
padding: 20px 22px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10.5px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-line-soft);
color: var(--color-ink-3);
background: var(--color-panel);
}
.input,
.select,
.textarea {
width: 100%;
padding: 7px 11px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
font-size: 13px;
color: var(--color-ink);
font-family: inherit;
outline: none;
transition: border-color 120ms ease;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: var(--color-accent);
}
.textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
resize: vertical;
}
.form-label {
display: block;
font-size: 11.5px;
color: var(--color-ink-3);
margin-bottom: 4px;
}
.form-row {
margin-bottom: 12px;
}
table.list {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.list th {
text-align: left;
font-size: 11.5px;
font-weight: 500;
color: var(--color-ink-3);
padding: 4px 0;
}
table.list td {
padding: 8px 0;
border-top: 1px solid var(--color-line-soft);
}
table.list tr:first-child td {
border-top: none;
}
.quiet {
color: var(--color-ink-3);
font-size: 12.5px;
}
.section-title {
font-size: 13px;
font-weight: 600;
}
.section-note {
font-size: 11.5px;
color: var(--color-ink-3);
}
.file-meta {
font-size: 11px;
color: var(--color-ink-3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.link-danger {
color: var(--color-danger);
font-size: 12.5px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.link-danger:hover {
text-decoration: underline;
}
/* 开关。真 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.switch > input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch > span {
position: relative;
flex-shrink: 0;
width: 30px;
height: 17px;
border-radius: 999px;
background: var(--color-line);
transition: background 0.16s;
}
.switch > span::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
transition: transform 0.16s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.switch > input:checked + span {
background: var(--color-accent);
}
.switch > input:checked + span::after {
transform: translateX(13px);
}
.switch > input:focus-visible + span {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* diff 渲染 */
pre.diff {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
}
pre.diff .add {
display: block;
color: var(--color-diff-add-text);
background: var(--color-diff-add-bg);
}
pre.diff .del {
display: block;
color: var(--color-diff-del-text);
background: var(--color-diff-del-bg);
}
@@ -1,5 +1,4 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// See https://svelte.dev/docs/kit/types#app
declare global {
namespace App {
// interface Error {}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="height: 100%">
<div style="display: contents; height: 100%">%sveltekit.body%</div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
/** 头像:有 avatarUrl 用图,否则显示首字母色块。 */
let {
displayName,
userId,
avatarUrl = null,
size = 28,
}: {
displayName?: string | null;
userId?: string | null;
avatarUrl?: string | null;
size?: number;
} = $props();
const initial = $derived((displayName || userId || "?").slice(0, 1).toUpperCase());
</script>
{#if avatarUrl}
<img
src={avatarUrl}
alt=""
class="shrink-0 rounded-full object-cover"
style="width:{size}px;height:{size}px"
/>
{:else}
<span
class="inline-flex shrink-0 items-center justify-center rounded-full bg-accent font-semibold text-white"
style="width:{size}px;height:{size}px;font-size:{Math.round(size * 0.42)}px"
aria-hidden="true">{initial}</span
>
{/if}
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
import { api, ApiError } from "./api.js";
import { toastOk, toastErr, toast } from "./stores.js";
import type { FileContent, VersionInfo, Role } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { projectId, path, role, onchanged, onclose }: { projectId: string; path: string; role: Role; onchanged: () => void; onclose?: () => void } = $props();
let file = $state<FileContent | null>(null);
let draft = $state("");
let loadError = $state<string | null>(null);
let conflict = $state<{ currentVersion: string; diff: string } | null>(null);
let showHistory = $state(false);
let history = $state<VersionInfo[]>([]);
const canEdit = $derived(role !== "VIEW");
async function load(): Promise<void> {
try {
file = await api<FileContent>(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
draft = file.encoding === "utf8" ? file.content : "";
loadError = null;
conflict = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void projectId;
void path;
void load();
});
async function save(): Promise<void> {
if (file === null) return;
try {
const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, {
method: "POST",
body: { path: file.path, baseVersion: file.version, content: draft },
});
toastOk("已提交 " + r.version);
await load();
onchanged();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && typeof e.details?.["currentVersion"] === "string") {
await showConflict(e.details["currentVersion"]);
} else {
toastErr(e instanceof Error ? e.message : String(e));
}
}
}
async function showConflict(currentVersion: string): Promise<void> {
if (file === null) return;
try {
const r = await api<{ diff: string }>(
`/database/api/projects/${projectId}/file/diff?path=${encodeURIComponent(file.path)}&from=${encodeURIComponent(file.version)}&to=${encodeURIComponent(currentVersion)}`,
);
conflict = { currentVersion, diff: r.diff };
file = { ...file, version: currentVersion };
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function acceptLatest(): Promise<void> {
conflict = null;
await load();
toast("已载入最新内容,请在此基础上合并", "info");
}
async function remove(): Promise<void> {
if (file === null || !confirm("删除文件 " + file.path + "?")) return;
try {
await api(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(file.path)}`, {
method: "DELETE",
body: { baseVersion: file.version },
});
toastOk("已删除");
file = null;
onchanged();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function openHistory(): Promise<void> {
try {
const r = await api<{ history: VersionInfo[] }>(`/database/api/projects/${projectId}/file/history?path=${encodeURIComponent(path)}`);
history = r.history;
showHistory = true;
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
function renderDiff(diff: string): string {
return diff
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/^\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>');
}
</script>
{#if loadError}
<div class="panel text-xs text-danger">{loadError}</div>
{:else if file}
<div class="panel">
<div class="mb-2.5 flex items-center justify-between">
<span class="file-meta">{file.path} @ {file.version}</span>
<div class="flex items-center gap-1.5">
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
{#if canEdit}
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
{/if}
{#if onclose}
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览"></button>
{/if}
</div>
</div>
{#if file.encoding === "base64"}
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else}
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea>
{/if}
{#if canEdit && file.encoding !== "base64"}
<div class="mt-3 flex justify-end">
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
{#if conflict}
<div class="mt-3.5 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下(你的基版 → 最新版)</div>
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})</div>
<div class="mt-2 flex justify-end">
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
</div>
</div>
{/if}
</div>
{:else}
<div class="panel quiet">加载中…</div>
{/if}
{#if showHistory}
<Modal title="版本历史" onclose={() => (showHistory = false)}>
<div class="max-h-80 overflow-y-auto">
{#each history as v (v.version)}
<div class="border-t border-line-soft py-2 text-xs first:border-t-0">
<span class="font-mono text-accent">{v.version}</span> {v.message}
<div class="text-ink-3">{new Date(v.committedAt).toLocaleString("zh-CN")}{v.author ? " · " + v.author : ""}</div>
</div>
{/each}
</div>
<div class="mt-3 flex justify-end">
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
</div>
</Modal>
{/if}
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { selectedFilePath, filesVersion } from "./browser.js";
import type { FileEntry, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let files = $state<FileEntry[] | null>(null);
let loadError = $state<string | null>(null);
let showNewFile = $state(false);
let newPath = $state("");
let newContent = $state("");
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null);
const canEdit = $derived(node.role !== "VIEW");
async function loadFiles(): Promise<void> {
try {
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`);
files = r.files;
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void node.id;
void $filesVersion;
void loadFiles();
});
async function submitNewFile(): Promise<void> {
const path = newPath.trim();
if (path === "") return;
try {
await api(`/database/api/projects/${node.id}/file`, {
method: "PUT",
body: { path, content: newContent },
});
toastOk("已创建");
showNewFile = false;
newPath = ""; newContent = "";
await loadFiles();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
function u8ToBase64(bytes: Uint8Array): string {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)) as unknown as number[]);
}
return btoa(bin);
}
async function doUpload(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
toastErr("文件超过 10MB 上限");
return;
}
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
toastOk("已上传 " + file.name);
await loadFiles();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
}
}
</script>
<div class="panel">
<div class="mb-1.5 flex items-center justify-between">
<div class="section-title">项目文件({files?.length ?? 0})</div>
{#if canEdit}
<div class="flex gap-1.5">
<button class="btn" onclick={() => (showNewFile = true)}><Icon name="plus" size={13} /> 新建文件</button>
<button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button>
<input bind:this={uploadInput} type="file" class="hidden" onchange={doUpload} />
</div>
{/if}
</div>
{#if files === null && loadError === null}
<div class="quiet py-5 text-center">加载中…</div>
{:else if loadError}
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if files && files.length === 0}
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
{:else if files}
<table class="list">
<tbody>
{#each files as f (f.path)}
<tr
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
<td class="file-meta text-right">{f.size} B</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if showNewFile}
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
<div class="form-row">
<label class="form-label" for="nf-path">路径</label>
<input id="nf-path" class="input font-mono" bind:value={newPath} placeholder="docs/intro.md" />
</div>
<div class="form-row">
<label class="form-label" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showNewFile = false)}>取消</button>
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
</div>
</Modal>
{/if}
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
/**
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
*
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
*
* 语义(契约 8.1 / ADR-0021):
* - 创建者授权(isCreatorGrant)不可收回、不可改;
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
*/
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
let grants = $state<Grant[] | null>(null);
let error = $state<string | null>(null);
let principalType = $state<"USER" | "GROUP">("USER");
let userIdInput = $state("");
let groupId = $state("");
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
let role = $state<Role>("VIEW");
let saving = $state(false);
const canManage = $derived(node.role === "MANAGE");
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
$effect(() => {
void node.id;
void load();
});
async function load(): Promise<void> {
grants = null;
error = null;
try {
const r = await api<{ grants: Grant[] }>(`/database/api/nodes/${node.id}/grants`);
grants = r.grants;
} catch (e) {
error = errText(e);
}
}
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
async function onTypeChange(): Promise<void> {
if (principalType !== "GROUP" || groupOptions !== null) return;
try {
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
groupOptions = r.groups;
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
} catch (e) {
toastErr(errText(e));
}
}
async function addGrant(): Promise<void> {
const principalId = principalType === "GROUP" ? groupId : userIdInput.trim();
if (principalId === "") {
toastErr("请填写主体");
return;
}
saving = true;
try {
// PUT /grants 是增量语义(putGrants),不是整表替换。
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType, principalId, role }] },
});
toastOk("已授予");
userIdInput = "";
await load();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function revoke(g: Grant): Promise<void> {
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
try {
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
method: "DELETE",
});
toastOk("已收回");
await load();
} catch (e) {
toastErr(errText(e));
}
}
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
await load();
} catch (e) {
toastErr(errText(e));
}
}
</script>
<div class="panel">
{#if error !== null}
<div class="py-2 text-[12.5px] text-danger">{error}</div>
{:else if grants === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else}
<table class="list">
<thead>
<tr><th>主体</th><th>级别</th><th></th></tr>
</thead>
<tbody>
{#if grants.length === 0}
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
{:else}
{#each grants as g (g.id)}
<tr>
<td>
<span class="inline-flex items-center gap-2">
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
<span class="font-mono text-[12px]">{g.principalId}</span>
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span>
</td>
<td class="file-meta">{g.role}</td>
<td class="text-right">
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
{#if !g.isCreatorGrant && canManage}
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
<Icon name="minus" size={12} /> 收回
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
{#if canManage}
<div class="my-3.5 border-t border-line-soft"></div>
<div class="section-title mb-2.5">新增授权</div>
<div class="flex flex-wrap items-center gap-2">
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
<option value="USER">用户</option>
<option value="GROUP">Group</option>
</select>
{#if principalType === "USER"}
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
{:else if groupOptions === null}
<span class="quiet flex-1">加载 Group 列表…</span>
{:else if groupOptions.length === 0}
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
{:else}
<select class="select min-w-0 flex-1" bind:value={groupId}>
{#each groupOptions as g (g.id)}
<option value={g.id}>{g.breadcrumb}</option>
{/each}
</select>
{/if}
<select class="select !w-[110px]" bind:value={role}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
{saving ? "授予中…" : "授予"}
</button>
</div>
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
{/if}
</div>
+762
View File
@@ -0,0 +1,762 @@
<script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import Avatar from "./Avatar.svelte";
// 后端返回扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[]>([]);
let loaded = $state(false);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
// 折叠的组 id(默认全展开)。用数组而非 Set:$state 的深层代理只跟踪普通对象/
// 数组,Set 的变更不会触发重渲染。
let collapsedIds = $state<string[]>([]);
const isCollapsed = (id: string): boolean => collapsedIds.includes(id);
const toggleCollapsed = (id: string): void => {
collapsedIds = isCollapsed(id) ? collapsedIds.filter((x) => x !== id) : [...collapsedIds, id];
};
let filterText = $state("");
let showArchived = $state(false);
let members = $state<MemberGroupMember[]>([]);
let membersLoaded = $state(false);
let membersError = $state<string | null>(null);
let memberFilter = $state("");
const selected = $derived(groups.find((g) => g.id === selectedId) ?? null);
const isArchived = $derived(selected?.archivedAt != null);
interface Row {
readonly g: MemberGroupNode;
readonly hasKids: boolean;
/** 过滤态下:自身是否命中(祖先链上的非命中项半透明显示)。 */
readonly hit: boolean;
}
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
const rows = $derived.by((): Row[] => {
const byParent = new Map<string | null, MemberGroupNode[]>();
const byId = new Map<string, MemberGroupNode>();
for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) ?? [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
const q = filterText.trim().toLowerCase();
let keep: Set<string> | null = null;
if (q !== "") {
keep = new Set<string>();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur: MemberGroupNode | undefined = g;
while (cur !== undefined) {
keep.add(cur.id);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
}
}
const out: Row[] = [];
const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) {
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) ?? []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠的祖先藏住)。
if (keep !== null || !isCollapsed(g.id)) walk(g.id);
}
};
walk(null);
// 兜底:父不在列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
});
const treeFoot = $derived.by(() => {
const active = groups.filter((g) => g.archivedAt === null);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
return (
`${active.length} 个活跃组 · ${totalMembers} 条成员关系` +
(archivedN > 0 ? ` · ${archivedN} 个已删除` : "")
);
});
/** 面包屑:祖先链(根在前,自身在末)。 */
const chain = $derived.by((): MemberGroupNode[] => {
if (selected === null) return [];
const byId = new Map(groups.map((g) => [g.id, g]));
const out: MemberGroupNode[] = [];
for (let cur: MemberGroupNode | undefined = selected; cur !== undefined; ) {
out.unshift(cur);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
return out;
});
const childCount = $derived(groups.filter((g) => g.parentId === selectedId).length);
const shownMembers = $derived.by(() => {
const q = memberFilter.trim().toLowerCase();
if (q === "") return members;
return members.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
m.feishuOpenId.toLowerCase().includes(q),
);
});
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>(
`/database/api/groups${showArchived ? "?includeArchived=1" : ""}`,
);
groups = r.groups;
listError = null;
loaded = true;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = [];
membersLoaded = false;
}
} catch (e) {
listError = errText(e);
loaded = true;
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
membersLoaded = false;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
membersLoaded = true;
} catch (e) {
membersError = errText(e);
membersLoaded = true;
}
}
onMount(loadGroups);
function select(id: string): void {
selectedId = id;
memberFilter = "";
void loadMembers();
}
async function toggleArchived(): Promise<void> {
showArchived = !showArchived;
await loadGroups();
}
/* ---------------- 右键菜单 ---------------- */
interface MenuItem {
readonly label?: string;
readonly ic?: import("./Icon.svelte").IconName;
readonly danger?: boolean;
readonly sep?: boolean;
readonly fn?: () => void;
}
let menu = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
function openMenu(e: MouseEvent, target: MemberGroupNode | null): void {
e.preventDefault();
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items: MenuItem[] =
target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => openCreate(null) }]
: target.archivedAt !== null
? [
{ label: "查看成员(只读)", ic: "users", fn: () => select(target.id) },
{ label: "恢复此 Group", ic: "restore", fn: () => void restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => openCreate(target) },
{ label: "添加成员", ic: "user", fn: () => { select(target.id); openAddMember(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => openRename(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => void deleteGroup(target) },
];
// 贴边翻转,避免菜单溢出视口(菜单宽 184、每项约 34)。
const w = 184;
const h = items.reduce((n, it) => n + (it.sep === true ? 9 : 34), 10);
menu = {
x: Math.min(e.clientX, window.innerWidth - w - 8),
y: Math.min(e.clientY, window.innerHeight - h - 8),
items,
};
}
/* ---------------- 弹窗 ---------------- */
let showCreate = $state(false);
let createParent = $state<MemberGroupNode | null>(null);
let newName = $state("");
let newDesc = $state("");
let showRename = $state(false);
let renameTarget = $state<MemberGroupNode | null>(null);
let editName = $state("");
let editDesc = $state("");
let showAdd = $state(false);
let addQuery = $state("");
let addResults = $state<UserSearchResult[]>([]);
let addSearching = $state(false);
function openCreate(parent: MemberGroupNode | null): void {
createParent = parent;
newName = "";
newDesc = "";
showCreate = true;
}
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
const parentId = createParent?.id ?? null;
try {
await api("/database/api/groups", {
method: "POST",
body: { name, parentId, ...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}) },
});
showCreate = false;
// 建完自动展开父节点,否则新子组藏在折叠的父下面看不见。
if (parentId !== null) collapsedIds = collapsedIds.filter((x) => x !== parentId);
toastOk("已创建成员组");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
function openRename(g: MemberGroupNode): void {
renameTarget = g;
editName = g.name;
editDesc = g.description ?? "";
showRename = true;
}
async function saveRename(): Promise<void> {
if (renameTarget === null) return;
const name = editName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
try {
// description 总是回传(含空串)—— 空串即清除描述(ADR-0028 决策6)。
await api(`/database/api/groups/${encodeURIComponent(renameTarget.id)}`, {
method: "PATCH",
body: { name, description: editDesc.trim() },
});
showRename = false;
toastOk("已保存");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (
!confirm(
`删除「${g.name}」?\n\n软删除:整棵子树一并标记删除,相关授权立即失效,` +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。",
)
)
return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
if (selectedId === g.id && !showArchived) {
selectedId = null;
members = [];
membersLoaded = false;
}
toastOk(`已删除 ${r.archivedCount} 个组(软删除,可恢复)`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
async function restoreGroup(g: MemberGroupNode): Promise<void> {
// 恢复语义与删除不对称(ADR-0028 决策7):只回该组 + 已归档祖先链,子树仍归档。
if (
!confirm(
`恢复「${g.name}」?\n\n其已删除的上级会一并恢复(否则它在树上无路径);` +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。",
)
)
return;
try {
const r = await api<{ restoredCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复 ${r.restoredCount} 个组`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
function openAddMember(): void {
addQuery = "";
addResults = [];
showAdd = true;
}
/** 成员选择器:搜全局用户,excludeGroupId 过滤掉本组已有成员。 */
async function searchUsers(): Promise<void> {
if (selectedId === null) return;
addSearching = true;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(addQuery.trim())}` +
`&excludeGroupId=${encodeURIComponent(selectedId)}`,
);
addResults = r.users;
} catch (e) {
toastErr(errText(e));
} finally {
addSearching = false;
}
}
async function addMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body: { userId },
});
toastOk("已添加成员");
addResults = addResults.filter((u) => u.userId !== userId);
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
async function removeMember(m: MemberGroupMember): Promise<void> {
if (selectedId === null) return;
if (!confirm(`将「${m.displayName || m.userId}」移出本组?其经由本组获得的授权立即失效。`)) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(m.userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
</script>
<svelte:window
onclick={() => (menu = null)}
onkeydown={(e) => {
if (e.key === "Escape") menu = null;
}}
/>
<div class="flex h-full min-h-0 items-stretch gap-3.5">
<!-- 左:组树 -->
<div class="panel flex w-[326px] shrink-0 flex-col !p-3.5" style="min-height:0">
<div class="mb-2.5 flex items-center gap-2">
<span class="flex text-accent"><Icon name="layers" size={17} /></span>
<div class="section-title flex-1">Group 树</div>
<button class="btn btn-sm" onclick={() => openCreate(null)}>
<Icon name="plus" size={13} /> 根组
</button>
</div>
<div class="relative mb-2">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="过滤组名…" bind:value={filterText} />
</div>
<label class="switch mb-2.5 text-[11.5px] text-ink-3">
<input type="checkbox" checked={showArchived} onchange={toggleArchived} />
<span></span>
显示已删除的组
</label>
<!-- 树空白处右键 = 建根组 -->
<div
class="-mx-1.5 min-h-0 flex-1 overflow-y-auto"
role="tree"
tabindex="-1"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("[data-node]") !== null) return;
openMenu(e, null);
}}
>
{#if !loaded}
<div class="quiet px-3 py-6 text-center">加载中…</div>
{:else if listError !== null}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if groups.length === 0}
<div class="quiet flex flex-col items-center gap-2 px-3 py-[22px] text-center">
<span class="flex text-line"><Icon name="layers" size={30} /></span>
暂无成员组 · 点上方「根组」开始
</div>
{:else if rows.length === 0}
<div class="quiet px-3 py-[22px] text-center">无匹配的组</div>
{:else}
{#each rows as { g, hasKids, hit } (g.id)}
{@const arch = g.archivedAt !== null}
<div
data-node
class="flex cursor-pointer select-none items-center gap-1.5 rounded-lg py-1.5 pr-2 text-[13px]"
class:bg-selected={selectedId === g.id}
class:opacity-50={!hit}
style="padding-left: {8 + g.depth * 15}px"
role="treeitem"
aria-selected={selectedId === g.id}
tabindex="-1"
onclick={() => select(g.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(g.id);
}
}}
oncontextmenu={(e) => openMenu(e, g)}
>
{#if hasKids}
<span
class="flex w-[15px] shrink-0 justify-center text-ink-3 transition-transform"
class:rotate-90={!(isCollapsed(g.id) && filterText.trim() === "")}
role="button"
tabindex="-1"
aria-label="折叠 / 展开"
onclick={(e) => {
e.stopPropagation();
toggleCollapsed(g.id);
}}
onkeydown={(e) => {
if (e.key === "Enter") toggleCollapsed(g.id);
}}
>
<Icon name="chevron" size={13} />
</span>
{:else}
<span class="inline-block w-[15px] shrink-0"></span>
{/if}
<span class="flex" class:text-accent={selectedId === g.id && !arch} class:text-ink-3={arch || selectedId !== g.id}>
<Icon name={arch ? "archive" : "group"} size={15} />
</span>
<span class="flex-1 truncate" class:text-ink-3={arch} class:line-through={arch}>{g.name}</span>
<span class="tag shrink-0" class:opacity-70={arch}>
<Icon name="user" size={10} />{g.memberCount}
</span>
{#if arch}
<span class="tag shrink-0 !text-[10px] opacity-85">已删除</span>
{/if}
</div>
{/each}
{/if}
</div>
<div class="section-note mt-2 border-t border-line-soft pt-2">{loaded ? treeFoot : ""}</div>
</div>
<!-- 右:成员表 -->
<div class="panel flex min-h-0 min-w-0 flex-1 flex-col !p-0">
{#if selected === null}
<div class="quiet m-auto flex flex-col items-center gap-2.5 p-7 text-center">
<span class="flex text-line"><Icon name="users" size={40} /></span>
从左侧选择一个 Group 查看成员
</div>
{:else}
{#if isArchived}
<!-- 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。 -->
<div
class="flex shrink-0 items-center gap-2.5 border-b border-line-soft bg-hover px-[18px] py-2.5 text-[12.5px]"
>
<span class="flex text-ink-3"><Icon name="archive" size={15} /></span>
<span class="flex-1">
此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限
</span>
<button class="btn !text-xs" onclick={() => void restoreGroup(selected)}>
<Icon name="restore" size={13} /> 恢复
</button>
</div>
{/if}
<div class="shrink-0 border-b border-line-soft px-[18px] pb-3 pt-4">
<div class="mb-1.5 text-xs">
{#each chain as c, i (c.id)}
{#if i > 0}<span class="mx-[5px] text-ink-3">/</span>{/if}
<span class={i === chain.length - 1 ? "font-medium text-ink" : "text-ink-3"}>{c.name}</span>
{/each}
</div>
<div class="flex items-center gap-2.5">
<span class="flex" class:text-ink-3={isArchived} class:text-accent={!isArchived}>
<Icon name={isArchived ? "archive" : "group"} size={20} />
</span>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold" class:text-ink-3={isArchived}>{selected.name}</div>
{#if selected.description !== null && selected.description !== ""}
<div class="section-note mt-0.5">{selected.description}</div>
{:else}
<div class="section-note mt-0.5 opacity-60">无描述</div>
{/if}
</div>
<!-- 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。 -->
{#if !isArchived}
<button class="btn !text-xs" onclick={() => openRename(selected)}>
<Icon name="pencil" size={13} /> 编辑
</button>
<button class="btn btn-primary !text-xs" onclick={openAddMember}>
<Icon name="plus" size={13} /> 添加成员
</button>
{/if}
</div>
<div class="mt-3 flex gap-4 text-xs text-ink-3">
<span class="inline-flex items-center gap-1"><Icon name="user" size={12} />{members.length} 名成员</span>
<span class="inline-flex items-center gap-1"><Icon name="layers" size={12} />层级 {selected.depth}</span>
<span class="inline-flex items-center gap-1"><Icon name="group" size={12} />{childCount} 个子组</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5 px-[18px] py-2.5">
<div class="relative max-w-[280px] flex-1">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="搜索成员…" bind:value={memberFilter} />
</div>
<span class="file-meta">
{memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`}
</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{#if !membersLoaded}
<div class="quiet px-3 py-9 text-center">加载中…</div>
{:else if membersError !== null}
<div class="px-3 py-9 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="quiet flex flex-col items-center gap-2.5 px-3 py-9 text-center">
<span class="flex text-line"><Icon name="users" size={34} /></span>
{isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"}
</div>
{:else if shownMembers.length === 0}
<div class="quiet px-3 py-[30px] text-center">无匹配成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>飞书 openId</th>
<th>加入时间</th>
{#if !isArchived}<th class="!text-right">操作</th>{/if}
</tr>
</thead>
<tbody>
{#each shownMembers as m (m.userId)}
<tr>
<td>
<span class="inline-flex items-center gap-2.5">
<Avatar displayName={m.displayName} userId={m.userId} avatarUrl={m.avatarUrl} size={28} />
<span class="font-medium">{m.displayName || "(未命名)"}</span>
</span>
</td>
<td class="file-meta">{m.userId}</td>
<td class="file-meta">{m.feishuOpenId || "—"}</td>
<td class="file-meta">{fmtDate(m.joinedAt)}</td>
{#if !isArchived}
<td class="text-right">
<button class="link-danger inline-flex items-center gap-1" onclick={() => void removeMember(m)}>
<Icon name="minus" size={12} /> 移除
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
</div>
<!-- 右键菜单 -->
{#if menu !== null}
<div
class="fixed z-[60] min-w-[184px] rounded-[10px] border border-line bg-panel p-[5px] text-[13px] shadow-[0_4px_20px_rgba(26,26,24,.07)]"
style="left:{menu.x}px;top:{menu.y}px"
role="menu"
tabindex="-1"
>
{#each menu.items as it, i (i)}
{#if it.sep === true}
<div class="mx-1.5 my-1 h-px bg-line-soft"></div>
{:else}
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-[11px] py-[7px] hover:bg-hover"
class:text-danger={it.danger === true}
role="menuitem"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
menu = null;
it.fn?.();
}}
onkeydown={(e) => {
if (e.key === "Enter") {
menu = null;
it.fn?.();
}
}}
>
{#if it.ic !== undefined}<span class="flex opacity-75"><Icon name={it.ic} size={14} /></span>{/if}
{it.label}
</div>
{/if}
{/each}
</div>
{/if}
{#if showCreate}
<Modal
title={createParent === null ? "新建根 Group" : `在「${createParent.name}」下新建子 Group`}
onclose={() => (showCreate = false)}
>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="form-row">
<label class="form-label" for="gc-desc">描述(可选)</label>
<input id="gc-desc" class="input" bind:value={newDesc} placeholder="一句话说明" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreate = false)}>取消</button>
<button class="btn btn-primary" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
{#if showRename && renameTarget !== null}
<Modal title="重命名 / 改描述" onclose={() => (showRename = false)}>
<div class="form-row">
<label class="form-label" for="gr-name">名称</label>
<input id="gr-name" class="input" bind:value={editName} />
</div>
<div class="form-row">
<label class="form-label" for="gr-desc">描述</label>
<input id="gr-desc" class="input" bind:value={editDesc} placeholder="留空则清除描述" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showRename = false)}>取消</button>
<button class="btn btn-primary" onclick={saveRename}>保存</button>
</div>
</Modal>
{/if}
{#if showAdd && selected !== null}
<Modal title={`向「${selected.name}」添加成员`} onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="ga-q">搜索用户(姓名 / userId / 飞书 openId)</label>
<div class="flex gap-2">
<input
id="ga-q"
class="input"
bind:value={addQuery}
placeholder="留空列出全部候选"
onkeydown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
/>
<button class="btn" onclick={searchUsers}><Icon name="search" size={13} /> 搜索</button>
</div>
<div class="section-note mt-1.5">已在本组的成员不会出现在结果里。</div>
</div>
<div class="max-h-[280px] overflow-y-auto">
{#if addSearching}
<div class="quiet px-3 py-6 text-center">搜索中…</div>
{:else if addResults.length === 0}
<div class="quiet px-3 py-6 text-center">无候选用户 · 先点「搜索」</div>
{:else}
{#each addResults as u (u.userId)}
<div class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0">
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={26} />
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{u.displayName || "(未命名)"}</div>
<div class="file-meta truncate">{u.feishuOpenId || u.userId}</div>
</div>
<button class="btn btn-sm" onclick={() => void addMember(u.userId)}>
<Icon name="plus" size={12} /> 添加
</button>
</div>
{/each}
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>关闭</button>
</div>
</Modal>
{/if}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts" module>
// 从已删除的 routes/adminPanels.ts 的 GROUP_ICONS 原样搬来(ADR-0029)。
export const ICONS = {
// Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录,与文件库的
// FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。
group:
"M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06",
users:
"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
plus: "M12 5v14M5 12h14",
pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z",
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z",
chevron: "m9 18 6-6-6-6",
layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5",
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2",
minus: "M5 12h14",
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
} as const;
export type IconName = keyof typeof ICONS;
</script>
<script lang="ts">
let { name, size = 16 }: { name: IconName; size?: number } = $props();
</script>
<svg
style="width:{size}px;height:{size}px"
class="shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><path d={ICONS[name]} /></svg>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
/**
* 文件库浏览器(树 + 详情 + 文件编辑栏)。
*
* 两处复用:老师端 /app(showUserFooter=true,侧栏底部带身份与退出)
* 与管理后台 /database/dashboard/library(false —— 外层壳已有身份区)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
import type { NodeChild } from "./types.js";
import TreeNode from "./TreeNode.svelte";
import NodeDetailPanel from "./NodeDetailPanel.svelte";
import FileEditor from "./FileEditor.svelte";
import Modal from "./Modal.svelte";
let { showUserFooter = false }: { showUserFooter?: boolean } = $props();
let roots = $state<NodeChild[] | null>(null);
let treeError = $state<string | null>(null);
let showCreateRoot = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
async function loadRoots(): Promise<void> {
try {
const r = await api<{ nodes: NodeChild[] }>("/database/api/nodes");
roots = r.nodes;
treeError = null;
} catch (e) {
treeError = e instanceof Error ? e.message : String(e);
}
}
onMount(loadRoots);
$effect(() => {
void $treeVersion;
void loadRoots();
});
async function createRoot(): Promise<void> {
const name = newName.trim();
if (name === "") return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: null,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateRoot = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
</script>
<div class="flex min-h-0 flex-1">
<!-- 侧栏 -->
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">文件库</span>
{#if $me?.isWebsiteAdmin}
<button class="btn btn-sm" onclick={() => (showCreateRoot = true)}>
+ 根目录
</button>
{/if}
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if roots === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if treeError}
<div class="px-3 py-6 text-center text-xs text-danger">{treeError}</div>
{:else if roots.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
</div>
{:else}
{#each roots as node (node.id)}
<TreeNode {node} depth={0} />
{/each}
{/if}
</div>
{#if showUserFooter}
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{/if}
</aside>
<!-- 主区 -->
<main class="flex-1 overflow-y-auto">
<NodeDetailPanel />
</main>
<!-- 右侧:文件预览/编辑栏(选中文件时出现) -->
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
<section class="flex w-[46%] min-w-[420px] shrink-0 flex-col overflow-y-auto border-l border-line-soft bg-bg p-4">
<FileEditor
projectId={$currentNode.id}
path={$selectedFilePath}
role={$currentNode.role}
onchanged={bumpFiles}
onclose={clearSelectedFile}
/>
</section>
{/if}
</div>
{#if showCreateRoot}
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
<div class="form-row">
<label class="form-label" for="root-name">名称</label>
<input id="root-name" class="input" bind:value={newName} placeholder="例如:物理教研" />
</div>
<div class="form-row">
<label class="form-label" for="root-kind">类型</label>
<select id="root-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="btn btn-primary" onclick={createRoot}>创建</button>
</div>
</Modal>
{/if}
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { onMount } from "svelte";
import { loadConfig, type AppConfig } from "./config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold tracking-wide text-ink">文件库</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">课程资源与教研文件,一处安放,随处可查</p>
{#if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a href="/app/dev-login-teacher" data-sveltekit-reload class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
</script>
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="w-full max-w-[440px] rounded-2xl border border-line-soft bg-panel p-6 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="mb-4 text-[15px] font-semibold">{title}</div>
{@render children()}
</div>
</div>
@@ -0,0 +1,163 @@
<script lang="ts">
import { api } from "./api.js";
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.js";
import { toastOk, toastErr } from "./stores.js";
import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
type Tab = "detail" | "files" | "grants";
let tab = $state<Tab>("detail");
let showCreateChild = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
const node = $derived($currentNode);
const crumbs = $derived($breadcrumb);
const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (canManage) out.push(["grants", "授权"]);
return out;
});
$effect(() => {
void node?.id;
tab = "detail";
clearSelectedFile();
});
async function createChild(): Promise<void> {
const name = newName.trim();
if (name === "" || node === null) return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: node.id,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateChild = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function renameNode(): Promise<void> {
if (node === null) return;
const name = prompt("新名称", node.name);
if (name === null) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "PATCH", body: { name } });
toastOk("已重命名");
bumpTree();
currentNode.update((n) => (n ? { ...n, name } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function deleteNode(): Promise<void> {
if (node === null || !confirm(`确认删除「${node.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
toastOk("已删除");
currentNode.set(null);
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
{#if node === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个文件夹或项目</div>
{:else}
<div class="mx-auto max-w-[880px] px-9 py-9">
<div class="mb-2 text-[12.5px] text-ink-3">
{#each crumbs as c, i (i)}
{#if i > 0}<span class="mx-1 text-line">/</span>{/if}
<span>{c.name ?? "…"}</span>
{/each}
</div>
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name}
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{node.role}</span>
</div>
<div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"}
<button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if}
{#if canManage}
<button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
<button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
{/if}
</div>
</div>
<div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
{#each tabs as [id, label] (id)}
<button
class="-mb-px border-b-2 px-3.5 py-2 text-[13px] transition {tab === id
? 'border-accent font-semibold text-ink'
: 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id)}
>{label}</button>
{/each}
</div>
{#if tab === "grants"}
<GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} />
{:else}
<OverviewPanel {node} />
{#if node.kind === "FOLDER"}
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
{/if}
{/if}
</div>
{/if}
{#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<div class="form-row">
<label class="form-label" for="child-name">名称</label>
<input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
</div>
<div class="form-row">
<label class="form-label" for="child-kind">类型</label>
<select id="child-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="btn btn-primary" onclick={createChild}>创建</button>
</div>
</Modal>
{/if}
@@ -0,0 +1,155 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
let { node }: { node: NodeDetail } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
/** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
$effect(() => {
void node.id;
exportJob = null;
});
function openEditDesc(): void {
descDraft = node.description ?? "";
showEditDesc = true;
}
async function saveDesc(): Promise<void> {
const description = descDraft.trim();
try {
await api(`/database/api/nodes/${node.id}`, {
method: "PATCH",
body: { description: description === "" ? null : description },
});
toastOk("简介已保存");
showEditDesc = false;
const next = description === "" ? null : description;
currentNode.update((n) => (n && n.id === node.id ? { ...n, description: next } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function submitExport(): Promise<void> {
try {
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
method: "POST",
body: { target: "manifest" },
});
toastOk("导出已提交");
void pollExport(r.jobId);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function pollExport(jobId: string): Promise<void> {
for (;;) {
await new Promise((r) => setTimeout(r, 800));
try {
const job = await api<ExportJob>(`/database/api/exports/${jobId}`);
exportJob = job;
if (job.status === "DONE" || job.status === "FAILED") break;
} catch {
break;
}
}
}
</script>
<div class="panel">
<div class="mb-4">
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
<div class="text-[13.5px] leading-7 text-ink">
{#if node.description}
{node.description}
{:else}
<span class="italic text-ink-3">暂无简介</span>
{/if}
{#if canEdit}
<button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if}
</div>
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<div>类型 <b class="font-semibold text-ink">{node.kind === "PROJECT" ? "项目" : "文件夹"}</b></div>
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleString("zh-CN")}</b></div>
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div>
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-wrap items-center gap-2.5">
<span class="quiet">独立权限</span>
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
{#if canManage}
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
{/if}
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2">
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
<button class="btn" onclick={submitExport}>开始导出</button>
{#if exportJob}
<span class="file-meta">
{#if exportJob.status === "DONE"}
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{:else if exportJob.status === "FAILED"}
失败:{exportJob.error ?? ""}
{:else}
{exportJob.status}
{/if}
</span>
{/if}
</div>
{/if}
</div>
{#if showEditDesc}
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
<div class="form-row">
<label class="form-label" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="input !leading-7" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div>
</Modal>
{/if}
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { toasts } from "./stores.js";
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each $toasts as t (t.id)}
<div class="max-w-[340px] rounded-lg px-4 py-2 text-sm text-white {t.kind === 'err' ? 'bg-[#7E2C26]' : 'bg-[#333230]'}">
{t.message}
</div>
{/each}
</div>
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import TreeNode from "./TreeNode.svelte";
import { api } from "./api.js";
import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js";
import { toastErr } from "./stores.js";
import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js";
let { node, depth }: { node: NodeChild; depth: number } = $props();
let children = $state<NodeChild[] | null>(null);
const isOpen = $derived($expanded.has(node.id));
const isSelected = $derived($currentNode?.id === node.id);
// 树刷新信号(增/删/移/重命名)→ 失效子节点缓存,展开状态下随之重载
$effect(() => {
void $treeVersion;
children = null;
});
$effect(() => {
if (isOpen && node.kind === "FOLDER" && children === null) {
api<{ nodes: NodeChild[] }>(`/database/api/nodes?parentId=${encodeURIComponent(node.id)}`)
.then((r) => (children = r.nodes))
.catch((e) => toastErr(e instanceof Error ? e.message : String(e)));
}
});
async function select(): Promise<void> {
if (node.kind === "FOLDER") toggleExpanded(node.id);
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${node.id}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${node.id}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<div>
<div
class="tree-item flex cursor-pointer items-center gap-1 rounded-lg px-1.5 py-1.5 select-none {isSelected ? 'bg-selected' : 'hover:bg-hover'}"
role="button"
tabindex="0"
onclick={select}
onkeydown={(e) => e.key === "Enter" && select()}
>
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-ink-3">
{#if node.kind === "FOLDER"}
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
{#if isOpen}<path d="M6 9l6 6 6-6z" />{:else}<path d="M9 6l6 6-6 6z" />{/if}
</svg>
{/if}
</span>
<span class="flex h-4 w-4 shrink-0 items-center justify-center {node.kind === 'PROJECT' ? 'text-ink' : 'text-ink-3'}">
{#if node.kind === "PROJECT"}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
{/if}
</span>
<span class="truncate">{node.name}</span>
{#if node.role !== "MANAGE"}
<span class="ml-auto pr-1 font-mono text-[10px] text-ink-3">{node.role}</span>
{/if}
</div>
{#if node.kind === "FOLDER" && isOpen}
<div class="ml-[15px] border-l border-guide pl-1">
{#if children === null}
<div class="px-3 py-1.5 text-xs text-ink-3"></div>
{:else}
{#each children as child (child.id)}
<TreeNode node={child} depth={depth + 1} />
{/each}
{/if}
</div>
{/if}
</div>
+49
View File
@@ -0,0 +1,49 @@
/** 与 /database/api/* 的约定一致;401 时清空会话(回到登录视图)。 */
import { me } from "./stores.js";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
}
}
export class UnauthenticatedError extends Error {
constructor() {
super("unauthenticated");
this.name = "UnauthenticatedError";
}
}
interface RequestOpts {
readonly method?: string;
readonly body?: unknown;
}
export async function api<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
const res = await fetch(path, {
credentials: "same-origin",
method: opts.method ?? "GET",
...(opts.body !== undefined
? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) }
: {}),
});
if (res.status === 401) {
me.set(null);
throw new UnauthenticatedError();
}
if (res.status === 204) return null as T;
const text = await res.text();
const data = text === "" ? null : (JSON.parse(text) as unknown);
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error ?? {};
throw new ApiError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err as Record<string, unknown>);
}
return data as T;
}
+34
View File
@@ -0,0 +1,34 @@
import { writable } from "svelte/store";
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
export const expanded = writable<Set<string>>(new Set());
export const currentNode = writable<NodeDetail | null>(null);
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
export const treeVersion = writable(0);
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
export const selectedFilePath = writable<string | null>(null);
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
export const filesVersion = writable(0);
export function bumpTree(): void {
treeVersion.update((v) => v + 1);
}
export function bumpFiles(): void {
filesVersion.update((v) => v + 1);
}
export function clearSelectedFile(): void {
selectedFilePath.set(null);
}
export function toggleExpanded(id: string): void {
expanded.update((set) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 前端 bootstrap:silo org slug(拼飞书 OAuth 链接用)+ dev 一键登录开关。
*
* 打 `/database/config` 而非 `/database/api/login-info`:后者由 teacherApp.ts 在
* silo org 查找成功之后才注册,org 缺失时整条链路不存在;前者在
* databaseRoutes.ts 顶部无条件注册。两者形状相同(见 src/database/README.md)。
*
* 免鉴权 —— org slug 本就出现在 OAuth URL 里,不构成敏感信息。
*/
import { api } from "./api.js";
export interface AppConfig {
readonly orgSlug: string;
readonly devLoginEnabled: boolean;
}
let cached: AppConfig | null = null;
export async function loadConfig(): Promise<AppConfig> {
if (cached !== null) return cached;
cached = await api<AppConfig>("/database/config");
return cached;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 会话装载:GET /database/api/me 一次,结果进 `me` store。
* 老师端与管理后台共用 —— 两处的区别只是拿到 me 之后怎么用
* (老师端未登录显示登录视图;管理后台未登录跳 /database/admin,
* 非 isWebsiteAdmin 显示无权提示)。
*/
import { get } from "svelte/store";
import { api, UnauthenticatedError } from "./api.js";
import { me, authChecked } from "./stores.js";
import type { MeResponse } from "./types.js";
/** 幂等:已检查过就不再打请求(路由间切换不重复拉取)。 */
export async function loadSession(force = false): Promise<void> {
if (get(authChecked) && !force) return;
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
}
/** 退出登录:清后端 cookie 再清前端 store。 */
export async function logout(): Promise<void> {
try {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
} catch {
/* 网络失败也照样清前端状态 */
}
me.set(null);
}
+26
View File
@@ -0,0 +1,26 @@
import { writable } from "svelte/store";
import type { MeResponse } from "./types.js";
/** 当前登录身份;null = 未登录(显示登录视图)。 */
export const me = writable<MeResponse | null>(null);
export const authChecked = writable(false);
export interface ToastItem {
readonly id: number;
readonly message: string;
readonly kind: "info" | "err";
}
let nextToastId = 1;
export const toasts = writable<ToastItem[]>([]);
export function toast(message: string, kind: ToastItem["kind"] = "info"): void {
const id = nextToastId++;
toasts.update((list) => [...list, { id, message, kind }]);
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, 3600);
}
export const toastOk = (m: string): void => toast(m, "info");
export const toastErr = (m: string): void => toast(m, "err");
+164
View File
@@ -0,0 +1,164 @@
/** 与后端 /database/api/* 响应形状对齐。 */
export type NodeKind = "FOLDER" | "PROJECT";
export type Role = "VIEW" | "EDIT" | "MANAGE";
export interface NodeChild {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly role: Role;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface BreadcrumbEntry {
readonly depth: number;
readonly id: string | null;
readonly name: string | null;
readonly kind: NodeKind;
}
export interface NodeDetail {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly description: string | null;
readonly role: Role;
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
readonly independentPermission: boolean;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface MeResponse {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContent {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export interface VersionInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
}
export interface ExportJob {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
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;
readonly parentId: string | null;
readonly name: string;
readonly description: string | null;
readonly depth: number;
readonly memberCount: number;
/** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
* 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */
readonly archivedAt: string | null;
}
export interface MemberGroupMember {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
/** 加入本组时间;ISO 串。 */
readonly joinedAt: string;
}
/** 节点授权(GET /database/api/nodes/:id/grants)。 */
export interface Grant {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean;
readonly createdAt: string;
}
/** Group 选择器候选(GET /database/api/groups/search)。 */
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接。 */
readonly breadcrumb: string;
}
/** 成员选择器候选(GET /database/api/users/search)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
/** 管理后台概览统计(GET /database/api/stats)。 */
export interface DashboardStats {
readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
/** ISO 串;后端 JSON 序列化后不再是 Date。 */
readonly when: string;
}>;
}
/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
export interface OrgMember {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrgRole;
readonly createdAt: string;
}
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import "../app.css";
import Toasts from "$lib/Toasts.svelte";
let { children } = $props();
</script>
<div class="h-full">
{@render children()}
</div>
<Toasts />
+7
View File
@@ -0,0 +1,7 @@
/**
* 纯 SPA:关掉 SSR 与预渲染,构建产物只有一个 fallback index.html
* (adapter-static + fallback,见 svelte.config.js),由 hub 后端在
* /app 与 /database/* 两个前缀下原样送出。
*/
export const ssr = false;
export const prerender = false;
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// 根路径不承载界面:老师端在 /app,管理后台在 /database。
onMount(() => {
void goto("/app", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -0,0 +1,22 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后直接是文件库浏览器。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import LibraryView from "$lib/LibraryView.svelte";
onMount(loadSession);
</script>
<svelte:head><title>文件库</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<div class="flex h-full flex-col">
<LibraryView showUserFooter />
</div>
{:else}
<LoginView />
{/if}
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// /database 本身不承载界面(与旧后端 /database/admin → dashboard 的跳转一致)。
onMount(() => {
void goto("/database/dashboard", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -0,0 +1,63 @@
<script lang="ts">
/**
* 管理后台登录页(迁自后端 renderLoginPage)。
* 已登录直接跳 dashboard —— 与旧后端路由 /database/admin 的行为一致。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import { loadConfig, type AppConfig } from "$lib/config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
await loadSession();
if ($me !== null) {
void goto("/database/dashboard", { replaceState: true });
return;
}
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<svelte:head><title>Database Admin · 登录</title></svelte:head>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold text-ink">Database Admin</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">使用飞书登录以管理数据库</p>
{#if !$authChecked}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{:else if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a
href="/database/dev-login"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover"
>⚡ 一键登录管理员</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
/**
* 管理后台外壳(迁自后端 renderDashboard 的侧栏 + 身份区)。
*
* 与旧实现的区别:六个 tab 是真 URL 路由(/database/dashboard/library 等),
* 不再是 location.hash + display:none 切换 —— 刷新不丢位置,链接可分享。
*
* 权限门:未登录跳 /database/admin;登录但非 OWNER/ADMIN(isWebsiteAdmin)
* 显示无权提示。语义与 ADR-0028 一致 —— 管理面板要求 silo org 的 OWNER/ADMIN。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import Avatar from "$lib/Avatar.svelte";
let { children } = $props();
const NAV = [
{ seg: "", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ seg: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
] as const;
const BASE = "/database/dashboard";
onMount(async () => {
await loadSession();
if ($me === null) void goto("/database/admin", { replaceState: true });
});
function href(seg: string): string {
return seg === "" ? BASE : `${BASE}/${seg}`;
}
function isActive(seg: string): boolean {
const path = page.url.pathname.replace(/\/$/, "");
return seg === "" ? path === BASE : path === `${BASE}/${seg}`;
}
</script>
<svelte:head><title>Database Admin</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">加载中…</div>
{:else if $me === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转到登录页…</div>
{:else if !$me.isWebsiteAdmin}
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[420px] rounded-2xl border border-line-soft bg-panel p-9 text-center shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<h2 class="mb-2 text-lg font-semibold text-ink">无权访问管理后台</h2>
<p class="mb-6 text-[13px] text-ink-3">
当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。
</p>
<a href="/app" class="btn btn-primary justify-center">前往文件库</a>
<button class="btn mt-3 w-full justify-center" onclick={logout}>退出登录</button>
</div>
</div>
{:else}
<div class="flex h-full">
<aside class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="border-b border-line-soft px-4 py-4">
<span class="text-[15px] font-semibold text-ink">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-0.5 p-2.5">
{#each NAV as item (item.seg)}
{@const active = isActive(item.seg)}
<a
href={href(item.seg)}
class="flex items-center gap-2.5 rounded-[10px] px-3.5 py-2 text-[13px] transition"
class:bg-selected={active}
class:text-ink={active}
class:font-semibold={active}
class:text-ink-3={!active}
class:hover:bg-hover={!active}
>
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={item.icon} /></svg>
{item.label}
</a>
{/each}
</nav>
<div class="m-2.5 flex items-center gap-2.5 border-t border-line-soft px-3 py-2.5">
<Avatar displayName={$me.displayName} userId={$me.userId} avatarUrl={$me.avatarUrl} size={26} />
<p class="min-w-0 flex-1 truncate text-[12.5px] text-ink" title={$me.userId}>{$me.displayName}</p>
<button class="btn !px-2.5 !py-[3px] !text-[11px]" onclick={logout}>退出</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,67 @@
<script lang="ts">
/** 概览(迁自后端 renderDashboard 的统计卡片 + 最近活动)。数据走 GET /database/api/stats。 */
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import type { DashboardStats } from "$lib/types.js";
let stats = $state<DashboardStats | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
stats = await api<DashboardStats>("/database/api/stats");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
const cards = $derived([
{ label: "文件夹", value: stats?.folders },
{ label: "项目", value: stats?.projects },
{ label: "文件", value: stats?.files },
{ label: "活跃授权", value: stats?.grants },
]);
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">概览</h1>
<p class="mb-5 text-[11.5px] text-ink-3">文件库实时数据</p>
{#if error}
<div class="panel text-[12.5px] text-danger">{error}</div>
{:else}
<div class="grid grid-cols-4 gap-4">
{#each cards as card (card.label)}
<div class="panel !px-5 !py-[18px]">
<p class="text-[12.5px] text-ink-3">{card.label}</p>
<p class="mt-1.5 text-[28px] font-semibold text-ink">{card.value ?? "—"}</p>
</div>
{/each}
</div>
<div class="panel mt-[18px]">
<h2 class="mb-2 text-[13.5px] font-semibold text-ink">最近活动</h2>
{#if stats === null}
<div class="quiet py-6 text-center">加载中…</div>
{:else if stats.recent.length === 0}
<div class="quiet py-[26px] text-center">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>
{:else}
{#each stats.recent as row (row.action + row.when + row.label)}
<div class="flex items-center gap-3 border-t border-line-soft py-2.5 text-[13px]">
<span class="tag shrink-0">{row.action}</span>
<span class="truncate text-ink">{row.label}</span>
<span class="ml-auto shrink-0 text-[11.5px] text-ink-3">{row.actor} · {fmtWhen(row.when)}</span>
</div>
{/each}
{/if}
</div>
{/if}
</section>
@@ -0,0 +1,10 @@
<script lang="ts">
/** Group 管理 tab —— MemberGroup 嵌套树(ADR-0028)。
* 外框 padding/overflow 对齐旧 `#tab-groups`(padding:20px;overflow:hidden):
* 两栏各自内部滚动,外层不滚。 */
import GroupAdmin from "$lib/GroupAdmin.svelte";
</script>
<div class="min-h-0 flex-1 overflow-hidden p-5">
<GroupAdmin />
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
/** 文件库 tab —— 与老师端 /app 同一个浏览器组件,区别只在侧栏身份区由外壳提供。 */
import LibraryView from "$lib/LibraryView.svelte";
</script>
<div class="flex min-h-0 flex-1 flex-col">
<LibraryView />
</div>
@@ -0,0 +1,4 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
</section>
@@ -0,0 +1,4 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">设置</h1>
<p class="text-[12.5px] text-ink-3">设置功能建设中</p>
</section>
@@ -0,0 +1,168 @@
<script lang="ts">
/**
* 用户管理(迁自后端 adminPanels.ts renderUsersPanel)。
*
* 用户 = silo org 的成员,走平台层 /api/org/:slug/members(见 src/admin/routes/membersRoutes.ts)。
* 与 Group 管理是两套体系:MemberGroup 是全局主体、不归属 org(ADR-0028),
* 这里管的是 org 成员与其角色。
*/
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import { loadConfig } from "$lib/config.js";
import { toastOk, toastErr } from "$lib/stores.js";
import type { OrgMember, OrgRole } from "$lib/types.js";
const ROLE_LABEL: Record<OrgRole, string> = {
OWNER: "所有者",
ADMIN: "管理员",
MEMBER: "普通老师",
};
const ROLES: readonly OrgRole[] = ["OWNER", "ADMIN", "MEMBER"];
let orgSlug = $state<string | null>(null);
let members = $state<OrgMember[] | null>(null);
let error = $state<string | null>(null);
let newOpenId = $state("");
let newName = $state("");
let newRole = $state<OrgRole>("MEMBER");
let adding = $state(false);
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
async function load(): Promise<void> {
if (base === null) return;
try {
const r = await api<{ members: OrgMember[] }>(`${base}/members`);
members = r.members;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
try {
orgSlug = (await loadConfig()).orgSlug;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
async function addMember(): Promise<void> {
const feishuOpenId = newOpenId.trim();
if (feishuOpenId === "") {
toastErr("请填写用户 openId");
return;
}
if (base === null) return;
adding = true;
try {
const displayName = newName.trim();
await api(`${base}/members`, {
method: "POST",
body: { feishuOpenId, role: newRole, ...(displayName !== "" ? { displayName } : {}) },
});
newOpenId = "";
newName = "";
toastOk("已添加");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
adding = false;
}
}
async function setRole(userId: string, role: string): Promise<void> {
if (base === null) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: { role } });
toastOk("角色已更新");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
await load();
}
}
async function revoke(userId: string, displayName: string): Promise<void> {
if (base === null) return;
if (!confirm(`移除成员「${displayName || userId}」?`)) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}/revoke`, { method: "POST" });
toastOk("已移除");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-4 text-lg font-semibold text-ink">用户管理</h1>
<div class="max-w-[880px]">
<div class="panel mb-3.5">
<div class="section-title mb-2.5">添加成员</div>
<div class="flex flex-wrap items-center gap-2">
<input class="input min-w-0 flex-[2]" placeholder="用户 openId(飞书 ou_ 开头)" bind:value={newOpenId} />
<input class="input min-w-0 flex-1" placeholder="显示名(可选)" bind:value={newName} />
<select class="select !w-[130px]" bind:value={newRole}>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addMember} disabled={adding}>
{adding ? "添加中…" : "添加"}
</button>
</div>
</div>
<div class="panel">
<div class="section-title mb-2.5">成员列表</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if members === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if members.length === 0}
<div class="quiet py-[18px] text-center">暂无成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m (m.userId)}
<tr>
<td class="text-ink">{m.displayName || m.userId}</td>
<td class="file-meta">{m.userId}</td>
<td>
<select
class="select !w-[110px] !px-2 !py-[3px] !text-xs"
value={m.role}
onchange={(e) => setRole(m.userId, e.currentTarget.value)}
>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
</td>
<td class="text-right">
<button class="link-danger" onclick={() => revoke(m.userId, m.displayName)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</section>
+35
View File
@@ -0,0 +1,35 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* 老师端 /app 与管理后台 /database/* 共用这一份 SPA 构建产物,由 hub 后端静态托管
* (见 hub/src/database/static.ts)。服务端不渲染任何页面,只提供 /database/api/*。
*
* 两个关键配置:
*
* - `appDir: '_filelib'` —— 默认 `_app` 会与 admin-web 在同一个 Fastify 实例上注册的
* 根 `/_app/*` 资源路由撞车(见 hub/src/admin/static.ts),Fastify 重复路由会直接
* 在启动时抛错。改名后两套 SPA 的资源路径互不干扰。
*
* - `paths.relative: false` —— 同一份 index.html 会在不同深度的 URL 下被送出
* (`/app`、`/database/dashboard/users`),相对资源路径会解析错。必须用绝对路径。
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
appDir: '_filelib',
paths: {
base: '',
relative: false,
},
},
};
export default config;
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
+27
View File
@@ -0,0 +1,27 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。
// 开发时 vite dev(:5173)把 API/认证/一键登录请求代理到后端(:8788);
// 页面路由全部由 SvelteKit 客户端路由处理,后端不参与。
const backend = "http://127.0.0.1:8788";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5173,
proxy: {
"/database/api": backend,
// 免鉴权 bootstrap(org slug + dev 开关);登录页和用户管理页都靠它。
"/database/config": backend,
"/auth": backend,
// 后端拥有的 DEV 一键登录端点(签 cookie 后 302);不代理会被 SPA 回退吃掉。
"/database/dev-login": backend,
"/app/dev-login": backend,
"/app/dev-login-teacher": backend,
// 平台层 org 成员 API(用户管理面板)。
"/api/org": backend,
},
},
});
+205 -10
View File
@@ -13,6 +13,7 @@
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"@larksuiteoapi/node-sdk": "^1.70.0",
"@prisma/client": "^6.19.3",
"ai": "^7.0.16",
@@ -902,6 +903,22 @@
"node": ">=18"
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -1033,6 +1050,83 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz",
"integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@fastify/static/node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/static/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1068,6 +1162,15 @@
"ws": "^8.19.0"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1934,6 +2037,15 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -1973,6 +2085,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2254,7 +2378,6 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -2447,8 +2570,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "3.0.3",
@@ -2947,6 +3069,23 @@
"giget": "dist/cli.mjs"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3013,7 +3152,6 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
@@ -3096,8 +3234,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
@@ -3546,6 +3683,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3588,6 +3734,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3609,6 +3767,30 @@
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -3792,6 +3974,22 @@
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -4370,8 +4568,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/shebang-command": {
"version": "2.0.0",
@@ -4570,7 +4767,6 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
@@ -4658,7 +4854,6 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.6"
}
+4 -3
View File
@@ -12,6 +12,7 @@
"@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"@larksuiteoapi/node-sdk": "^1.70.0",
"@prisma/client": "^6.19.3",
"ai": "^7.0.16",
@@ -33,7 +34,7 @@
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build && npm run database:build",
"build": "tsc -p tsconfig.json && npm run admin:build && npm run filelib:build",
"start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit",
"audit:production": "npm audit --omit=dev --audit-level=high",
@@ -49,7 +50,7 @@
"test:watch": "vitest",
"admin:dev": "npm run dev --prefix admin-web",
"admin:build": "npm run build --prefix admin-web",
"database:dev": "npm run dev --prefix database-admin",
"database:build": "npm run build --prefix database-admin"
"filelib:dev": "npm run dev --prefix filelib-web",
"filelib:build": "npm run build --prefix filelib-web"
}
}
@@ -0,0 +1,92 @@
-- File library (文件库) — 语义锚定:仓库根《文件库-接口契约.md》、.omo/文件库-开工计划.md (D11D19)。
-- 本地无 PG 时手写;有 PG 后可用 `prisma migrate diff` 核对与 schema 的一致性。
-- CreateEnum
CREATE TYPE "FileLibNodeKind" AS ENUM ('FOLDER', 'PROJECT');
CREATE TYPE "FileLibProvisionStatus" AS ENUM ('PROVISIONING', 'READY', 'FAILED');
CREATE TYPE "FileLibRole" AS ENUM ('VIEW', 'EDIT', 'MANAGE');
CREATE TYPE "FileLibPrincipalType" AS ENUM ('USER', 'GROUP');
CREATE TYPE "FileLibExportStatus" AS ENUM ('QUEUED', 'RUNNING', 'DONE', 'FAILED');
-- CreateTable
CREATE TABLE "FileLibNode" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"kind" "FileLibNodeKind" NOT NULL,
"name" TEXT NOT NULL,
"nameLower" TEXT NOT NULL,
"pathIds" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"provisionStatus" "FileLibProvisionStatus" NOT NULL DEFAULT 'READY',
"storageDir" TEXT,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibNode_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FileLibGrant" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"principalType" "FileLibPrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"role" "FileLibRole" NOT NULL,
"isCreatorGrant" BOOLEAN NOT NULL DEFAULT false,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "FileLibGrant_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FileLibProjectSettings" (
"nodeId" TEXT NOT NULL,
"independentPermissionsEnabled" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "FileLibProjectSettings_pkey" PRIMARY KEY ("nodeId")
);
CREATE TABLE "FileLibExportJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"target" TEXT NOT NULL,
"params" JSONB NOT NULL,
"status" "FileLibExportStatus" NOT NULL DEFAULT 'QUEUED',
"downloadUrl" TEXT,
"error" TEXT,
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibExportJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex (schema-declared)
CREATE INDEX "FileLibNode_organizationId_parentId_deletedAt_idx" ON "FileLibNode"("organizationId", "parentId", "deletedAt");
CREATE INDEX "FileLibNode_organizationId_pathIds_idx" ON "FileLibNode"("organizationId", "pathIds");
CREATE INDEX "FileLibNode_creatorId_idx" ON "FileLibNode"("creatorId");
CREATE INDEX "FileLibGrant_organizationId_revokedAt_idx" ON "FileLibGrant"("organizationId", "revokedAt");
CREATE INDEX "FileLibGrant_principalType_principalId_revokedAt_idx" ON "FileLibGrant"("principalType", "principalId", "revokedAt");
CREATE INDEX "FileLibGrant_nodeId_revokedAt_idx" ON "FileLibGrant"("nodeId", "revokedAt");
CREATE INDEX "FileLibExportJob_organizationId_status_idx" ON "FileLibExportJob"("organizationId", "status");
-- D14:活跃兄弟节点大小写不敏感唯一。root 的 parentId 为 NULL,用 COALESCE 归入同一键空间。
CREATE UNIQUE INDEX "FileLibNode_active_sibling_name_key"
ON "FileLibNode"("organizationId", COALESCE("parentId", ''), "nameLower")
WHERE "deletedAt" IS NULL;
-- 契约 2.3:同一节点上同一 principal 至多一条活跃授权(Postgres 原生 UNIQUE 无法约束 NULL revokedAt)。
CREATE UNIQUE INDEX "FileLibGrant_active_unique"
ON "FileLibGrant"("nodeId", "principalType", "principalId")
WHERE "revokedAt" IS NULL;
-- AddForeignKey
ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileLibNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "FileLibGrant" ADD CONSTRAINT "FileLibGrant_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibProjectSettings" ADD CONSTRAINT "FileLibProjectSettings_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibExportJob" ADD CONSTRAINT "FileLibExportJob_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- 为 FileLibNode 加简介字段,老师在创建/概览页填写。
ALTER TABLE "FileLibNode" ADD COLUMN "description" TEXT;
+125
View File
@@ -50,6 +50,7 @@ model Organization {
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
fileLibNodes FileLibNode[]
@@index([status])
}
@@ -985,3 +986,127 @@ model CapabilityCredentialVersion {
@@index([keyId])
@@index([createdByUserId])
}
// --- File library (文件库) -------------------------------------------------
//
// 独立模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与 .omo/文件库-开工计划.md
// (D11D19)锚定。与上面的 Folder/Project(hub 自己的 explorer,ADR-0021)是两套
// 体系,不复用、不互相引用。
/// 文件库目录树节点:文件夹(容器)或项目(叶子,关联 git 仓库)。
/// parentId 是树的权威关系;pathIds 是 id 编码的物化路径(派生),随 create/move
/// 在事务内维护(计划 D12;name 不入路径,rename 不重写后代)。
model FileLibNode {
id String @id
organizationId String
parentId String?
kind FileLibNodeKind
name String
/// D14:NFC+trim 的小写形式;活跃兄弟节点大小写不敏感唯一(部分唯一索引在迁移 SQL)。
nameLower String
/// id 编码物化路径,形如 "/rootId/childId/selfId"。祖先展开与前缀查询都用它。
pathIds String
/// D11:创建者不可变,自动持有 isCreatorGrant=true 的 MANAGE grant。
/// 故意不建 FK:这是不可变历史事实,不随 User 生命周期变化。
creatorId String
/// 老师可填写的简介,创建/概览页展示,通俗易懂地说明这个节点的用途。
description String?
/// 项目 provisioning 状态机(DB/git 双写协调,Metis 风险#1);文件夹恒 READY。
provisionStatus FileLibProvisionStatus @default(READY)
/// 项目仓库目录(绝对路径);文件夹为 null。
storageDir String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent FileLibNode? @relation("fileLibTree", fields: [parentId], references: [id], onDelete: Restrict)
children FileLibNode[] @relation("fileLibTree")
grants FileLibGrant[]
projectSettings FileLibProjectSettings?
exportJobs FileLibExportJob[]
@@index([organizationId, parentId, deletedAt])
@@index([organizationId, pathIds])
@@index([creatorId])
}
enum FileLibNodeKind {
FOLDER
PROJECT
}
enum FileLibProvisionStatus {
PROVISIONING
READY
FAILED
}
/// 文件库权限级别:MANAGE > EDIT > VIEW(契约 2.2,只取最高、无降权)。
enum FileLibRole {
VIEW
EDIT
MANAGE
}
enum FileLibPrincipalType {
USER
GROUP
}
/// 契约 2.3:grant 直接挂在节点上,最终权限 = max(个人, 递归 Group, 祖先继承)。
/// 活跃授权唯一性由迁移里的部分唯一索引保证(revokedAt IS NULL)。
model FileLibGrant {
id String @id @default(cuid())
organizationId String
nodeId String
principalType FileLibPrincipalType
principalId String
role FileLibRole
/// D11:创建者自动 grant;独立权限开关关闭时,项目级 grant 里只有它仍生效。
isCreatorGrant Boolean @default(false)
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([organizationId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([nodeId, revokedAt])
}
/// 契约 P5/D11:项目独立权限开关。默认关闭(仅继承);关闭时项目级非创建者
/// grant 冻结不删除,重新开启即恢复。
model FileLibProjectSettings {
nodeId String @id
independentPermissionsEnabled Boolean @default(false)
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
}
enum FileLibExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
/// 契约 D10:导出为异步任务。外部导出工具参数 OPEN-6,adapter 就位前先建模型。
model FileLibExportJob {
id String @id @default(cuid())
organizationId String
nodeId String
target String
params Json
status FileLibExportStatus @default(QUEUED)
downloadUrl String?
error String?
createdByUserId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([organizationId, status])
}
+74 -24
View File
@@ -3,26 +3,35 @@
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。
**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/`
(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个
免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。
**前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/`
同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** ——
两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、
以及把构建产物托管出去。服务端不渲染任何 HTML。
后端路由:
- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }`
- `GET /database/config` —— 免鉴权。返回 `{ orgSlug, devLoginEnabled }`
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。
`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。)
- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。
- `GET /database/dev-login` —— 仅开发。见下。
- `GET /database``GET /database/*` —— SPA shell / 客户端路由 fallback
`static.ts``registerDatabaseSpa`;资产在 `/database/_app/*`
- `GET /database``GET /database/*``GET /app``GET /app/*` —— SPA shell /
客户端路由 fallback`static.ts``registerDatabaseSpa`)。
- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`
以避开 `admin-web` 在根上注册的 `/_app/*`(同名会让 Fastify 启动即抛重复路由)。
SPA 页面(`database-admin``paths.base='/database'`):
SPA 页面(`filelib-web`,真 URL 路由、无 hash):
- `/database/admin` —— 飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`slug 来自
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**
- `/app` —— 老师端文件库。未登录显示登录卡片。
- `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`
回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie
- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页;
**登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由:
`/database/dashboard`(概览)、`/library``/users``/groups``/search``/settings`
> **注册顺序要点**concrete 路由(`/database/config`、`/database/dev-login`)必须在
> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证),
> **注册顺序要点**concrete 路由(`/database/config`、`/database/api/*`、
> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的
> `/database/*`、`/app/*` fallback 之前注册(已在 `plugin.ts` 保证),
> 否则通配会 shadow 它们。
## 开发模式:用环境变量开启一键登录
@@ -72,22 +81,63 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| 文件 | 职责 |
|------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()`;先挂 concrete 路由再挂 SPA |
| `routes/databaseRoutes.ts` | 后端 JSON / redirect 路由(`/database/config``/database/dev-login`),**数据端点加在这里** |
| `static.ts` | `registerDatabaseSpa`:托管 `database-admin/build` 的 SPA + `/database/*` fallback |
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | `/database/config``/database/api/stats`、dev 旁路 + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app``/database` 两个 SPA 回退 |
| `filelib/` | 文件库领域层(见下) |
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/...")`
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/api/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。**页面**则加在 `database-admin/src/routes/`SvelteKit 路由)
注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。
## SPA 构建与托管
## 文件库(filelib/)
- 前端在 `hub/database-admin/``npm run build`(或 hub 根的 `npm run database:build`
产出到 `database-admin/build/`。hub 的 `npm run build` 会把两个 SPA 一起带出来。
- `static.ts` 默认从 `../../database-admin/build` 读产物;可用 `CPH_DATABASE_UI_DIR`
覆盖。产物缺失时降级:只 warn,不挂 SPA,`/database/config``/database/dev-login` 仍可用。
- 本地开发:hub 根 `npm run database:dev` 起 Vite,它把 `/api``/auth``/database`
代理到 `127.0.0.1:8788`
独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11D19 等)
出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`,
内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN)
另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是
两套体系,不复用。**
| 文件 | 职责 |
|------|------|
| `filelib/model.ts` | 角色秩(MANAGE>EDIT>VIEW)、D14 命名规则、FileLibError |
| `filelib/permission.ts` | 纯权限 reducer(取最高/不降权/祖先继承/D11 冻结),不碰 IO |
| `filelib/treeService.ts` | 树增删改查;每个写操作同事务落审计 |
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
| `filelib/routeShared.ts` | 路由共享件(依赖装配/错误映射/请求体校验) |
环境变量:
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
> 持久化 git 包到位后此问题消失。
关键语义速查:
- **D8**:无权限 → 404(不泄露存在性);越权 → 403;Group 服务故障 → 503
- **D11**:creator 不可变 + 自动 MANAGE;独立权限关闭时项目级非创建者 grant 冻结
- **D12**:move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁
- **D15**:删除只打标本节点,"任一祖先已删"即整支不可见
- **8.1**:MANAGE 仅创建者可授/收;creator grant 不可动
- **审计**:一切写操作在业务事务内写 AuditEntry(同事务,失败即回滚);
文件内容写先 versionStore.commit 再审计(宁多版本,不造假审计)
## 约定(与 admin 面一致)
+79
View File
@@ -0,0 +1,79 @@
/**
* 文件库审计 sink(契约 C3 的入驻适配)。
*
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
*/
import type { Prisma } from "@prisma/client";
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
export const FILE_LIB_AUDIT_ACTIONS = {
folderCreate: "folder.create",
folderRename: "folder.rename",
folderMove: "folder.move",
folderDelete: "folder.delete",
projectCreate: "project.create",
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
independentEnable: "project.independent_permission.enable",
independentDisable: "project.independent_permission.disable",
independentChange: "project.independent_permission.change",
fileUpload: "file.upload",
fileRename: "file.rename",
fileDelete: "file.delete",
fileCommit: "file.commit",
fileConflictDetected: "file.conflict_detected",
exportRun: "export.run",
adminForceAdjust: "admin.force_adjust",
// ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
groupCreate: "group.create",
groupUpdate: "group.update",
groupDelete: "group.delete",
groupRestore: "group.restore",
groupMemberAdd: "group.member_add",
groupMemberRemove: "group.member_remove",
} as const;
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
export interface FileLibAuditEntry {
readonly action: string;
readonly actorUserId: string;
readonly organizationId: string;
readonly objectType: FileLibAuditObjectType;
readonly objectId: string;
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
readonly objectPath: string;
readonly detail?: Record<string, unknown> | undefined;
}
/**
* 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
* (需求 5.1"操作成功则日志必存在"的强保证)。
*/
export async function writeFileLibAudit(
tx: Prisma.TransactionClient,
entry: FileLibAuditEntry,
): Promise<void> {
const metadata: Record<string, unknown> = {
objectType: entry.objectType,
objectId: entry.objectId,
objectPath: entry.objectPath,
...(entry.detail ?? {}),
};
await tx.auditEntry.create({
data: {
action: entry.action,
actorUserId: entry.actorUserId,
organizationId: entry.organizationId,
metadata: metadata as Prisma.InputJsonValue,
},
});
}
+195
View File
@@ -0,0 +1,195 @@
/**
* 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。
*
* ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。
* 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑;
* 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。
*/
import { randomUUID } from "node:crypto";
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
import type { FileDeps } from "./fileService.js";
export interface ExportAdapterInput {
readonly storageDir: string;
readonly target: string;
readonly params: Record<string, unknown>;
readonly listFiles: (prefix?: string) => Promise<readonly { path: string; size: number }[]>;
readonly readFile: (path: string) => Promise<Buffer>;
}
export interface ExportArtifact {
readonly filename: string;
readonly content: Buffer;
}
export interface ExportAdapter {
readonly target: string;
run(input: ExportAdapterInput): Promise<ExportArtifact>;
}
/** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */
export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter {
return {
target: "manifest",
async run(input) {
const files = await input.listFiles();
const lines = [
`# Export manifest (stub adapter)`,
`target: ${input.target}`,
`storageDir: ${input.storageDir}`,
`files: ${files.length}`,
``,
...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`),
];
return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") };
},
};
}
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
const artifacts = new Map<string, ExportArtifact>();
export interface ExportDeps extends FileDeps {
readonly adapters: readonly ExportAdapter[];
}
export interface ExportJobDto {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: Date;
}
/** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */
export async function submitExport(
deps: ExportDeps,
actor: FileLibActor,
projectId: string,
target: string,
params: Record<string, unknown>,
): Promise<ExportJobDto> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, "VIEW"),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "export applies to projects only");
}
const adapter = deps.adapters.find((a) => a.target === target);
if (adapter === undefined) {
throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`);
}
if (node.storageDir === null) {
throw new FileLibError(409, "project_not_ready", "project repository is not ready");
}
const jobId = randomUUID();
const job = await deps.prisma.$transaction(async (tx) => {
const created = await tx.fileLibExportJob.create({
data: {
id: jobId,
organizationId: deps.organizationId,
nodeId: node.id,
target,
params: params as never,
status: "QUEUED",
createdByUserId: actor.userId,
},
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "export_job",
objectId: jobId,
objectPath: node.pathIds,
detail: { target, params },
});
return created;
});
const storageDir = node.storageDir;
setImmediate(() => {
void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined);
});
return toDto(job);
}
async function runExportJob(
deps: ExportDeps,
adapter: ExportAdapter,
jobId: string,
storageDir: string,
target: string,
params: Record<string, unknown>,
): Promise<void> {
await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } });
try {
const artifact = await adapter.run({
storageDir,
target,
params,
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
readFile: (path) => deps.versionStore.read(storageDir, path),
});
artifacts.set(jobId, artifact);
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
});
} catch (error) {
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "FAILED", error: String(error) },
});
}
}
export async function getExportJob(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportJobDto> {
const job = await deps.prisma.fileLibExportJob.findFirst({
where: { id: jobId, organizationId: deps.organizationId },
});
if (job === null) throw new FileLibError(404, "export_not_found", "export job not found");
// D8:对源项目无 View → 404(不泄露 job 存在性)。
await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW"));
return toDto(job);
}
export async function downloadExport(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportArtifact> {
await getExportJob(deps, actor, jobId);
const artifact = artifacts.get(jobId);
if (artifact === undefined) {
throw new FileLibError(409, "export_not_ready", "export artifact is not available");
}
return artifact;
}
function toDto(job: {
id: string;
nodeId: string;
target: string;
status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
error: string | null;
createdAt: Date;
}): ExportJobDto {
return {
id: job.id,
nodeId: job.nodeId,
target: job.target,
status: job.status,
error: job.error,
createdAt: job.createdAt,
};
}
+275
View File
@@ -0,0 +1,275 @@
/**
* 文件内容服务(Phase 3):路径安全 + 版本化文件操作 + 审计。
*
* 顺序铁律(Metis 风险#1 的落地):
* - 内容写:先 versionStore.commit(业务事实本体)→ 再 DB 事务(审计)。
* 宁多一个无审计的版本,不造一条假审计。
* - conflict:写 file.conflict_detected(冲突本身就是事件),再抛 409。
* - 读:随取随读,不写审计(需求 5.2 未列读操作)。
*/
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { CommitResult, FileEntry, VersionInfo, VersionStore } from "./versionStore.js";
import type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js";
import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32;
export const FILE_CONTENT_MAX_BYTES = 10 * 1024 * 1024; // OPEN-5 初值
const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
/**
* 路径安全(Metis 安全红线):NFC;拒绝反斜杠、控制字符、空段、
* "." / ".." / ".git" 段、绝对路径、超长/超深。返回规范化相对路径。
*/
export function validateFilePath(raw: string): string {
const normalized = raw.normalize("NFC");
if (normalized.length === 0 || normalized.length > FILE_PATH_MAX_LENGTH) {
throw new FileLibError(400, "invalid_path", `path must be 1..${FILE_PATH_MAX_LENGTH} characters`);
}
if (normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", "path must use '/' separators");
}
if (CONTROL_CHARS.test(normalized)) {
throw new FileLibError(400, "invalid_path", "path must not contain control characters");
}
const segments = normalized.split("/");
if (segments.length > FILE_PATH_MAX_DEPTH) {
throw new FileLibError(400, "invalid_path", `path depth exceeds ${FILE_PATH_MAX_DEPTH}`);
}
for (const segment of segments) {
if (FORBIDDEN_SEGMENTS.has(segment)) {
throw new FileLibError(400, "invalid_path", `forbidden path segment: "${segment}"`);
}
}
return normalized;
}
export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient;
readonly versionStore: VersionStore;
}
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
async function requireProject(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
minRole: "VIEW" | "EDIT",
): Promise<ProjectChain> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, minRole),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "file operations apply to projects only");
}
if (node.provisionStatus === "PROVISIONING") {
throw new FileLibError(409, "project_not_ready", "project repository is still provisioning");
}
if (node.provisionStatus === "FAILED") {
throw new FileLibError(409, "project_not_ready", "project repository provisioning failed");
}
if (node.storageDir === null) {
throw new FileLibError(500, "storage_missing", "project has no storage directory");
}
return { node, storageDir: node.storageDir };
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContentDto {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export function decodeContent(content: string, encoding: FileContentEncoding): string | Buffer {
return encoding === "base64" ? Buffer.from(content, "base64") : content;
}
function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding; readonly content: string } {
// 粗判二进制:含 NUL 字节即按 base64 返回(需求 2.5 在线编辑仅针对文本)。
return buffer.includes(0)
? { encoding: "base64", content: buffer.toString("base64") }
: { encoding: "utf8", content: buffer.toString("utf8") };
}
function checkSize(content: string | Buffer): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > FILE_CONTENT_MAX_BYTES) {
throw new FileLibError(413, "file_too_large", `file exceeds ${FILE_CONTENT_MAX_BYTES} bytes`);
}
}
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
action: string,
project: ProjectChain,
filePath: string,
detail: Record<string, unknown>,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "file",
objectId: project.node.id,
objectPath: `${project.node.pathIds}:${filePath}`,
detail,
});
});
}
/* ---------------------------------------------------------------- 读操作 */
export async function listFiles(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
prefix?: string,
): Promise<readonly FileEntry[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.list(project.storageDir, prefix === undefined ? undefined : validateFilePath(prefix));
}
export async function readFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<FileContentDto> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
const { encoding, content } = encodeContent(buffer);
return { path: filePath, version, encoding, content, size: buffer.byteLength };
}
/** 原始字节下载(浏览器 save-as 用):JSON 之外的第二条读取通道,同样的 VIEW 门禁。 */
export async function readFileRaw(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<{ readonly buffer: Buffer; readonly version: string; readonly filename: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
return { buffer, version, filename: filePath.split("/").pop() ?? "download" };
}
export async function fileHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
limit?: number,
): Promise<readonly VersionInfo[]> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.history(project.storageDir, filePath, limit);
}
export async function diffFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
from: string,
to: string,
): Promise<{ readonly diff: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return { diff: await deps.versionStore.diff(project.storageDir, filePath, from, to) };
}
/* ---------------------------------------------------------------- 写操作 */
export interface CommitInput {
readonly path: string;
/** null = 新建(已存在则 409);编辑时传 readFile 拿到的 version。 */
readonly baseVersion: string | null;
readonly content: string;
readonly encoding?: FileContentEncoding | undefined;
readonly message?: string | undefined;
}
/**
* 统一写入口(上传/编辑共用):先 commit,成功写 file.commit / file.upload 审计;
* 冲突写 file.conflict_detected 后抛 409(details 带 currentVersion,编辑 UI 用它拉 diff)。
*/
export async function commitFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
input: CommitInput,
): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion,
content,
message: input.message,
author: actor.userId,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion: input.baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(
deps,
actor,
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message: input.message ?? null },
);
return { version: result.version };
}
export async function deleteFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
baseVersion: string,
): Promise<void> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion);
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion });
}
+319
View File
@@ -0,0 +1,319 @@
/**
* 授权管理(契约 8.1 矩阵的服务端强制)。
*
* 矩阵:
* - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动
* - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者
* - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡)
* - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计
*
* D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。
*/
import { Prisma } from "@prisma/client";
import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client";
import { FileLibError, type FileLibRole } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import {
requireAccessInTx,
type AccessDeps,
type FileLibActor,
type InitialGrant,
} from "./treeService.js";
export interface GrantDto {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
readonly createdAt: Date;
}
function toDto(grant: FileLibGrant): GrantDto {
return {
id: grant.id,
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt,
};
}
type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient };
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
async function requireManage(
deps: Deps,
actor: FileLibActor,
nodeId: string,
tx?: Tx,
): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> {
if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE");
return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE"));
}
/** 列出节点活跃授权(需 MANAGE)。 */
export async function listGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly GrantDto[]> {
await requireManage(deps, actor, nodeId);
const grants = await deps.prisma.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return grants.map(toDto);
}
export interface PutGrantsResult {
readonly granted: number;
readonly updated: number;
readonly grants: readonly GrantDto[];
}
/**
* 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update);
* 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。
*/
export async function putGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const isCreator = node.creatorId === actor.userId;
for (const item of items) {
if (item.role === "MANAGE" && !isCreator) {
throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE");
}
if (item.principalType === "USER" && item.principalId === node.creatorId) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
}
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: grants.map(toDto) };
});
}
/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */
export async function revokeGrant(
deps: Deps,
actor: FileLibActor,
nodeId: string,
grantId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const grant = await tx.fileLibGrant.findFirst({
where: { id: grantId, nodeId: node.id, revokedAt: null },
});
if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found");
if (grant.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (grant.role === "MANAGE" && node.creatorId !== actor.userId) {
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
}
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
});
});
}
/**
* 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免;
* 每一条变更都落 admin.force_adjust 审计(高危留痕)。
*/
export async function forceAdjustGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "force adjust requires website administrator");
}
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "update",
principalType: item.principalType,
principalId: item.principalId,
from: existing.role,
to: item.role,
});
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "grant",
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
});
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: grants.map(toDto) };
});
}
/** 项目独立权限开关(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 };
});
}
function validateGrantItems(items: readonly InitialGrant[]): void {
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
const seen = new Set<string>();
for (const item of items) {
if (item.principalType !== "USER" && item.principalType !== "GROUP") {
throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`);
}
if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") {
throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`);
}
if (item.principalId.trim() === "") {
throw new FileLibError(400, "invalid_request", "principalId must not be empty");
}
const key = `${item.principalType}:${item.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`);
seen.add(key);
}
}
async function audit(
tx: Prisma.TransactionClient,
deps: Deps,
actor: FileLibActor,
action: string,
nodeId: string,
pathIds: string,
detail: Record<string, unknown>,
): Promise<void> {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: nodeId,
objectPath: pathIds,
detail,
});
}
+52
View File
@@ -0,0 +1,52 @@
/**
* GroupResolver port(契约 C2)。
*
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
* 调用方只依赖此 port,不换调用点。
*/
import type { PrismaClient } from "@prisma/client";
export interface GroupResolver {
resolveMemberGroupIds(userId: string): Promise<readonly string[]>;
}
/**
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
* (以及潜在的迁移对照),新代码不要使用。
*
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
*/
export function createTeamGroupResolver(
prisma: PrismaClient,
organizationId: string,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
const memberships = await prisma.teamMembership.findMany({
where: {
userId,
revokedAt: null,
team: { organizationId, archivedAt: null },
},
select: { teamId: true },
});
return memberships.map((m) => m.teamId);
},
};
}
/** 单测 mock:静态 用户→组 映射。 */
export function createStaticGroupResolver(
map: Readonly<Record<string, readonly string[]>>,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
return map[userId] ?? [];
},
};
}
@@ -0,0 +1,49 @@
/**
* GroupResolver 的 HTTP 实现(契约 C2,Group 团队服务到位后启用,
* 经 HUB_GROUP_SERVICE_URL 配置)。
*
* 语义红线:
* - 失败 → FileLibError(503, group_unavailable)。依赖故障不是"无权限",
* 绝不伪装成 404/403(计划 D13)。
* - 我方绝不自己推祖先:返回什么用什么,不在本地补逻辑。
*/
import { FileLibError } from "./model.js";
import type { GroupResolver } from "./groupResolver.js";
export interface HttpGroupResolverConfig {
readonly baseUrl: string;
readonly timeoutMs?: number;
/** 测试可注入假 fetch;生产用全局 fetch。 */
readonly fetchFn?: typeof fetch;
}
export function createHttpGroupResolver(config: HttpGroupResolverConfig): GroupResolver {
const timeoutMs = config.timeoutMs ?? 2_000;
const fetchFn = config.fetchFn ?? fetch;
return {
async resolveMemberGroupIds(userId) {
const url = `${config.baseUrl.replace(/\/$/, "")}/groups/resolve-member-groups?userId=${encodeURIComponent(userId)}`;
let response: Response;
try {
response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
} catch (error) {
throw new FileLibError(503, "group_unavailable", `group service unreachable: ${String(error)}`);
}
if (!response.ok) {
throw new FileLibError(503, "group_unavailable", `group service returned ${response.status}`);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new FileLibError(503, "group_unavailable", "group service returned malformed JSON");
}
const groupIds = (body as { groupIds?: unknown }).groupIds;
if (!Array.isArray(groupIds) || groupIds.some((id) => typeof id !== "string")) {
throw new FileLibError(503, "group_unavailable", "group service returned malformed payload");
}
return groupIds as readonly string[];
},
};
}
+47
View File
@@ -0,0 +1,47 @@
/**
* 文件库 HTTP 门禁(契约 C4 的入驻适配)。
*
* 身份链:hub session(飞书 OAuth / dev bypass)→ silo org membership。
* 网站管理员 = org 的 OWNER/ADMIN(D19:仅 root 创建与 force_adjust 特权,
* 不给内容读旁路);普通成员 = 任何活跃 membership;非成员 = 403。
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import { requireSession, sendError } from "../../admin/auth/guards.js";
import type { FileLibActor } from "./treeService.js";
export interface FileLibGuardDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
/** 文件库归属的 silo org(ADR-0020/0025)。 */
readonly organizationId: string;
}
const WEBSITE_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
/** 每个 /database/api/* 端点第一行调它;返回 null 时响应已发出,fail closed。 */
export async function requireFileLibActor(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibGuardDeps,
): Promise<FileLibActor | null> {
const auth = await requireSession(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
});
if (auth === null) return null;
const membership = await deps.prisma.organizationMembership.findFirst({
where: { organizationId: deps.organizationId, userId: auth.user.id, revokedAt: null },
select: { role: true },
});
if (membership === null) {
await sendError(reply, 403, "forbidden", "not a member of this organization");
return null;
}
return {
userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
};
}
@@ -0,0 +1,35 @@
/**
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。
*
* resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重
* (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上,
* G 及其全部子孙的成员都命中(需求 3.2 权限沿树向下 → 解析沿树向上收集)。
*
* 实时、不缓存(契约 D4/G4):成员变更在下一次受保护请求即可见。
* MemberGroup 全局(无 organizationId),解析不做 org scope。
* 两条 Prisma 查询,不用裸 SQL(与 treeService 风格一致)。
*/
import type { PrismaClient } from "@prisma/client";
import type { GroupResolver } from "./groupResolver.js";
export function createMemberGroupResolver(prisma: PrismaClient): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
// 1) 活跃直接组:成员未撤销 + 组未归档。
const direct = await prisma.memberGroupMembership.findMany({
where: { userId, revokedAt: null, group: { archivedAt: null } },
select: { groupId: true },
});
if (direct.length === 0) return [];
const directIds = direct.map((m) => m.groupId);
// 2) 经闭包取活跃祖先(含 depth0 自身);祖先组须未归档。
const ancestors = await prisma.memberGroupClosure.findMany({
where: { descendantId: { in: directIds }, ancestor: { archivedAt: null } },
select: { ancestorId: true },
});
return [...new Set(ancestors.map((a) => a.ancestorId))];
},
};
}
@@ -0,0 +1,607 @@
/**
* 成员组(MemberGroup)管理服务(ADR-0028)。
*
* 语义锚定:
* - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org
* (deps.organizationId)—— MemberGroup 无 orgId,审计沿用文件库 sink(决策4)。
* - 权限门禁:创建/删除/成员增删仅网站管理员(silo org OWNER/ADMIN);
* 搜索(授权选择器)不限管理员 —— 选组授权是 Manage 持有者的能力(决策2)。
* - 软删除:archivedAt 打标;删组级联软删整棵子树(闭包 ancestorId=G);
* 闭包/成员行保留,list/解析按 archivedAt 过滤(决策4)。
* - 闭包维护:仅 create —— 插 (G,G,0),再对 parent P 插
* (a.ancestorId, G, a.depth+1) for a in closure where descendantId=P。
* v1 不支持 reparent(决策5)。
*
* 与 hub Team 不同:成员是全局用户,不要求 org membership;按 userId 或
* User.feishuOpenId(全局 @unique)解析。
*/
import type { PrismaClient, Prisma } from "@prisma/client";
import { FileLibError } from "./model.js";
import type { FileLibActor } from "./treeService.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
export interface MemberGroupServiceDeps {
readonly prisma: PrismaClient;
/** silo org id —— 仅用于审计归属(MemberGroup 全局无 orgId,决策4)。 */
readonly organizationId: string;
}
export interface MemberGroupDto {
readonly id: string;
readonly parentId: string | null;
readonly name: string;
readonly description: string | null;
/** 到根的边数(根 = 0);由闭包行数推导。 */
readonly depth: number;
readonly memberCount: number;
/** 软删标记(决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 */
readonly archivedAt: Date | null;
}
export interface MemberGroupMemberDto {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
/** 加入本组时间(membership.createdAt),用于成员表排序/展示。 */
readonly joinedAt: Date;
}
/** 成员选择器候选(加成员弹窗搜索用)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接;无祖先时即自身名。 */
readonly breadcrumb: string;
}
export interface CreateMemberGroupInput {
readonly name: string;
readonly description?: string | undefined;
readonly parentId?: string | null | undefined;
}
export interface AddMemberInput {
readonly userId?: string | undefined;
readonly feishuOpenId?: string | undefined;
}
/** 改名/改描述(决策6)。字段缺省 = 不动;description 传空串 = 清空。 */
export interface UpdateMemberGroupInput {
readonly name?: string | undefined;
readonly description?: string | undefined;
}
type Tx = Prisma.TransactionClient;
/* ---------------------------------------------------------------- 内部工具 */
/** 管理门禁:非网站管理员一律 403(决策2)。 */
function requireAdmin(actor: FileLibActor): void {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "group management requires website administrator");
}
}
/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */
function normalizeGroupName(raw: string): string {
const name = raw.trim();
if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty");
if (name.length > 100) throw new FileLibError(400, "invalid_request", "group name too long (max 100)");
return name;
}
async function requireActiveGroup(
client: PrismaClient | Tx,
groupId: string,
): Promise<{ readonly id: string; readonly name: string }> {
const group = await client.memberGroup.findFirst({
where: { id: groupId, archivedAt: null },
select: { id: true, name: true },
});
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
return group;
}
/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */
async function resolveUser(
tx: Tx,
input: AddMemberInput,
): Promise<{
readonly id: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}> {
const select = { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } as const;
if (input.userId !== undefined && input.userId !== "") {
const user = await tx.user.findUnique({ where: { id: input.userId }, select });
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.userId}`);
return user;
}
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
const user = await tx.user.findUnique({
where: { feishuOpenId: input.feishuOpenId },
select,
});
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.feishuOpenId}`);
return user;
}
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
}
/* ---------------------------------------------------------------- 公共操作 */
/**
* 创建成员组(建根 / 建子)。仅网站管理员。事务内维护闭包。
* parentId 给定时校验其活跃存在;闭包:插自身 depth0 + 继承 parent 的祖先。
*/
export async function createMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
input: CreateMemberGroupInput,
): Promise<MemberGroupDto> {
requireAdmin(actor);
const name = normalizeGroupName(input.name);
const description = input.description?.trim() || null;
const parentId = input.parentId ?? null;
return deps.prisma.$transaction(async (tx) => {
let parentClosure: { ancestorId: string; depth: number }[] = [];
if (parentId !== null) {
const parent = await tx.memberGroup.findFirst({
where: { id: parentId, archivedAt: null },
select: { id: true },
});
if (parent === null) throw new FileLibError(404, "group_not_found", "parent group not found");
parentClosure = await tx.memberGroupClosure.findMany({
where: { descendantId: parentId },
select: { ancestorId: true, depth: true },
});
}
const group = await tx.memberGroup.create({
data: { name, parentId, ...(description !== null ? { description } : {}) },
select: { id: true, parentId: true, name: true, description: true },
});
// 闭包维护:自身 depth0,再继承 parent 的每个祖先(depth+1)。
await tx.memberGroupClosure.create({
data: { ancestorId: group.id, descendantId: group.id, depth: 0 },
});
if (parentClosure.length > 0) {
await tx.memberGroupClosure.createMany({
data: parentClosure.map((a) => ({
ancestorId: a.ancestorId,
descendantId: group.id,
depth: a.depth + 1,
})),
});
}
// parent 的闭包行数 = parent.depth + 1 = 新组 depth(闭包不变量)。
const depth = parentClosure.length;
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupCreate,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { name, parentId },
});
return {
id: group.id,
parentId: group.parentId,
name: group.name,
description: group.description,
depth,
memberCount: 0,
archivedAt: null,
};
});
}
/**
* 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1
* 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。
*/
export async function updateMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
input: UpdateMemberGroupInput,
): Promise<MemberGroupDto> {
requireAdmin(actor);
if (input.name === undefined && input.description === undefined) {
throw new FileLibError(400, "invalid_request", "name or description is required");
}
const name = input.name === undefined ? undefined : normalizeGroupName(input.name);
return deps.prisma.$transaction(async (tx) => {
await requireActiveGroup(tx, groupId);
const group = await tx.memberGroup.update({
where: { id: groupId },
data: {
...(name !== undefined ? { name } : {}),
...(input.description !== undefined
? { description: input.description.trim() || null }
: {}),
},
select: { id: true, parentId: true, name: true, description: true },
});
// depth 由闭包行数推导(与 listMemberGroups 同一不变量);update 不改闭包。
const closureCount = await tx.memberGroupClosure.count({ where: { descendantId: groupId } });
const memberCount = await tx.memberGroupMembership.count({
where: { groupId, revokedAt: null },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: {
...(name !== undefined ? { name } : {}),
...(input.description !== undefined ? { description: group.description } : {}),
},
});
return {
id: group.id,
parentId: group.parentId,
name: group.name,
description: group.description,
depth: closureCount - 1,
memberCount,
archivedAt: null, // requireActiveGroup 已保证是活跃组
};
});
}
/**
* 软删除成员组:级联软删整棵子树(闭包 ancestorId=G 的全部活跃 descendant)。
* 闭包/成员行保留;list/解析按 archivedAt 过滤,整支立即停止贡献权限。
*/
export async function deleteMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
): Promise<{ readonly archivedCount: number }> {
requireAdmin(actor);
return deps.prisma.$transaction(async (tx) => {
const group = await requireActiveGroup(tx, groupId);
const subtree = await tx.memberGroupClosure.findMany({
where: { ancestorId: groupId },
select: { descendantId: true },
});
const ids = subtree.map((r) => r.descendantId);
const now = new Date();
const result = await tx.memberGroup.updateMany({
where: { id: { in: ids }, archivedAt: null },
data: { archivedAt: now },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { name: group.name, archivedCount: result.count },
});
return { archivedCount: result.count };
});
}
/**
* 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7):
* - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。
* - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、
* depth 推导(闭包行数)与"祖先必活跃"的前提脱节。
* - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次
* 恢复意外把整支历史组全部重新授权。
* 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。
*/
export async function restoreMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
): Promise<{ readonly restoredCount: number }> {
requireAdmin(actor);
return deps.prisma.$transaction(async (tx) => {
const group = await tx.memberGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, archivedAt: true },
});
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
if (group.archivedAt === null) {
throw new FileLibError(409, "not_archived", "group is not archived");
}
// 自身 + 祖先(闭包 descendantId=G 含 depth0 自身),只挑已归档的解标。
const chain = await tx.memberGroupClosure.findMany({
where: { descendantId: groupId },
select: { ancestorId: true },
});
const ids = chain.map((r) => r.ancestorId);
const result = await tx.memberGroup.updateMany({
where: { id: { in: ids }, archivedAt: { not: null } },
data: { archivedAt: null },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { name: group.name, restoredCount: result.count },
});
return { restoredCount: result.count };
});
}
/**
* 组扁平列表(前端自行按 parentId/depth 拼树);仅网站管理员。
* includeArchived=true 时连已归档组一并返回(带 archivedAt 标记),供后台展示/恢复;
* 默认只返回活跃组 —— 权限相关的调用方一律走默认。
*/
export async function listMemberGroups(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
includeArchived = false,
): Promise<readonly MemberGroupDto[]> {
requireAdmin(actor);
const groups = await deps.prisma.memberGroup.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: "asc" },
select: { id: true, parentId: true, name: true, description: true, archivedAt: true },
});
if (groups.length === 0) return [];
const ids = groups.map((g) => g.id);
// depth:每个组的闭包行数(自身 + 祖先)- 1。级联软删保证活跃组的祖先必活跃。
const closure = await deps.prisma.memberGroupClosure.findMany({
where: { descendantId: { in: ids } },
select: { descendantId: true },
});
const closureCount = new Map<string, number>();
for (const row of closure) {
closureCount.set(row.descendantId, (closureCount.get(row.descendantId) ?? 0) + 1);
}
const counts = await deps.prisma.memberGroupMembership.groupBy({
by: ["groupId"],
where: { groupId: { in: ids }, revokedAt: null },
_count: { _all: true },
});
const countByGroup = new Map(counts.map((c) => [c.groupId, c._count._all]));
return groups.map((g) => ({
id: g.id,
parentId: g.parentId,
name: g.name,
description: g.description,
depth: (closureCount.get(g.id) ?? 1) - 1,
memberCount: countByGroup.get(g.id) ?? 0,
archivedAt: g.archivedAt,
}));
}
/**
* 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在,
* 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。
*/
export async function listMembers(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
): Promise<readonly MemberGroupMemberDto[]> {
requireAdmin(actor);
const exists = await deps.prisma.memberGroup.findUnique({
where: { id: groupId },
select: { id: true },
});
if (exists === null) throw new FileLibError(404, "group_not_found", "group not found");
const rows = await deps.prisma.memberGroupMembership.findMany({
where: { groupId, revokedAt: null },
select: {
createdAt: true,
user: { select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } },
},
orderBy: { createdAt: "asc" },
});
return rows.map((r) => ({
userId: r.user.id,
displayName: r.user.displayName,
feishuOpenId: r.user.feishuOpenId,
avatarUrl: r.user.avatarUrl,
joinedAt: r.createdAt,
}));
}
/** 加成员(userId 或 feishuOpenId 解析);已是活跃成员 → 409。仅网站管理员。 */
export async function addMember(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
input: AddMemberInput,
): Promise<MemberGroupMemberDto> {
requireAdmin(actor);
return deps.prisma.$transaction(async (tx) => {
const group = await requireActiveGroup(tx, groupId);
const user = await resolveUser(tx, input);
const existing = await tx.memberGroupMembership.findFirst({
where: { groupId: group.id, userId: user.id, revokedAt: null },
select: { id: true },
});
if (existing !== null) {
throw new FileLibError(409, "already_member", "user is already a member of this group");
}
const created = await tx.memberGroupMembership.create({
data: { groupId: group.id, userId: user.id },
select: { createdAt: true },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { userId: user.id },
});
return {
userId: user.id,
displayName: user.displayName,
feishuOpenId: user.feishuOpenId,
avatarUrl: user.avatarUrl,
joinedAt: created.createdAt,
};
});
}
/** 移成员(软删 revokedAt);不在组 → 404。仅网站管理员。 */
export async function removeMember(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
userId: string,
): Promise<void> {
requireAdmin(actor);
await deps.prisma.$transaction(async (tx) => {
const group = await requireActiveGroup(tx, groupId);
const membership = await tx.memberGroupMembership.findFirst({
where: { groupId: group.id, userId, revokedAt: null },
select: { id: true },
});
if (membership === null) throw new FileLibError(404, "member_not_found", "group member not found");
await tx.memberGroupMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { userId },
});
});
}
/**
* 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2)
* —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大
* 已有能力面,只是把"盲敲 id"变成"搜索选择"。
* excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。
*/
export async function searchUsers(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
q: string,
excludeGroupId?: string,
limit = 20,
): Promise<readonly UserSearchResult[]> {
requireAdmin(actor);
const keyword = q.trim();
let excludeIds: string[] = [];
if (excludeGroupId !== undefined && excludeGroupId !== "") {
const rows = await deps.prisma.memberGroupMembership.findMany({
where: { groupId: excludeGroupId, revokedAt: null },
select: { userId: true },
});
excludeIds = rows.map((r) => r.userId);
}
const users = await deps.prisma.user.findMany({
where: {
...(excludeIds.length > 0 ? { id: { notIn: excludeIds } } : {}),
...(keyword === ""
? {}
: {
OR: [
{ displayName: { contains: keyword, mode: "insensitive" as const } },
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
],
}),
},
take: limit,
orderBy: { displayName: "asc" },
select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true },
});
return users.map((u) => ({
userId: u.id,
displayName: u.displayName,
feishuOpenId: u.feishuOpenId,
avatarUrl: u.avatarUrl,
}));
}
/**
* 授权选择器搜索(契约 C2 /groups/search)。**不限管理员**(决策2)。
* 活跃组按名过滤,breadcrumb 由活跃祖先链按 depth 排序拼成。
*/
export async function searchMemberGroups(
deps: MemberGroupServiceDeps,
q: string,
limit = 20,
): Promise<readonly MemberGroupSearchResult[]> {
const keyword = q.trim();
const groups = await deps.prisma.memberGroup.findMany({
where: {
archivedAt: null,
...(keyword === "" ? {} : { name: { contains: keyword, mode: "insensitive" as const } }),
},
take: limit,
orderBy: { name: "asc" },
select: { id: true, name: true },
});
if (groups.length === 0) return [];
const ids = groups.map((g) => g.id);
// 祖先链(仅活跃祖先);depth 越大越靠根。
const closure = await deps.prisma.memberGroupClosure.findMany({
where: { descendantId: { in: ids }, ancestor: { archivedAt: null } },
select: { descendantId: true, ancestorId: true, depth: true },
});
const ancestorIds = [...new Set(closure.map((c) => c.ancestorId))];
const names = await deps.prisma.memberGroup.findMany({
where: { id: { in: ancestorIds } },
select: { id: true, name: true },
});
const nameById = new Map(names.map((n) => [n.id, n.name]));
const chainByGroup = new Map<string, { ancestorId: string; depth: number }[]>();
for (const row of closure) {
const arr = chainByGroup.get(row.descendantId) ?? [];
arr.push({ ancestorId: row.ancestorId, depth: row.depth });
chainByGroup.set(row.descendantId, arr);
}
return groups.map((g) => {
const chain = (chainByGroup.get(g.id) ?? []).slice().sort((a, b) => b.depth - a.depth);
const breadcrumb = chain
.map((c) => nameById.get(c.ancestorId) ?? "")
.filter((s) => s !== "")
.join(" / ");
return { id: g.id, name: g.name, breadcrumb: breadcrumb || g.name };
});
}
+59
View File
@@ -0,0 +1,59 @@
/**
* 文件库领域基础:角色秩、命名规则(D14)、错误类型。
*
* 语义锚定:仓库根《文件库-接口契约.md》(2.2 权限等级 / D8 可见性 / D14 命名)
* 与 .omo/文件库-开工计划.md。本模块是独立文件库,不复用 hub 的
* Folder/Project/PermissionGrant 体系。
*/
export const FILE_LIB_ROLES = ["VIEW", "EDIT", "MANAGE"] as const;
export type FileLibRole = (typeof FILE_LIB_ROLES)[number];
/** 契约 2.2:MANAGE > EDIT > VIEW,严格全序。 */
export const ROLE_RANK: Record<FileLibRole, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
export function roleAtLeast(role: FileLibRole, min: FileLibRole): boolean {
return ROLE_RANK[role] >= ROLE_RANK[min];
}
/** 业务错误。statusCode 由路由层映射为 HTTP 响应(D8 语义在此层只表达为 code)。 */
export class FileLibError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
/** 结构化附加信息(如 409 时的 currentVersion),路由层并入错误响应。 */
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "FileLibError";
}
}
export const NODE_NAME_MAX_LENGTH = 128;
/** D14:禁 `/`;反斜杠同样禁止(它会变成存储路径的分隔符,且易用于伪装)。控制字符禁。 */
const FORBIDDEN_NAME_CHARS = /[/\\\p{C}]/u;
/**
* D14:NFC 归一化 + trim,然后校验长度与字符集。
* 违规抛 FileLibError(400, "invalid_name"),路由层原样透传。
*/
export function normalizeNodeName(raw: string): string {
const name = raw.normalize("NFC").trim();
if (name.length === 0) {
throw new FileLibError(400, "invalid_name", "name must not be empty");
}
if (name.length > NODE_NAME_MAX_LENGTH) {
throw new FileLibError(400, "invalid_name", `name exceeds ${NODE_NAME_MAX_LENGTH} characters`);
}
if (FORBIDDEN_NAME_CHARS.test(name)) {
throw new FileLibError(400, "invalid_name", "name must not contain '/', '\\' or control characters");
}
return name;
}
/** D14:活跃兄弟节点大小写不敏感唯一的比较键(DB 层另有部分唯一索引兜底)。 */
export function nameKey(normalizedName: string): string {
return normalizedName.toLowerCase();
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 纯权限 reducer(契约 P6 / D11 / D8)。
*
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
* 存储做密集单测与随机化不变量测试。
*/
import type { FileLibRole } from "./model.js";
import { ROLE_RANK } from "./model.js";
export interface FileLibGrantFact {
readonly nodeId: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
}
export interface EffectiveRoleInput {
/** 目标节点(self)。 */
readonly nodeId: string;
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[];
/** self ancestors 上的全部活跃 grant(revokedAt 已由获取层过滤)。 */
readonly grants: readonly FileLibGrantFact[];
}
/**
* 契约 P6:effective(user, R) = max { grant.role | s ∈ {user} groups*(user),
* r ∈ {R} ancestors(R) };无匹配 → null(无任何权限)。
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
*
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
* grant 不受开关影响。
*/
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;
}
return best;
}
/**
* D8 可见性语义:
* - 完全无权限(effective === null)→ "not_found"(路由层映射 404,不泄露存在性);
* - 有权限但不足 → "forbidden"(路由层映射 403)。
*/
export type AccessVerdict =
| { readonly allowed: true; readonly role: FileLibRole }
| { readonly allowed: false; readonly reason: "not_found" | "forbidden" };
export function checkAccess(effective: FileLibRole | null, min: FileLibRole): AccessVerdict {
if (effective === null) return { allowed: false, reason: "not_found" };
if (ROLE_RANK[effective] < ROLE_RANK[min]) return { allowed: false, reason: "forbidden" };
return { allowed: true, role: effective };
}
+89
View File
@@ -0,0 +1,89 @@
/**
* /database/api/* 路由共享件:依赖装配、actor 门禁、统一错误映射。
* 约定(与 admin 面一致):绝对路径;guard 前置 fail closed;查询 scope 到 silo org。
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { FileLibError } from "./model.js";
import { requireFileLibActor } from "./guards.js";
import type { FileLibActor, TreeServiceDeps } from "./treeService.js";
import type { GroupResolver } from "./groupResolver.js";
import type { VersionStore } from "./versionStore.js";
import type { ExportAdapter } from "./exportService.js";
export interface FileLibRouteDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly organizationId: string;
readonly storageRoot: string;
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
readonly exportAdapters: readonly ExportAdapter[];
}
/** 组装 treeService 依赖(路由处理内直接使用)。 */
export function treeDeps(deps: FileLibRouteDeps): TreeServiceDeps {
return {
prisma: deps.prisma,
groupResolver: deps.groupResolver,
versionStore: deps.versionStore,
organizationId: deps.organizationId,
storageRoot: deps.storageRoot,
};
}
/** 端点第一行调用;null = 响应已发(401/403),fail closed。 */
export async function actorOrNull(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibRouteDeps,
): Promise<FileLibActor | null> {
return requireFileLibActor(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
organizationId: deps.organizationId,
});
}
/** 统一错误出口:FileLibError → 语义码;P2002 → 409;其余 → 500(不泄露内部)。 */
export async function sendRouteError(reply: FastifyReply, error: unknown): Promise<void> {
if (error instanceof FileLibError) {
await reply.status(error.statusCode).send({
error: { code: error.code, message: error.message, ...(error.details ?? {}) },
});
return;
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
await reply.status(409).send({ error: { code: "conflict", message: "uniqueness conflict" } });
return;
}
reply.log.error({ err: error }, "filelib route: unexpected error");
await reply.status(500).send({ error: { code: "internal", message: "internal error" } });
}
/** 请求体轻量校验(抛 FileLibError 400)。 */
export function bodyObject(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new FileLibError(400, "invalid_request", "request body must be a JSON object");
}
return body as Record<string, unknown>;
}
export function requireString(obj: Record<string, unknown>, key: string): string {
const value = obj[key];
if (typeof value !== "string" || value === "") {
throw new FileLibError(400, "invalid_request", `missing or invalid field: ${key}`);
}
return value;
}
export function optionalString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw new FileLibError(400, "invalid_request", `invalid field: ${key}`);
}
return value;
}
+598
View File
@@ -0,0 +1,598 @@
/**
* 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。
*
* 语义锚定:
* - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess)
* - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts
* - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环
* - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底)
* - D15 删除只打标本节点;"任一祖先已删"即整支不可见
* - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字
* - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径,
* rename 不重写后代;move 用一次前缀重写维护)
*
* 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建
* 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走
* effectiveRole,没有 admin 旁路。
*/
import { randomUUID } from "node:crypto";
import path from "node:path";
import { Prisma } from "@prisma/client";
import type { PrismaClient, FileLibNode } from "@prisma/client";
import {
FileLibError,
nameKey,
normalizeNodeName,
type FileLibRole,
} from "./model.js";
import { checkAccess, effectiveRole } from "./permission.js";
import type { GroupResolver } from "./groupResolver.js";
import type { VersionStore } from "./versionStore.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
export interface FileLibActor {
readonly userId: string;
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
readonly isWebsiteAdmin: boolean;
}
export interface TreeServiceDeps {
readonly prisma: PrismaClient;
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
/** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */
readonly organizationId: string;
/** 项目 git 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
readonly storageRoot: string;
}
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
export interface InitialGrant {
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
}
type Tx = Prisma.TransactionClient;
interface Chain {
readonly node: FileLibNode;
/** 根在前、直接父在后;不含 node 自身。 */
readonly ancestors: readonly FileLibNode[];
}
/* ---------------------------------------------------------------- 内部工具 */
/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */
function ancestorIdsOf(node: FileLibNode): string[] {
return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1);
}
/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */
async function loadVisibleChain(
tx: Tx,
organizationId: string,
nodeId: string,
): Promise<Chain> {
const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } });
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
const ancestorIds = ancestorIdsOf(node);
const ancestors = ancestorIds.length === 0
? []
: await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } });
if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) {
// D15:已删子树对外"不存在"(D8 不泄露)。
throw new FileLibError(404, "node_not_found", "node not found");
}
const byId = new Map(ancestors.map((a) => [a.id, a]));
const ordered = ancestorIds
.map((id) => byId.get(id))
.filter((a): a is FileLibNode => a !== undefined);
return { node, ancestors: ordered };
}
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
async function resolveRole(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
chain: Chain,
): Promise<FileLibRole | null> {
const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id];
const grants = await tx.fileLibGrant.findMany({
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,
});
}
/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */
async function requireAccess(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
nodeId: string,
minRole: FileLibRole,
): Promise<Chain & { readonly role: FileLibRole }> {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const role = await resolveRole(tx, deps, actor, chain);
const verdict = checkAccess(role, minRole);
if (!verdict.allowed) {
throw verdict.reason === "not_found"
? new FileLibError(404, "node_not_found", "node not found")
: new FileLibError(403, "forbidden", `requires ${minRole}`);
}
return { ...chain, role: verdict.role };
}
/**
* 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做
* 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。
*/
export async function requireAccessInTx(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
nodeId: string,
minRole: FileLibRole,
): Promise<Chain & { readonly role: FileLibRole }> {
return requireAccess(tx, deps, actor, nodeId, minRole);
}
/** P2002(活跃兄弟名部分唯一索引)→ 409。 */
function rethrowNameConflict(error: unknown, name: string): never {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`);
}
throw error;
}
function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string {
const table = kind === "PROJECT"
? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete }
: { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete };
return table[verb];
}
function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void {
const seen = new Set<string>();
for (const grant of grants) {
const key = `${grant.principalType}:${grant.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`);
seen.add(key);
if (grant.principalType === "USER" && grant.principalId === actor.userId) {
throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant");
}
// v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。
}
}
/* ---------------------------------------------------------------- 公共操作 */
export interface CreateNodeInput {
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly description?: string | undefined;
readonly grants?: readonly InitialGrant[] | undefined;
}
/**
* 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。
* creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。
*/
export async function createNode(
deps: TreeServiceDeps,
actor: FileLibActor,
input: CreateNodeInput,
): Promise<FileLibNode> {
const name = normalizeNodeName(input.name);
const initialGrants = input.grants ?? [];
validateInitialGrants(actor, initialGrants);
const id = randomUUID();
let pathIds: string;
let storageDir: string | null = null;
const created = await deps.prisma.$transaction(async (tx) => {
if (input.parentId === null) {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "root creation requires website administrator");
}
pathIds = `/${id}`;
} else {
const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT");
if (parent.node.kind !== "FOLDER") {
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
}
pathIds = `${parent.node.pathIds}/${id}`;
}
if (input.kind === "PROJECT") {
storageDir = path.join(deps.storageRoot, id);
}
let node: FileLibNode;
try {
node = await tx.fileLibNode.create({
data: {
id,
organizationId: deps.organizationId,
parentId: input.parentId,
kind: input.kind,
name,
nameLower: nameKey(name),
pathIds,
creatorId: actor.userId,
provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY",
storageDir,
...(input.description !== undefined && input.description.trim() !== ""
? { description: input.description.trim() }
: {}),
},
});
} catch (error) {
rethrowNameConflict(error, name);
}
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: id,
principalType: "USER",
principalId: actor.userId,
role: "MANAGE",
isCreatorGrant: true,
createdByUserId: actor.userId,
},
});
for (const grant of initialGrants) {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: id,
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
createdByUserId: actor.userId,
},
});
}
if (input.kind === "PROJECT") {
await tx.fileLibProjectSettings.create({
data: { nodeId: id, independentPermissionsEnabled: false },
});
}
await writeFileLibAudit(tx, {
action: nodeAction(input.kind, "Create"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: input.kind === "PROJECT" ? "project" : "folder",
objectId: id,
objectPath: pathIds,
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
});
for (const grant of initialGrants) {
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: id,
objectPath: pathIds,
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
});
}
return node;
});
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
if (input.kind === "PROJECT" && storageDir !== null) {
try {
await deps.versionStore.init(storageDir);
return await deps.prisma.fileLibNode.update({
where: { id: created.id },
data: { provisionStatus: "READY" },
});
} catch (error) {
await deps.prisma.fileLibNode
.update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } })
.catch(() => undefined);
throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`);
}
}
return created;
}
/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */
export async function renameNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
rawName: string,
): Promise<FileLibNode> {
const name = normalizeNodeName(rawName);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
let updated: FileLibNode;
try {
updated = await tx.fileLibNode.update({
where: { id: node.id },
data: { name, nameLower: nameKey(name) },
});
} catch (error) {
rethrowNameConflict(error, name);
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Rename"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { from: node.name, to: name },
});
return updated;
});
}
/**
* 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员);
* 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。
*/
export async function moveNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
newParentId: string | null,
): Promise<FileLibNode> {
return deps.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`;
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
if (node.parentId === newParentId) return node;
let newPathIds: string;
if (newParentId === null) {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "moving to root requires website administrator");
}
newPathIds = `/${node.id}`;
} else {
const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT");
if (parent.node.kind !== "FOLDER") {
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
}
if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) {
throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree");
}
newPathIds = `${parent.node.pathIds}/${node.id}`;
}
const oldPrefix = node.pathIds;
let updated: FileLibNode;
try {
updated = await tx.fileLibNode.update({
where: { id: node.id },
data: { parentId: newParentId, pathIds: newPathIds },
});
// 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。
await tx.$executeRaw`
UPDATE "FileLibNode"
SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int)
WHERE "organizationId" = ${deps.organizationId}
AND "pathIds" LIKE ${oldPrefix + "/%"}
`;
} catch (error) {
rethrowNameConflict(error, node.name);
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Move"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: newPathIds,
detail: { fromParentId: node.parentId, toParentId: newParentId },
});
return updated;
});
}
/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */
export async function softDeleteNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Delete"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name },
});
});
}
/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */
export async function getEffectiveRole(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<FileLibRole | null> {
return deps.prisma.$transaction(async (tx) => {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const role = await resolveRole(tx, deps, actor, chain);
if (role === null) throw new FileLibError(404, "node_not_found", "node not found");
return role;
});
}
export interface BreadcrumbEntry {
readonly depth: number;
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
readonly id: string | null;
readonly name: string | null;
readonly kind: "FOLDER" | "PROJECT";
}
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
export async function breadcrumb(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly BreadcrumbEntry[]> {
return deps.prisma.$transaction(async (tx) => {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const selfRole = await resolveRole(tx, deps, actor, chain);
if (checkAccess(selfRole, "VIEW").allowed !== true) {
throw new FileLibError(404, "node_not_found", "node not found");
}
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const chainNodes = [...chain.ancestors, chain.node];
const chainIds = chainNodes.map((n) => n.id);
const allGrants = await tx.fileLibGrant.findMany({
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,
});
const visible = role !== null;
return {
depth,
id: visible ? current.id : null,
name: visible ? current.name : null,
kind: current.kind,
};
});
});
}
export interface ChildNodeDto {
readonly id: string;
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly role: FileLibRole;
readonly createdAt: Date;
readonly updatedAt: Date;
}
/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */
export async function listChildren(
deps: TreeServiceDeps,
actor: FileLibActor,
parentId: string | null,
): Promise<readonly ChildNodeDto[]> {
return deps.prisma.$transaction(async (tx) => {
let parentAncestorIds: string[] = [];
if (parentId !== null) {
const parent = await requireAccess(tx, deps, actor, parentId, "VIEW");
parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id];
}
const children = await tx.fileLibNode.findMany({
where: { organizationId: deps.organizationId, parentId, deletedAt: null },
orderBy: [{ kind: "asc" }, { nameLower: "asc" }],
});
if (children.length === 0) return [];
// D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。
const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)];
const allGrants = await tx.fileLibGrant.findMany({
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[] = [];
for (const child of children) {
const role = effectiveRole({
nodeId: child.id,
nodeKind: child.kind,
ancestorIds: parentAncestorIds,
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
userId: actor.userId,
groupIds,
grants: allGrants,
});
if (role === null) continue;
out.push({
id: child.id,
parentId: child.parentId,
kind: child.kind,
name: child.name,
role,
createdAt: child.createdAt,
updatedAt: child.updatedAt,
});
}
return out;
});
}
/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */
export async function updateNodeDescription(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
description: string | null,
): Promise<FileLibNode> {
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT");
return tx.fileLibNode.update({
where: { id: node.id },
data: { description: description?.trim() || null },
});
});
}
+280
View File
@@ -0,0 +1,280 @@
/**
* VersionStore port(契约 C1)+ 开发用内存实现。
*
* 版本团队交付 npm 工具包后,用同一接口替换 createInMemoryVersionStore。
* 语义红线(计划"Mock 保真红线"):冲突走返回值(S1)、baseVersion=null 表新建(S2)、
* init 幂等(S7)、同 projectDir 写操作串行化(S4)、文件级版本(D16)。
*
* 持久化:传 persistPath 时把仓库快照落盘(JSON),重启后恢复 —— 纯粹为开发期
* demo 稳定,不改变任何语义;生产由真包替换,此文件不参与。
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { FileLibError } from "./model.js";
export type VersionId = string;
export interface CommitRequest {
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
readonly baseVersion: VersionId | null;
readonly content: string | Buffer;
readonly message?: string | undefined;
readonly author?: string | undefined;
}
export type CommitResult =
| { readonly status: "ok"; readonly version: VersionId }
| { readonly status: "conflict"; readonly currentVersion: VersionId };
export interface VersionInfo {
readonly version: VersionId;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export interface VersionStore {
init(projectDir: string): Promise<void>;
list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
head(projectDir: string, filePath: string): Promise<VersionId>;
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
}
interface StoredVersion {
readonly version: VersionId;
readonly content: Buffer;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
readonly deleted: boolean;
}
interface Repo {
/** 每文件一条版本链(D16:版本是文件级的,互不干扰)。 */
readonly files: Map<string, StoredVersion[]>;
counter: number;
}
/** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
function toBuffer(content: string | Buffer): Buffer {
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
}
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
function naiveDiff(fromText: string, toText: string): string {
const a = fromText.split("\n");
const b = toText.split("\n");
const out: string[] = ["--- a", "+++ b"];
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const al = a[i];
const bl = b[i];
if (al === bl) {
if (al !== undefined) out.push(` ${al}`);
} else {
if (al !== undefined) out.push(`-${al}`);
if (bl !== undefined) out.push(`+${bl}`);
}
}
return out.join("\n");
}
export function createInMemoryVersionStore(persistPath?: string): VersionStore {
const repos = new Map<string, Repo>();
const serialize = createKeySerializer();
/* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */
interface PersistedVersion extends Omit<StoredVersion, "content"> {
content: string; // base64
}
function loadPersisted(): void {
if (persistPath === undefined) return;
try {
const raw = JSON.parse(readFileSync(persistPath, "utf8")) as {
repos: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }>;
};
for (const [dir, repo] of Object.entries(raw.repos)) {
const files = new Map<string, StoredVersion[]>();
for (const [filePath, versions] of Object.entries(repo.files)) {
files.set(filePath, versions.map((v) => ({ ...v, content: Buffer.from(v.content, "base64") })));
}
repos.set(dir, { files, counter: repo.counter });
}
} catch { /* 无快照或损坏 → 空库起步(开发语义) */ }
}
function savePersisted(): void {
if (persistPath === undefined) return;
const out: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }> = {};
for (const [dir, repo] of repos) {
const files: Record<string, PersistedVersion[]> = {};
for (const [filePath, versions] of repo.files) {
files[filePath] = versions.map((v) => ({ ...v, content: v.content.toString("base64") }));
}
out[dir] = { counter: repo.counter, files };
}
try {
mkdirSync(path.dirname(persistPath), { recursive: true });
const tmp = `${persistPath}.tmp`;
writeFileSync(tmp, JSON.stringify({ repos: out }), "utf8");
renameSync(tmp, persistPath);
} catch { /* 快照失败不影响开发使用 */ }
}
loadPersisted();
function requireRepo(projectDir: string): Repo {
const repo = repos.get(projectDir);
if (repo === undefined) {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
return repo;
}
function liveVersion(repo: Repo, filePath: string): StoredVersion {
const chain = repo.files.get(filePath);
const latest = chain?.[chain.length - 1];
if (chain === undefined || latest === undefined || latest.deleted) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return latest;
}
function findVersion(repo: Repo, filePath: string, version: VersionId): StoredVersion {
const found = repo.files.get(filePath)?.find((v) => v.version === version);
if (found === undefined) {
throw new FileLibError(404, "version_not_found", `version not found: ${filePath}@${version}`);
}
return found;
}
return {
async init(projectDir) {
await serialize(projectDir, async () => {
// S7:幂等,重复调用不报错、不重建。
const created = repos.get(projectDir) === undefined;
repos.set(projectDir, repos.get(projectDir) ?? { files: new Map(), counter: 0 });
if (created) savePersisted();
});
},
async list(projectDir, prefix) {
const repo = requireRepo(projectDir);
const out: FileEntry[] = [];
for (const [path, chain] of repo.files) {
const latest = chain[chain.length - 1];
if (latest === undefined || latest.deleted) continue;
if (prefix !== undefined && !path.startsWith(prefix)) continue;
out.push({ path, size: latest.content.byteLength });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
return liveVersion(requireRepo(projectDir), filePath).version;
},
async read(projectDir, filePath, at) {
const repo = requireRepo(projectDir);
if (at !== undefined) return findVersion(repo, filePath, at).content;
return liveVersion(repo, filePath).content;
},
async commit(projectDir, filePath, req) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
// S2:新建(baseVersion null)要求文件当前不存在;否则要求 baseVersion 精确等于当前版本(S1)。
if (req.baseVersion === null) {
if (currentVersion !== null) return { status: "conflict", currentVersion };
} else if (req.baseVersion !== currentVersion) {
return {
status: "conflict",
currentVersion: currentVersion ?? req.baseVersion,
};
}
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: toBuffer(req.content),
message: req.message ?? `commit ${version}`,
author: req.author,
committedAt: new Date().toISOString(),
deleted: false,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
if (currentVersion === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
if (baseVersion !== currentVersion) return { status: "conflict", currentVersion };
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: Buffer.alloc(0),
message: `remove ${filePath}`,
author: undefined,
committedAt: new Date().toISOString(),
deleted: true,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
const repo = requireRepo(projectDir);
const a = findVersion(repo, filePath, from);
const b = findVersion(repo, filePath, to);
return naiveDiff(a.content.toString("utf8"), b.content.toString("utf8"));
},
async history(projectDir, filePath, limit) {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const infos: VersionInfo[] = chain.map((v) => ({
version: v.version,
message: v.message,
author: v.author,
committedAt: v.committedAt,
}));
const ordered = infos.reverse();
return limit !== undefined ? ordered.slice(0, limit) : ordered;
},
};
}
+155 -15
View File
@@ -5,11 +5,12 @@
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves.
*
* The login page and dashboard are now served by the `database-admin` SvelteKit
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the
* concrete JSON/redirect routes the SPA depends on:
* 标准前后端分离:本文件**不渲染任何 HTML**。登录页与管理后台六个 tab 全部由
* `hub/filelib-web` 这一个 SvelteKit SPA 提供(同一份产物挂 /app 与 /database,
* 见 ../static.ts / registerDatabaseSpa)。此处只留 SPA 依赖的 JSON/跳转端点:
*
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
* /database/api/stats — 概览页统计(需登录 + silo org OWNER/ADMIN)
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
*
* The dev bypass (/database/dev-login) is self-contained here and gated by
@@ -22,7 +23,19 @@
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import path from "node:path";
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 { registerTeacherApp } from "./teacherApp.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
@@ -38,14 +51,33 @@ export async function registerDatabaseRoutes(
app: FastifyInstance,
config: DatabaseRouteConfig,
): Promise<void> {
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
// the page needs to build the Feishu login link and toggle the dev button —
// no secrets, no user data.
app.get("/database/config", async () => {
return {
siloOrganizationSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
};
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
let filelibDepsForStats: FileLibRouteDeps | null = null;
// 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
// 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册,
// 两处并存是为了兼容既有前端调用点。
app.get("/database/config", async () => ({
orgSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
}));
// 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
// 计数与审计流,不是某个节点的授权视图,所以不走 per-node 的 role 判定。
app.get("/database/api/stats", async (request, reply) => {
if (filelibDepsForStats === null) {
return reply.status(503).send({ error: { code: "unavailable", message: "filelib not ready" } });
}
const actor = await actorOrNull(request, reply, filelibDepsForStats);
if (actor === null) return reply;
if (!actor.isWebsiteAdmin) {
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
}
try {
return await loadDashboardStats(config.prisma, filelibDepsForStats);
} catch (error) {
return sendRouteError(reply, error);
}
});
// DEV ONLY bypass — self-contained here, registered only when the flag is on
@@ -99,8 +131,116 @@ export async function registerDatabaseRoutes(
});
}
// Add more /database/* JSON routes here. Guard data routes with requireSession
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
const siloOrg = await config.prisma.organization.findUnique({
where: { slug: config.siloOrganizationSlug },
select: { id: true },
});
if (siloOrg === null) {
app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered");
return;
}
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json"));
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
const filelibDeps: FileLibRouteDeps = {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
organizationId: siloOrg.id,
storageRoot,
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
? createMemberGroupResolver(config.prisma)
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)],
};
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
siloOrganizationSlug: config.siloOrganizationSlug,
allowDevLoginBypass: config.allowDevLoginBypass,
});
// 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。
// SPA 化后目标是真路由(不再是 #library 锚点)。
app.get("/database/library", async (_request, reply) =>
reply.redirect("/database/dashboard/library"),
);
filelibDepsForStats = filelibDeps;
}
/* ------------------------------------------------------------ 概览页统计 */
interface DashboardStats {
readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
readonly when: Date;
}>;
}
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
async function loadDashboardStats(
prisma: PrismaClient,
deps: FileLibRouteDeps,
): Promise<DashboardStats> {
const organizationId = deps.organizationId;
const [folders, projects, grants] = await Promise.all([
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }),
prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }),
]);
// 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计)
const readyProjects = await prisma.fileLibNode.findMany({
where: {
organizationId, kind: "PROJECT", deletedAt: null,
provisionStatus: "READY", storageDir: { not: null },
},
select: { storageDir: true },
});
let files = 0;
for (const project of readyProjects) {
if (project.storageDir === null) continue;
try {
files += (await deps.versionStore.list(project.storageDir)).length;
} catch { /* repo 缺失(如重启未恢复)不计 */ }
}
const entries = await prisma.auditEntry.findMany({
where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } },
orderBy: { createdAt: "desc" },
take: 8,
});
const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))];
const users = actorIds.length === 0
? []
: await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } });
const nameById = new Map(users.map((u) => [u.id, u.displayName]));
const recent = entries.map((entry) => {
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
const label =
(typeof meta["name"] === "string" ? meta["name"] : undefined) ??
(typeof meta["to"] === "string" ? meta["to"] : undefined) ??
(typeof meta["path"] === "string" ? meta["path"] : undefined) ??
(typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : "");
return {
action: entry.action,
actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown",
label,
when: entry.createdAt,
};
});
return { folders, projects, files, grants, recent };
}
+235
View File
@@ -0,0 +1,235 @@
/**
* /database/api/* 文件内容与导出端点(契约 9.3/9.4)。
* 冲突流:POST commits 返回 200 {version} 或 409 {currentVersion}(编辑 UI 拉 diff 后重提)。
*/
import type { FastifyInstance, FastifyRequest } from "fastify";
import {
commitFile,
deleteFile,
diffFile,
fileHistory,
listFiles,
readFile,
readFileRaw,
type FileContentEncoding,
} from "../filelib/fileService.js";
import {
createManifestStubAdapter,
downloadExport,
getExportJob,
submitExport,
} from "../filelib/exportService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerFileRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const fileDeps = {
prisma: deps.prisma,
organizationId: deps.organizationId,
groupResolver: deps.groupResolver,
versionStore: deps.versionStore,
};
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
function pathParam(request: FastifyRequest): string {
const path = (request.query as { path?: string }).path;
if (path === undefined) throw new FileLibError(400, "invalid_request", "missing query: path");
return path;
}
function encodingParam(raw: unknown): FileContentEncoding {
if (raw === undefined || raw === "utf8") return "utf8";
if (raw === "base64") return "base64";
throw new FileLibError(400, "invalid_request", "encoding must be utf8 or base64");
}
app.get("/database/api/projects/:id/files", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const prefix = (request.query as { prefix?: string }).prefix;
return { files: await listFiles(fileDeps, actor, id, prefix) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file", 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 readFile(fileDeps, actor, id, pathParam(request));
} catch (error) {
return sendRouteError(reply, error);
}
});
// 原始字节下载(Content-Disposition: attachment;浏览器直接 save-as)
app.get("/database/api/projects/:id/file/raw", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const raw = await readFileRaw(fileDeps, actor, id, pathParam(request));
return reply
.header("Content-Disposition",
`attachment; filename="${encodeURIComponent(raw.filename)}"; filename*=UTF-8''${encodeURIComponent(raw.filename)}`)
.header("X-Content-Version", raw.version)
.type("application/octet-stream")
.send(raw.buffer);
} catch (error) {
return sendRouteError(reply, error);
}
});
// 新建/上传(baseVersion 恒 null;已存在 → 409)
app.put("/database/api/projects/:id/file", 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);
const result = await commitFile(fileDeps, actor, id, {
path: requireString(body, "path"),
baseVersion: null,
content: requireString(body, "content"),
encoding: encodingParam(body["encoding"]),
message: optionalString(body, "message"),
});
return reply.status(201).send(result);
} catch (error) {
return sendRouteError(reply, error);
}
});
// 提交编辑(乐观并发;409 → details.currentVersion + file.conflict_detected 审计)
app.post("/database/api/projects/:id/file/commits", 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);
const baseVersion = body["baseVersion"];
if (typeof baseVersion !== "string" || baseVersion === "") {
throw new FileLibError(400, "invalid_request", "baseVersion must be a non-empty string");
}
return await commitFile(fileDeps, actor, id, {
path: requireString(body, "path"),
baseVersion,
content: requireString(body, "content"),
encoding: encodingParam(body["encoding"]),
message: optionalString(body, "message"),
});
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/projects/:id/file", 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);
await deleteFile(fileDeps, actor, id, pathParam(request), requireString(body, "baseVersion"));
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file/diff", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const query = request.query as { from?: string; to?: string };
if (query.from === undefined || query.to === undefined) {
throw new FileLibError(400, "invalid_request", "missing query: from / to");
}
return await diffFile(fileDeps, actor, id, pathParam(request), query.from, query.to);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file/history", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const rawLimit = (request.query as { limit?: string }).limit;
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
}
return { history: await fileHistory(fileDeps, actor, id, pathParam(request), limit) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 导出(D10 异步) */
app.post("/database/api/projects/:id/exports", 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);
const params = body["params"];
if (params !== undefined && (typeof params !== "object" || params === null || Array.isArray(params))) {
throw new FileLibError(400, "invalid_request", "params must be an object");
}
const job = await submitExport(
exportDeps,
actor,
id,
requireString(body, "target"),
(params as Record<string, unknown> | undefined) ?? {},
);
return reply.status(202).send({ jobId: job.id, status: job.status });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/exports/:jobId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { jobId } = request.params as { jobId: string };
return await getExportJob(exportDeps, actor, jobId);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/exports/:jobId/download", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { jobId } = request.params as { jobId: string };
const artifact = await downloadExport(exportDeps, actor, jobId);
return reply
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
.type("application/octet-stream")
.send(artifact.content);
} catch (error) {
return sendRouteError(reply, error);
}
});
}
+301
View File
@@ -0,0 +1,301 @@
/**
* /database/api/* 树与授权端点(契约 9.1/9.2 + C2 过渡搜索)。
* 约定:绝对路径;actorOrNull 前置;业务全走 filelib 服务层;错误统一 sendRouteError。
*/
import type { FastifyInstance } from "fastify";
import {
breadcrumb,
createNode,
getEffectiveRole,
listChildren,
moveNode,
renameNode,
softDeleteNode,
updateNodeDescription,
type FileLibActor,
type InitialGrant,
} from "../filelib/treeService.js";
import {
forceAdjustGrants,
listGrants,
putGrants,
revokeGrant,
setIndependentPermission,
} from "../filelib/grantService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
treeDeps,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerFileLibRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const tree = treeDeps(deps);
const grantDeps = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
/* ------------------------------------------------------------ 身份 */
// 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。
// displayName/avatarUrl 供侧栏身份区显示 —— 页面不再服务端渲染(ADR-0029),
// 旧 renderDashboard 在 handler 里查 Prisma 拿到的名字,现在必须由这里带出去,
// 否则前端只有 userId 可显示。
app.get("/database/api/me", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { displayName: true, avatarUrl: true },
});
return {
userId: actor.userId,
isWebsiteAdmin: actor.isWebsiteAdmin,
displayName: user?.displayName ?? actor.userId,
avatarUrl: user?.avatarUrl ?? null,
};
});
/* ------------------------------------------------------------ 树节点 */
app.get("/database/api/nodes", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const query = request.query as { parentId?: string };
const parentId = query.parentId === undefined || query.parentId === "" ? null : query.parentId;
return { nodes: await listChildren(tree, actor, parentId) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/nodes", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const parentIdRaw = body["parentId"];
if (parentIdRaw !== null && typeof parentIdRaw !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
const kindRaw = requireString(body, "kind");
if (kindRaw !== "FOLDER" && kindRaw !== "PROJECT") {
throw new FileLibError(400, "invalid_request", "kind must be FOLDER or PROJECT");
}
const grants = parseGrants(body["grants"]);
const description = optionalString(body, "description");
const node = await createNode(tree, actor, {
parentId: parentIdRaw,
kind: kindRaw,
name: requireString(body, "name"),
description: description || undefined,
grants,
});
return reply.status(201).send({ node });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/nodes/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const role = await getEffectiveRole(tree, actor, id); // 无权限即 404(D8)
const node = await deps.prisma.fileLibNode.findFirst({
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,
parentId: node.parentId,
kind: node.kind,
name: node.name,
description: node.description,
role,
provisionStatus: node.provisionStatus,
independentPermission: settings?.independentPermissionsEnabled ?? false,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
},
};
} catch (error) {
return sendRouteError(reply, error);
}
});
app.patch("/database/api/nodes/:id", 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);
const name = optionalString(body, "name");
const hasParent = Object.prototype.hasOwnProperty.call(body, "parentId");
const hasDesc = Object.prototype.hasOwnProperty.call(body, "description");
if (name === undefined && !hasParent && !hasDesc) {
throw new FileLibError(400, "invalid_request", "nothing to update (name? parentId? description?)");
}
let node;
if (name !== undefined) node = await renameNode(tree, actor, id, name);
if (hasParent) {
const parentId = body["parentId"];
if (parentId !== null && typeof parentId !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
node = await moveNode(tree, actor, id, parentId);
}
if (hasDesc) {
const d = body["description"];
if (d !== null && typeof d !== "string") {
throw new FileLibError(400, "invalid_request", "description must be a string or null");
}
node = await updateNodeDescription(tree, actor, id, typeof d === "string" ? d : null);
}
return { node };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/nodes/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
await softDeleteNode(tree, actor, id);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/nodes/:id/breadcrumb", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { breadcrumb: await breadcrumb(tree, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/nodes/:id/effective-permission", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { role: await getEffectiveRole(tree, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 授权 */
app.get("/database/api/nodes/:id/grants", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { grants: await listGrants(grantDeps, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.put("/database/api/nodes/:id/grants", 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);
const items = parseGrants(body["grants"]);
if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants");
return await putGrants(grantDeps, actor, id, items);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/nodes/:id/grants/:grantId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id, grantId } = request.params as { id: string; grantId: string };
await revokeGrant(grantDeps, actor, id, grantId);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
// D19 高危通道:网站管理员凭 id 强制调整,全部留 admin.force_adjust 审计。
app.put("/database/api/admin/nodes/:id/grants", 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);
const items = parseGrants(body["grants"]);
if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants");
return await forceAdjustGrants(grantDeps, actor, id, items);
} catch (error) {
return sendRouteError(reply, error);
}
});
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)。此处不再注册,避免重复。
}
function parseGrants(raw: unknown): InitialGrant[] | undefined {
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) throw new FileLibError(400, "invalid_request", "grants must be an array");
return raw.map((item) => {
if (typeof item !== "object" || item === null) {
throw new FileLibError(400, "invalid_request", "grant entries must be objects");
}
const grant = item as Record<string, unknown>;
const principalType = grant["principalType"];
if (principalType !== "USER" && principalType !== "GROUP") {
throw new FileLibError(400, "invalid_request", "grant.principalType must be USER or GROUP");
}
const role = grant["role"];
if (role !== "VIEW" && role !== "EDIT" && role !== "MANAGE") {
throw new FileLibError(400, "invalid_request", "grant.role must be VIEW, EDIT or MANAGE");
}
if (typeof grant["principalId"] !== "string" || grant["principalId"] === "") {
throw new FileLibError(400, "invalid_request", "grant.principalId must be a non-empty string");
}
return { principalType, principalId: grant["principalId"], role };
});
}
@@ -0,0 +1,196 @@
/**
* /database/api/groups/* 成员组管理端点(ADR-0028)。
* 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService;
* 错误统一 sendRouteError。
*
* 管理端点(CRUD + 成员)由 service 层门禁到网站管理员;搜索端点不限管理员
* (授权选择器是 Manage 持有者的能力,决策2)。
*/
import type { FastifyInstance } from "fastify";
import {
addMember,
createMemberGroup,
deleteMemberGroup,
listMemberGroups,
listMembers,
removeMember,
restoreMemberGroup,
searchMemberGroups,
searchUsers,
updateMemberGroup,
} from "../filelib/memberGroupService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerMemberGroupRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId };
/* ------------------------------------------------------------ 搜索(授权选择器) */
// 契约 C2 /groups/search:活跃组 + breadcrumb。**非管理员可调**(决策2)。
// 注:必须先于 "/database/api/groups" 之类的段前缀之外单独成路径,Fastify
// 静态路由不会 shadow,顺序无关;此处与其它端点平级注册。
app.get("/database/api/groups/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const q = (request.query as { q?: string }).q ?? "";
return { groups: await searchMemberGroups(svc, q) };
} catch (error) {
return sendRouteError(reply, error);
}
});
// 成员选择器:搜全局用户。仅管理员(service 层门禁)。
// excludeGroupId 过滤掉该组已有活跃成员。
app.get("/database/api/users/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const query = request.query as { q?: string; excludeGroupId?: string };
return { users: await searchUsers(svc, actor, query.q ?? "", query.excludeGroupId) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 组 CRUD */
// includeArchived=1 时连已归档组一并返回(带 archivedAt),供后台展示/恢复。
app.get("/database/api/groups", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const raw = (request.query as { includeArchived?: string }).includeArchived;
const includeArchived = raw === "1" || raw === "true";
return { groups: await listMemberGroups(svc, actor, includeArchived) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/groups", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const parentIdRaw = body["parentId"];
if (parentIdRaw !== undefined && parentIdRaw !== null && typeof parentIdRaw !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
const group = await createMemberGroup(svc, actor, {
name: requireString(body, "name"),
description: optionalString(body, "description"),
parentId: parentIdRaw === undefined ? null : parentIdRaw,
});
return reply.status(201).send({ group });
} catch (error) {
return sendRouteError(reply, error);
}
});
// 改名 / 改描述(决策6)。不接受 parentId —— reparent 仍不在 v1(决策5)。
app.patch("/database/api/groups/:id", 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 (body["parentId"] !== undefined) {
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
}
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
// (它把 "" 也归为 undefined)。
const descRaw = body["description"];
if (descRaw !== undefined && descRaw !== null && typeof descRaw !== "string") {
throw new FileLibError(400, "invalid_request", "description must be a string");
}
const group = await updateMemberGroup(svc, actor, id, {
name: optionalString(body, "name"),
description: descRaw === undefined || descRaw === null ? undefined : descRaw,
});
return { group };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/groups/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const result = await deleteMemberGroup(svc, actor, id);
return { archivedCount: result.archivedCount };
} catch (error) {
return sendRouteError(reply, error);
}
});
// 恢复(取消归档)。与删除不对称:只恢复该组 + 已归档祖先链,不动子树(决策7)。
app.post("/database/api/groups/: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 };
const result = await restoreMemberGroup(svc, actor, id);
return { restoredCount: result.restoredCount };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 成员 */
app.get("/database/api/groups/:id/members", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { members: await listMembers(svc, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/groups/:id/members", 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);
const userId = optionalString(body, "userId");
const feishuOpenId = optionalString(body, "feishuOpenId");
if (userId === undefined && feishuOpenId === undefined) {
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
}
const member = await addMember(svc, actor, id, { userId, feishuOpenId });
return reply.status(201).send({ member });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/groups/:id/members/:userId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id, userId } = request.params as { id: string; userId: string };
await removeMember(svc, actor, id, userId);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 老师端后端支撑(标准前后端分离):
* GET /database/api/login-info — 登录页配置(org slug / dev 开关)
* GET /app/dev-login-teacher — DEV ONLY 一键登录(普通老师)
*
* 服务端不渲染任何页面。`/app/*` 的静态托管与 SPA 回退在 ../static.ts —— 那里
* 与管理后台 `/database/*` 共用同一份 filelib-web 构建产物,资源路由只注册一次。
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
export interface TeacherAppConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
/** 飞书 OAuth 链接按 silo org slug 构造。 */
readonly siloOrganizationSlug: string;
/** DEV ONLY(双重门禁,见 database/plugin.ts):一键登录端点与按钮同进同退。 */
readonly allowDevLoginBypass: boolean;
}
export async function registerTeacherApp(
app: FastifyInstance,
config: TeacherAppConfig,
): Promise<void> {
// 登录页配置(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
app.get("/database/api/login-info", async () => ({
orgSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
}));
if (!config.allowDevLoginBypass) return;
registerDevLogins(app, config);
}
/** DEV ONLY:普通老师一键登录端点(双重门禁见 plugin.ts)。
* 老师端不提供管理员登录 —— 管理员从 /database/admin 进。 */
function registerDevLogins(app: FastifyInstance, config: TeacherAppConfig): void {
app.get("/app/dev-login-teacher", async (_request, reply) => {
const prisma = config.prisma;
const organization = await prisma.organization.findFirst({
where: { status: "ACTIVE" },
select: { id: true },
});
if (organization === null) {
return reply.status(404).send({ error: { code: "no_org", message: "no active organization" } });
}
let membership = await prisma.organizationMembership.findFirst({
where: { organizationId: organization.id, role: "MEMBER", revokedAt: null },
select: { userId: true, organizationId: true },
});
if (membership === null) {
const teacher = await prisma.user.upsert({
where: { feishuOpenId: "ou_dev_teacher" },
update: {},
create: { feishuOpenId: "ou_dev_teacher", displayName: "测试老师" },
});
await prisma.organizationMembership.create({
data: { organizationId: organization.id, userId: teacher.id, role: "MEMBER" },
});
membership = { userId: teacher.id, organizationId: organization.id };
}
const connection = await prisma.organizationFeishuApplicationConnection.findFirst({
where: { organizationId: membership.organizationId, status: "ACTIVE" },
select: { id: true, organizationId: true },
});
if (connection === null) {
return reply.status(404).send({ error: { code: "no_connection", message: "no active Feishu connection for org" } });
}
let identity = await prisma.feishuUserIdentity.findFirst({
where: { userId: membership.userId, connectionId: connection.id },
select: { id: true, connectionId: true },
});
if (identity === null) {
identity = await prisma.feishuUserIdentity.create({
data: { connectionId: connection.id, userId: membership.userId, openId: "ou_dev_teacher" },
select: { id: true, connectionId: true },
});
}
setSessionCookie(reply, config.sessionSecret, membership.userId, identity.id, identity.connectionId, connection.organizationId);
reply.log.warn({ userId: membership.userId }, "DEV teacher-app login bypass (regular teacher) used");
return reply.redirect("/app");
});
}
function setSessionCookie(
reply: { setCookie: (name: string, value: string, opts: Record<string, unknown>) => void },
secret: string,
userId: string,
feishuIdentityId: string,
feishuConnectionId: string,
feishuOrganizationId: string,
): void {
const token = signSession({ userId, feishuIdentityId, feishuConnectionId, feishuOrganizationId }, secret);
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: false,
maxAge: 7 * 24 * 60 * 60,
});
}
+43 -70
View File
@@ -1,98 +1,71 @@
/**
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
* and the SPA index fallback for client-side routes under `/database/*`.
* 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web`
* 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。
*
* The SvelteKit project lives in `hub/database-admin/` and is built with
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
* colliding). On disk the files still live at `build/_app/*`; this handler maps
* the `/database`-prefixed URLs back to those files.
* 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html
* 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*
*
* Run `npm run build` in database-admin/ to produce the static output. In
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
* 三类路由:
* /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为
* `_filelib`,以避开 admin-web 在根上注册的 /_app/*
* (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。
* /app, /app/* — 老师端 SPA 回退
* /database, /database/* — 管理后台 SPA 回退
*
* Mirrors src/admin/static.ts.
* 具体路由(/database/api/*、/database/dev-login、/app/dev-login-teacher 等)由
* databaseRoutes.ts / teacherApp.ts 先注册;Fastify 按具体度匹配,通配不遮蔽它们。
*
* Override the UI directory with `CPH_FILELIB_UI_DIR` if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
import { dirname, join, resolve as resolvePath } from "node:path";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
function resolveUiDir(): string {
const override = process.env["CPH_DATABASE_UI_DIR"];
const override = process.env["CPH_FILELIB_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "database-admin", "build"));
return resolvePath(join(here, "..", "..", "filelib-web", "build"));
}
/** 构建产物根目录下的顶层静态文件(SvelteKit 把 static/ 原样拷到这里)。 */
const TOP_LEVEL_FILES = ["favicon.svg", "favicon.ico", "robots.txt"] as const;
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
"filelib-web/build not found; /app and /database SPA shells disabled. Run `npm run build --prefix filelib-web` to enable. JSON APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
// but on disk they're under build/_app/*.
app.get("/database/_app/*", async (request, reply) => {
const rel = (request.params as { "*": string })["*"];
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
try {
const buf = await readFile(join(uiDir, "_app", safe));
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
// 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。
// 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径
// (filelib-web 的 paths.relative=false 保证了这点)。
await app.register(fastifyStatic, {
root: join(uiDir, "_filelib"),
prefix: "/_filelib/",
decorateReply: true,
});
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
app.get("/database/favicon.svg", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "favicon.svg"));
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
app.get("/database/robots.txt", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "robots.txt"));
return reply.type("text/plain; charset=utf-8").send(buf);
} catch {
return reply.status(404).send();
}
});
for (const name of TOP_LEVEL_FILES) {
if (!existsSync(join(uiDir, name))) continue;
app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir));
}
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
// /database/dev-login, and the asset routes above) are more specific, so
// Fastify's router matches them before this wildcard. Everything else under
// /database serves index.html so SvelteKit's client router can resolve the view.
app.get("/database", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
app.get("/database/*", async (_request, reply) => {
return reply.type("text/html; charset=utf-8").send(indexHtml);
});
// SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以
// 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。
const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
reply.type("text/html; charset=utf-8").send(indexHtml);
app.get("/app", sendIndex);
app.get("/app/*", sendIndex);
app.get("/database", sendIndex);
app.get("/database/*", sendIndex);
}
+7 -7
View File
@@ -17,16 +17,16 @@ export function isSiloHttpRateLimitExempt(url: string): boolean {
if (path === "/_app" || path.startsWith("/_app/")) return true;
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
// database-admin SvelteKit build output, served under /database (base path;
// see database/static.ts).
if (path === "/database/_app" || path.startsWith("/database/_app/")) return true;
if (path === "/database/favicon.svg" || path === "/database/robots.txt") return true;
// filelib-web SvelteKit build output. appDir 改名为 `_filelib` 以避开根 /_app/*
// (见 database/static.ts);同一份产物服务 /app 与 /database 两个前缀。
if (path === "/_filelib" || path.startsWith("/_filelib/")) return true;
// SPA index shells for client-side routes (not APIs). The /database/* shell is
// blanket-exempt like /admin/* since client routes are unknowable up front;
// this also covers the once-per-load /database/config bootstrap.
// SPA index shells for client-side routes (not APIs). /admin/*、/database/*
// /app/* 整体豁免 —— 客户端路由无法预先枚举;这也覆盖了每次加载一次的
// /database/config bootstrap
if (path === "/admin" || path.startsWith("/admin/")) return true;
if (path === "/database" || path.startsWith("/database/")) return true;
if (path === "/app" || path.startsWith("/app/")) return true;
return false;
}
+227
View File
@@ -0,0 +1,227 @@
/**
* 文件库 HTTP 端点集成测试(真实 Postgres + Fastify inject)。
* 覆盖:401 门禁、D8(404/403)、8.1 授权矩阵、文件冲突流(409+审计)、导出状态机。
* 运行前提:本地 PG 且已 migrate(同 filelib-tree.test.ts)。
*/
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import Fastify, { type FastifyInstance } from "fastify";
import fastifyCookie from "@fastify/cookie";
import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
import { signSession, SESSION_COOKIE_NAME } from "../../src/admin/auth/session.js";
import { registerFileLibRoutes } from "../../src/database/routes/filelibRoutes.js";
import { registerFileRoutes } from "../../src/database/routes/fileRoutes.js";
import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
import { createManifestStubAdapter } from "../../src/database/filelib/exportService.js";
import type { FileLibRouteDeps } from "../../src/database/filelib/routeShared.js";
const SECRET = "filelib-test-secret";
let app: FastifyInstance;
let deps: FileLibRouteDeps;
function cookie(userId: string, openId: string): string {
return `${SESSION_COOKIE_NAME}=${signSession({ userId, feishuOpenId: openId }, SECRET)}`;
}
const ADMIN_COOKIE = () => cookie("u_admin", "ou_admin");
const ALICE_COOKIE = () => cookie("u_alice", "ou_alice");
const BOB_COOKIE = () => cookie("u_bob", "ou_bob");
async function seedUsers(): Promise<void> {
await prisma.user.create({ data: { id: "u_admin", feishuOpenId: "ou_admin", displayName: "Admin" } });
await prisma.user.create({ data: { id: "u_alice", feishuOpenId: "ou_alice", displayName: "Alice" } });
await prisma.user.create({ data: { id: "u_bob", feishuOpenId: "ou_bob", displayName: "Bob" } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u_admin", role: "OWNER" },
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u_alice", role: "MEMBER" },
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u_bob", role: "MEMBER" },
});
}
beforeEach(async () => {
await resetDb();
await seedUsers();
const versionStore = createInMemoryVersionStore();
deps = {
prisma,
sessionSecret: SECRET,
organizationId: DEFAULT_ORG_ID,
storageRoot: "/tmp/filelib-it",
groupResolver: createStaticGroupResolver({}),
versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)],
};
app = Fastify({ logger: false });
await app.register(fastifyCookie);
await registerFileLibRoutes(app, deps);
await registerFileRoutes(app, deps);
await app.ready();
});
afterAll(async () => {
await app?.close();
});
async function createRoot(cookieHeader: string, name = "物理"): Promise<string> {
const res = await app.inject({
method: "POST", url: "/database/api/nodes",
headers: { cookie: cookieHeader },
payload: { parentId: null, kind: "FOLDER", name },
});
expect(res.statusCode).toBe(201);
return res.json().node.id as string;
}
describe("filelib http · 门禁与 D8", () => {
it("无 session → 401", async () => {
const res = await app.inject({ method: "GET", url: "/database/api/nodes" });
expect(res.statusCode).toBe(401);
});
it("/me:OWNER → isWebsiteAdmin=true;MEMBER → false", async () => {
const admin = await app.inject({ method: "GET", url: "/database/api/me", headers: { cookie: ADMIN_COOKIE() } });
expect(admin.json().isWebsiteAdmin).toBe(true);
const alice = await app.inject({ method: "GET", url: "/database/api/me", headers: { cookie: ALICE_COOKIE() } });
expect(alice.json().isWebsiteAdmin).toBe(false);
});
it("root 创建:MEMBER 403;无权限节点 GET 404、VIEW 越权 DELETE 403", async () => {
const forbidden = await app.inject({
method: "POST", url: "/database/api/nodes",
headers: { cookie: ALICE_COOKIE() },
payload: { parentId: null, kind: "FOLDER", name: "越权" },
});
expect(forbidden.statusCode).toBe(403);
const rootId = await createRoot(ADMIN_COOKIE());
const notFound = await app.inject({ method: "GET", url: `/database/api/nodes/${rootId}`, headers: { cookie: BOB_COOKIE() } });
expect(notFound.statusCode).toBe(404);
await app.inject({
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
headers: { cookie: ADMIN_COOKIE() },
payload: { grants: [{ principalType: "USER", principalId: "u_alice", role: "VIEW" }] },
});
const visible = await app.inject({ method: "GET", url: `/database/api/nodes/${rootId}`, headers: { cookie: ALICE_COOKIE() } });
expect(visible.statusCode).toBe(200);
expect(visible.json().node.role).toBe("VIEW");
const denied = await app.inject({ method: "DELETE", url: `/database/api/nodes/${rootId}`, headers: { cookie: ALICE_COOKIE() } });
expect(denied.statusCode).toBe(403);
});
});
describe("filelib http · 8.1 授权矩阵", () => {
it("非创建者的 MANAGE 授 MANAGE → 403;创建者可授;creator grant 不可收回", async () => {
const rootId = await createRoot(ADMIN_COOKIE());
// admin(创建者)授 alice MANAGE → 允许
const put1 = await app.inject({
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
headers: { cookie: ADMIN_COOKIE() },
payload: { grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }] },
});
expect(put1.statusCode).toBe(200);
// alice(MANAGE 但非创建者)授 bob MANAGE → 403
const put2 = await app.inject({
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
headers: { cookie: ALICE_COOKIE() },
payload: { grants: [{ principalType: "USER", principalId: "u_bob", role: "MANAGE" }] },
});
expect(put2.statusCode).toBe(403);
expect(put2.json().error.code).toBe("only_creator_can_grant_manage");
// alice 授 bob EDIT → 允许
const put3 = await app.inject({
method: "PUT", url: `/database/api/nodes/${rootId}/grants`,
headers: { cookie: ALICE_COOKIE() },
payload: { grants: [{ principalType: "USER", principalId: "u_bob", role: "EDIT" }] },
});
expect(put3.statusCode).toBe(200);
// creator grant 不可收回
const list = await app.inject({ method: "GET", url: `/database/api/nodes/${rootId}/grants`, headers: { cookie: ADMIN_COOKIE() } });
const creatorGrant = list.json().grants.find((g: { isCreatorGrant: boolean }) => g.isCreatorGrant);
const revoke = await app.inject({
method: "DELETE", url: `/database/api/nodes/${rootId}/grants/${creatorGrant.id}`,
headers: { cookie: ADMIN_COOKIE() },
});
expect(revoke.statusCode).toBe(403);
expect(revoke.json().error.code).toBe("cannot_touch_creator");
});
});
describe("filelib http · 文件冲突流", () => {
it("上传→读→提交→409 冲突→conflict 审计落库", async () => {
const res = await app.inject({
method: "POST", url: "/database/api/nodes",
headers: { cookie: ADMIN_COOKIE() },
payload: { parentId: null, kind: "PROJECT", name: "TH-141" },
});
const projectId = res.json().node.id as string;
const up = await app.inject({
method: "PUT", url: `/database/api/projects/${projectId}/file`,
headers: { cookie: ADMIN_COOKIE() },
payload: { path: "docs/a.md", content: "第一版" },
});
expect(up.statusCode).toBe(201);
const read = await app.inject({
method: "GET", url: `/database/api/projects/${projectId}/file?path=docs/a.md`,
headers: { cookie: ADMIN_COOKIE() },
});
expect(read.json().content).toBe("第一版");
const v1 = read.json().version as string;
const commit = await app.inject({
method: "POST", url: `/database/api/projects/${projectId}/file/commits`,
headers: { cookie: ADMIN_COOKIE() },
payload: { path: "docs/a.md", baseVersion: v1, content: "第二版" },
});
expect(commit.statusCode).toBe(200);
const conflict = await app.inject({
method: "POST", url: `/database/api/projects/${projectId}/file/commits`,
headers: { cookie: ADMIN_COOKIE() },
payload: { path: "docs/a.md", baseVersion: v1, content: "幽灵版" },
});
expect(conflict.statusCode).toBe(409);
expect(conflict.json().error.currentVersion).toBe(commit.json().version);
const audit = await prisma.auditEntry.findFirst({
where: { organizationId: DEFAULT_ORG_ID, action: "file.conflict_detected" },
});
expect(audit).not.toBeNull();
});
});
describe("filelib http · 导出状态机", () => {
it("提交 → 轮询 DONE → 下载 manifest", async () => {
const res = await app.inject({
method: "POST", url: "/database/api/nodes",
headers: { cookie: ADMIN_COOKIE() },
payload: { parentId: null, kind: "PROJECT", name: "导出源" },
});
const projectId = res.json().node.id as string;
const submit = await app.inject({
method: "POST", url: `/database/api/projects/${projectId}/exports`,
headers: { cookie: ADMIN_COOKIE() },
payload: { target: "manifest" },
});
expect(submit.statusCode).toBe(202);
const jobId = submit.json().jobId as string;
let job: { status: string } = { status: "QUEUED" };
for (let i = 0; i < 50 && job.status !== "DONE" && job.status !== "FAILED"; i += 1) {
await new Promise((r) => setTimeout(r, 50));
const poll = await app.inject({ method: "GET", url: `/database/api/exports/${jobId}`, headers: { cookie: ADMIN_COOKIE() } });
job = poll.json();
}
expect(job.status).toBe("DONE");
const download = await app.inject({ method: "GET", url: `/database/api/exports/${jobId}/download`, headers: { cookie: ADMIN_COOKIE() } });
expect(download.statusCode).toBe(200);
expect(download.body).toContain("manifest");
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* 文件库树服务集成测试(真实 Postgres)。
* 覆盖:root 创建规则、父级 EDIT+ 创建、D8(404/403)、D11 toggle 冻结、
* D12 move(环拒绝/目标授权/并发对移)、D14 兄弟名冲突、D15 祖先删除整支隐藏、
* D17 breadcrumb 占位、provisioning 状态机、审计落库。
* 运行前提:本地 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,
renameNode,
moveNode,
softDeleteNode,
breadcrumb,
getEffectiveRole,
listChildren,
type FileLibActor,
type TreeServiceDeps,
} from "../../src/database/filelib/treeService.js";
import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
import { FileLibError } from "../../src/database/filelib/model.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 deps(): TreeServiceDeps {
return {
prisma,
groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
versionStore: createInMemoryVersionStore(),
organizationId: DEFAULT_ORG_ID,
storageRoot: "/tmp/filelib-test",
};
}
async function seedUsers(): Promise<void> {
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 } });
}
}
beforeEach(async () => {
await resetDb();
await seedUsers();
});
describe("treeService · 创建规则", () => {
it("root 仅网站管理员可建;创建者自动 MANAGE", async () => {
await expect(createNode(deps(), ALICE, { parentId: null, kind: "FOLDER", name: "根" }))
.rejects.toMatchObject({ statusCode: 403 });
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
expect(await getEffectiveRole(deps(), ADMIN, root.id)).toBe("MANAGE");
});
it("非 root:父级无权限 404、仅 VIEW 403、EDIT+ 可建;项目下禁止子节点", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
await expect(createNode(deps(), BOB, { parentId: root.id, kind: "FOLDER", name: "必修一" }))
.rejects.toMatchObject({ statusCode: 404 });
await createNode(deps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "化学",
grants: [{ principalType: "USER", principalId: "u_alice", role: "VIEW" }],
});
await expect(createNode(deps(), ALICE, { parentId: (await listChildren(deps(), ALICE, null))[0]!.id, kind: "FOLDER", name: "x" }))
.rejects.toMatchObject({ statusCode: 403 });
const child = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
const project = await createNode(deps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" });
expect(project.provisionStatus).toBe("READY"); // provisioning 状态机:mock init 后 READY
expect(project.storageDir).toContain(project.id);
await expect(createNode(deps(), ADMIN, { parentId: project.id, kind: "FOLDER", name: "非法" }))
.rejects.toMatchObject({ statusCode: 400, code: "invalid_parent" });
});
it("D14:活跃兄弟名大小写不敏感唯一 → 409", async () => {
await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "Physics" });
await expect(createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "physics" }))
.rejects.toMatchObject({ statusCode: 409, code: "name_conflict" });
});
});
describe("treeService · D11 独立权限开关", () => {
it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => {
const project = await createNode(deps(), ADMIN, {
parentId: null, kind: "PROJECT", name: "TH-141",
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
});
await expect(getEffectiveRole(deps(), ALICE, project.id))
.rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见
expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE");
await prisma.fileLibProjectSettings.update({
where: { nodeId: project.id },
data: { independentPermissionsEnabled: true },
});
expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); // 恢复
});
});
describe("treeService · rename / move / delete", () => {
it("rename 需 MANAGE;兄弟名冲突 409", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
const other = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修二" });
await expect(renameNode(deps(), ADMIN, other.id, "必修一"))
.rejects.toMatchObject({ statusCode: 409 });
await expect(renameNode(deps(), BOB, other.id, "改名"))
.rejects.toMatchObject({ statusCode: 404 });
expect((await renameNode(deps(), ADMIN, other.id, "选修")).name).toBe("选修");
});
it("move:拒绝移入自己的子树;目标父需 EDIT+;Group 授权链路生效", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
const b = await createNode(deps(), ADMIN, { parentId: a.id, kind: "FOLDER", name: "B" });
await expect(moveNode(deps(), ADMIN, a.id, b.id))
.rejects.toMatchObject({ statusCode: 400, code: "move_into_own_subtree" });
// bob 在 g_physics 组;组在 root 上有 EDIT → bob 可把 B 移到 root(D12:本节点 MANAGE + 目标父 EDIT)
await createNode(deps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享区",
grants: [{ principalType: "GROUP", principalId: "g_physics", role: "EDIT" }],
});
const shared = (await listChildren(deps(), ADMIN, null)).find((n) => n.name === "共享区")!;
await expect(moveNode(deps(), BOB, b.id, shared.id))
.rejects.toMatchObject({ statusCode: 404 }); // bob 对 B 无 MANAGE → 404
await prisma.fileLibGrant.create({
data: { organizationId: DEFAULT_ORG_ID, nodeId: b.id, principalType: "GROUP", principalId: "g_physics", role: "MANAGE" },
});
const moved = await moveNode(deps(), BOB, b.id, shared.id);
expect(moved.parentId).toBe(shared.id);
const sharedRow = await prisma.fileLibNode.findUnique({ where: { id: shared.id } });
expect(moved.pathIds).toBe(`${sharedRow!.pathIds}/${b.id}`);
});
it("move 到 root 需网站管理员", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
await createNode(deps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "给爱丽丝",
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
});
await expect(moveNode(deps(), ALICE, a.id, null))
.rejects.toMatchObject({ statusCode: 404 }); // ALICE 对 a 无权限
await expect(moveNode(deps(), ADMIN, a.id, null)).resolves.toMatchObject({ parentId: null });
});
it("D15:祖先删除 → 整支后代不可见(404)", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
await softDeleteNode(deps(), ADMIN, root.id);
await expect(getEffectiveRole(deps(), ADMIN, a.id)).rejects.toMatchObject({ statusCode: 404 });
await expect(renameNode(deps(), ADMIN, a.id, "B")).rejects.toMatchObject({ statusCode: 404 });
});
});
describe("treeService · D17 breadcrumb", () => {
it("无 View 的祖先只给占位,不泄露名字", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const a = await createNode(deps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "A" });
await prisma.fileLibGrant.create({
data: { organizationId: DEFAULT_ORG_ID, nodeId: a.id, principalType: "USER", principalId: "u_alice", role: "VIEW" },
});
const crumbs = await breadcrumb(deps(), ALICE, a.id);
expect(crumbs).toHaveLength(2);
expect(crumbs[0]).toMatchObject({ id: null, name: null }); // root 对 ALICE 不可见
expect(crumbs[1]).toMatchObject({ id: a.id, name: "A" });
});
});
describe("treeService · 审计落库(C3)", () => {
it("创建/改名/移动/删除均写 AuditEntry", async () => {
const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
await renameNode(deps(), ADMIN, root.id, "物理学");
await softDeleteNode(deps(), ADMIN, root.id);
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual(["folder.create", "folder.rename", "folder.delete"]);
});
});
+311
View File
@@ -0,0 +1,311 @@
/**
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
* (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。
* 运行前提:本地 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 {
addMember,
createMemberGroup,
deleteMemberGroup,
listMemberGroups,
listMembers,
removeMember,
searchMemberGroups,
searchUsers,
updateMemberGroup,
type MemberGroupServiceDeps,
} from "../../src/database/filelib/memberGroupService.js";
import { createMemberGroupResolver } from "../../src/database/filelib/memberGroupResolver.js";
import {
createNode,
getEffectiveRole,
type FileLibActor,
type TreeServiceDeps,
} from "../../src/database/filelib/treeService.js";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
function svc(): MemberGroupServiceDeps {
return { prisma, organizationId: DEFAULT_ORG_ID };
}
/** treeService deps 用真实 MemberGroup 解析器,串起「组授权 → 成员生效」全链路。 */
function treeDeps(): TreeServiceDeps {
return {
prisma,
groupResolver: createMemberGroupResolver(prisma),
versionStore: createInMemoryVersionStore(),
organizationId: DEFAULT_ORG_ID,
storageRoot: "/tmp/member-groups-test",
};
}
async function seedUsers(): Promise<void> {
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 } });
}
}
/** 建嵌套链 A>B>C,返回三者 id。 */
async function seedChain(): Promise<{ a: string; b: string; c: string }> {
const a = await createMemberGroup(svc(), ADMIN, { name: "A" });
const b = await createMemberGroup(svc(), ADMIN, { name: "B", parentId: a.id });
const c = await createMemberGroup(svc(), ADMIN, { name: "C", parentId: b.id });
return { a: a.id, b: b.id, c: c.id };
}
beforeEach(async () => {
await resetDb();
await seedUsers();
});
describe("memberGroupService · 创建与闭包", () => {
it("建根 depth0;建子继承祖先闭包,depth 递增", async () => {
const { a, b, c } = await seedChain();
// 闭包不变量:C 有 (A,C,2)/(B,C,1)/(C,C,0) 三行。
const closureC = await prisma.memberGroupClosure.findMany({
where: { descendantId: c },
orderBy: { depth: "asc" },
});
expect(closureC.map((r) => [r.ancestorId, r.depth])).toEqual([
[c, 0], [b, 1], [a, 2],
]);
const groups = await listMemberGroups(svc(), ADMIN);
const byId = new Map(groups.map((g) => [g.id, g]));
expect(byId.get(a)?.depth).toBe(0);
expect(byId.get(b)?.depth).toBe(1);
expect(byId.get(c)?.depth).toBe(2);
expect(byId.get(b)?.parentId).toBe(a);
});
it("父组不存在/已归档 → 404", async () => {
await expect(createMemberGroup(svc(), ADMIN, { name: "X", parentId: "nope" }))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupResolver · 解析(直接组 活跃祖先)", () => {
it("成员在 C → 解析得 {C,B,A};无所属 → 空", async () => {
const { a, b, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
const resolver = createMemberGroupResolver(prisma);
const ids = await resolver.resolveMemberGroupIds("u_alice");
expect([...ids].sort()).toEqual([a, b, c].sort());
expect(await resolver.resolveMemberGroupIds("u_bob")).toEqual([]);
});
});
describe("memberGroupService · 3.2 祖先授权递归传递", () => {
it("给祖先组 A 授文件夹权限 → C 的成员经 effectiveRole 拿到该权限", async () => {
const { a, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
// ADMIN 建根文件夹,授权给"祖先组 A"。ALICE 只在 C,靠祖先方向解析命中 A。
const folder = await createNode(treeDeps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享",
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
});
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
});
});
describe("memberGroupService · 改名/改描述(决策6)", () => {
it("改名不动闭包:depth/parentId/子树关系全保持", async () => {
const { a, b, c } = await seedChain();
const before = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
const updated = await updateMemberGroup(svc(), ADMIN, b, { name: "B2", description: "改后" });
expect(updated.name).toBe("B2");
expect(updated.description).toBe("改后");
expect(updated.parentId).toBe(a);
expect(updated.depth).toBe(1);
// 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。
const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
expect(after).toEqual(before);
// C 仍在 B 之下,depth 不变。
const byId = new Map((await listMemberGroups(svc(), ADMIN)).map((g) => [g.id, g]));
expect(byId.get(c)?.depth).toBe(2);
expect(byId.get(c)?.parentId).toBe(b);
});
it("空描述清空;字段缺省则不动;两者皆缺 → 400", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G", description: "原描述" });
expect((await updateMemberGroup(svc(), ADMIN, g.id, { description: "" })).description).toBeNull();
// 只传 name → 描述保持(此时已是 null)。
const renamed = await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
expect(renamed.name).toBe("G2");
expect(renamed.description).toBeNull();
await expect(updateMemberGroup(svc(), ADMIN, g.id, {})).rejects.toMatchObject({ statusCode: 400 });
});
it("空名 → 400;已归档组 → 404;非管理员 → 403", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: " " })).rejects.toMatchObject({ statusCode: 400 });
await expect(updateMemberGroup(svc(), ALICE, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
await deleteMemberGroup(svc(), ADMIN, g.id);
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 });
});
it("改名写 group.update 审计", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual(["group.create", "group.update"]);
});
});
describe("memberGroupService · 级联软删 + 实时失效", () => {
it("软删 B → B、C 从 list/解析消失,经此支的权限立即失效", async () => {
const { a, b, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
const folder = await createNode(treeDeps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享",
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
});
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
const { archivedCount } = await deleteMemberGroup(svc(), ADMIN, b);
expect(archivedCount).toBe(2); // B + C
const remaining = (await listMemberGroups(svc(), ADMIN)).map((g) => g.id);
expect(remaining).toEqual([a]);
// C 已归档 → ALICE 的直接组失效 → 解析空 → 对该文件夹不再可见(D8 → 404)。
expect(await createMemberGroupResolver(prisma).resolveMemberGroupIds("u_alice")).toEqual([]);
await expect(getEffectiveRole(treeDeps(), ALICE, folder.id)).rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupService · 成员增删", () => {
it("重复添加 → 409;移除后可重新添加", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await expect(addMember(svc(), ADMIN, g.id, { userId: "u_alice" }))
.rejects.toMatchObject({ statusCode: 409 });
await removeMember(svc(), ADMIN, g.id, "u_alice");
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(0);
// 重加(revokedAt 软删允许 @@unique([groupId,userId,revokedAt]) 下的新活跃行)。
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(1);
});
it("按飞书 openId 解析成员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
const m = await addMember(svc(), ADMIN, g.id, { feishuOpenId: "ou_bob" });
expect(m.userId).toBe("u_bob");
});
it("移除不存在成员 → 404", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(removeMember(svc(), ADMIN, g.id, "u_alice"))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupService · 管理门禁(决策2)", () => {
it("非管理员调 CRUD/成员 → 403;搜索不限管理员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(createMemberGroup(svc(), ALICE, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
await expect(deleteMemberGroup(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
await expect(listMemberGroups(svc(), ALICE)).rejects.toMatchObject({ statusCode: 403 });
await expect(listMembers(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
await expect(addMember(svc(), ALICE, g.id, { userId: "u_bob" })).rejects.toMatchObject({ statusCode: 403 });
await expect(removeMember(svc(), ALICE, g.id, "u_bob")).rejects.toMatchObject({ statusCode: 403 });
// 搜索:非管理员可调(授权选择器)。
const results = await searchMemberGroups(svc(), "G");
expect(results.map((r) => r.id)).toContain(g.id);
});
});
describe("memberGroupService · 成员选择器 searchUsers", () => {
it("按显示名/openId 搜;excludeGroupId 排除已在组成员;仅管理员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
// 空 q 列出全部(3 个 seed 用户)。
expect((await searchUsers(svc(), ADMIN, "")).length).toBe(3);
// 按 displayName 命中(seed 的 displayName 即 id)。
expect((await searchUsers(svc(), ADMIN, "alice")).map((u) => u.userId)).toEqual(["u_alice"]);
// 按 openId 命中。
expect((await searchUsers(svc(), ADMIN, "ou_bob")).map((u) => u.userId)).toEqual(["u_bob"]);
// 已在组的人被排除 —— 避免选中必然 409 的候选。
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
const ids = (await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId);
expect(ids).not.toContain("u_alice");
expect(ids).toContain("u_bob");
// 移除后重新成为候选(revokedAt 软删)。
await removeMember(svc(), ADMIN, g.id, "u_alice");
expect((await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId)).toContain("u_alice");
await expect(searchUsers(svc(), ALICE, "")).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("memberGroupService · 成员表字段", () => {
it("listMembers 返回 openId/avatar/joinedAt(供成员表列展示)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
const [m] = await listMembers(svc(), ADMIN, g.id);
expect(m).toMatchObject({
userId: "u_alice",
displayName: "u_alice",
feishuOpenId: "ou_alice",
avatarUrl: null,
});
expect(m?.joinedAt).toBeInstanceOf(Date);
});
});
describe("memberGroupService · 搜索 breadcrumb", () => {
it("breadcrumb 由活跃祖先链按 depth 拼(根在前)", async () => {
const { c } = await seedChain();
const results = await searchMemberGroups(svc(), "C");
const hit = results.find((r) => r.id === c);
expect(hit?.breadcrumb).toBe("A / B / C");
});
it("归档组不出现在搜索(G3)", async () => {
const { a, b } = await seedChain();
await deleteMemberGroup(svc(), ADMIN, b); // 归档 B、C
const ids = (await searchMemberGroups(svc(), "")).map((r) => r.id);
expect(ids).toContain(a);
expect(ids).not.toContain(b);
});
});
describe("memberGroupService · 审计(C3/决策4)", () => {
it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await removeMember(svc(), ADMIN, g.id, "u_alice");
await deleteMemberGroup(svc(), ADMIN, g.id);
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual([
"group.create", "group.member_add", "group.member_remove", "group.delete",
]);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* 文件路径安全单测(Metis 安全红线:穿越/.git/分隔符/深度)。
*/
import { describe, expect, it } from "vitest";
import { validateFilePath, FILE_PATH_MAX_DEPTH, FILE_PATH_MAX_LENGTH } from "../../src/database/filelib/fileService.js";
import { FileLibError } from "../../src/database/filelib/model.js";
describe("validateFilePath · 路径安全", () => {
it("正常相对路径通过(含 CJK 与多级)", () => {
expect(validateFilePath("a.md")).toBe("a.md");
expect(validateFilePath("docs/讲义/第一课.md")).toBe("docs/讲义/第一课.md");
expect(validateFilePath("a/b/c/d.txt")).toBe("a/b/c/d.txt");
});
it("拒绝穿越:.. 段、绝对路径", () => {
expect(() => validateFilePath("../x")).toThrowError(FileLibError);
expect(() => validateFilePath("a/../../x")).toThrowError(FileLibError);
expect(() => validateFilePath("/etc/passwd")).toThrowError(FileLibError);
});
it("拒绝 .git 段与点段", () => {
expect(() => validateFilePath(".git")).toThrowError(FileLibError);
expect(() => validateFilePath("a/.git/config")).toThrowError(FileLibError);
expect(() => validateFilePath("./a.md")).toThrowError(FileLibError);
});
it("拒绝反斜杠、控制字符、空段(双斜杠/尾斜杠)", () => {
expect(() => validateFilePath("a\\b.md")).toThrowError(FileLibError);
expect(() => validateFilePath("a\nb.md")).toThrowError(FileLibError);
expect(() => validateFilePath("a//b.md")).toThrowError(FileLibError);
expect(() => validateFilePath("a/b/")).toThrowError(FileLibError);
});
it("拒绝空路径、超长、超深", () => {
expect(() => validateFilePath("")).toThrowError(FileLibError);
expect(() => validateFilePath("a".repeat(FILE_PATH_MAX_LENGTH + 1))).toThrowError(FileLibError);
expect(() => validateFilePath(Array(FILE_PATH_MAX_DEPTH + 1).fill("d").join("/"))).toThrowError(FileLibError);
expect(validateFilePath(Array(FILE_PATH_MAX_DEPTH).fill("d").join("/"))).toContain("d/");
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* 命名规则(D14)单测。
*/
import { describe, expect, it } from "vitest";
import { FileLibError, nameKey, normalizeNodeName, NODE_NAME_MAX_LENGTH } from "../../src/database/filelib/model.js";
describe("normalizeNodeName · D14", () => {
it("trim 空白", () => {
expect(normalizeNodeName(" 物理必修一 ")).toBe("物理必修一");
});
it("NFC 归一化(分解形式 → 合成形式)", () => {
expect(normalizeNodeName("é")).toBe("é");
});
it("CJK 与常见字符原样通过", () => {
expect(normalizeNodeName("TH-141 表面张力 (上)")).toBe("TH-141 表面张力 (上)");
});
it("空名/全空白 → invalid_name", () => {
expect(() => normalizeNodeName("")).toThrowError(FileLibError);
expect(() => normalizeNodeName(" ")).toThrowError(FileLibError);
});
it("超长 → invalid_name", () => {
expect(() => normalizeNodeName("a".repeat(NODE_NAME_MAX_LENGTH + 1))).toThrowError(FileLibError);
expect(normalizeNodeName("a".repeat(NODE_NAME_MAX_LENGTH))).toHaveLength(NODE_NAME_MAX_LENGTH);
});
it("拒绝斜杠与反斜杠", () => {
expect(() => normalizeNodeName("a/b")).toThrowError(FileLibError);
expect(() => normalizeNodeName("a\\b")).toThrowError(FileLibError);
});
it("拒绝控制字符", () => {
expect(() => normalizeNodeName("a\nb")).toThrowError(FileLibError);
expect(() => normalizeNodeName("a\tb")).toThrowError(FileLibError);
expect(() => normalizeNodeName("a\0b")).toThrowError(FileLibError);
});
});
describe("nameKey · 大小写不敏感比较键", () => {
it("ASCII 折叠", () => {
expect(nameKey("Physics")).toBe("physics");
});
it("CJK 不变", () => {
expect(nameKey("物理")).toBe("物理");
});
});
+159
View File
@@ -0,0 +1,159 @@
/**
* 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结;
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
*/
import { describe, expect, it } from "vitest";
import { checkAccess, effectiveRole, type EffectiveRoleInput, type FileLibGrantFact } from "../../src/database/filelib/permission.js";
const base: EffectiveRoleInput = {
nodeId: "N",
nodeKind: "FOLDER",
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
independentPermissionsEnabled: false,
userId: "u1",
groupIds: ["g1"],
grants: [],
};
function grant(partial: Partial<FileLibGrantFact>): FileLibGrantFact {
return {
nodeId: "N",
principalType: "USER",
principalId: "u1",
role: "VIEW",
isCreatorGrant: false,
...partial,
};
}
describe("effectiveRole · 契约 P6 矩阵", () => {
it("无任何 grant → null(无权限)", () => {
expect(effectiveRole(base)).toBeNull();
});
it("个人直接 grant 在 self 上 → 生效", () => {
expect(effectiveRole({ ...base, grants: [grant({ role: "EDIT" })] })).toBe("EDIT");
});
it("Group grant 且用户在组内 → 生效", () => {
expect(effectiveRole({ ...base, grants: [grant({ principalType: "GROUP", principalId: "g1", role: "MANAGE" })] })).toBe("MANAGE");
});
it("Group grant 但用户不在组内 → null", () => {
expect(effectiveRole({ ...base, grants: [grant({ principalType: "GROUP", principalId: "g9" })] })).toBeNull();
});
it("祖先链上的 grant 向下继承", () => {
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "A", role: "EDIT" })] })).toBe("EDIT");
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "R", role: "VIEW" })] })).toBe("VIEW");
});
it("取最高:个人低权限 + Group 高权限 → 高权限(个人不能降权)", () => {
const grants = [
grant({ role: "VIEW" }),
grant({ principalType: "GROUP", principalId: "g1", role: "MANAGE" }),
];
expect(effectiveRole({ ...base, grants })).toBe("MANAGE");
});
it("取最高:链上多处 grant,最高者胜", () => {
const grants = [
grant({ nodeId: "R", role: "MANAGE" }),
grant({ nodeId: "A", role: "VIEW" }),
grant({ nodeId: "N", role: "EDIT" }),
];
expect(effectiveRole({ ...base, grants })).toBe("MANAGE");
});
it("链外节点的 grant 不参与", () => {
expect(effectiveRole({ ...base, grants: [grant({ nodeId: "X", role: "MANAGE" })] })).toBeNull();
});
it("他人的个人 grant 不参与", () => {
expect(effectiveRole({ ...base, grants: [grant({ principalId: "u2", role: "MANAGE" })] })).toBeNull();
});
});
describe("effectiveRole · D11 独立权限开关", () => {
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
it("开关关闭:项目级非创建者 grant 冻结", () => {
const grants = [grant({ nodeId: "P", role: "EDIT" })];
expect(effectiveRole({ ...project, grants })).toBeNull();
});
it("开关关闭:创建者 grant 仍生效", () => {
const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })];
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
});
it("开关关闭:祖先链 grant 不受影响", () => {
const grants = [
grant({ nodeId: "P", role: "MANAGE" }), // 冻结
grant({ nodeId: "A", role: "VIEW" }), // 生效
];
expect(effectiveRole({ ...project, grants })).toBe("VIEW");
});
it("开关开启:项目级 grant 恢复参与", () => {
const grants = [grant({ nodeId: "P", role: "EDIT" })];
expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT");
});
it("文件夹忽略开关(self grant 照常参与)", () => {
const grants = [grant({ nodeId: "N", role: "EDIT" })];
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
});
});
describe("effectiveRole · 随机化不变量", () => {
// 确定性 LCG,替代 property-based 框架(不新增依赖)。
function lcg(seed: number): () => number {
let s = seed;
return () => {
s = (s * 1103515245 + 12345) % 2147483648;
return s / 2147483648;
};
}
const roles = ["VIEW", "EDIT", "MANAGE"] as const;
it("effective 恰为所有可用 grant 的最高秩(单调、顺序无关)", () => {
const rand = lcg(42);
for (let round = 0; round < 200; round += 1) {
const nodes = ["N", "A", "R", "X"];
const principals = ["u1", "u2", "g1", "g9"];
const grants: FileLibGrantFact[] = Array.from({ length: Math.floor(rand() * 12) }, () => {
const principalId = principals[Math.floor(rand() * principals.length)]!;
return grant({
nodeId: nodes[Math.floor(rand() * nodes.length)]!,
principalType: principalId.startsWith("g") ? "GROUP" : "USER",
principalId,
role: roles[Math.floor(rand() * roles.length)]!,
});
});
const input: EffectiveRoleInput = { ...base, grants };
const applicableRanks = grants
.filter((g) => ["N", "A", "R"].includes(g.nodeId))
.filter((g) => (g.principalType === "USER" ? g.principalId === "u1" : g.principalId === "g1"))
.map((g) => ({ VIEW: 1, EDIT: 2, MANAGE: 3 })[g.role]);
const expected = applicableRanks.length === 0 ? 0 : Math.max(...applicableRanks);
const actual = effectiveRole(input);
expect(actual === null ? 0 : { VIEW: 1, EDIT: 2, MANAGE: 3 }[actual]).toBe(expected);
// 顺序无关
expect(effectiveRole({ ...input, grants: [...grants].reverse() })).toBe(actual);
}
});
});
describe("checkAccess · D8 语义", () => {
it("无权限 → not_found(404 不泄露)", () => {
expect(checkAccess(null, "VIEW")).toEqual({ allowed: false, reason: "not_found" });
});
it("权限不足 → forbidden(403)", () => {
expect(checkAccess("VIEW", "EDIT")).toEqual({ allowed: false, reason: "forbidden" });
});
it("权限足够 → allowed 并带回实际角色", () => {
expect(checkAccess("MANAGE", "EDIT")).toEqual({ allowed: true, role: "MANAGE" });
});
});

Some files were not shown because too many files have changed in this diff Show More