diff --git a/.omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json b/.omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json deleted file mode 100644 index 35dd765..0000000 --- a/.omo/run-continuation/ses_0705f8afbffePWIq7GrFfwfFwV.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_0705f8afbffePWIq7GrFfwfFwV", - "updatedAt": "2026-07-23T15:40:15.630Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-23T15:40:15.630Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json b/.omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json deleted file mode 100644 index 13afc86..0000000 --- a/.omo/run-continuation/ses_07252990dffeRFmXnvdyynty2z.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07252990dffeRFmXnvdyynty2z", - "updatedAt": "2026-07-23T06:33:33.086Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-23T06:33:33.086Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json b/.omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json deleted file mode 100644 index 7c08344..0000000 --- a/.omo/run-continuation/ses_075d214a0ffe6mU8VNgukUfH5L.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_075d214a0ffe6mU8VNgukUfH5L", - "updatedAt": "2026-07-22T14:15:01.741Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-22T14:15:01.741Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json b/.omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json deleted file mode 100644 index 1ab24cd..0000000 --- a/.omo/run-continuation/ses_075d3282bffejz0HFE00taOaGM.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_075d3282bffejz0HFE00taOaGM", - "updatedAt": "2026-07-22T14:13:54.785Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-22T14:13:54.785Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json b/.omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json deleted file mode 100644 index 5220743..0000000 --- a/.omo/run-continuation/ses_0781484caffeKJHXVKiD4BqEJw.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_0781484caffeKJHXVKiD4BqEJw", - "updatedAt": "2026-07-22T06:17:18.238Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-22T06:17:18.238Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json b/.omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json deleted file mode 100644 index a20e325..0000000 --- a/.omo/run-continuation/ses_07b163c13ffeudULxSfIfK986T.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07b163c13ffeudULxSfIfK986T", - "updatedAt": "2026-07-21T13:55:58.878Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-21T13:55:58.878Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json b/.omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json deleted file mode 100644 index e11862c..0000000 --- a/.omo/run-continuation/ses_07b1e6c8effeq7bdmj6NgO0LqC.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07b1e6c8effeq7bdmj6NgO0LqC", - "updatedAt": "2026-07-21T13:38:01.132Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-21T13:38:01.132Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json b/.omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json deleted file mode 100644 index c0350d6..0000000 --- a/.omo/run-continuation/ses_07b932dddffecSipFLwlJOvoJ9.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07b932dddffecSipFLwlJOvoJ9", - "updatedAt": "2026-07-21T11:27:10.420Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-21T11:27:10.420Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json b/.omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json deleted file mode 100644 index 6e90994..0000000 --- a/.omo/run-continuation/ses_07b9c4268ffeic4mMXCPUt225j.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07b9c4268ffeic4mMXCPUt225j", - "updatedAt": "2026-07-21T11:18:53.449Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-21T11:18:53.449Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json b/.omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json deleted file mode 100644 index 15ecee9..0000000 --- a/.omo/run-continuation/ses_07b9c4346ffeVBdV5pf4h6s9PW.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07b9c4346ffeVBdV5pf4h6s9PW", - "updatedAt": "2026-07-21T11:18:29.979Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-21T11:18:29.979Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json b/.omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json deleted file mode 100644 index 49b51ec..0000000 --- a/.omo/run-continuation/ses_07b9c9a75ffeOyJ4ewp3DBvXhz.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07b9c9a75ffeOyJ4ewp3DBvXhz", - "updatedAt": "2026-07-21T11:21:09.776Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-21T11:21:09.776Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json b/.omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json deleted file mode 100644 index 9a0ffe1..0000000 --- a/.omo/run-continuation/ses_07cdbac0effeiBrdcHLUUUfwuF.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_07cdbac0effeiBrdcHLUUUfwuF", - "updatedAt": "2026-07-23T15:37:59.918Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-23T15:37:59.918Z" - } - } -} \ No newline at end of file diff --git a/.omo/文件库-开工计划.md b/.omo/文件库-开工计划.md deleted file mode 100644 index 945d2d0..0000000 --- a/.omo/文件库-开工计划.md +++ /dev/null @@ -1,114 +0,0 @@ -# 文件库 · 开工计划(v1.0) - -> 配套文档:《文件库-接口契约.md》(v0.1,仓库根目录)。本计划已吸收规划顾问评审意见,包含开工前必须先冻结的**架构收口决策 D11–D19**(回写契约 v0.2 时并入)。 -> 工期按 1 人全栈估算;前端从 Phase 2 后可并行。 - -## 开工判定 - -**可以开工。** 四个外部依赖(版本包 / Group / 审计 / 平台身份)全部有契约可依、可 mock 并行开发;无阻塞项。唯一前置:本文件的 D11–D19 决策在写第一行 schema 前过一遍(半天,自查即可,有异议再升级)。 - -## 架构收口决策(新增,先冻结再动工) - -| 编号 | 决策 | 理由 | -|---|---|---| -| D11 | `creator` 是 Node 的**不可变列**,创建时同时落一条 Manage grant;独立权限开关关闭时,**creator 的 Manage 仍生效**,其余项目级 grant 冻结不参与计算 | 否则 creator 可能被自己关掉的开关锁死 | -| D12 | 移动节点授权 = **本节点 Manage + 目标父 Edit+**;移动在事务 + Postgres 咨询锁内完成,防并发成环 | 预检环检测在并发下不安全 | -| D13 | Group resolve 失败返回 **503**(不伪装 404/403);每次 HTTP 请求 fresh resolve,不跨请求缓存;单请求内多次校验合并为一次调用(此点与 Group 团队确认,OPEN-9) | 依赖故障 ≠ 无权限;契约 G4 要求实时 | -| D14 | 命名规则:NFC 归一化、trim、≤128 字符、禁 `/` 与控制字符;**活跃兄弟节点大小写不敏感唯一**;根节点全局唯一 | 缺命名规则必有脏数据 | -| D15 | 删除 = 只给被删节点打标,后代**不递归打标**,靠"任一祖先已删"过滤;所有查询链路强制此过滤(含递归 SQL,不只靠 Prisma middleware) | 递归打标的写放大与恢复复杂度不可控 | -| D16 | `baseVersion` 为**文件级版本**(GET file 返回的 version);无关文件的提交不产生冲突 | 与 C1 `head(dir, filePath)` 语义一致 | -| D17 | breadcrumb 只暴露用户有 View 的祖先名称;无权限祖先显示占位符 `…`,不暴露名字 | 404 不泄露语义(D8)延伸到面包屑 | -| D18 | v1 **单副本部署**(git 仓库在本地磁盘);多副本需共享存储或分片,后置 | 不解决不存在的扩展问题 | -| D19 | 网站管理员**不隐式读内容**;force_adjust 凭 node id 操作(id 从审计或用户上报获得) | C4 已定的最小权限原则 | - -## 阶段计划 - -### Phase 0 · 骨架与身份先行(1–2 天) - -- [ ] 过 D11–D19,回写契约 v0.2 -- [ ] repo 骨架 `server/` + `web/`,Postgres docker-compose,CI(lint / typecheck / vitest / 集成测试用真实 Postgres service container) -- [ ] **JWT 中间件 + 本地 JWKS 测试服务 + 签名 token fixtures**(有效/过期/错 issuer/错 audience/错算法/未知 kid/轮换)——身份先行,不做"宽松 mock 身份" -- [ ] 4 个 port 接口定义 + mock:`VersionStore`、`GroupResolver`、`AuditSink`(直写 outbox 表的实现就是真的,mock 的是对端服务)、`ExportAdapter` -- [ ] fastify-swagger 接入,OpenAPI 骨架——**OpenAPI 随每个端点同步产出,不留到最后** -- **验收**:骨架可 `pnpm dev` 起服务;本地 JWKS 六类 token fixture(有效/过期/错 issuer/错 audience/错算法/未知 kid)中间件行为全对;CI 流水线绿 - -### Phase 1 · 数据模型与权限引擎(2–3 天)⚑ 心脏 - -- [ ] Prisma schema:`Node`(parentId **权威** + id 编码的 materialized path 派生列,name 不入 path)、`Grant`、`ProjectIndependentSettings`、`OutboxEvent`(含 attempts/nextAttemptAt/lease/lastError,relay 字段一次到位)、`ExportJob` -- [ ] 数据库约束:kind 枚举、项目无子节点、活跃兄弟名唯一(部分唯一索引,root 的 NULL parent 特殊处理)、活跃 grant 按 (nodeId, principalType, principalId) 唯一、独立设置仅项目 -- [ ] **纯权限 reducer**:输入已解析的 grants + 祖先链 + 用户组集合,输出 role|null;不碰 DB/网络。fast-check 属性测试:max 单调、加 grant 只升不降、祖先继承、toggle 行为、顺序无关、软删过滤 -- [ ] 数据获取层 + `effective(user, node)` 组装 -- [ ] 树操作服务:create(creator 自动 Manage、root 仅管理员且必带 ≥1 Manage)、rename、move(D12 事务+咨询锁)、soft delete(D15)、breadcrumb(D17) -- **验收**:并发对移(A→B 与 B→A)、移动+并发建子、property tests、删除祖先过滤,真实 Postgres 全绿 - -### Phase 2 · 树与授权 API(1–2 天) - -- [ ] nodes 端点组 + grants 端点组 + independent-permission 开关 + effective-permission 自查 -- [ ] 授权矩阵校验(契约 8.1:creator-only 授 Manage、Manage 不可动 creator、force_adjust 独立通道) -- [ ] 全部写操作落 outbox 事件(C3 词汇表) -- **验收**:契约 8.1 矩阵逐格 API 测试;D8 的 404/403/503 三分语义测试 - -### Phase 3 · 仓库生命周期与文件 API(2 天) - -- [ ] 项目 provisioning 状态机 `PROVISIONING → READY | FAILED`:先建 DB 行(PROVISIONING)→ 调 `VersionStore.init`(幂等)→ 置 READY;失败可重试;孤儿仓库对账 job -- [ ] 每项目写锁(Postgres 咨询锁,跨进程安全)——不假设版本包能跨进程串行 -- [ ] 路径安全:canonical 后必须落在项目根内、拒绝 `.git`、禁 symlink 逃逸、路径长度上限 -- [ ] 上传限额(单文件 10MB / 单项目配额,数值 OPEN-5)+ 流式接收 -- [ ] files 端点组全量(list/read/upload/commits→409/diff/history/delete)+ 双客户端冲突 e2e -- **验收**:DB/git 故障注入(init 失败、提交后崩溃)→ 对账能收敛;路径穿越语料库测试全拒 - -### Phase 4 · 外部集成(1–2 天) - -- [ ] `GroupResolver` HTTP client:超时/5xx/429/畸形 JSON/万级 group 的故障注入测试;失败 → 503(D13) -- [ ] Audit relay worker:租约领取、指数退避、eventId 幂等、重启恢复、积压告警 -- [ ] 导出 job:状态机 + 轮询 + 下载权限校验;`ExportAdapter` 桩(参数 OPEN-6 未定前**不计入完成标准**) -- **验收**:relay 重启不丢不重(对端幂等);group 故障时写操作 503、读操作按 D13 - -### Phase 5 · 前端(3–5 天,Phase 2 后并行启动) - -- [ ] 登录(平台 SSO 占位 + 本地 JWKS 直通开关) -- [ ] 树浏览(懒加载 + 分页)、breadcrumb(D17 占位符)、创建对话框(类型 + 批量授权选择器,Group search 接 C2) -- [ ] 权限管理面板(按 8.1 矩阵控制可选项,creator 标识) -- [ ] 文件页:查看/上传/下载/历史列表(编辑 UI 不归我方,留对接位) -- [ ] 导出按钮 + job 轮询 -- **验收**:无权限节点全链路不可见;授权面板不会送出矩阵禁止的组合 - -### Phase 6 · 硬化与交付(1–2 天) - -- [ ] 404/403/503 全端点核对;并发与重试幂等抽查 -- [ ] 备份脚本(DB + git 仓库一致性快照)、健康检查(DB/JWKS/磁盘/Group)、磁盘水位告警 -- [ ] OpenAPI 终稿 + 部署脚本 + 一页运维手册 - -## 里程碑 - -| 里程碑 | 内容 | 累计工期 | -|---|---|---| -| M1 | Phase 0–1:骨架 + 权限引擎 + 树操作可跑 | ~4 天 | -| M2 | Phase 2–3:后端 API 全量(含文件冲突流) | ~7 天 | -| M3 | Phase 4:外部集成(导出不计) | ~9 天 | -| M4 | Phase 5–6:前端 + 硬化,可交付 | ~14–18 天 | - -## Mock 保真红线(mock 可以顶,但不许骗) - -| Mock | 不许掩盖的事 | 真身到位后的契约测试 | -|---|---|---| -| VersionStore | 磁盘延迟、半初始化、锁残留、跨进程并发 | 真包跑临时仓库:并发 commit/init 重试/遍历与 symlink 语料 | -| GroupResolver | 超时、5xx、畸形响应、大规模组集 | 故障注入 HTTP 桩全过;我方绝不自己推祖先 | -| GroupSearch(前端选择器) | 与 resolve 是**两个独立端点**,不可用 resolve mock 顶替 | C2 `/groups/search` 真身到位后跑分页/空结果/超时 | -| Audit 对端 | 重复投递、乱序、长时间不可用 | relay 重启恢复 + 对端幂等 | -| JWT | issuer/audience/算法/轮换 | 本地 JWKS 全用例 | -| ExportAdapter | 参数、幂等、耗时 | 参数定了再写,之前不算完成 | - -## Top 5 风险 - -1. **DB/git 分裂**(崩溃导致元数据、仓库、审计三边不一致)→ provisioning 状态机 + 对账 job,Phase 3 前置 -2. **移动子树改变整树权限** → D12 事务+锁+源目标双授权,并发测试 -3. **creator/独立权限开关交互** → D11 先冻结 -4. **外部契约缺口**(导出参数、请求内 resolve 合并)→ OPEN 清单跟踪,mock 保真红线不许掩盖 -5. **存储滥用/路径逃逸** → 路径安全 + 配额进 Phase 3 验收,不拖到硬化 - -## OPEN 清单(在契约文档基础上增补) - -- OPEN-9:单请求内合并多次 resolve 是否符合 G4"实时"语义(找 Group 团队确认) -- OPEN-10:恶意软件扫描归属(我方/平台/不做) -- 其余 OPEN-1~8 见《文件库-接口契约.md》第 10 节 diff --git a/CONTEXT.md b/CONTEXT.md index 0bac2d5..0cba565 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/docs/adr/0028-member-group-management-and-resolution.md b/docs/adr/0028-member-group-management-and-resolution.md new file mode 100644 index 0000000..17dae92 --- /dev/null +++ b/docs/adr/0028-member-group-management-and-resolution.md @@ -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. diff --git a/docs/adr/0029-web-surface-frontend-backend-separation.md b/docs/adr/0029-web-surface-frontend-backend-separation.md new file mode 100644 index 0000000..5022ab1 --- /dev/null +++ b/docs/adr/0029-web-surface-frontend-backend-separation.md @@ -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 ` - - diff --git a/hub/filelib-web/package-lock.json b/hub/filelib-web/package-lock.json index 8a6f9c7..f306d41 100644 --- a/hub/filelib-web/package-lock.json +++ b/hub/filelib-web/package-lock.json @@ -8,6 +8,8 @@ "name": "filelib-web", "version": "0.1.0", "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", @@ -130,6 +132,13 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -412,6 +421,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", @@ -422,6 +438,58 @@ "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/@sveltejs/load-config": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", @@ -747,6 +815,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -820,6 +895,16 @@ "node": ">=6" } }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -946,6 +1031,16 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1246,6 +1341,16 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -1389,6 +1494,28 @@ "node": ">=6" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1490,6 +1617,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/hub/filelib-web/package.json b/hub/filelib-web/package.json index 43c706a..08320e6 100644 --- a/hub/filelib-web/package.json +++ b/hub/filelib-web/package.json @@ -4,12 +4,15 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" + "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", diff --git a/hub/filelib-web/src/App.svelte b/hub/filelib-web/src/App.svelte deleted file mode 100644 index cc69f92..0000000 --- a/hub/filelib-web/src/App.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -{#if !$authChecked} -
加载中…
-{:else if $me} - -{:else} - -{/if} - - diff --git a/hub/filelib-web/src/app.css b/hub/filelib-web/src/app.css index 1beee79..526846b 100644 --- a/hub/filelib-web/src/app.css +++ b/hub/filelib-web/src/app.css @@ -23,8 +23,7 @@ } html, -body, -#app { +body { height: 100%; } @@ -47,6 +46,204 @@ body { 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; diff --git a/hub/filelib-web/src/app.d.ts b/hub/filelib-web/src/app.d.ts new file mode 100644 index 0000000..e1551fc --- /dev/null +++ b/hub/filelib-web/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://svelte.dev/docs/kit/types#app +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/hub/filelib-web/src/app.html b/hub/filelib-web/src/app.html new file mode 100644 index 0000000..1e7531f --- /dev/null +++ b/hub/filelib-web/src/app.html @@ -0,0 +1,16 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/hub/filelib-web/src/lib/Avatar.svelte b/hub/filelib-web/src/lib/Avatar.svelte new file mode 100644 index 0000000..fd1ad7c --- /dev/null +++ b/hub/filelib-web/src/lib/Avatar.svelte @@ -0,0 +1,31 @@ + + +{#if avatarUrl} + +{:else} + +{/if} diff --git a/hub/filelib-web/src/lib/FileEditor.svelte b/hub/filelib-web/src/lib/FileEditor.svelte index a046e1e..8339366 100644 --- a/hub/filelib-web/src/lib/FileEditor.svelte +++ b/hub/filelib-web/src/lib/FileEditor.svelte @@ -3,6 +3,7 @@ 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(); @@ -104,32 +105,32 @@ {#if loadError} -
{loadError}
+
{loadError}
{:else if file} -
+
- {file.path} @ {file.version} -
- 下载 - + {file.path} @ {file.version} +
+ 下载 + {#if canEdit} - + {/if} {#if onclose} - + {/if}
{#if file.encoding === "base64"} -
二进制文件({file.size} B),不支持在线编辑
+
二进制文件({file.size} B),不支持在线编辑
{:else} - + {/if} {#if canEdit && file.encoding !== "base64"}
- +
{/if} @@ -139,13 +140,13 @@
{@html renderDiff(conflict.diff)}
请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})
- +
{/if}
{:else} -
加载中…
+
加载中…
{/if} {#if showHistory} @@ -159,7 +160,7 @@ {/each}
- +
{/if} diff --git a/hub/filelib-web/src/lib/FilesPanel.svelte b/hub/filelib-web/src/lib/FilesPanel.svelte index 8d3b7ca..0806afd 100644 --- a/hub/filelib-web/src/lib/FilesPanel.svelte +++ b/hub/filelib-web/src/lib/FilesPanel.svelte @@ -4,6 +4,7 @@ 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(); @@ -12,7 +13,8 @@ let showNewFile = $state(false); let newPath = $state(""); let newContent = $state(""); - let uploadInput: HTMLInputElement; + // bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。 + let uploadInput = $state(null); const canEdit = $derived(node.role !== "VIEW"); @@ -84,34 +86,34 @@ } -
-
-
项目文件({files?.length ?? 0})
+
+
+
项目文件({files?.length ?? 0})
{#if canEdit} -
- - +
+ +
{/if}
{#if files === null && loadError === null} -
加载中…
+
加载中…
{:else if loadError}
{loadError}
{:else if files && files.length === 0} -
空仓库 · 可新建或上传文件
+
空仓库 · 可新建或上传文件
{:else if files} - +
{#each files as f (f.path)} selectedFilePath.set(f.path)} > - - + + {/each} @@ -121,17 +123,17 @@ {#if showNewFile} (showNewFile = false)}> -
- - +
+ +
-
- - +
+ +
- - + +
{/if} diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte new file mode 100644 index 0000000..3199707 --- /dev/null +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -0,0 +1,191 @@ + + +
+ {#if error !== null} +
{error}
+ {:else if grants === null} +
加载中…
+ {:else} +
{f.path}{f.size} B{f.path}{f.size} B
+ + + + + {#if grants.length === 0} + + {:else} + {#each grants as g (g.id)} + + + + + + {/each} + {/if} + +
主体级别
暂无显式授权
+ + + {g.principalId} + {#if g.isCreatorGrant}(创建者){/if} + + {g.role} + + {#if !g.isCreatorGrant && canManage} + + {/if} +
+ {/if} + + {#if canManage} +
+
新增授权
+
+ + + {#if principalType === "USER"} + + {:else if groupOptions === null} + 加载 Group 列表… + {:else if groupOptions.length === 0} + 暂无可选 Group · 先到「Group 管理」建一个 + {:else} + + {/if} + + + +
+
MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)
+ {/if} + +
diff --git a/hub/filelib-web/src/lib/GroupAdmin.svelte b/hub/filelib-web/src/lib/GroupAdmin.svelte new file mode 100644 index 0000000..4577c88 --- /dev/null +++ b/hub/filelib-web/src/lib/GroupAdmin.svelte @@ -0,0 +1,762 @@ + + + (menu = null)} + onkeydown={(e) => { + if (e.key === "Escape") menu = null; + }} +/> + +
+ +
+
+ +
Group 树
+ +
+ +
+ + + + +
+ + + + +
{ + if ((e.target as HTMLElement).closest("[data-node]") !== null) return; + openMenu(e, null); + }} + > + {#if !loaded} +
加载中…
+ {:else if listError !== null} +
{listError}
+ {:else if groups.length === 0} +
+ + 暂无成员组 · 点上方「根组」开始 +
+ {:else if rows.length === 0} +
无匹配的组
+ {:else} + {#each rows as { g, hasKids, hit } (g.id)} + {@const arch = g.archivedAt !== null} +
select(g.id)} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + select(g.id); + } + }} + oncontextmenu={(e) => openMenu(e, g)} + > + {#if hasKids} + { + e.stopPropagation(); + toggleCollapsed(g.id); + }} + onkeydown={(e) => { + if (e.key === "Enter") toggleCollapsed(g.id); + }} + > + + + {:else} + + {/if} + + + + + {g.name} + + {g.memberCount} + + {#if arch} + 已删除 + {/if} +
+ {/each} + {/if} +
+ +
{loaded ? treeFoot : ""}
+
+ + +
+ {#if selected === null} +
+ + 从左侧选择一个 Group 查看成员 +
+ {:else} + {#if isArchived} + +
+ + + 此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限 + + +
+ {/if} + +
+
+ {#each chain as c, i (c.id)} + {#if i > 0}/{/if} + {c.name} + {/each} +
+ +
+ + + +
+
{selected.name}
+ {#if selected.description !== null && selected.description !== ""} +
{selected.description}
+ {:else} +
无描述
+ {/if} +
+ + {#if !isArchived} + + + {/if} +
+ +
+ {members.length} 名成员 + 层级 {selected.depth} + {childCount} 个子组 +
+
+ +
+
+ + + + +
+ + {memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`} + +
+ +
+ {#if !membersLoaded} +
加载中…
+ {:else if membersError !== null} +
{membersError}
+ {:else if members.length === 0} +
+ + {isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"} +
+ {:else if shownMembers.length === 0} +
无匹配成员
+ {:else} + + + + + + + + {#if !isArchived}{/if} + + + + {#each shownMembers as m (m.userId)} + + + + + + {#if !isArchived} + + {/if} + + {/each} + +
成员userId飞书 openId加入时间操作
+ + + {m.displayName || "(未命名)"} + + {m.userId}{m.feishuOpenId || "—"}{fmtDate(m.joinedAt)} + +
+ {/if} +
+ {/if} +
+
+ + +{#if menu !== null} + +{/if} + +{#if showCreate} + (showCreate = false)} + > +
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if showRename && renameTarget !== null} + (showRename = false)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if showAdd && selected !== null} + (showAdd = false)}> +
+ +
+ { + if (e.key === "Enter") void searchUsers(); + }} + /> + +
+
已在本组的成员不会出现在结果里。
+
+ +
+ {#if addSearching} +
搜索中…
+ {:else if addResults.length === 0} +
无候选用户 · 先点「搜索」
+ {:else} + {#each addResults as u (u.userId)} +
+ +
+
{u.displayName || "(未命名)"}
+
{u.feishuOpenId || u.userId}
+
+ +
+ {/each} + {/if} +
+ +
+ +
+
+{/if} diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte new file mode 100644 index 0000000..7c98aed --- /dev/null +++ b/hub/filelib-web/src/lib/Icon.svelte @@ -0,0 +1,41 @@ + + + + + diff --git a/hub/filelib-web/src/lib/BrowserShell.svelte b/hub/filelib-web/src/lib/LibraryView.svelte similarity index 63% rename from hub/filelib-web/src/lib/BrowserShell.svelte rename to hub/filelib-web/src/lib/LibraryView.svelte index 87d841f..5a65257 100644 --- a/hub/filelib-web/src/lib/BrowserShell.svelte +++ b/hub/filelib-web/src/lib/LibraryView.svelte @@ -1,7 +1,14 @@ -
+
@@ -118,24 +124,24 @@ {#if showCreateRoot} (showCreateRoot = false)}> -
- - +
+ +
-
- -
-
- - +
+ +
- - + +
{/if} diff --git a/hub/filelib-web/src/lib/LoginView.svelte b/hub/filelib-web/src/lib/LoginView.svelte index a5cf7f2..8b950f2 100644 --- a/hub/filelib-web/src/lib/LoginView.svelte +++ b/hub/filelib-web/src/lib/LoginView.svelte @@ -1,18 +1,13 @@ -
+
简介
@@ -77,7 +94,7 @@ 暂无简介 {/if} {#if canEdit} - + {/if}
@@ -85,39 +102,54 @@
+
类型 {node.kind === "PROJECT" ? "项目" : "文件夹"}
我的角色 {roleLabel}
-
创建时间 {new Date(node.createdAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}
-
最近修改 {new Date(node.updatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}
+
创建时间 {new Date(node.createdAt).toLocaleString("zh-CN")}
+
更新时间 {new Date(node.updatedAt).toLocaleString("zh-CN")}
-
+ + {#if node.kind === "PROJECT"} +
+
+ 独立权限 + {node.independentPermission ? "开启" : "关闭"} + {#if canManage} + + {/if} + 关闭时仅继承父级权限(创建者除外) +
-
导出
-
- - {#if exportJob} - - {#if exportJob.status === "DONE"} - 完成 · 下载 - {:else if exportJob.status === "FAILED"} - 失败:{exportJob.error ?? ""} - {:else} - {exportJob.status}… - {/if} - - {/if} -
+
+ +
导出
+
+ + + {#if exportJob} + + {#if exportJob.status === "DONE"} + 完成 · 下载 + {:else if exportJob.status === "FAILED"} + 失败:{exportJob.error ?? ""} + {:else} + {exportJob.status}… + {/if} + + {/if} +
+ {/if}
{#if showEditDesc} (showEditDesc = false)}> -
- - +
+ +
- - + +
{/if} diff --git a/hub/filelib-web/src/lib/config.ts b/hub/filelib-web/src/lib/config.ts new file mode 100644 index 0000000..95f8a6d --- /dev/null +++ b/hub/filelib-web/src/lib/config.ts @@ -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 { + if (cached !== null) return cached; + cached = await api("/database/config"); + return cached; +} diff --git a/hub/filelib-web/src/lib/session.ts b/hub/filelib-web/src/lib/session.ts new file mode 100644 index 0000000..35910da --- /dev/null +++ b/hub/filelib-web/src/lib/session.ts @@ -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 { + if (get(authChecked) && !force) return; + + try { + me.set(await api("/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 { + try { + await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); + } catch { + /* 网络失败也照样清前端状态 */ + } + me.set(null); +} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 60bfc35..f0bffd6 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -36,6 +36,9 @@ export interface NodeDetail { export interface MeResponse { readonly userId: string; readonly isWebsiteAdmin: boolean; + /** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */ + readonly displayName: string; + readonly avatarUrl: string | null; } export interface FileEntry { @@ -83,3 +86,79 @@ export interface GroupSearchResult { 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; +} diff --git a/hub/filelib-web/src/main.ts b/hub/filelib-web/src/main.ts deleted file mode 100644 index 727d356..0000000 --- a/hub/filelib-web/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { mount } from "svelte"; -import App from "./App.svelte"; -import "./app.css"; - -export default mount(App, { target: document.getElementById("app")! }); diff --git a/hub/filelib-web/src/routes/+layout.svelte b/hub/filelib-web/src/routes/+layout.svelte new file mode 100644 index 0000000..f03dd7a --- /dev/null +++ b/hub/filelib-web/src/routes/+layout.svelte @@ -0,0 +1,12 @@ + + +
+ {@render children()} +
+ + diff --git a/hub/filelib-web/src/routes/+layout.ts b/hub/filelib-web/src/routes/+layout.ts new file mode 100644 index 0000000..adc15e4 --- /dev/null +++ b/hub/filelib-web/src/routes/+layout.ts @@ -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; diff --git a/hub/filelib-web/src/routes/+page.svelte b/hub/filelib-web/src/routes/+page.svelte new file mode 100644 index 0000000..389dc3a --- /dev/null +++ b/hub/filelib-web/src/routes/+page.svelte @@ -0,0 +1,11 @@ + + +
跳转中…
diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte new file mode 100644 index 0000000..26fd7c1 --- /dev/null +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -0,0 +1,22 @@ + + +文件库 + +{#if !$authChecked} +
加载中…
+{:else if $me} +
+ +
+{:else} + +{/if} diff --git a/hub/filelib-web/src/routes/database/+page.svelte b/hub/filelib-web/src/routes/database/+page.svelte new file mode 100644 index 0000000..eaa5117 --- /dev/null +++ b/hub/filelib-web/src/routes/database/+page.svelte @@ -0,0 +1,11 @@ + + +
跳转中…
diff --git a/hub/filelib-web/src/routes/database/admin/+page.svelte b/hub/filelib-web/src/routes/database/admin/+page.svelte new file mode 100644 index 0000000..d7b2c8e --- /dev/null +++ b/hub/filelib-web/src/routes/database/admin/+page.svelte @@ -0,0 +1,63 @@ + + +Database Admin · 登录 + +
+
+
Database Admin
+

使用飞书登录以管理数据库

+ + {#if !$authChecked} +

加载中…

+ {:else if info} + 使用飞书登录 + + {#if info.devLoginEnabled} +
+ 开发模式 + +
+ ⚡ 一键登录管理员 +

仅开发环境可见 · 跳过飞书 OAuth

+ {/if} + {:else if loadFailed} +

无法加载登录配置,请稍后重试

+ {:else} +

加载中…

+ {/if} +
+
diff --git a/hub/filelib-web/src/routes/database/dashboard/+layout.svelte b/hub/filelib-web/src/routes/database/dashboard/+layout.svelte new file mode 100644 index 0000000..655f099 --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/+layout.svelte @@ -0,0 +1,99 @@ + + +Database Admin + +{#if !$authChecked} +
加载中…
+{:else if $me === null} +
跳转到登录页…
+{:else if !$me.isWebsiteAdmin} +
+
+

无权访问管理后台

+

+ 当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。 +

+ 前往文件库 + +
+
+{:else} +
+ + +
+ {@render children()} +
+
+{/if} diff --git a/hub/filelib-web/src/routes/database/dashboard/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/+page.svelte new file mode 100644 index 0000000..2654946 --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/+page.svelte @@ -0,0 +1,67 @@ + + +
+

概览

+

文件库实时数据

+ + {#if error} +
{error}
+ {:else} +
+ {#each cards as card (card.label)} +
+

{card.label}

+

{card.value ?? "—"}

+
+ {/each} +
+ +
+

最近活动

+ {#if stats === null} +
加载中…
+ {:else if stats.recent.length === 0} +
暂无文件库活动 · 到「文件库」里创建第一个文件夹吧
+ {:else} + {#each stats.recent as row (row.action + row.when + row.label)} +
+ {row.action} + {row.label} + {row.actor} · {fmtWhen(row.when)} +
+ {/each} + {/if} +
+ {/if} +
diff --git a/hub/filelib-web/src/routes/database/dashboard/groups/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/groups/+page.svelte new file mode 100644 index 0000000..5f6de69 --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/groups/+page.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/hub/filelib-web/src/routes/database/dashboard/library/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/library/+page.svelte new file mode 100644 index 0000000..e3c6512 --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/library/+page.svelte @@ -0,0 +1,8 @@ + + +
+ +
diff --git a/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte new file mode 100644 index 0000000..6154d90 --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte @@ -0,0 +1,4 @@ +
+

查询

+

查询功能建设中

+
diff --git a/hub/filelib-web/src/routes/database/dashboard/settings/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/settings/+page.svelte new file mode 100644 index 0000000..67201bb --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/settings/+page.svelte @@ -0,0 +1,4 @@ +
+

设置

+

设置功能建设中

+
diff --git a/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte new file mode 100644 index 0000000..fbaede7 --- /dev/null +++ b/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte @@ -0,0 +1,168 @@ + + +
+

用户管理

+ +
+
+
添加成员
+
+ + + + +
+
+ +
+
成员列表
+ + {#if error} +
{error}
+ {:else if members === null} +
加载中…
+ {:else if members.length === 0} +
暂无成员
+ {:else} + + + + + + + + + + + {#each members as m (m.userId)} + + + + + + + {/each} + +
成员userId角色
{m.displayName || m.userId}{m.userId} + + + +
+ {/if} +
+
+
diff --git a/hub/filelib-web/svelte.config.js b/hub/filelib-web/svelte.config.js new file mode 100644 index 0000000..87af393 --- /dev/null +++ b/hub/filelib-web/svelte.config.js @@ -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; diff --git a/hub/filelib-web/tsconfig.json b/hub/filelib-web/tsconfig.json index 80e1189..0d61fe3 100644 --- a/hub/filelib-web/tsconfig.json +++ b/hub/filelib-web/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "target": "ES2022", "module": "ESNext", @@ -12,6 +13,5 @@ "resolveJsonModule": true, "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"] - }, - "include": ["src/**/*.ts", "src/**/*.svelte"] + } } diff --git a/hub/filelib-web/vite.config.ts b/hub/filelib-web/vite.config.ts index 9e4b9c6..ef7e8d4 100644 --- a/hub/filelib-web/vite.config.ts +++ b/hub/filelib-web/vite.config.ts @@ -1,25 +1,27 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; -// 老师端独立 SPA:构建产物由 hub 后端静态托管于 /app; -// 开发时 vite dev(:5173)把 API/认证请求代理到后端(:8788)。 +// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。 +// 开发时 vite dev(:5173)把 API/认证/一键登录请求代理到后端(:8788); +// 页面路由全部由 SvelteKit 客户端路由处理,后端不参与。 const backend = "http://127.0.0.1:8788"; export default defineConfig({ - plugins: [svelte(), tailwindcss()], - base: "/app/", - build: { - outDir: "dist", - emptyOutDir: true, - }, + 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, }, }, }); diff --git a/hub/prisma/migrations/20260723120000_member_group_hierarchy/migration.sql b/hub/prisma/migrations/20260723120000_member_group_hierarchy/migration.sql new file mode 100644 index 0000000..b17f6ec --- /dev/null +++ b/hub/prisma/migrations/20260723120000_member_group_hierarchy/migration.sql @@ -0,0 +1,70 @@ +-- Global, unlimited-depth member group hierarchy (requirement 3.1-3.3). +-- Managed only by the platform super administrator; deliberately NOT +-- org-scoped. Stores membership + nesting only, never permission data. +-- Deletion is soft (archivedAt / revokedAt markers); a group delete +-- cascade-soft-deletes its whole subtree as an application operation. +-- The permission side (GROUP principal, FOLDER resource, grant inheritance) +-- is intentionally deferred to a later migration. + +-- CreateTable +CREATE TABLE "MemberGroup" ( + "id" TEXT NOT NULL, + "parentId" TEXT, + "name" TEXT NOT NULL, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "archivedAt" TIMESTAMP(3), + + CONSTRAINT "MemberGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemberGroupMembership" ( + "id" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "MemberGroupMembership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemberGroupClosure" ( + "ancestorId" TEXT NOT NULL, + "descendantId" TEXT NOT NULL, + "depth" INTEGER NOT NULL, + + CONSTRAINT "MemberGroupClosure_pkey" PRIMARY KEY ("ancestorId", "descendantId") +); + +-- CreateIndex +CREATE INDEX "MemberGroup_parentId_archivedAt_idx" ON "MemberGroup"("parentId", "archivedAt"); + +-- CreateIndex +CREATE INDEX "MemberGroupMembership_userId_revokedAt_idx" ON "MemberGroupMembership"("userId", "revokedAt"); + +-- CreateIndex +CREATE INDEX "MemberGroupMembership_groupId_revokedAt_idx" ON "MemberGroupMembership"("groupId", "revokedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberGroupMembership_groupId_userId_revokedAt_key" ON "MemberGroupMembership"("groupId", "userId", "revokedAt"); + +-- CreateIndex +CREATE INDEX "MemberGroupClosure_descendantId_idx" ON "MemberGroupClosure"("descendantId"); + +-- AddForeignKey +ALTER TABLE "MemberGroup" ADD CONSTRAINT "MemberGroup_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "MemberGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupMembership" ADD CONSTRAINT "MemberGroupMembership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_ancestorId_fkey" FOREIGN KEY ("ancestorId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberGroupClosure" ADD CONSTRAINT "MemberGroupClosure_descendantId_fkey" FOREIGN KEY ("descendantId") REFERENCES "MemberGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 4818b16..0779fa9 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -194,6 +194,7 @@ model User { heldLocks ProjectAgentLock[] @relation("lockHolder") feishuBindings ProjectGroupBinding[] @relation("bindingCreator") teamMemberships TeamMembership[] + memberGroupMemberships MemberGroupMembership[] externalPrincipalMemberships ExternalPrincipalMembership[] permissionGrants PermissionGrant[] @relation("grantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") @@ -394,6 +395,67 @@ model TeamExternalBinding { @@index([teamId, revokedAt]) } +// --- Member groups (global, nestable authorization principal) ------------ + +/// Global, unlimited-depth member group. Managed only by the platform super administrator (ADR-0023); +/// Deletion is soft: `archivedAt` is a marker. +/// Deleting a group cascade-soft-deletes its whole subtree — an application +/// operation (walk the subtree, stamp archivedAt), not a DB constraint. +model MemberGroup { + id String @id @default(cuid()) + parentId String? + name String + description String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + archivedAt DateTime? + + parent MemberGroup? @relation("groupTree", fields: [parentId], references: [id], onDelete: Restrict) + children MemberGroup[] @relation("groupTree") + memberships MemberGroupMembership[] + asAncestor MemberGroupClosure[] @relation("ancestor") + asDescendant MemberGroupClosure[] @relation("descendant") + + @@index([parentId, archivedAt]) +} + +/// User↔group many-to-many; a user may belong to multiple groups. Soft delete +/// via `revokedAt` (same pattern as TeamMembership) allows re-adding a removed +/// member. `userId, revokedAt` index is the resolution hot path: fetch a user's direct groups. +model MemberGroupMembership { + id String @id @default(cuid()) + groupId String + userId String + createdAt DateTime @default(now()) + revokedAt DateTime? + + group MemberGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId, revokedAt]) + @@index([userId, revokedAt]) + @@index([groupId, revokedAt]) +} + +/// Transitive closure of the MemberGroup tree. Every group has a depth=0 +/// self row. Turns ancestor/descendant resolution into one indexed join +/// instead of a recursive CTE; maintained only on create / reparent (rare +/// super-admin ops, so the write cost is amortized against hot reads). On soft +/// delete the closure rows are retained; resolution filters by +/// MemberGroup.archivedAt. Reparent must reject a new parent inside the moved +/// subtree (cycle guard). +model MemberGroupClosure { + ancestorId String + descendantId String + depth Int + + ancestor MemberGroup @relation("ancestor", fields: [ancestorId], references: [id], onDelete: Cascade) + descendant MemberGroup @relation("descendant", fields: [descendantId], references: [id], onDelete: Cascade) + + @@id([ancestorId, descendantId]) + @@index([descendantId]) +} + /// Locally synchronized Feishu external principal membership. model ExternalDirectoryConnection { id String @id @default(cuid()) diff --git a/hub/src/database/README.md b/hub/src/database/README.md index 101f2bf..af2bde1 100644 --- a/hub/src/database/README.md +++ b/hub/src/database/README.md @@ -3,13 +3,36 @@ `/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它, 所以服务器启动时能正确识别这些路由。 -页面: +**前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/` +同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** —— +两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、 +以及把构建产物托管出去。服务端不渲染任何 HTML。 -- `/database/admin` —— 飞书登录页(唯一登录方式) -- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin` +后端路由: -登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/`, -回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie。 +- `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/*`、`GET /app`、`GET /app/*` —— SPA shell / + 客户端路由 fallback(`static.ts` 的 `registerDatabaseSpa`)。 +- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`, + 以避开 `admin-web` 在根上注册的 `/_app/*`(同名会让 Fastify 启动即抛重复路由)。 + +SPA 页面(`filelib-web`,真 URL 路由、无 hash): + +- `/app` —— 老师端文件库。未登录显示登录卡片。 +- `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/`, + 回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。 +- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页; + **登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由: + `/database/dashboard`(概览)、`/library`、`/users`、`/groups`、`/search`、`/settings`。 + +> **注册顺序要点**:concrete 路由(`/database/config`、`/database/api/*`、 +> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的 +> `/database/*`、`/app/*` fallback 之前注册(已在 `plugin.ts` 保证), +> 否则通配会 shadow 它们。 ## 开发模式:用环境变量开启一键登录 @@ -29,7 +52,8 @@ HUB_DEV_LOGIN_BYPASS="true" 开启后: -- `/database/admin` 登录页显示「⚡ 一键登录管理员」按钮 +- `/database/config` 返回 `devLoginEnabled: true`,SPA 登录页据此显示 + 「⚡ 一键登录管理员」按钮 - 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth 回调完全一样的 session,然后跳到 `/database/dashboard` @@ -58,21 +82,25 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | 文件 | 职责 | |------|------| | `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` | -| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 | -| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API | +| `routes/databaseRoutes.ts` | `/database/config`、`/database/api/stats`、dev 旁路 + 各子路由装配点 | +| `routes/filelibRoutes.ts` | 文件库 树/授权 API | | `routes/fileRoutes.ts` | 文件库 文件内容/导出 API | -| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 | +| `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, {...})` -注册一次。 +注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。 ## 文件库(filelib/) -独立文件库模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与 -`.omo/文件库-开工计划.md`(D11–D19)锚定。**与 hub 自己的 Folder/Project -(ADR-0021 explorer)是两套体系,不复用。** +独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11–D19 等) +出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`, +内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN) +另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是 +两套体系,不复用。** | 文件 | 职责 | |------|------| @@ -83,7 +111,9 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) | | `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port | | `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) | -| `filelib/groupResolver.ts` | 契约 C2 port + Team 过渡实现 | +| `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) | @@ -92,7 +122,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 环境变量: - `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`) -- `HUB_GROUP_SERVICE_URL` — Group 团队服务地址(C2);未配置时读 hub Team(扁平) +- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub + MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team) > ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**, > 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的 diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts index f58a92c..fe01d09 100644 --- a/hub/src/database/filelib/audit.ts +++ b/hub/src/database/filelib/audit.ts @@ -32,9 +32,16 @@ export const FILE_LIB_AUDIT_ACTIONS = { 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"; +export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group"; export interface FileLibAuditEntry { readonly action: string; diff --git a/hub/src/database/filelib/groupResolver.ts b/hub/src/database/filelib/groupResolver.ts index a3aa546..2134476 100644 --- a/hub/src/database/filelib/groupResolver.ts +++ b/hub/src/database/filelib/groupResolver.ts @@ -2,8 +2,10 @@ * GroupResolver port(契约 C2)。 * * 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。 - * Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此 - * port,真身到位后替换实现,不换调用点。 + * ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器 + * (`createMemberGroupResolver`,见 memberGroupResolver.ts); + * `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。 + * 调用方只依赖此 port,不换调用点。 */ import type { PrismaClient } from "@prisma/client"; @@ -13,9 +15,11 @@ export interface GroupResolver { } /** - * 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。 - * 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎 - * 的 Group 通路今天就是真的,而不是 mock。 + * @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为 + * `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照 + * (以及潜在的迁移对照),新代码不要使用。 + * + * 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。 */ export function createTeamGroupResolver( prisma: PrismaClient, diff --git a/hub/src/database/filelib/memberGroupResolver.ts b/hub/src/database/filelib/memberGroupResolver.ts new file mode 100644 index 0000000..60c2730 --- /dev/null +++ b/hub/src/database/filelib/memberGroupResolver.ts @@ -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))]; + }, + }; +} diff --git a/hub/src/database/filelib/memberGroupService.ts b/hub/src/database/filelib/memberGroupService.ts new file mode 100644 index 0000000..8035d8d --- /dev/null +++ b/hub/src/database/filelib/memberGroupService.ts @@ -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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 }; + }); +} diff --git a/hub/src/database/plugin.ts b/hub/src/database/plugin.ts index 4d17c16..6d9561c 100644 --- a/hub/src/database/plugin.ts +++ b/hub/src/database/plugin.ts @@ -16,6 +16,7 @@ import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import { registerDatabaseRoutes } from "./routes/databaseRoutes.js"; +import { registerDatabaseSpa } from "./static.js"; export interface DatabasePluginConfig { readonly prisma: PrismaClient; @@ -42,4 +43,8 @@ export async function registerDatabasePlugin( siloOrganizationSlug: config.siloOrganizationSlug, allowDevLoginBypass, }); + + // SPA shell + fallback, registered after the concrete /database/* routes above + // so the /database/* wildcard does not shadow them. + await registerDatabaseSpa(app); } diff --git a/hub/src/database/routes/adminPanels.ts b/hub/src/database/routes/adminPanels.ts deleted file mode 100644 index 6a54981..0000000 --- a/hub/src/database/routes/adminPanels.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。 - * - * 用户管理 = org 成员(/api/org/:orgSlug/members); - * Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams), - * 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。 - */ - -function apiBase(orgSlug: string): string { - return `/api/org/${encodeURIComponent(orgSlug)}`; -} - -/* ---------------------------------------------------------------- 用户管理 */ - -export function renderUsersPanel(orgSlug: string): string { - return ` -
-
-
添加成员
-
- - - - -
-
-
-
成员列表
- - - -
成员userId角色
-
-
-`; -} - -/* ---------------------------------------------------------------- Group 管理 */ - -export function renderGroupsPanel(orgSlug: string): string { - return ` -
-
-
-
新建 Group
-
-
-
-
-
-
-
Group 列表
-
-
-
-
-
从左侧选择一个 Group 查看成员
-
-
-`; -} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index dbb1c31..9cfaf35 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -5,36 +5,43 @@ * Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the * literal string it serves. * - * /database/admin — Feishu-only login page (+ dev one-click button) - * /database/dashboard — sidebar + content admin shell, session-gated + * 标准前后端分离:本文件**不渲染任何 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 (button + /database/dev-login) is self-contained here and - * gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS - * + NODE_ENV). Production requires real Feishu OAuth. + * The dev bypass (/database/dev-login) is self-contained here and gated by + * allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS + + * NODE_ENV). Production requires real Feishu OAuth. + * + * NOTE: concrete routes here MUST be registered before the SPA fallback + * (registerDatabaseSpa serves /database and /database/*), or the wildcard would + * shadow them. */ import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import path from "node:path"; -import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js"; +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 { renderLibraryBrowser } from "./libraryBrowser.js"; -import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; -import { createTeamGroupResolver } from "../filelib/groupResolver.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 { UI_HEAD_FONTS, UI_THEME_CSS } from "./uiTheme.js"; +import { actorOrNull, sendRouteError } from "../filelib/routeShared.js"; import type { FileLibRouteDeps } from "../filelib/routeShared.js"; export interface DatabaseRouteConfig { readonly prisma: PrismaClient; /** HMAC secret for the signed session cookie — reused from the admin plane. */ readonly sessionSecret: string; - /** Silo Organization slug — builds the org-scoped Feishu login link. */ + /** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */ readonly siloOrganizationSlug: string; /** DEV ONLY. Enables the one-click button and the /database/dev-login route. */ readonly allowDevLoginBypass: boolean; @@ -47,19 +54,30 @@ export async function registerDatabaseRoutes( // 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。 let filelibDepsForStats: FileLibRouteDeps | null = null; - app.get("/database/admin", async (request, reply) => { - // Already signed in → straight to the dashboard. - if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) { - return reply.redirect("/database/dashboard"); - } - return reply.type("text/html").send(renderLoginPage(config)); - }); + // 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。 + // 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册, + // 两处并存是为了兼容既有前端调用点。 + app.get("/database/config", async () => ({ + orgSlug: config.siloOrganizationSlug, + devLoginEnabled: config.allowDevLoginBypass, + })); - app.get("/database/dashboard", async (request, reply) => { - const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config); - if (user === null) return reply.redirect("/database/admin"); - const stats = await loadDashboardStats(config.prisma, filelibDepsForStats); - return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug)); + // 概览页统计。登录 + 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 @@ -113,9 +131,10 @@ export async function registerDatabaseRoutes( }); } - // 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。 + // 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。 // 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); - // GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); + // 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 }, @@ -134,13 +153,14 @@ export async function registerDatabaseRoutes( organizationId: siloOrg.id, storageRoot, groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === "" - ? createTeamGroupResolver(config.prisma, siloOrg.id) + ? 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, @@ -148,9 +168,10 @@ export async function registerDatabaseRoutes( allowDevLoginBypass: config.allowDevLoginBypass, }); - // 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。 + // 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。 + // SPA 化后目标是真路由(不再是 #library 锚点)。 app.get("/database/library", async (_request, reply) => - reply.redirect("/database/dashboard#library"), + reply.redirect("/database/dashboard/library"), ); filelibDepsForStats = filelibDeps; @@ -174,9 +195,8 @@ interface DashboardStats { /** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */ async function loadDashboardStats( prisma: PrismaClient, - deps: FileLibRouteDeps | null, -): Promise { - if (deps === null) return null; + deps: FileLibRouteDeps, +): Promise { const organizationId = deps.organizationId; const [folders, projects, grants] = await Promise.all([ prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }), @@ -224,195 +244,3 @@ async function loadDashboardStats( }); return { folders, projects, files, grants, recent }; } - -/** Verify the session cookie and load the user, or null if not signed in. */ -async function resolveUser( - rawCookie: string | undefined, - config: DatabaseRouteConfig, -): Promise<{ displayName: string } | null> { - if (rawCookie === undefined || rawCookie === "") return null; - const session = verifySession(rawCookie, config.sessionSecret); - if (session === null) return null; - const user = await config.prisma.user.findUnique({ - where: { id: session.userId }, - select: { displayName: true }, - }); - return user; -} - -/** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */ -function pageHead(title: string): string { - return ` - - - ${title} - ${UI_HEAD_FONTS} - -`; -} - -function renderLoginPage(config: DatabaseRouteConfig): string { - const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`; - const devButton = config.allowDevLoginBypass - ? `
- 开发模式 - -
- ⚡ 一键登录管理员 -

仅开发环境可见 · 跳过飞书 OAuth

` - : ""; - - return ` - -${pageHead("Database Admin · 登录")} - -
-
-
Database Admin
-

使用飞书登录以管理数据库

- 使用飞书登录 - ${devButton} -
-
- -`; -} - -/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */ -const NAV_TABS: ReadonlyArray<{ id: string; label: string; icon: string }> = [ - { id: "overview", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" }, - { id: "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" }, - { id: "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" }, - { id: "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" }, - { id: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" }, - { id: "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" }, -]; - -function renderDashboard(displayName: string, stats: DashboardStats | null, orgSlug: string): string { - const nav = NAV_TABS.map((t) => ` - `).join("\n"); - - const cards = [ - { label: "文件夹", value: stats?.folders ?? "—" }, - { label: "项目", value: stats?.projects ?? "—" }, - { label: "文件", value: stats?.files ?? "—" }, - { label: "活跃授权", value: stats?.grants ?? "—" }, - ].map((s) => ` -
-

${s.label}

-

${s.value}

-
`).join(""); - - const recentRows = stats === null || stats.recent.length === 0 - ? `
暂无文件库活动 · 到「文件库」里创建第一个文件夹吧
` - : stats.recent.map((r) => ` -
- ${escapeHtml(r.action)} - ${escapeHtml(r.label)} - ${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))} -
`).join(""); - - const initial = escapeHtml(displayName.slice(0, 1) || "U"); - - return ` - -${pageHead("Database Admin")} - -
- - -
-
-

概览

-

文件库实时数据

-
- ${cards} -
-
-

最近活动

- ${recentRows} -
-
- - - - - - - - - - -
-
- - - -`; -} - -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -} diff --git a/hub/src/database/routes/filelibRoutes.ts b/hub/src/database/routes/filelibRoutes.ts index 16bf13b..39cd339 100644 --- a/hub/src/database/routes/filelibRoutes.ts +++ b/hub/src/database/routes/filelibRoutes.ts @@ -44,10 +44,22 @@ export async function registerFileLibRoutes( /* ------------------------------------------------------------ 身份 */ // 前端判断能力面用:是否网站管理员(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; - return { userId: actor.userId, isWebsiteAdmin: actor.isWebsiteAdmin }; + 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, + }; }); /* ------------------------------------------------------------ 树节点 */ @@ -261,29 +273,8 @@ export async function registerFileLibRoutes( } }); - /* ------------------------------------------------------------ Group 搜索(过渡) */ - - // 过渡实现:读 hub Team(C2 /groups/search 由 Group 团队交付后切换)。 - 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 ?? "").trim(); - const teams = await deps.prisma.team.findMany({ - where: { - organizationId: deps.organizationId, - archivedAt: null, - ...(q === "" ? {} : { name: { contains: q, mode: "insensitive" as const } }), - }, - take: 20, - orderBy: { name: "asc" }, - select: { id: true, name: true }, - }); - return { groups: teams.map((t) => ({ id: t.id, name: t.name, breadcrumb: t.name })) }; - } catch (error) { - return sendRouteError(reply, error); - } - }); + // Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub + // MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。 } function parseGrants(raw: unknown): InitialGrant[] | undefined { diff --git a/hub/src/database/routes/libraryBrowser.ts b/hub/src/database/routes/libraryBrowser.ts deleted file mode 100644 index bc61435..0000000 --- a/hub/src/database/routes/libraryBrowser.ts +++ /dev/null @@ -1,596 +0,0 @@ -/** - * 管理员后台「文件库」页内浏览器区块(树 | 内容 | 预览三栏,含授权管理)。 - * - * renderLibraryBrowser() 返回可嵌入任意后台布局的 HTML+JS 片段: - * 以 #lib-root 为根、内部用 q() 作用域选择器,不与宿主页其他元素冲突。 - * 与独立页版浏览器的区别:去掉了自己的 wordmark/用户栏(由后台外壳提供), - * 保留管理员工具(根目录创建、授权面板、独立权限开关)。 - */ - -export function renderLibraryBrowser(): string { - return ` -
- -
-
- 文件库 - -
-
-
- - -
-
从左侧选择一个文件夹或项目
-
- - - - -
-
-
- - - -`; -} diff --git a/hub/src/database/routes/libraryPage.ts b/hub/src/database/routes/libraryPage.ts deleted file mode 100644 index 396f2d8..0000000 --- a/hub/src/database/routes/libraryPage.ts +++ /dev/null @@ -1,801 +0,0 @@ -/** - * /database/library —— 文件库浏览页(管理员后台的文件工具)。 - * - * 服务端渲染外壳 + 浏览器端 JS 调 /database/api/*(同源 session cookie)。 - * 设计令牌与全局 UI 主题(uiTheme.ts)一致。能力面全部由 API 的 404/403 表达(D8)。 - */ - -import type { FastifyInstance } from "fastify"; -import type { PrismaClient } from "@prisma/client"; -import { SESSION_COOKIE_NAME, verifySession } from "../../admin/auth/session.js"; - -export interface LibraryPageConfig { - readonly prisma: PrismaClient; - readonly sessionSecret: string; -} - -export async function registerLibraryPage( - app: FastifyInstance, - config: LibraryPageConfig, -): Promise { - app.get("/database/library", async (request, reply) => { - const raw = request.cookies[SESSION_COOKIE_NAME]; - const session = raw === undefined || raw === "" ? null : verifySession(raw, config.sessionSecret); - if (session === null) return reply.redirect("/database/admin"); - const user = await config.prisma.user.findUnique({ - where: { id: session.userId }, - select: { displayName: true }, - }); - if (user === null) return reply.redirect("/database/admin"); - return reply.type("text/html").send(renderLibraryPage(user.displayName)); - }); -} - -function renderLibraryPage(displayName: string): string { - const initial = displayName.slice(0, 1).replace(/[&<>"']/, "U"); - return ` - - - - - 文件库 - - - - - -
- -
-
从左侧选择一个文件夹或项目
-
-
- - -
- - - -`; -} diff --git a/hub/src/database/routes/memberGroupRoutes.ts b/hub/src/database/routes/memberGroupRoutes.ts new file mode 100644 index 0000000..0050aae --- /dev/null +++ b/hub/src/database/routes/memberGroupRoutes.ts @@ -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 { + 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); + } + }); +} diff --git a/hub/src/database/routes/teacherApp.ts b/hub/src/database/routes/teacherApp.ts index 665bf16..871f896 100644 --- a/hub/src/database/routes/teacherApp.ts +++ b/hub/src/database/routes/teacherApp.ts @@ -1,15 +1,12 @@ /** - * 老师端用户前端托管(标准前后端分离): - * GET /app/* — filelib-web(Svelte SPA)构建产物静态托管 + SPA 回退 - * GET /database/api/login-info — 登录页配置(org slug / dev 开关) - * GET /app/dev-login{,-teacher} — DEV ONLY 一键登录(管理员 / 普通老师) + * 老师端后端支撑(标准前后端分离): + * GET /database/api/login-info — 登录页配置(org slug / dev 开关) + * GET /app/dev-login-teacher — DEV ONLY 一键登录(普通老师) * - * 服务端不再渲染老师端页面;页面由独立前端工程 hub/filelib-web 产出。 + * 服务端不渲染任何页面。`/app/*` 的静态托管与 SPA 回退在 ../static.ts —— 那里 + * 与管理后台 `/database/*` 共用同一份 filelib-web 构建产物,资源路由只注册一次。 */ -import fs from "node:fs"; -import path from "node:path"; -import fastifyStatic from "@fastify/static"; import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; @@ -33,26 +30,6 @@ export async function registerTeacherApp( devLoginEnabled: config.allowDevLoginBypass, })); - // 标准分离:静态托管 SPA 构建产物;非文件路径回退 index.html 交给前端。 - const distDir = path.resolve(import.meta.dirname, "../../../filelib-web/dist"); - if (fs.existsSync(path.join(distDir, "index.html"))) { - await app.register(fastifyStatic, { root: distDir, prefix: "/app/", decorateReply: true }); - app.setNotFoundHandler((request, reply) => { - if (request.url.startsWith("/app")) { - return reply.sendFile("index.html", distDir); - } - return reply.status(404).send({ error: { code: "not_found", message: "not found" } }); - }); - } else { - app.log.warn({ distDir }, "filelib-web dist not found; build it with `npm run build --prefix filelib-web`"); - app.get("/app", async (_request, reply) => - reply - .status(503) - .type("text/plain") - .send("filelib-web 未构建。请先运行 npm run build --prefix hub/filelib-web"), - ); - } - if (!config.allowDevLoginBypass) return; registerDevLogins(app, config); } diff --git a/hub/src/database/routes/uiTheme.ts b/hub/src/database/routes/uiTheme.ts deleted file mode 100644 index 70b3b28..0000000 --- a/hub/src/database/routes/uiTheme.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * 全局共享 UI 主题(老师端 /app、管理员后台 /database、文件页 /database/library)。 - * - * 方向:高级简约、零视觉疲劳 —— 暖白底、发丝边框、近黑主按钮(唯一强调色)、 - * 无渐变、无彩色、无阴影(弹层仅一丝)、Inter 全界面、克制的动效。 - * 所有页面的设计令牌收敛于此,调色只改这里。 - */ - -export const UI_HEAD_FONTS = ` - - -`; - -export const UI_THEME_CSS = ` - :root { - --bg: #FCFCFB; - --panel: #FFFFFF; - --sidebar: #F7F7F5; - --text: #1A1A18; - --text-2: #6B6A66; - --text-3: #9C9B96; - --border: #ECECE8; - --border-soft: #F1F1EE; - --hover: #F4F4F1; - --selected: #EBEBE7; - --accent: #1A1A18; - --accent-hover: #333330; - --danger: #A13A33; - --guide: #E9E9E5; - --diff-add-bg: #F3F6F2; - --diff-add-text: #4A6741; - --diff-del-bg: #F8F2F1; - --diff-del-text: #A13A33; - --sans: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; - --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - --shadow-pop: 0 4px 20px rgba(26,26,24,.07); - } - * { box-sizing: border-box; } - html, body { margin: 0; height: 100%; } - body { - font-family: var(--sans); - background: var(--bg); color: var(--text); - font-size: 14px; line-height: 1.65; - -webkit-font-smoothing: antialiased; - } - .hidden { display: none !important; } - h1, h2, h3 { margin: 0; } - a { color: var(--text); text-decoration: none; } - a:hover { text-decoration: underline; } - - /* 按钮:近黑实心(主)/ 发丝边幽灵(次)/ 文字型(危险) */ - .btn { - display: inline-flex; align-items: center; gap: 5px; - padding: 6px 14px; border-radius: 8px; - border: 1px solid var(--border); background: var(--panel); - color: var(--text); font-size: 12.5px; font-weight: 500; - cursor: pointer; transition: all 120ms ease; text-decoration: none; - } - .btn:hover { background: var(--hover); text-decoration: none; } - .btn-primary { - background: var(--accent); border-color: var(--accent); color: #fff; - } - .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } - .btn-danger { border-color: transparent; background: transparent; color: var(--danger); } - .btn-danger:hover { background: #A13A3312; } - - /* 面板与标签 */ - .panel { - background: var(--panel); border: 1px solid var(--border-soft); - border-radius: 10px; padding: 20px 22px; - } - .tag { - font-size: 10.5px; font-weight: 500; font-family: var(--mono); - padding: 2px 8px; border-radius: 999px; - border: 1px solid var(--border-soft); color: var(--text-3); background: var(--panel); - } - .tag-role { color: var(--text-2); border-color: var(--border); } - - /* 页签 */ - .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border-soft); margin-bottom: 18px; } - .tab { - padding: 8px 14px; font-size: 13px; color: var(--text-3); - border: none; background: none; cursor: pointer; - border-bottom: 2px solid transparent; margin-bottom: -1px; - transition: color 120ms ease; - } - .tab:hover { color: var(--text); } - .tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); } - - /* 表单 */ - .input, .select, .textarea { - width: 100%; padding: 7px 11px; border-radius: 8px; - border: 1px solid var(--border); background: var(--panel); - font-size: 13px; color: var(--text); font-family: inherit; - outline: none; transition: border-color 120ms ease; - } - .input:focus, .select:focus, .textarea:focus { border-color: var(--accent); } - .textarea { font-family: var(--mono); font-size: 12.5px; line-height: 1.75; resize: vertical; } - .form-label { display: block; font-size: 11.5px; color: var(--text-3); margin-bottom: 4px; } - .form-row { margin-bottom: 12px; } - .inline-form { display: flex; gap: 8px; align-items: center; } - .inline-form .input { flex: 1; } - - /* 表格 */ - 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(--text-3); padding: 4px 0; } - table.list td { padding: 8px 0; border-top: 1px solid var(--border-soft); } - table.list tr:first-child td { border-top: none; } - - /* 弹层 */ - .modal-mask { - position: fixed; inset: 0; z-index: 40; - background: rgba(26,26,24,.3); - display: flex; align-items: center; justify-content: center; padding: 16px; - } - .modal-card { - width: 100%; max-width: 430px; background: var(--panel); - border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px; - box-shadow: var(--shadow-pop); - } - .modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; } - .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } - .toast { - border-radius: 8px; padding: 8px 16px; font-size: 12.5px; color: #fff; - background: #333230; max-width: 330px; - } - .toast-err { background: #7E2C26; } - #toast-root { position: fixed; bottom: 18px; right: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; } - - .quiet { color: var(--text-3); font-size: 12.5px; } - .divider { border-top: 1px solid var(--border-soft); margin: 14px 0; } - .section-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; } - .section-note { font-size: 11.5px; color: var(--text-3); margin-top: 6px; } - .file-path { font-family: var(--mono); font-size: 12.5px; color: var(--text); } - .file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); } - .link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; } - .link-danger:hover { text-decoration: underline; } -`; diff --git a/hub/src/database/static.ts b/hub/src/database/static.ts new file mode 100644 index 0000000..9fcacb6 --- /dev/null +++ b/hub/src/database/static.ts @@ -0,0 +1,71 @@ +/** + * 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web` + * 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。 + * + * 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html + * 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*。 + * + * 三类路由: + * /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为 + * `_filelib`,以避开 admin-web 在根上注册的 /_app/* + * (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。 + * /app, /app/* — 老师端 SPA 回退 + * /database, /database/* — 管理后台 SPA 回退 + * + * 具体路由(/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, 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_FILELIB_UI_DIR"]; + if (override && override.trim() !== "") return resolvePath(override); + const here = dirname(fileURLToPath(import.meta.url)); + 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 { + const uiDir = resolveUiDir(); + if (!existsSync(join(uiDir, "index.html"))) { + app.log.warn( + { uiDir }, + "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"); + + // 构建资源。@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, + }); + + for (const name of TOP_LEVEL_FILES) { + if (!existsSync(join(uiDir, name))) continue; + app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir)); + } + + // SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以 + // 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。 + const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise => + 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); +} diff --git a/hub/src/deployment/siloRateLimit.ts b/hub/src/deployment/siloRateLimit.ts index c17b056..efe1f59 100644 --- a/hub/src/deployment/siloRateLimit.ts +++ b/hub/src/deployment/siloRateLimit.ts @@ -2,8 +2,8 @@ * Silo-wide HTTP request rate limit (ADR-0022 `requestRate`). * * Counts dynamic traffic only: APIs, auth, and other application handlers. - * Static SPA assets and the org-admin HTML shell are exempt so a single page - * load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget. + * Static SPA assets and the admin HTML shells are exempt so a single page load + * (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget. */ /** Paths that must not consume the silo HTTP request-rate budget. */ @@ -12,12 +12,21 @@ export function isSiloHttpRateLimitExempt(url: string): boolean { if (path === "/api/healthz") return true; - // SvelteKit build output and top-level static files (see admin/static.ts). + // admin-web SvelteKit build output at the root, and top-level static files + // (see admin/static.ts). if (path === "/_app" || path.startsWith("/_app/")) return true; if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true; - // SPA index shell for client-side routes (not an API). + // 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). /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; } diff --git a/hub/test/integration/member-groups.test.ts b/hub/test/integration/member-groups.test.ts new file mode 100644 index 0000000..f00b15b --- /dev/null +++ b/hub/test/integration/member-groups.test.ts @@ -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 { + 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", + ]); + }); +}); diff --git a/hub/test/unit/silo-rate-limit.test.ts b/hub/test/unit/silo-rate-limit.test.ts index 97cd8f4..191574b 100644 --- a/hub/test/unit/silo-rate-limit.test.ts +++ b/hub/test/unit/silo-rate-limit.test.ts @@ -27,6 +27,17 @@ describe("isSiloHttpRateLimitExempt", () => { expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true); }); + it("exempts the filelib-web SPA assets and both of its mount prefixes", () => { + // appDir 改名为 `_filelib`(见 src/database/static.ts):同一份产物服务 + // 老师端 /app 与管理后台 /database。 + expect(isSiloHttpRateLimitExempt("/_filelib/version.json")).toBe(true); + expect(isSiloHttpRateLimitExempt("/_filelib/immutable/chunks/foo.js?v=1")).toBe(true); + expect(isSiloHttpRateLimitExempt("/app")).toBe(true); + expect(isSiloHttpRateLimitExempt("/app/dev-login-teacher")).toBe(true); + expect(isSiloHttpRateLimitExempt("/database")).toBe(true); + expect(isSiloHttpRateLimitExempt("/database/dashboard/users")).toBe(true); + }); + it("still rate-limits APIs and auth", () => { expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false); expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false); diff --git a/hub/test/unit/spa-route-registration.test.ts b/hub/test/unit/spa-route-registration.test.ts new file mode 100644 index 0000000..38f6cdb --- /dev/null +++ b/hub/test/unit/spa-route-registration.test.ts @@ -0,0 +1,43 @@ +/** + * 两套 SPA 静态托管共存于同一个 Fastify 实例。 + * + * 这是 ADR-0029 的承重约束的回归测试:filelib-web 的 appDir 若用 SvelteKit 默认的 + * `_app`,就会与 admin-web 在根上注册的 `/_app/*` 撞成重复路由,Fastify 启动即抛错。 + * 改名为 `_filelib` 后两者共存 —— 这里把两个注册函数挂到同一实例上验证。 + */ +import Fastify from "fastify"; +import { describe, expect, it } from "vitest"; +import { registerStaticSpa } from "../../src/admin/static.js"; +import { registerDatabaseSpa } from "../../src/database/static.js"; + +describe("SPA static route registration", () => { + it("admin-web and filelib-web shells coexist without duplicate routes", async () => { + const app = Fastify({ logger: false }); + await registerStaticSpa(app); + await registerDatabaseSpa(app); + await expect(app.ready()).resolves.toBeDefined(); + await app.close(); + }); + + it("serves the same index.html at /app and /database, and assets under /_filelib", async () => { + const app = Fastify({ logger: false }); + await registerDatabaseSpa(app); + await app.ready(); + + const appShell = await app.inject({ method: "GET", url: "/app" }); + const dbShell = await app.inject({ method: "GET", url: "/database/dashboard/users" }); + + // 前置:构建产物必须存在。缺失时 registerDatabaseSpa 只 warn 不注册路由, + // 断言会全部落到 404 —— 那说明该先跑 `npm run build --prefix filelib-web`, + // 不是测试不适用,所以这里不跳过而是直接失败。 + expect(appShell.statusCode).toBe(200); + expect(dbShell.statusCode).toBe(200); + // 同一份字节:SPA 回退不读请求(ADR-0029)。 + expect(dbShell.body).toBe(appShell.body); + expect(appShell.headers["content-type"]).toContain("text/html"); + // 资源用绝对路径引用(paths.relative=false),否则深层 URL 下会解析错。 + expect(appShell.body).toContain("/_filelib/"); + + await app.close(); + }); +}); diff --git a/文件库-接口契约.md b/文件库-接口契约.md deleted file mode 100644 index 2a1de4a..0000000 --- a/文件库-接口契约.md +++ /dev/null @@ -1,304 +0,0 @@ -# 文件库系统 · 接口契约与决策(v0.1 我方提案) - -> **读者**:版本团队、Group 团队、审计团队、平台方、编辑团队、产品。 -> **用法**:本文档是我方(文件库)提出的对齐基线。所有决策带 `D-x` 编号、所有契约带 `C-x` 编号,评审时请按编号引用("D5 我们不同意,建议……")。评审通过后本文件冻结,各方照此实现;任何变更走文档修订,不接受口头对齐。 -> **状态**:v0.1 提案,未冻结。 - ---- - -## 1. 系统边界与分工 - -| 子系统 | 负责方 | 与我方关系 | -|---|---|---| -| 文件库(目录树 / 权限引擎 / 内容存储 / 对外 API / 前端) | **我方** | — | -| 版本能力(git 化、commit、diff、历史) | 版本团队 | 交付 **npm 工具包**,我方在自有存储上集成(见 C1) | -| Group 系统(全局嵌套组) | Group 团队 | 我方**实时消费**其查询接口(见 C2) | -| 审计系统(存储 / 查询 / 保留) | 审计团队 | 我方**上报事件**(见 C3);查询界面归审计方 | -| 平台身份(登录 / 角色) | 平台方 | 我方验签消费角色(见 C4) | -| 在线编辑与冲突合并 UI(需求 2.5) | 编辑团队 | **消费我方文件 API**(见第 9 节) | -| 导出工具 | 已有工具 | 我方按需传参调用(参数清单 OPEN-6) | - -**内容归属**:文件内容(git 仓库)物理存储在文件库自己的服务器上,由我方管理;版本团队不持有任何数据,只交付代码。 - -## 2. 总体架构 - -``` -编辑团队(2.5 UI) ──┐ -其他消费方 ──┤ HTTP + JWT - ▼ - ┌─────────────────────┐ - │ 文件库服务(我方) │ - │ 目录树 + 权限引擎 │──── 实时查询 ──▶ Group 系统(C2) - │ 文件 API + 授权 API │──── 验签 JWT ──▶ 平台身份(C4) - └─────────┬───────────┘ - │ import(函数调用) - ▼ - ┌─────────────────────┐ outbox 中继(at-least-once) - │ 版本工具包(C1,npm) │ ┌──────────────────────▶ 审计系统(C3) - │ 操作我方磁盘上的 │ │ - │ git 仓库(内容归我方)│ ▼ - └─────────────────────┘ 我方 DB:业务表 + outbox 表(同事务) -``` - -## 3. 决策总表 - -| 编号 | 决策 | 一句话理由 | 影响方 | -|---|---|---|---| -| D1 | 文件库后端 TypeScript + Fastify + Prisma(PostgreSQL);前端 React | 与平台技术心智一致 | 我方 | -| D2 | 文件内容物理存储在文件库服务器,版本能力以 npm 包交付 | 需求 2.1"项目初始化为 Git 仓库",仓库是项目的存储 | 版本团队 | -| D3 | 版本包冲突用**返回值**表达,异常仅用于系统错误 | 冲突是正常业务流,不是故障 | 版本、编辑 | -| D4 | Group 查询**实时调用、不缓存** | 需求 3.3"成员变更实时生效" | Group | -| D5 | 审计走"本地 outbox + 后台中继"满足"同事务或可靠消息" | 跨服务做不到真同事务,outbox 是最简单的可靠方案 | 审计 | -| D6 | 平台身份用 JWT(RS256)验签,平台角色 `platform.admin` 映射网站管理员 | 无状态、无需每次回调平台 | 平台 | -| D7 | 权限管理规则按第 8 节补齐(2.4 空洞的我方版本) | 需求 2.4 为空,必须先有规则才能写码 | 产品确认 | -| D8 | 无 View 权限时 API 返回 **404**(不泄露存在性);有 View 但操作越权返回 403 | 防资源探测 | 所有消费方 | -| D9 | 软删除:删除即打标隐藏,所有 API 默认不可见 | 需求 2.6/3.3 | 我方 | -| D10 | 导出为**异步任务**(提交返回 job,轮询取结果) | 导出耗时不确定,同步会超时 | 编辑/前端 | - ---- - -## 4. C1 · 版本工具包契约(给版本团队) - -**交付形态**:npm 包(TypeScript,自带类型定义),运行在文件库后端进程内。实现技术(isomorphic-git / nodegit / 其他)由版本团队自选,**本契约只约束接口与语义**。 - -### 4.1 接口签名 - -```typescript -export type VersionId = string; // 不透明字符串,调用方不得解析(当前为 git commit hash) - -export interface CommitRequest { - /** 编辑的起始版本;null 表示新建文件 */ - baseVersion: VersionId | null; - content: string | Buffer; // 文本用 string(UTF-8),二进制用 Buffer - message?: string; // 缺省由包自动生成 - author?: string; // 操作者标识,写入版本记录 -} - -export type CommitResult = - | { status: "ok"; version: VersionId } - | { status: "conflict"; currentVersion: VersionId }; - -export interface VersionInfo { - version: VersionId; - message: string; - author?: string; - committedAt: string; // ISO 8601 -} - -export interface FileEntry { path: string; size: number; } - -export interface VersionStore { - init(projectDir: string): Promise; // S7: 幂等 - list(projectDir: string, prefix?: string): Promise; - head(projectDir: string, filePath: string): Promise; // 文件不存在 → FileNotFoundError - read(projectDir: string, filePath: string, at?: VersionId): Promise; // 缺省读最新 - commit(projectDir: string, filePath: string, req: CommitRequest): Promise; - remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise; - diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise; // unified diff - history(projectDir: string, filePath: string, limit?: number): Promise; -} -``` - -### 4.2 语义规则 - -| 编号 | 规则 | -|---|---| -| S1 | **冲突不是异常**。`baseVersion` 落后于当前版本时返回 `{status:"conflict"}`;异常仅用于 IO 故障、仓库损坏等系统错误 | -| S2 | `baseVersion: null` = 新建;路径已存在时返回 conflict | -| S3 | 二进制文件与文本同样版本化;`diff` 仅保证对文本有意义,二进制可返回占位说明 | -| S4 | 包必须保证**同一 projectDir 的写操作(commit/remove)串行化**,调用方可并发调用 | -| S5 | 存储布局不透明:我方不直接读写仓库目录,一切经包接口 | -| S6 | 在线编辑仅针对文本文件(需求 2.5);二进制材料走上传/下载 | -| S7 | `init` 幂等,重复调用不报错、不重建 | -| S8 | 规模假设:单项目文件数千级、单文件 10MB 以内,超出另行对齐 | - ---- - -## 5. C2 · Group 查询契约(给 Group 团队) - -**形态**:HTTP + JSON。我方只读,不写 Group 系统任何数据(需求 3.3)。 - -### 5.1 接口 - -``` -GET /groups/resolve-member-groups?userId={userId} - → { "groupIds": ["g1","g2",...] } - # 权限计算专用:用户直接所属的全部 group + 这些 group 的所有祖先 group,去重。 - # 方向是"向祖先"收集(需求 3.2:权限沿 group 树向下传递)。 - -GET /groups/search?q={keyword}&limit={n} - → [{ "id":"g1","name":"物理教研组","breadcrumb":"总部 / 教研 / 物理" }] - # 前端授权选择器用。 - -GET /groups/{groupId} - → { "id":"g1","name":"物理教研组","parentId":"g0","status":"active" } -``` - -### 5.2 语义规则 - -| 编号 | 规则 | -|---|---| -| G1 | `groupId` 为不透明、全局唯一字符串 | -| G2 | resolve 只含**祖先方向**,不含子孙;用户无所属时返回空数组 | -| G3 | 软删除的 group 不出现在任何接口结果中 | -| G4 | **实时性**:我方每次权限计算都实时调用 resolve,不缓存;Group 方成员变更须即刻在 resolve 结果中可见(需求 3.3) | -| G5 | 文件夹/项目的授权记录只存在文件库,Group 系统不感知(需求 3.3) | -| G6 | 可用性:resolve 位于我方**每一次受保护请求**的权限计算路径上,其可用性即文件库可用性 → 需要 Group 方给出延迟与可用性承诺(OPEN-1) | - ---- - -## 6. C3 · 审计上报契约(给审计团队) - -### 6.1 机制(满足需求 5.1"同事务或可靠消息") - -1. 我方在每个关键写操作的**同一数据库事务**内,向本地 `audit_outbox` 表写入事件; -2. 后台中继进程读取 outbox,POST 到审计系统 `POST /audit/events`,**at-least-once**,失败重试; -3. 审计系统按 `eventId` **幂等去重**; -4. 中继只负责送达,业务操作不因审计系统不可用而失败(但事件绝不丢)。 - -### 6.2 事件信封(补齐需求 5.3 空缺的字段定义) - -```json -{ - "eventId": "01J…", // 我方生成的唯一 id,幂等键 - "schemaVersion": 1, - "occurredAt": "2026-07-21T10:00:00.000Z", - "sourceService": "filelib", - "actor": { "userId": "u123", "platformRole": "teacher" }, - "action": "permission.grant", // 见 6.3 词汇表 - "object": { "type": "folder", "id": "n456", "path": "/物理/必修一" }, - "result": "success", // success | failure - "errorCode": null, - "detail": { /* 按 action 而定的负载,如 grant 的 {principalType, principalId, role} */ } -} -``` - -### 6.3 action 词汇表(文件库范围,对照需求 5.2) - -| action | 需求 5.2 对应 | -|---|---| -| `folder.create` / `folder.rename` / `folder.move` / `folder.delete` | 创建/删除/移动/重命名文件夹 | -| `project.create` / `project.rename` / `project.move` / `project.delete` | 同上(项目) | -| `permission.grant` / `permission.update` / `permission.revoke` | 权限变更(detail 含个人/Group、Manage/Edit/View) | -| `project.independent_permission.enable` / `.disable` / `.change` | 项目独立权限开启/关闭/变更 | -| `file.upload` / `file.rename` / `file.delete` | 材料文件管理 | -| `file.commit` | 文件编辑提交(含冲突合并后提交;编辑经我方 API 落盘,故由我方上报) | -| `file.conflict_detected` | 冲突检测发生(detail 含起始版本与冲突版本) | -| `export.run` | 导出操作 | -| `admin.force_adjust` | 网站管理员强制权限调整(高危) | - -Group 相关动作(创建/删除/成员变更/嵌套变更)由 **Group 系统自行上报**;归档类动作待归档功能定案后补充(OPEN-4)。 - -### 6.4 边界 - -- 审计的存储、防篡改、保留策略(需求 5.5 ≥180 天)、查询接口与查询界面,全部归审计系统; -- 文件库前端**不内嵌**审计查询页(若产品要求嵌入,OPEN-8 再议)。 - ---- - -## 7. C4 · 平台身份契约(给平台方) - -1. 调用方在 `Authorization: Bearer ` 中携带平台签发的令牌(RS256);平台通过 JWKS endpoint 分发公钥,我方只做验签 + 过期检查,**不回调平台、不建用户表**; -2. claims 约定:`sub`(用户 id,全局唯一)、`roles`(平台角色数组)、`exp`、`iss`; -3. **角色映射**:`roles` 含 `platform.admin` → 文件库"网站管理员";其余合法令牌 → 普通用户;无令牌/验签失败/过期 → `401`; -4. 网站管理员权限范围:创建根目录、强制权限调整(必审计)、以及平台方赋予的其他高危操作;**不隐式穿透**各节点的业务权限(要管理某子树须被显式授权或走强制调整并留痕); -5. 前端登录跳转平台 SSO,具体流程由前端与平台方另行对齐。 - ---- - -## 8. 权限规则 · 2.4 补齐版(D7,产品确认后冻结) - -### 8.1 创建者与授权矩阵 - -| 角色 | 能授/改/收的级别 | 限制 | -|---|---|---| -| **节点创建者** | Manage / Edit / View | 自身 Manage 不可被收回(转让 OPEN-3) | -| **Manage 持有者** | Edit / View | **不可**授予 Manage;**不可**修改/收回创建者的任何权限 | -| **Edit / View** | 无授权能力 | — | -| **网站管理员** | 任意(走 `admin.force_adjust`,必审计) | 不属于日常授权路径 | - -### 8.2 各级别能力清单 - -| 能力 | View | Edit | Manage | -|---|:---:|:---:|:---:| -| 浏览树 / 读文件 / 下载 | ✓ | ✓ | ✓ | -| 导出 | ✓ | ✓ | ✓ | -| 创建子文件夹/项目(需求:父级 Edit+) | | ✓ | ✓ | -| 编辑文本文件 / 上传 / 删改项目内材料 | | ✓ | ✓ | -| 重命名 / 移动 / 删除**本节点** | | | ✓ | -| 授权管理(按 8.1 矩阵) | | | ✓ | -| 项目独立权限开关 | | | ✓ | - -### 8.3 其余规则 - -- **P3 根目录**:仅网站管理员可创建;创建时必须指定 ≥1 名 Manage 持有者(可以不是创建者本人),保证每棵子树都有权限链起点; -- **P4 空权限创建**:允许;此时仅创建者可见可用; -- **P5 项目独立权限**:默认关闭(仅继承父链);开启后项目级授权参与 max 计算;关闭时项目级授权**冻结不删除**,重新开启即恢复; -- **P6 计算规则**(与需求 2.3 一致的形式化): - - `effective(user, R) = max { grant.role | grant ∈ grants(s, r), s ∈ {user} ∪ resolveGroups(user), r ∈ {R} ∪ ancestors(R) }`,无 grant → 无权限。个人低权限**不构成降权**(只取最高,不做减法); -- **P7 可见性**:对某节点无任何权限的用户,该节点对其不可见(API 行为见 D8)。 - ---- - -## 9. 文件库对外 API(消费方:编辑团队及其他) - -**约定**:REST + JSON + JWT(C4);统一错误信封 `{ "error": { "code", "message", "details?" } }`;D8 可见性语义(无 View → 404;越权操作 → 403)。 - -### 9.1 树与节点 - -``` -GET /nodes?parentId={id} # 列子节点(缺省列根);仅返回有 View 的 -POST /nodes # 创建文件夹/项目 {parentId, type, name, grants?[]} - # parentId=null 仅网站管理员;type=project 时自动调 C1.init -GET /nodes/{id} # 节点详情 + 我的 effective 权限 + breadcrumb -PATCH /nodes/{id} # 重命名/移动 {name?, parentId?} (需本节点 Manage) -DELETE /nodes/{id} # 软删除(需 Manage) -``` - -### 9.2 授权 - -``` -GET /nodes/{id}/grants # 授权列表(需 Manage) -PUT /nodes/{id}/grants # 批量授予/修改 [{principalType:user|group, principalId, role}] -DELETE /nodes/{id}/grants/{grantId} # 收回 -GET /nodes/{id}/effective-permission # 当前用户在此节点的生效权限(自查) -PUT /projects/{id}/independent-permission # {enabled: bool} 独立权限开关 -``` - -### 9.3 文件内容(编辑团队的主接口) - -``` -GET /projects/{id}/files?prefix= # 文件清单 -GET /projects/{id}/files/{path} # → {content, version} 编辑起手式:拿到起始版本 -PUT /projects/{id}/files/{path} # 新建/上传 {content} (需 Edit) -POST /projects/{id}/files/{path}/commits # 提交编辑 {baseVersion, content, message?} - # → 200 {version} | 409 {currentVersion} -GET /projects/{id}/files/{path}/diff?from=&to= # 冲突时取差异(unified diff) -GET /projects/{id}/files/{path}/history # 版本历史 -DELETE /projects/{id}/files/{path} # {baseVersion} (需 Edit) -``` - -**冲突合并流程(配合编辑团队 2.5)**:编辑 UI 用 `GET files/{path}` 记录 `version` → 用户编辑 → `POST commits` 带 `baseVersion` → 若 `409`,UI 用 `diff?from=base&to=current` 拉取差异,展示三方合并区 → 用户写出最终内容后再次 `POST commits`(baseVersion 换为 currentVersion)。 - -### 9.4 导出 - -``` -POST /projects/{id}/exports {target, params} → {jobId} # D10 异步 -GET /exports/{jobId} → {status, downloadUrl?} -``` - ---- - -## 10. OPEN 清单(需对方/产品确认,不阻塞我方开工) - -| 编号 | 事项 | 等谁 | -|---|---|---| -| OPEN-1 | Group resolve 的延迟/可用性 SLA 数值 | Group 团队 | -| OPEN-2 | 审计事件投递的 endpoint、鉴权方式、保留期确认(需求建议 ≥180 天) | 审计团队 | -| OPEN-3 | 创建者离职/转让后 Manage 链如何处理 | 产品 | -| OPEN-4 | 归档功能(需求已划线"暂时未定") | 产品 | -| OPEN-5 | 二进制材料的大小上限与在线预览诉求 | 产品 | -| OPEN-6 | 导出工具的参数清单(需求原文"待对接时确认") | 导出工具方 | -| OPEN-7 | 软删除的恢复入口、"后台标签"管理界面归属 | 产品 | -| OPEN-8 | 网站管理员强制调整的前端入口(我方做还是平台做)、审计查询页是否嵌入文件库前端 | 产品 |