Compare commits

..

1 Commits

Author SHA1 Message Date
hongjr03 4e7b158ff9 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35
Silo hostname already carries tenancy. Admin SPA routes become /admin/...,
legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
2026-07-18 17:36:08 +00:00
169 changed files with 2231 additions and 14407 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based # Builds and lints the Rust implementation crates under crates/ (the rule-based
# lesson checker). # checker that "stands in Lean's position" at product runtime).
# #
# This is an INTERNAL gate on the implementation's own health # Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no # (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# decision-to-implementation conformance gate — implementations align to the # spec-to-implementation conformance gate — implementations align to the Lean
# ADRs by human review, not by CI. See the repo README. # contract by human review, not by CI. See the repo README.
on: on:
push: push:
+2 -2
View File
@@ -1,8 +1,8 @@
name: hub check name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/. # Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half. # The Hub is the Feishu-group collaboration + agent runtime half
# This is an INTERNAL gate on the Hub's own # (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half. # health, like checker-check is for the Rust half.
on: on:
+20
View File
@@ -0,0 +1,20 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+4
View File
@@ -1,3 +1,7 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root) # Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target /target
**/*.pdf **/*.pdf
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md), recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022. Numerical with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
ceilings remain open until production-like calibration. ceilings remain open until production-like calibration.
The implementation frontier is: The implementation frontier is:
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
## Question ## Question
After the readiness investigations and resulting fixes are resolved, can one After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test health, deploy a clean repeatable release procedure prove build/test/spec health, deploy a clean
production-like environment, exercise critical tenant and agent journeys, production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely? verify observability and recovery, and either roll forward or roll back safely?
@@ -8,7 +8,7 @@ Blocked by: 04
Separate or unify run-bound audit entries, pre-run security/permission events, Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening structured messages, and operational recovery events without weakening
the pinned AuditEntry-to-run relation. Decide durability, `Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence. observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,7 +40,9 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant. Emergency Platform Grant.
The complete accepted decision and implementation divergences are in The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md), [ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md). and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain Exact numeric session/invitation/step-up limits and browser mechanics remain
+16 -12
View File
@@ -1,25 +1,29 @@
# AGENTS.md —— agent 操作手册(全 repo) # AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org - 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。 独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd - Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退 credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability, process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024)。 不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。 - 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。 Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用 - 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走 客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023)。 双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、 - 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -40,12 +44,12 @@
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede 3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+10 -8
View File
@@ -1,23 +1,25 @@
# CLAUDE.md —— agent 操作手册(全 repo) # CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede 3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
-4
View File
@@ -99,7 +99,3 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**: **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. 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 _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
+23 -16
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。 教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。** 这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
## 安装 `cph` 命令行 ## 安装 `cph` 命令行
@@ -33,40 +33,47 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
``` ```
README.md ← 本文件:总览 + 宪法(下面 5 条) README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo) CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源) docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
CONTEXT.md平台语言词汇表(术语与禁用说法) spec/ Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行 cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005) render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署 hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 crates/ (exporter/ …) ← 将来的其他部件,平级于 spec/
``` ```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。 未来部件(如 exporter)复用,而非各自重造。
## 宪法 ## 宪法
4 条是本仓库的协作约定,是一切工作的前提。 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
1. **角色 —— ADR 是决策真相** 1. **角色 —— Lean 是研发侧的上游参照**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档 `spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
2. **对齐机制 —— 人肉承载,无机器兜底** 2. **对齐机制 —— Lean 只做上游参照**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。 不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **形态 —— 自包含** 3. **资产性 —— 由 review 纪律承载,无机器兜底**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定 这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
4. **深度判据 —— 只收录分歧点** 4. **形态 —— 它是人机共识的契约**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面) 契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI ## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml` Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace 必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。 全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+2 -3
View File
@@ -1,8 +1,7 @@
# crates/ # crates/
These crates implement the rule-based lesson checker whose semantics are These crates implement the rule-based lesson checker that aligns to the
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson, semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics. `element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
+11 -9
View File
@@ -19,11 +19,13 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic. /// Severity of the render-coverage ("element ignored under a target") diagnostic.
/// ///
/// **PINNED to `warning` by ADR-0005:** when a /// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// `(kind, target)` pair has no render rule the checker reports that the element /// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the /// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable /// severity as a const makes "it is a warning, not an error" a greppable,
/// fact rather than an inline literal. /// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]). /// The result of running [`check`] (or the check phases of [`build`]).
@@ -55,12 +57,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity. /// Whether any collected diagnostic is `Error`-severity.
/// ///
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson /// **Legality decision (spec alignment).** `!has_errors()` is the
/// legality: a lesson is *legal* iff its diagnostics contain no error-level /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI /// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// gate enforcing ADR↔implementation alignment (repo constitution); it is /// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// kept greppable here so a reviewer can tie the orchestrator's gate to /// gate enforcing this alignment (repo constitution); it is kept greppable
/// the ADR. /// here so a reviewer can tie the orchestrator's gate to the Lean master.
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.diagnostics self.diagnostics
.iter() .iter()
+15 -10
View File
@@ -2,7 +2,7 @@
//! //!
//! Every other crate in the workspace depends on these types to report //! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`] //! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an //! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a //! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint. //! [`Diagnostic`] tying them together with a human message and a fix hint.
//! //!
@@ -17,27 +17,32 @@ use serde::Serialize;
/// Severity of a diagnostic. /// Severity of a diagnostic.
/// ///
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.** /// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// ///
/// ```text /// ```text
/// warning | error /// inductive Severity where
/// | warning
/// | error
/// ``` /// ```
/// ///
/// This two-valued shape is a **contract decision**, not an accident: the /// This two-valued shape is a **contract decision**, not an accident: the Lean
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we /// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is /// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with /// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). /// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
/// ///
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo /// There is no CI gate enforcing this alignment (see the repo constitution);
/// constitution); it is maintained by review, which is why the decision is /// it is maintained by review, which is why this correspondence is documented
/// documented here rather than only in the ADR. /// here rather than only in the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity { pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored /// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. ADR-0010 `warning`. /// element. Mirrors Lean `Severity.warning`.
Warning, Warning,
/// Blocking: the artifact is invalid. ADR-0010 `error`. /// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error, Error,
} }
+38 -25
View File
@@ -3,7 +3,9 @@
//! This crate is the **loader**, not the full checker. It reads //! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared //! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an //! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics. //! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//! //!
//! Scope boundaries (deliberately staying in lane): //! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and //! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -21,7 +23,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file. /// An ordered, in-memory lesson loaded from an engineering file.
/// ///
/// `parts` is an ordered /// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest /// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
/// is declarative" — the `[[parts]]` array order is the single source of truth). /// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
@@ -84,8 +86,9 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given. /// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>, pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target /// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes ADR-0011's "render /// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// coverage is a declaration, not a payload": the declaration keeps *which /// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic), /// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps. /// while the rendering "how" lives in the template/steps.
/// ///
@@ -99,10 +102,13 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011). /// The artifact an export target produces (ADR-0009/0011).
/// ///
/// **Pinned by ADR-0011** as an ADT with fields: /// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// Artifact = singleFile (filepath) | fileTree (root, outputs) /// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// ``` /// ```
/// ///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product /// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -114,20 +120,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → /// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`]. /// [`Artifact::FileTree`].
/// ///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔ /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// implementation alignment (see the repo constitution) — it is maintained by /// alignment (see the repo constitution) — it is maintained by review, which is
/// review, which is why the decision is documented here. /// why the correspondence is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact { pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering /// One bundled document landing at `filepath` (relative to the engineering
/// root). ADR-0011 `singleFile`. The default artifact shape. /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
SingleFile { SingleFile {
/// Where the single product is written (relative to the engineering /// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`. /// root), e.g. `build/student.pdf`.
filepath: PathBuf, filepath: PathBuf,
}, },
/// A set of files under `root` matching the `outputs` glob. ADR-0011 /// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `fileTree`. /// `Artifact.fileTree`.
FileTree { FileTree {
/// The output directory (relative to the engineering root). /// The output directory (relative to the engineering root).
root: PathBuf, root: PathBuf,
@@ -150,10 +156,14 @@ impl Artifact {
/// One typed build step (ADR-0011). /// One typed build step (ADR-0011).
/// ///
/// **Pinned by ADR-0011** as an ADT: /// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field) /// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// ``` /// ```
/// ///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a /// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -173,20 +183,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step { pub enum Step {
/// Compile a template file (relative to the engineering root) into the /// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. ADR-0011 /// artifact; the framework injects the manifest. Mirrors Lean
/// `typstCompile`. /// `Step.typstCompile`.
TypstCompile { TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`. /// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf, template: PathBuf,
}, },
/// Run a shell command — the escape hatch. ADR-0011 `shell`. /// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
Shell { Shell {
/// The command line to run. /// The command line to run.
run: String, run: String,
}, },
/// Assemble a single-file markdown deliverable by concatenating each /// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011 /// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// `assembleMarkdown` (ADR-0015). Not a typst build — the /// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself. /// framework owns the read/concatenate/write itself.
AssembleMarkdown { AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`, /// The per-element markdown content field to assemble (e.g. `slides`,
@@ -216,11 +226,12 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim). /// `[info]` table (passed through to render targets verbatim).
/// ///
/// The *canonical* model whose /// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array /// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this /// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary. No /// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable. /// CI gate enforces this alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info { pub struct Info {
/// Lesson title. /// Lesson title.
@@ -229,6 +240,7 @@ pub struct Info {
/// so this is a list, not a single name. Empty when `[info]` declares no /// so this is a list, not a single name. Empty when `[info]` declares no
/// `author`. The on-disk `author` accepts either a bare string (one author) /// `author`. The on-disk `author` accepts either a bare string (one author)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`. /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>, pub authors: Vec<String>,
} }
@@ -278,7 +290,8 @@ struct RawProject {
name: String, name: String,
} }
/// The authoring-surface `[info]`: the raw form that exists for /// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load /// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in. /// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -288,7 +301,7 @@ struct RawInfo {
} }
/// On-disk `author`: either a single name (`author = "…"`) or a list /// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). A fill-in convenience whose /// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] /// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears. /// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -299,7 +312,7 @@ enum RawAuthor {
} }
impl RawAuthor { impl RawAuthor {
/// Flatten to the ordered author list: a single /// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
/// name becomes a one-element list; a list passes through verbatim. /// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> { fn into_vec(self) -> Vec<String> {
match self { match self {
@@ -1,153 +0,0 @@
# 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.
@@ -1,132 +0,0 @@
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
## Status
Accepted.
## Context
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
teacher-facing file library (`/app`), and the database admin back office
(`/database`). They arrived at different times and diverged in how HTML reached
the browser.
`/admin` and `/app` were already separated: the backend serves a prebuilt static
`index.html` and never inspects the request; all data flows through JSON
endpoints. `/database` was not. Roughly 1770 lines across four modules
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
assembled HTML template strings server-side, reading the session cookie and
querying Prisma inside the page handler, with layout expressed as inline
`style="…"` attributes and behavior as `<script>` text.
A prior migration (`12628c9`) introduced a fourth frontend project,
`hub/database-admin/`, intended to replace those pages. It was never wired up:
the concrete route `/database/dashboard` is more specific than the SPA wildcard
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
was unreachable. That project's file header claimed the SPA served the dashboard
and that `/database/config` existed; neither was true. The `npm run build` script
also never built it, so the `existsSync` guard in `database/static.ts` failed on
every deploy and the shell was permanently disabled.
Duplicated visual rules were the practical cost: card padding and type sizes were
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
were genuinely shared.
## Decision
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
request; all per-user and per-request data is fetched by the client from JSON
endpoints under `/api/*` or `/database/api/*`.
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
and mounted at two prefixes.** They share the file library browser, the session
layer, the toast host, and the design tokens; splitting them would duplicate all
of it. `hub/database-admin` is deleted — superseded before it ever served a
request.
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
Fastify instance, and are load-bearing:
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
with the root `/_app/*` asset route that `admin-web` owns
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
collision is a boot failure, not a silent misroute.
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
paths would resolve against the wrong base.
**Client-side navigation uses real URL routes, not hash fragments or hidden
sections.** The six back-office tabs are `/database/dashboard`,
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
Refresh preserves position and links are shareable — the previous
`location.hash` + `display:none` scheme lost both.
Concrete routes must be registered before the SPA wildcards. This is an ordering
obligation on `database/plugin.ts`, not an incidental detail: the earlier
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
route outranks the fallback.
## Consequences
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
carries no security weight; every endpoint keeps its own `fail closed` guard.
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
org-wide counts and the audit stream rather than a per-node permission view.
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
handler read from Prisma to render chrome has to become part of a JSON payload
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
until these were added. When migrating a server-rendered surface, the data the
template closed over is part of the contract being ported, not an incidental
detail of the old implementation.
- Editing a page no longer requires a Hub restart in development; `vite dev`
serves the frontend and proxies data requests to the Hub. In production the
`index.html` is cached in memory at startup, so a frontend rebuild does require
a restart.
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
extending that list.
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
tokens plus an `@layer components` block for the shared component classes
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
`routes/uiTheme.ts` is deleted; both halves live there now.
The first cut of this migration kept only the tokens and restated button,
input, and panel styling inline in every component. That reproduced the
duplication the old code had — the admin panels visibly regressed — so the
component layer was ported too. Components carry layout utilities; they do not
restate component styling. The one admitted exception is a data-derived value
(tree indent computed from `depth`), which cannot be a static class.
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
- **A migrated surface is only done when its endpoint coverage matches.** Two
panels were rebuilt from a superficially similar component that predated the
migration rather than from the server module they replaced, and the mismatch
was invisible in the rendered page:
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
a soft-deleted group could not be restored through the UI at all even though
the backend fully supported it.
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
.../grants` and `PUT .../independent-permission` had no caller. Permission
editing is the point of the back office, and it was unreachable.
Diffing the route table against the frontend's `api()` call sites catches this;
reading the new page does not.
## Deferred
- `/admin` (admin-web) stays a separate project. It has its own design language
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
merging it is not motivated by shared code.
- The `search` and `settings` tabs remain placeholders, as they were server-side.
- Serving `/admin` and `/database` from a single SPA, which would remove the
`appDir` collision constraint entirely.
@@ -1,157 +0,0 @@
# ADR 0030: The File Library VersionStore Is a Real Git Repository per Project
## Status
Accepted.
## Context
The file library (`hub/src/database/filelib/`, an independent subsystem that does
not reuse the Hub's own `Folder`/`Project` tree from ADR-0021) stores each project
as a versioned file tree behind the `VersionStore` port (contract C1). Until now
the only implementation was `createInMemoryVersionStore`: a `Map` of per-file
version chains, with `VersionId` as a per-repository monotonic counter
(`v1`, `v2`, …), a hand-written line differ, and an optional JSON snapshot of the
entire storage root written to `<storageRoot>/.version-store.json` so that a
process restart did not lose the demo data.
Two things about the surrounding design were already settled in code and are
confirmed here rather than changed:
- **A `FOLDER` node has no on-disk existence.** `FileLibNode.storageDir` is
`NULL` for folders. The tree is `parentId` plus the `pathIds` materialized path;
nothing in the filesystem mirrors it.
- **Projects are flat under one root, keyed by id.** `storageDir` is
`<storageRoot>/<nodeId>` where `nodeId` is a `randomUUID()`. Names never enter
the path, which is why `renameNode` touches no disk state and does not rewrite
descendant paths.
What was never true is the part the names implied. `HUB_FILELIB_STORAGE_ROOT` was
documented as "the project git repository root" and `fileService` was documented
as observing a "git first, then audit" ordering, but no code in the repository
ever invoked git. `versionStore.init(storageDir)` inserted a `Map` entry; the
directory was never created. Every project's entire content and history lived in
one process-global JSON file. The header comment and `README.md` both marked this
as a placeholder awaiting an npm package from the versioning team.
That package has not arrived, and the in-memory store's properties are not
acceptable for real teacher data: a corrupt or lost `.version-store.json` loses
every project at once, the whole storage root is rewritten on every commit, and
`VersionId` values are meaningless outside the process that minted them.
## Decision
**Each file library project is a real Git repository at
`<storageRoot>/<nodeId>`.** `VersionStore.init` creates the directory and runs
`git init` there. This is the production implementation;
`createInMemoryVersionStore` is retained for tests only.
**`VersionId` is a Git commit hash.** The full 40-hex object name, as printed by
`git rev-parse`. It is no longer a per-repository counter.
**File-level versioning (D16) maps onto commit history as follows.** A write
touches exactly one path and produces exactly one commit. The version of a file is
the hash of the most recent commit that modified that path — `git log -1 --
<path>`. Consequently:
- Two files in one project have independent versions, because a commit that
touches `a.md` does not appear in `git log -- b.md`. This preserves the D16
property that advancing one file does not invalidate another file's
`baseVersion`, even though commits are repository-global objects.
- `baseVersion` checking (S1/S2) compares the caller's id against the current
per-file version. `baseVersion: null` means create, and conflicts if the path
already exists at `HEAD`.
- Reading version `V` of a path means `git show V:<path>`, which is the content as
of that commit, not the content the commit introduced to some other file.
**Deletion is a commit, not a tombstone record.** `remove` runs `git rm` and
commits, so the path is absent from `HEAD` and `list` stops reporting it, while
`git show <olderVersion>:<path>` still resolves. The in-memory store expressed
this as a `deleted: true` chain entry; the observable API semantics are the same.
**Git is invoked as a subprocess, not through a library.** `node:child_process`
`execFile` with an argument array, no new npm dependency. Every invocation is
hardened, and the hardening is load-bearing rather than incidental:
- `-c core.hooksPath=` and `-c commit.gpgsign=false`, plus
`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_SYSTEM=/dev/null`. A project
repository is *data*, uploaded by teachers. Without this, a committed
`.git/hooks/` entry or a developer's global `gitconfig` would execute or alter
server-side behavior.
- `GIT_LITERAL_PATHSPECS=1` and `--` before every path, so a filename is never
reinterpreted as an option or as pathspec magic (`:(glob)`).
- `GIT_TERMINAL_PROMPT=0`, so a repository never blocks a request waiting on
credentials.
- Author identity is passed per-commit via `GIT_AUTHOR_*`/`GIT_COMMITTER_*`
environment variables, never written into the repository's config. The git
author name is the acting user's `displayName` (falling back to `userId` when
absent), and the email is `<userId>@filelib.paradigm-edu.net`. The email
deliberately keys on `userId` rather than the display name, because nicknames
change and identity attribution must not drift with them. Characters that would
break git's ident line (`<`, `>`, newlines) are stripped from the name.
- `--git-dir=<projectDir>/.git` and `--work-tree=<projectDir>` are pinned on
every invocation, and `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE`/
`GIT_OBJECT_DIRECTORY` are removed from the child environment. Git otherwise
searches *upward* for a `.git`, and the storage root is frequently nested inside
another repository — the local development default `hub/.filelib-repos` sits
inside this very repo. Without pinning, operations on a project directory that
has no repository of its own silently retarget the enclosing repository.
Existence is therefore tested on the filesystem (`<projectDir>/.git`), not with
`git rev-parse --git-dir`, which merely echoes a pinned value back.
**Writes to one repository remain serialized in-process**, as under S4, because
concurrent git invocations contend on `index.lock`. This is a single-process
guarantee only; see Consequences.
## Consequences
- `.version-store.json` is not read or migrated by the new store. Existing
development data under `HUB_FILELIB_STORAGE_ROOT` does not appear in the git
store; those projects report `repo_not_found` until recreated. No production
data exists to migrate, since the in-memory store was never production-viable.
- `VersionId` changes shape in API responses (`GET .../files/*`, history, and the
409 `currentVersion` detail). Clients must keep treating it as an opaque
string; `filelib-web` already does.
- `VersionInfo.author` now comes back as the git author name, which is the acting
user's display name at commit time (or the `userId` when no display name is
known). Commits written without an author carry a fixed `filelib` identity
rather than `undefined`. Display names are point-in-time: renaming a user does
not rewrite existing commits, and the stable identifier stays in the email.
- `VersionStore.commit`/`remove` take a structured `CommitAuthor`
(`{ userId, displayName? }`) rather than a bare author string, so the port can
express both the stable key and the display label. Deletion carries the same
identity as any other commit.
- Serialization is per-process. Two Hub processes sharing a storage root can race
on the same repository and surface a git lock error rather than a clean
conflict. The alpha Silo deployment (ADR-0025) is one process per organization,
so this is not currently reachable; a multi-process deployment needs either a
database advisory lock keyed by project id or a single writer.
- `git` must be present on the host. Absence is a startup-visible failure of
project creation (`provision_failed`), not a silent degradation.
- Repository content is now attacker-influenced data on disk. The path validation
in `fileService.validateFilePath` (rejecting `..`, `.git`, absolute paths,
control characters) moves from hygiene to a security boundary, and
`versionStore` re-checks it rather than trusting callers.
## Alternatives considered
- **`isomorphic-git` or `simple-git`.** Both add a dependency to carry work that
three `execFile` calls do. `isomorphic-git` additionally reimplements the object
layer, so its bugs would be ours to diagnose.
- **One commit per repository state, with the repository head as the version.**
Simpler mapping, but it breaks D16: any write would invalidate every other
file's `baseVersion`, turning independent edits into false conflicts.
- **Keeping the counter as `VersionId` alongside git.** Requires a durable
counter-to-hash mapping outside git, which is the state the decision removes.
- **Bare repositories with a git index-only write path.** Avoids a working tree,
but every read and write becomes plumbing (`hash-object`, `update-index`,
`commit-tree`), for no benefit at this scale.
## Deferred
- Cross-process write serialization (advisory lock keyed by project id).
- Garbage collection and pack maintenance policy for long-lived repositories.
- Whether export builds (`exportService`) should read a git tree directly instead
of going through the `listFiles`/`readFile` port.
- Recovering `provisionStatus=FAILED` projects by re-running `init`; the status
machine records the failure but nothing retries it yet.
+1 -4
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` | | 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` | | 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` | | 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` | | 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` | | 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` | | 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` | | 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,9 +66,6 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。 如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调 ## 4. 配置事件与卡片回调
进入“事件与回调”。 进入“事件与回调”。
+1 -17
View File
@@ -23,18 +23,12 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# HUB_AGENT_MAX_TURNS=25 # HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS="1" HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900" HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_HTTP_BODY_LIMIT_BYTES="73400320" HUB_HTTP_BODY_LIMIT_BYTES="1048576"
HUB_MAX_FILES_PER_MESSAGE="8" HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILE_BYTES="26214400" HUB_MAX_FILE_BYTES="26214400"
HUB_HTTP_REQUESTS_PER_MINUTE="120" HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120" HUB_FEISHU_EVENTS_PER_MINUTE="120"
# 文件库单文件上限(缺省 10 MiB)。这两个值是串联的:上传把文件内容放在
# JSON body 里,二进制过 base64 体积涨 4/3。所以有效上限是
# min(本值, HUB_HTTP_BODY_LIMIT_BYTES × 3/4);body limit 太小时本值不可达,
# 且报错是 Fastify 的 413 Payload Too Large 而不是 file_too_large。
HUB_FILELIB_MAX_FILE_BYTES="52428800"
# Persistent system-managed root for project workspaces. Production must use an # Persistent system-managed root for project workspaces. Production must use an
# absolute path outside the deployment/release tree; install_service.sh defaults # absolute path outside the deployment/release tree; install_service.sh defaults
# to this path and rejects any overlap before installing the unit. # to this path and rejects any overlap before installing the unit.
@@ -51,18 +45,8 @@ HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires # Absolute path to the `cph` binary (ADR-0016). Production preflight requires
# the file to be executable and `cph --version` to succeed. # the file to be executable and `cph --version` to succeed.
#
# Always set this explicitly. `cph` is also the command name of the unrelated
# PyPI package conda-package-handling, so on any host with miniconda on PATH a
# bare `cph` resolves to the wrong tool and exports fail with an argparse
# "invalid choice: 'build'" that gives no hint about the name collision.
CPH_BIN="/usr/local/bin/cph" CPH_BIN="/usr/local/bin/cph"
# The `cph-render` typst package directory (the folder holding lib.typ /
# typst.toml). Needed by PDF export: when unset, cph falls back to resolving the
# repo-relative `render/`, which does not exist in a deployed layout.
CPH_RENDER_DIR="/opt/curriculum-project-hub/render"
# Hub bind address and port. Production defaults to loopback for a local TLS # Hub bind address and port. Production defaults to loopback for a local TLS
# reverse proxy; both values are validated and honored by the HTTP server. # reverse proxy; both values are validated and honored by the HTTP server.
HOST="127.0.0.1" HOST="127.0.0.1"
-7
View File
@@ -5,13 +5,6 @@ dist/
.env.* .env.*
!.env.example !.env.example
.secrets/ .secrets/
.dev-keyring.json
.dev-workspaces/
.dev-skills/
.filelib-repos/
admin-web/node_modules/ admin-web/node_modules/
admin-web/build/ admin-web/build/
admin-web/.svelte-kit/ admin-web/.svelte-kit/
filelib-web/node_modules/
filelib-web/build/
filelib-web/.svelte-kit/
+1 -4
View File
@@ -82,11 +82,10 @@ REMOTE
rsync -az --delete \ rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \ --exclude node_modules --exclude dist --exclude .env \
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \ --exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
--exclude filelib-web/node_modules --exclude filelib-web/build --exclude filelib-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)" echo "[fleet] npm ci + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c ' flock /var/lock/cph-hub-release-publish bash -c '
@@ -98,11 +97,9 @@ flock /var/lock/cph-hub-release-publish bash -c '
cd "$HUB_DIR" cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web npm ci --prefix admin-web
npm ci --prefix filelib-web
npm run audit:production npm run audit:production
npm run build npm run build
test -f admin-web/build/index.html test -f admin-web/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete" touch "$RELEASE_DIR/.complete"
' '
REMOTE REMOTE
+3 -4
View File
@@ -60,11 +60,10 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete. # 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and # `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'" "cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi fi
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
-2013
View File
File diff suppressed because it is too large Load Diff
-35
View File
@@ -1,35 +0,0 @@
{
"name": "filelib-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.2",
"typescript": "^5.7.0",
"vite": "^8.0.16"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/language": "^6.12.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"vite-plugin-wasm": "^3.6.0"
}
}
-263
View File
@@ -1,263 +0,0 @@
@import "tailwindcss";
/* 全局 UI 主题令牌(与 hub 端 uiTheme.ts 同源) */
@theme {
--color-bg: #fcfcfb;
--color-panel: #ffffff;
--color-sidebar: #f7f7f5;
--color-ink: #1a1a18;
--color-ink-2: #6b6a66;
--color-ink-3: #9c9b96;
--color-line: #ecece8;
--color-line-soft: #f1f1ee;
--color-hover: #f4f4f1;
--color-selected: #ebebe7;
--color-accent: #1a1a18;
--color-accent-hover: #333330;
--color-danger: #a13a33;
--color-guide: #e9e9e5;
--color-diff-add-bg: #f3f6f2;
--color-diff-add-text: #4a6741;
--color-diff-del-bg: #f8f2f1;
--color-diff-del-text: #a13a33;
}
html,
body {
height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-ink);
font-family:
"Inter",
-apple-system,
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
sans-serif;
font-size: 14px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* 共享组件层(ADR-0029)。
*
* 从已删除的 hub/src/database/routes/uiTheme.ts 原样搬来。组件只带布局工具类,
* 不重述这里的组件样式 —— 第一版迁移只搬了上面的 @theme 令牌,把按钮/输入框/
* 面板样式在每个组件里内联重写了一遍,后台随即明显退化。 */
@layer components {
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
color: var(--color-ink);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 120ms ease;
white-space: nowrap;
}
.btn:hover {
background: var(--color-hover);
}
.btn-sm {
padding: 4px 9px;
font-size: 11.5px;
}
.btn-primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.btn-danger {
border-color: transparent;
background: transparent;
color: var(--color-danger);
}
.btn-danger:hover {
background: color-mix(in srgb, var(--color-danger) 7%, transparent);
}
.panel {
background: var(--color-panel);
border: 1px solid var(--color-line-soft);
border-radius: 10px;
padding: 20px 22px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10.5px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-line-soft);
color: var(--color-ink-3);
background: var(--color-panel);
}
.input,
.select,
.textarea {
width: 100%;
padding: 7px 11px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
font-size: 13px;
color: var(--color-ink);
font-family: inherit;
outline: none;
transition: border-color 120ms ease;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: var(--color-accent);
}
.textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
resize: vertical;
}
.form-label {
display: block;
font-size: 11.5px;
color: var(--color-ink-3);
margin-bottom: 4px;
}
.form-row {
margin-bottom: 12px;
}
table.list {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.list th {
text-align: left;
font-size: 11.5px;
font-weight: 500;
color: var(--color-ink-3);
padding: 4px 0;
}
table.list td {
padding: 8px 0;
border-top: 1px solid var(--color-line-soft);
}
table.list tr:first-child td {
border-top: none;
}
.quiet {
color: var(--color-ink-3);
font-size: 12.5px;
}
.section-title {
font-size: 13px;
font-weight: 600;
}
.section-note {
font-size: 11.5px;
color: var(--color-ink-3);
}
.file-meta {
font-size: 11px;
color: var(--color-ink-3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.link-danger {
color: var(--color-danger);
font-size: 12.5px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.link-danger:hover {
text-decoration: underline;
}
/* 开关。真 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.switch > input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch > span {
position: relative;
flex-shrink: 0;
width: 30px;
height: 17px;
border-radius: 999px;
background: var(--color-line);
transition: background 0.16s;
}
.switch > span::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
transition: transform 0.16s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.switch > input:checked + span {
background: var(--color-accent);
}
.switch > input:checked + span::after {
transform: translateX(13px);
}
.switch > input:focus-visible + span {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* diff 渲染 */
pre.diff {
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
}
pre.diff .add {
display: block;
color: var(--color-diff-add-text);
background: var(--color-diff-add-bg);
}
pre.diff .del {
display: block;
color: var(--color-diff-del-text);
background: var(--color-diff-del-bg);
}
-12
View File
@@ -1,12 +0,0 @@
// See https://svelte.dev/docs/kit/types#app
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
-16
View File
@@ -1,16 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="height: 100%">
<div style="display: contents; height: 100%">%sveltekit.body%</div>
</body>
</html>
-31
View File
@@ -1,31 +0,0 @@
<script lang="ts">
/** 头像:有 avatarUrl 用图,否则显示首字母色块。 */
let {
displayName,
userId,
avatarUrl = null,
size = 28,
}: {
displayName?: string | null;
userId?: string | null;
avatarUrl?: string | null;
size?: number;
} = $props();
const initial = $derived((displayName || userId || "?").slice(0, 1).toUpperCase());
</script>
{#if avatarUrl}
<img
src={avatarUrl}
alt=""
class="shrink-0 rounded-full object-cover"
style="width:{size}px;height:{size}px"
/>
{:else}
<span
class="inline-flex shrink-0 items-center justify-center rounded-full bg-accent font-semibold text-white"
style="width:{size}px;height:{size}px;font-size:{Math.round(size * 0.42)}px"
aria-hidden="true">{initial}</span
>
{/if}
-121
View File
@@ -1,121 +0,0 @@
<script lang="ts">
/**
* CodeMirror 6 编辑器封装。自动根据文件扩展名选择语法高亮。
* 只在浏览器 mount 后创建 EditorView(CodeMirror 依赖 DOM)。
*/
import { onMount, onDestroy } from "svelte";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { html } from "@codemirror/lang-html";
import { css } from "@codemirror/lang-css";
import { typst } from "codemirror-lang-typst";
import type { Extension } from "@codemirror/state";
let { value = "", readonly = false, filename = "", onchange }: {
value?: string;
readonly?: boolean;
filename?: string;
onchange?: (value: string) => void;
} = $props();
let container = $state<HTMLDivElement | null>(null);
let view: EditorView | null = null;
/** 根据文件名后缀选语言扩展 */
function langExtension(name: string): Extension[] {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
case "md":
case "markdown":
return [markdown()];
case "js":
case "mjs":
case "cjs":
return [javascript()];
case "ts":
case "mts":
case "cts":
return [javascript({ typescript: true })];
case "jsx":
return [javascript({ jsx: true })];
case "tsx":
return [javascript({ jsx: true, typescript: true })];
case "json":
case "jsonc":
return [json()];
case "html":
case "htm":
case "svelte":
case "vue":
return [html()];
case "css":
case "scss":
return [css()];
case "typ":
case "typst":
return [typst()];
default:
return [];
}
}
onMount(() => {
if (!container) return;
const extensions: Extension[] = [
basicSetup,
...langExtension(filename),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
];
if (readonly) extensions.push(EditorState.readOnly.of(true));
view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: container,
});
});
onDestroy(() => {
view?.destroy();
view = null;
});
// 外部 value 变化时(如冲突载入最新),替换编辑器内容。
$effect(() => {
if (view && view.state.doc.toString() !== value) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}
});
</script>
<div bind:this={container} class="code-editor-wrapper"></div>
<style>
.code-editor-wrapper {
border: 1px solid var(--color-line);
border-radius: 8px;
overflow: hidden;
font-size: 13px;
line-height: 1.65;
}
.code-editor-wrapper :global(.cm-editor) {
height: 100%;
max-height: 60vh;
overflow: auto;
}
.code-editor-wrapper :global(.cm-editor.cm-focused) {
outline: none;
}
.code-editor-wrapper :global(.cm-scroller) {
overflow: auto;
}
</style>
-206
View File
@@ -1,206 +0,0 @@
<script lang="ts">
/**
* 文件编辑器(模态框形式)。打开后加载文件内容并使用 CodeMirror 编辑,
* 支持语法高亮、版本冲突处理、历史查看与删除。
*/
import { api, ApiError } from "./api.js";
import { toastOk, toastErr, toast } from "./stores.js";
import type { FileContent, VersionInfo, Role } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import CodeEditor from "./CodeEditor.svelte";
let { projectId, path, role, onchanged, onclose }: {
projectId: string;
path: string;
role: Role;
onchanged: () => void;
onclose: () => void;
} = $props();
let file = $state<FileContent | null>(null);
let draft = $state("");
let loadError = $state<string | null>(null);
let conflict = $state<{ currentVersion: string; diff: string } | null>(null);
let showHistory = $state(false);
let history = $state<VersionInfo[]>([]);
const canEdit = $derived(role !== "VIEW");
const filename = $derived(path.split("/").pop() ?? "");
async function load(): Promise<void> {
try {
file = await api<FileContent>(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(path)}`);
draft = file.encoding === "utf8" ? file.content : "";
loadError = null;
conflict = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void projectId;
void path;
void load();
});
async function save(): Promise<void> {
if (file === null) return;
// 内容未变化时不提交,避免产生空 commit。
if (draft === file.content) {
toastOk("内容无变化,未提交");
return;
}
try {
const r = await api<{ version: string }>(`/database/api/projects/${projectId}/file/commits`, {
method: "POST",
body: { path: file.path, baseVersion: file.version, content: draft },
});
toastOk("已提交 " + r.version);
await load();
onchanged();
} catch (e) {
if (e instanceof ApiError && e.status === 409 && typeof e.details?.["currentVersion"] === "string") {
await showConflict(e.details["currentVersion"]);
} else {
toastErr(e instanceof Error ? e.message : String(e));
}
}
}
async function showConflict(currentVersion: string): Promise<void> {
if (file === null) return;
try {
const r = await api<{ diff: string }>(
`/database/api/projects/${projectId}/file/diff?path=${encodeURIComponent(file.path)}&from=${encodeURIComponent(file.version)}&to=${encodeURIComponent(currentVersion)}`,
);
conflict = { currentVersion, diff: r.diff };
file = { ...file, version: currentVersion };
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function acceptLatest(): Promise<void> {
conflict = null;
await load();
toast("已载入最新内容,请在此基础上合并", "info");
}
async function remove(): Promise<void> {
if (file === null || !confirm("删除文件 " + file.path + "?")) return;
try {
await api(`/database/api/projects/${projectId}/file?path=${encodeURIComponent(file.path)}`, {
method: "DELETE",
body: { baseVersion: file.version },
});
toastOk("已删除");
file = null;
onchanged();
onclose();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function openHistory(): Promise<void> {
try {
const r = await api<{ history: VersionInfo[] }>(`/database/api/projects/${projectId}/file/history?path=${encodeURIComponent(path)}`);
history = r.history;
showHistory = true;
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
function renderDiff(diff: string): string {
return diff
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/^\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>');
}
</script>
<!-- 主编辑器模态框:宽屏 overlay -->
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="flex h-[85vh] w-full max-w-[900px] flex-col rounded-2xl border border-line-soft bg-panel shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<!-- 顶栏 -->
<div class="flex shrink-0 items-center justify-between border-b border-line-soft px-6 py-4">
<div class="flex min-w-0 items-center gap-3">
<span class="text-[15px] font-semibold text-ink truncate">{filename}</span>
</div>
<div class="flex items-center gap-1.5">
{#if file}
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>
<Icon name="download" size={13} /> 下载
</a>
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
{#if canEdit}
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除</button>
{/if}
{/if}
<button class="btn !px-2.5" onclick={onclose} title="关闭" aria-label="关闭编辑器"></button>
</div>
</div>
<!-- 编辑器主体 -->
<div class="flex-1 overflow-y-auto px-6 py-4">
{#if loadError}
<div class="text-xs text-danger">{loadError}</div>
{:else if file === null}
<div class="quiet">加载中…</div>
{:else if file.encoding === "base64"}
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else}
<CodeEditor value={draft} filename={path} readonly={!canEdit} onchange={(v) => (draft = v)} />
{/if}
{#if conflict}
<div class="mt-4 rounded-xl border border-[#E8E2C8] bg-[#FCFBF4] p-4">
<div class="mb-2 text-[13px] font-semibold text-[#6E6329]">冲突:他人已提交 {conflict.currentVersion},差异如下</div>
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后重新提交(基版已更新为 {conflict.currentVersion})</div>
<div class="mt-2 flex justify-end">
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
</div>
</div>
{/if}
</div>
<!-- 底栏 -->
{#if canEdit && file && file.encoding !== "base64"}
<div class="flex shrink-0 justify-end border-t border-line-soft px-6 py-3">
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
</div>
</div>
{#if showHistory}
<Modal title="版本历史" onclose={() => (showHistory = false)}>
<div class="max-h-80 overflow-y-auto">
{#each history as v (v.version)}
<div class="flex items-center gap-2.5 border-t border-line-soft py-2.5 first:border-t-0">
<span
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white"
aria-hidden="true"
>{(v.author ?? "?").slice(0, 1).toUpperCase()}</span>
<div class="min-w-0 flex-1">
<p class="text-[12.5px] text-ink">{v.message}</p>
<p class="text-[11px] text-ink-3">
{#if v.author}<span>{v.author}</span> · {/if}{new Date(v.committedAt).toLocaleString("zh-CN")}
</p>
</div>
</div>
{/each}
</div>
<div class="mt-3 flex justify-end">
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
</div>
</Modal>
{/if}
-375
View File
@@ -1,375 +0,0 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { selectedFilePath, filesVersion } from "./browser.js";
import { loadConfig } from "./config.js";
import type { FileEntry, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let files = $state<FileEntry[] | null>(null);
let loadError = $state<string | null>(null);
let showNewFile = $state(false);
let newPath = $state("");
let newContent = $state("");
let newMessage = $state("");
// 上传弹窗:选完文件先暂存,等用户确认路径与 commit 信息再传。
let pendingFile = $state<File | null>(null);
let uploadPath = $state("");
let uploadMessage = $state("");
let uploading = $state(false);
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null);
/** 上传上限由 /database/config 下发(后端 HUB_FILELIB_MAX_FILE_BYTES)。 */
let maxFileBytes = $state<number | null>(null);
const maxLabel = $derived(
maxFileBytes === null ? "" : `${(maxFileBytes / 1024 / 1024).toFixed(maxFileBytes % (1024 * 1024) === 0 ? 0 : 1)}MB`,
);
const canEdit = $derived(node.role !== "VIEW");
/**
* 当前浏览目录("" = 根,否则以 "/" 结尾)。文件夹是从扁平 path 列表派生的
* 虚拟层(ADR-0030 —— git 版本存储里只有文件,没有目录对象),不对应任何
* 独立的后端资源,纯前端按 "/" 分段分组即可,无需新增接口。
*/
let currentDir = $state("");
let viewMode = $state<"list" | "grid">(loadViewMode());
function restoreCurrentDir(nodeId: string): string {
try {
return sessionStorage.getItem(`filelib.dir.${nodeId}`) ?? "";
} catch {
return "";
}
}
function loadViewMode(): "list" | "grid" {
try {
return localStorage.getItem("filelib.viewMode") === "grid" ? "grid" : "list";
} catch {
return "list";
}
}
function setViewMode(mode: "list" | "grid"): void {
viewMode = mode;
try {
localStorage.setItem("filelib.viewMode", mode);
} catch {
// 隐私模式等场景下 localStorage 可能不可用;视图切换仍在当前会话内生效,只是不跨会话记忆。
}
}
async function loadFiles(): Promise<void> {
try {
const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`);
files = r.files;
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void node.id;
void $filesVersion;
void loadFiles();
});
// 切换项目时恢复对应项目的目录位置。
$effect(() => {
void node.id;
currentDir = restoreCurrentDir(node.id);
});
// currentDir 变化时持久化。
$effect(() => {
try {
sessionStorage.setItem(`filelib.dir.${node.id}`, currentDir);
} catch { /* ignore */ }
});
$effect(() => {
void loadConfig()
.then((c) => (maxFileBytes = c.maxFileBytes))
.catch(() => (maxFileBytes = null));
});
interface FolderRow {
readonly kind: "folder";
readonly name: string;
readonly path: string;
}
interface FileRow {
readonly kind: "file";
readonly name: string;
readonly path: string;
readonly size: number;
}
/** 按当前目录分组:落在 currentDir 前缀下、第一段之后还有 "/" 的算子文件夹,否则是本层文件。 */
const rows = $derived.by((): { folders: FolderRow[]; files: FileRow[] } | null => {
if (files === null) return null;
const folderNames = new Set<string>();
const fileRows: FileRow[] = [];
for (const f of files) {
if (!f.path.startsWith(currentDir)) continue;
const rest = f.path.slice(currentDir.length);
const slash = rest.indexOf("/");
if (slash === -1) fileRows.push({ kind: "file", name: rest, path: f.path, size: f.size });
else folderNames.add(rest.slice(0, slash));
}
const folders = [...folderNames]
.sort((a, b) => a.localeCompare(b))
.map((name): FolderRow => ({ kind: "folder", name, path: `${currentDir}${name}/` }));
fileRows.sort((a, b) => a.name.localeCompare(b.name));
return { folders, files: fileRows };
});
const breadcrumbSegs = $derived(currentDir === "" ? [] : currentDir.slice(0, -1).split("/"));
function enterFolder(path: string): void {
currentDir = path;
}
function goUp(): void {
if (currentDir === "") return;
const segs = currentDir.slice(0, -1).split("/");
segs.pop();
currentDir = segs.length === 0 ? "" : `${segs.join("/")}/`;
}
function gotoBreadcrumb(index: number): void {
currentDir = index < 0 ? "" : `${breadcrumbSegs.slice(0, index + 1).join("/")}/`;
}
async function submitNewFile(): Promise<void> {
const path = newPath.trim();
if (path === "") return;
const message = newMessage.trim();
try {
await api(`/database/api/projects/${node.id}/file`, {
method: "PUT",
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
body: message === "" ? { path, content: newContent } : { path, content: newContent, message },
});
toastOk("已创建");
showNewFile = false;
newPath = ""; newContent = ""; newMessage = "";
await loadFiles();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
function u8ToBase64(bytes: Uint8Array): string {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)) as unknown as number[]);
}
return btoa(bin);
}
/** 选文件只负责暂存与预填;真正上传在弹窗确认后。 */
function pickFile(e: Event): void {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
// 上限取后端值;拉不到就不在前端拦 —— 后端横竖会以 413 file_too_large 兼底,
// 前端这道只是省一次往返。
if (maxFileBytes !== null && file.size > maxFileBytes) {
toastErr(`文件超过 ${maxLabel} 上限`);
return;
}
pendingFile = file;
uploadPath = `${currentDir}${file.name}`;
uploadMessage = "";
}
function cancelUpload(): void {
pendingFile = null;
uploadPath = "";
uploadMessage = "";
}
async function submitUpload(): Promise<void> {
const file = pendingFile;
const targetPath = uploadPath.trim();
if (file === null || targetPath === "") return;
uploading = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const message = uploadMessage.trim();
const body: Record<string, string> = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
if (message !== "") body["message"] = message;
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
toastOk(`已上传 ${file.name}`);
cancelUpload();
await loadFiles();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
} finally {
uploading = false;
}
}
</script>
<div class="panel">
<div class="mb-1.5 flex items-center justify-between">
<div class="section-title">项目文件({files?.length ?? 0})</div>
{#if canEdit}
<div class="flex gap-1.5">
<button class="btn" onclick={() => { newPath = currentDir; showNewFile = true; }}><Icon name="plus" size={13} /> 新建文件</button>
<button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button>
<input bind:this={uploadInput} type="file" class="hidden" onchange={pickFile} />
</div>
{/if}
</div>
{#if files === null && loadError === null}
<div class="quiet py-5 text-center">加载中…</div>
{:else if loadError}
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if files && files.length === 0}
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
{:else if rows}
<!-- 地址栏:上级 + 面包屑,与视图切换同一行,windows 资源管理器的标准布局 -->
<div class="mb-2 flex items-center justify-between gap-2 border-b border-line-soft pb-2">
<div class="flex min-w-0 items-center gap-0.5 overflow-x-auto text-[12.5px] text-ink-3">
<button
class="mr-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-ink-3 disabled:opacity-30 {currentDir !== '' ? 'hover:bg-hover hover:text-ink' : ''}"
onclick={goUp}
disabled={currentDir === ""}
title="返回上级"
aria-label="返回上级"
><Icon name="arrowUp" size={13} /></button>
<button class="shrink-0 rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(-1)}>根目录</button>
{#each breadcrumbSegs as seg, i (i)}
<span class="shrink-0 text-line">/</span>
<button class="shrink-0 truncate rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(i)}>{seg}</button>
{/each}
</div>
<div class="flex shrink-0 gap-1">
<button
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'list' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
onclick={() => setViewMode("list")}
title="列表视图"
aria-label="列表视图"
aria-pressed={viewMode === "list"}
><Icon name="viewList" size={14} /></button>
<button
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'grid' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
onclick={() => setViewMode("grid")}
title="大图标视图"
aria-label="大图标视图"
aria-pressed={viewMode === "grid"}
><Icon name="viewGrid" size={14} /></button>
</div>
</div>
{#if rows.folders.length === 0 && rows.files.length === 0}
<div class="quiet py-5 text-center">此文件夹为空</div>
{:else if viewMode === "list"}
<table class="list">
<tbody>
{#each rows.folders as folder (folder.path)}
<tr
class="cursor-pointer hover:bg-hover"
role="button"
tabindex="0"
onclick={() => enterFolder(folder.path)}
onkeydown={(e) => e.key === "Enter" && enterFolder(folder.path)}
>
<td><span class="inline-flex items-center gap-2 text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="folder" size={15} /></span>{folder.name}</span></td>
<td class="file-meta text-right"></td>
</tr>
{/each}
{#each rows.files as f (f.path)}
<tr
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<td><span class="inline-flex items-center gap-2 font-mono text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="file" size={14} /></span>{f.name}</span></td>
<td class="file-meta text-right">{f.size} B</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(84px,1fr))] gap-1 py-1">
{#each rows.folders as folder (folder.path)}
<button
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center hover:bg-hover"
onclick={() => enterFolder(folder.path)}
>
<span class="text-ink-3"><Icon name="folder" size={34} /></span>
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{folder.name}</span>
</button>
{/each}
{#each rows.files as f (f.path)}
<button
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<span class="text-ink-3"><Icon name="file" size={34} /></span>
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{f.name}</span>
</button>
{/each}
</div>
{/if}
{/if}
</div>
{#if showNewFile}
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
<div class="form-row">
<label class="form-label" for="nf-path">路径</label>
<input id="nf-path" class="input font-mono" bind:value={newPath} placeholder="docs/intro.md" />
</div>
<div class="form-row">
<label class="form-label" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div>
<div class="form-row">
<label class="form-label" for="nf-msg">提交信息(可选)</label>
<input id="nf-msg" class="input" bind:value={newMessage} placeholder="留空则自动生成" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showNewFile = false)}>取消</button>
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
</div>
</Modal>
{/if}
{#if pendingFile}
<Modal title="上传文件" onclose={cancelUpload}>
<div class="form-row">
<span class="form-label">已选文件</span>
<p class="font-mono text-[12.5px] text-ink">{pendingFile.name}<span class="quiet"> · {pendingFile.size} B</span></p>
</div>
<div class="form-row">
<label class="form-label" for="up-path">保存到路径</label>
<input id="up-path" class="input font-mono" bind:value={uploadPath} placeholder="材料/课件.pptx" />
</div>
<div class="form-row">
<label class="form-label" for="up-msg">提交信息(可选)</label>
<input id="up-msg" class="input" bind:value={uploadMessage} placeholder="留空则自动生成" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={cancelUpload} disabled={uploading}>取消</button>
<button class="btn btn-primary" onclick={submitUpload} disabled={uploading || uploadPath.trim() === ""}>
{uploading ? "上传中…" : "上传"}
</button>
</div>
</Modal>
{/if}
-192
View File
@@ -1,192 +0,0 @@
<script lang="ts">
/**
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
*
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
*
* 语义(契约 8.1 / ADR-0021):
* - 创建者授权(isCreatorGrant)不可收回、不可改;
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
*/
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
let grants = $state<Grant[] | null>(null);
let error = $state<string | null>(null);
let principalType = $state<"USER" | "GROUP">("USER");
let userIdInput = $state("");
let groupId = $state("");
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
let role = $state<Role>("VIEW");
let saving = $state(false);
const canManage = $derived(node.role === "MANAGE");
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
$effect(() => {
void node.id;
void load();
});
async function load(): Promise<void> {
grants = null;
error = null;
try {
const r = await api<{ grants: Grant[] }>(`/database/api/nodes/${node.id}/grants`);
grants = r.grants;
} catch (e) {
error = errText(e);
}
}
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
async function onTypeChange(): Promise<void> {
if (principalType !== "GROUP" || groupOptions !== null) return;
try {
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
groupOptions = r.groups;
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
} catch (e) {
toastErr(errText(e));
}
}
async function addGrant(): Promise<void> {
const principalId = principalType === "GROUP" ? groupId : userIdInput.trim();
if (principalId === "") {
toastErr("请填写主体");
return;
}
saving = true;
try {
// PUT /grants 是增量语义(putGrants),不是整表替换。
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType, principalId, role }] },
});
toastOk("已授予");
userIdInput = "";
await load();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function revoke(g: Grant): Promise<void> {
if (!confirm(`收回「${g.principalName}」的 ${g.role} 授权?`)) return;
try {
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
method: "DELETE",
});
toastOk("已收回");
await load();
} catch (e) {
toastErr(errText(e));
}
}
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
await load();
} catch (e) {
toastErr(errText(e));
}
}
</script>
<div class="panel">
{#if error !== null}
<div class="py-2 text-[12.5px] text-danger">{error}</div>
{:else if grants === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else}
<table class="list">
<thead>
<tr><th>主体</th><th>级别</th><th></th></tr>
</thead>
<tbody>
{#if grants.length === 0}
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
{:else}
{#each grants as g (g.id)}
<tr>
<td>
<span class="inline-flex items-center gap-2">
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
<!-- 展示名优先(后端已解析);id 作为 title 供排查。 -->
<span class="font-medium" title={g.principalId}>{g.principalName}</span>
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span>
</td>
<td class="file-meta">{g.role}</td>
<td class="text-right">
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
{#if !g.isCreatorGrant && canManage}
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
<Icon name="minus" size={12} /> 收回
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
{#if canManage}
<div class="my-3.5 border-t border-line-soft"></div>
<div class="section-title mb-2.5">新增授权</div>
<div class="flex flex-wrap items-center gap-2">
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
<option value="USER">用户</option>
<option value="GROUP">Group</option>
</select>
{#if principalType === "USER"}
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
{:else if groupOptions === null}
<span class="quiet flex-1">加载 Group 列表…</span>
{:else if groupOptions.length === 0}
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
{:else}
<select class="select min-w-0 flex-1" bind:value={groupId}>
{#each groupOptions as g (g.id)}
<option value={g.id}>{g.breadcrumb}</option>
{/each}
</select>
{/if}
<select class="select !w-[110px]" bind:value={role}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
{saving ? "授予中…" : "授予"}
</button>
</div>
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
{/if}
</div>
-762
View File
@@ -1,762 +0,0 @@
<script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import Avatar from "./Avatar.svelte";
// 后端返回扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[]>([]);
let loaded = $state(false);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
// 折叠的组 id(默认全展开)。用数组而非 Set:$state 的深层代理只跟踪普通对象/
// 数组,Set 的变更不会触发重渲染。
let collapsedIds = $state<string[]>([]);
const isCollapsed = (id: string): boolean => collapsedIds.includes(id);
const toggleCollapsed = (id: string): void => {
collapsedIds = isCollapsed(id) ? collapsedIds.filter((x) => x !== id) : [...collapsedIds, id];
};
let filterText = $state("");
let showArchived = $state(false);
let members = $state<MemberGroupMember[]>([]);
let membersLoaded = $state(false);
let membersError = $state<string | null>(null);
let memberFilter = $state("");
const selected = $derived(groups.find((g) => g.id === selectedId) ?? null);
const isArchived = $derived(selected?.archivedAt != null);
interface Row {
readonly g: MemberGroupNode;
readonly hasKids: boolean;
/** 过滤态下:自身是否命中(祖先链上的非命中项半透明显示)。 */
readonly hit: boolean;
}
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
const rows = $derived.by((): Row[] => {
const byParent = new Map<string | null, MemberGroupNode[]>();
const byId = new Map<string, MemberGroupNode>();
for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) ?? [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
const q = filterText.trim().toLowerCase();
let keep: Set<string> | null = null;
if (q !== "") {
keep = new Set<string>();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur: MemberGroupNode | undefined = g;
while (cur !== undefined) {
keep.add(cur.id);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
}
}
const out: Row[] = [];
const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) {
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) ?? []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠的祖先藏住)。
if (keep !== null || !isCollapsed(g.id)) walk(g.id);
}
};
walk(null);
// 兜底:父不在列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
});
const treeFoot = $derived.by(() => {
const active = groups.filter((g) => g.archivedAt === null);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
return (
`${active.length} 个活跃组 · ${totalMembers} 条成员关系` +
(archivedN > 0 ? ` · ${archivedN} 个已删除` : "")
);
});
/** 面包屑:祖先链(根在前,自身在末)。 */
const chain = $derived.by((): MemberGroupNode[] => {
if (selected === null) return [];
const byId = new Map(groups.map((g) => [g.id, g]));
const out: MemberGroupNode[] = [];
for (let cur: MemberGroupNode | undefined = selected; cur !== undefined; ) {
out.unshift(cur);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
return out;
});
const childCount = $derived(groups.filter((g) => g.parentId === selectedId).length);
const shownMembers = $derived.by(() => {
const q = memberFilter.trim().toLowerCase();
if (q === "") return members;
return members.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
m.feishuOpenId.toLowerCase().includes(q),
);
});
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>(
`/database/api/groups${showArchived ? "?includeArchived=1" : ""}`,
);
groups = r.groups;
listError = null;
loaded = true;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = [];
membersLoaded = false;
}
} catch (e) {
listError = errText(e);
loaded = true;
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
membersLoaded = false;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
membersLoaded = true;
} catch (e) {
membersError = errText(e);
membersLoaded = true;
}
}
onMount(loadGroups);
function select(id: string): void {
selectedId = id;
memberFilter = "";
void loadMembers();
}
async function toggleArchived(): Promise<void> {
showArchived = !showArchived;
await loadGroups();
}
/* ---------------- 右键菜单 ---------------- */
interface MenuItem {
readonly label?: string;
readonly ic?: import("./Icon.svelte").IconName;
readonly danger?: boolean;
readonly sep?: boolean;
readonly fn?: () => void;
}
let menu = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
function openMenu(e: MouseEvent, target: MemberGroupNode | null): void {
e.preventDefault();
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items: MenuItem[] =
target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => openCreate(null) }]
: target.archivedAt !== null
? [
{ label: "查看成员(只读)", ic: "users", fn: () => select(target.id) },
{ label: "恢复此 Group", ic: "restore", fn: () => void restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => openCreate(target) },
{ label: "添加成员", ic: "user", fn: () => { select(target.id); openAddMember(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => openRename(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => void deleteGroup(target) },
];
// 贴边翻转,避免菜单溢出视口(菜单宽 184、每项约 34)。
const w = 184;
const h = items.reduce((n, it) => n + (it.sep === true ? 9 : 34), 10);
menu = {
x: Math.min(e.clientX, window.innerWidth - w - 8),
y: Math.min(e.clientY, window.innerHeight - h - 8),
items,
};
}
/* ---------------- 弹窗 ---------------- */
let showCreate = $state(false);
let createParent = $state<MemberGroupNode | null>(null);
let newName = $state("");
let newDesc = $state("");
let showRename = $state(false);
let renameTarget = $state<MemberGroupNode | null>(null);
let editName = $state("");
let editDesc = $state("");
let showAdd = $state(false);
let addQuery = $state("");
let addResults = $state<UserSearchResult[]>([]);
let addSearching = $state(false);
function openCreate(parent: MemberGroupNode | null): void {
createParent = parent;
newName = "";
newDesc = "";
showCreate = true;
}
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
const parentId = createParent?.id ?? null;
try {
await api("/database/api/groups", {
method: "POST",
body: { name, parentId, ...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}) },
});
showCreate = false;
// 建完自动展开父节点,否则新子组藏在折叠的父下面看不见。
if (parentId !== null) collapsedIds = collapsedIds.filter((x) => x !== parentId);
toastOk("已创建成员组");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
function openRename(g: MemberGroupNode): void {
renameTarget = g;
editName = g.name;
editDesc = g.description ?? "";
showRename = true;
}
async function saveRename(): Promise<void> {
if (renameTarget === null) return;
const name = editName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
try {
// description 总是回传(含空串)—— 空串即清除描述(ADR-0028 决策6)。
await api(`/database/api/groups/${encodeURIComponent(renameTarget.id)}`, {
method: "PATCH",
body: { name, description: editDesc.trim() },
});
showRename = false;
toastOk("已保存");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (
!confirm(
`删除「${g.name}」?\n\n软删除:整棵子树一并标记删除,相关授权立即失效,` +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。",
)
)
return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
if (selectedId === g.id && !showArchived) {
selectedId = null;
members = [];
membersLoaded = false;
}
toastOk(`已删除 ${r.archivedCount} 个组(软删除,可恢复)`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
async function restoreGroup(g: MemberGroupNode): Promise<void> {
// 恢复语义与删除不对称(ADR-0028 决策7):只回该组 + 已归档祖先链,子树仍归档。
if (
!confirm(
`恢复「${g.name}」?\n\n其已删除的上级会一并恢复(否则它在树上无路径);` +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。",
)
)
return;
try {
const r = await api<{ restoredCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复 ${r.restoredCount} 个组`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(errText(e));
}
}
function openAddMember(): void {
addQuery = "";
addResults = [];
showAdd = true;
}
/** 成员选择器:搜全局用户,excludeGroupId 过滤掉本组已有成员。 */
async function searchUsers(): Promise<void> {
if (selectedId === null) return;
addSearching = true;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(addQuery.trim())}` +
`&excludeGroupId=${encodeURIComponent(selectedId)}`,
);
addResults = r.users;
} catch (e) {
toastErr(errText(e));
} finally {
addSearching = false;
}
}
async function addMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body: { userId },
});
toastOk("已添加成员");
addResults = addResults.filter((u) => u.userId !== userId);
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
async function removeMember(m: MemberGroupMember): Promise<void> {
if (selectedId === null) return;
if (!confirm(`将「${m.displayName || m.userId}」移出本组?其经由本组获得的授权立即失效。`)) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(m.userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
</script>
<svelte:window
onclick={() => (menu = null)}
onkeydown={(e) => {
if (e.key === "Escape") menu = null;
}}
/>
<div class="flex h-full min-h-0 items-stretch gap-3.5">
<!-- 左:组树 -->
<div class="panel flex w-[326px] shrink-0 flex-col !p-3.5" style="min-height:0">
<div class="mb-2.5 flex items-center gap-2">
<span class="flex text-accent"><Icon name="layers" size={17} /></span>
<div class="section-title flex-1">Group 树</div>
<button class="btn btn-sm" onclick={() => openCreate(null)}>
<Icon name="plus" size={13} /> 根组
</button>
</div>
<div class="relative mb-2">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="过滤组名…" bind:value={filterText} />
</div>
<label class="switch mb-2.5 text-[11.5px] text-ink-3">
<input type="checkbox" checked={showArchived} onchange={toggleArchived} />
<span></span>
显示已删除的组
</label>
<!-- 树空白处右键 = 建根组 -->
<div
class="-mx-1.5 min-h-0 flex-1 overflow-y-auto"
role="tree"
tabindex="-1"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("[data-node]") !== null) return;
openMenu(e, null);
}}
>
{#if !loaded}
<div class="quiet px-3 py-6 text-center">加载中…</div>
{:else if listError !== null}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if groups.length === 0}
<div class="quiet flex flex-col items-center gap-2 px-3 py-[22px] text-center">
<span class="flex text-line"><Icon name="layers" size={30} /></span>
暂无成员组 · 点上方「根组」开始
</div>
{:else if rows.length === 0}
<div class="quiet px-3 py-[22px] text-center">无匹配的组</div>
{:else}
{#each rows as { g, hasKids, hit } (g.id)}
{@const arch = g.archivedAt !== null}
<div
data-node
class="flex cursor-pointer select-none items-center gap-1.5 rounded-lg py-1.5 pr-2 text-[13px]"
class:bg-selected={selectedId === g.id}
class:opacity-50={!hit}
style="padding-left: {8 + g.depth * 15}px"
role="treeitem"
aria-selected={selectedId === g.id}
tabindex="-1"
onclick={() => select(g.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(g.id);
}
}}
oncontextmenu={(e) => openMenu(e, g)}
>
{#if hasKids}
<span
class="flex w-[15px] shrink-0 justify-center text-ink-3 transition-transform"
class:rotate-90={!(isCollapsed(g.id) && filterText.trim() === "")}
role="button"
tabindex="-1"
aria-label="折叠 / 展开"
onclick={(e) => {
e.stopPropagation();
toggleCollapsed(g.id);
}}
onkeydown={(e) => {
if (e.key === "Enter") toggleCollapsed(g.id);
}}
>
<Icon name="chevron" size={13} />
</span>
{:else}
<span class="inline-block w-[15px] shrink-0"></span>
{/if}
<span class="flex" class:text-accent={selectedId === g.id && !arch} class:text-ink-3={arch || selectedId !== g.id}>
<Icon name={arch ? "archive" : "group"} size={15} />
</span>
<span class="flex-1 truncate" class:text-ink-3={arch} class:line-through={arch}>{g.name}</span>
<span class="tag shrink-0" class:opacity-70={arch}>
<Icon name="user" size={10} />{g.memberCount}
</span>
{#if arch}
<span class="tag shrink-0 !text-[10px] opacity-85">已删除</span>
{/if}
</div>
{/each}
{/if}
</div>
<div class="section-note mt-2 border-t border-line-soft pt-2">{loaded ? treeFoot : ""}</div>
</div>
<!-- 右:成员表 -->
<div class="panel flex min-h-0 min-w-0 flex-1 flex-col !p-0">
{#if selected === null}
<div class="quiet m-auto flex flex-col items-center gap-2.5 p-7 text-center">
<span class="flex text-line"><Icon name="users" size={40} /></span>
从左侧选择一个 Group 查看成员
</div>
{:else}
{#if isArchived}
<!-- 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。 -->
<div
class="flex shrink-0 items-center gap-2.5 border-b border-line-soft bg-hover px-[18px] py-2.5 text-[12.5px]"
>
<span class="flex text-ink-3"><Icon name="archive" size={15} /></span>
<span class="flex-1">
此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限
</span>
<button class="btn !text-xs" onclick={() => void restoreGroup(selected)}>
<Icon name="restore" size={13} /> 恢复
</button>
</div>
{/if}
<div class="shrink-0 border-b border-line-soft px-[18px] pb-3 pt-4">
<div class="mb-1.5 text-xs">
{#each chain as c, i (c.id)}
{#if i > 0}<span class="mx-[5px] text-ink-3">/</span>{/if}
<span class={i === chain.length - 1 ? "font-medium text-ink" : "text-ink-3"}>{c.name}</span>
{/each}
</div>
<div class="flex items-center gap-2.5">
<span class="flex" class:text-ink-3={isArchived} class:text-accent={!isArchived}>
<Icon name={isArchived ? "archive" : "group"} size={20} />
</span>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold" class:text-ink-3={isArchived}>{selected.name}</div>
{#if selected.description !== null && selected.description !== ""}
<div class="section-note mt-0.5">{selected.description}</div>
{:else}
<div class="section-note mt-0.5 opacity-60">无描述</div>
{/if}
</div>
<!-- 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。 -->
{#if !isArchived}
<button class="btn !text-xs" onclick={() => openRename(selected)}>
<Icon name="pencil" size={13} /> 编辑
</button>
<button class="btn btn-primary !text-xs" onclick={openAddMember}>
<Icon name="plus" size={13} /> 添加成员
</button>
{/if}
</div>
<div class="mt-3 flex gap-4 text-xs text-ink-3">
<span class="inline-flex items-center gap-1"><Icon name="user" size={12} />{members.length} 名成员</span>
<span class="inline-flex items-center gap-1"><Icon name="layers" size={12} />层级 {selected.depth}</span>
<span class="inline-flex items-center gap-1"><Icon name="group" size={12} />{childCount} 个子组</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5 px-[18px] py-2.5">
<div class="relative max-w-[280px] flex-1">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="搜索成员…" bind:value={memberFilter} />
</div>
<span class="file-meta">
{memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`}
</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{#if !membersLoaded}
<div class="quiet px-3 py-9 text-center">加载中…</div>
{:else if membersError !== null}
<div class="px-3 py-9 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="quiet flex flex-col items-center gap-2.5 px-3 py-9 text-center">
<span class="flex text-line"><Icon name="users" size={34} /></span>
{isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"}
</div>
{:else if shownMembers.length === 0}
<div class="quiet px-3 py-[30px] text-center">无匹配成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>飞书 openId</th>
<th>加入时间</th>
{#if !isArchived}<th class="!text-right">操作</th>{/if}
</tr>
</thead>
<tbody>
{#each shownMembers as m (m.userId)}
<tr>
<td>
<span class="inline-flex items-center gap-2.5">
<Avatar displayName={m.displayName} userId={m.userId} avatarUrl={m.avatarUrl} size={28} />
<span class="font-medium">{m.displayName || "(未命名)"}</span>
</span>
</td>
<td class="file-meta">{m.userId}</td>
<td class="file-meta">{m.feishuOpenId || "—"}</td>
<td class="file-meta">{fmtDate(m.joinedAt)}</td>
{#if !isArchived}
<td class="text-right">
<button class="link-danger inline-flex items-center gap-1" onclick={() => void removeMember(m)}>
<Icon name="minus" size={12} /> 移除
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
</div>
<!-- 右键菜单 -->
{#if menu !== null}
<div
class="fixed z-[60] min-w-[184px] rounded-[10px] border border-line bg-panel p-[5px] text-[13px] shadow-[0_4px_20px_rgba(26,26,24,.07)]"
style="left:{menu.x}px;top:{menu.y}px"
role="menu"
tabindex="-1"
>
{#each menu.items as it, i (i)}
{#if it.sep === true}
<div class="mx-1.5 my-1 h-px bg-line-soft"></div>
{:else}
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-[11px] py-[7px] hover:bg-hover"
class:text-danger={it.danger === true}
role="menuitem"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
menu = null;
it.fn?.();
}}
onkeydown={(e) => {
if (e.key === "Enter") {
menu = null;
it.fn?.();
}
}}
>
{#if it.ic !== undefined}<span class="flex opacity-75"><Icon name={it.ic} size={14} /></span>{/if}
{it.label}
</div>
{/if}
{/each}
</div>
{/if}
{#if showCreate}
<Modal
title={createParent === null ? "新建根 Group" : `在「${createParent.name}」下新建子 Group`}
onclose={() => (showCreate = false)}
>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="form-row">
<label class="form-label" for="gc-desc">描述(可选)</label>
<input id="gc-desc" class="input" bind:value={newDesc} placeholder="一句话说明" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreate = false)}>取消</button>
<button class="btn btn-primary" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
{#if showRename && renameTarget !== null}
<Modal title="重命名 / 改描述" onclose={() => (showRename = false)}>
<div class="form-row">
<label class="form-label" for="gr-name">名称</label>
<input id="gr-name" class="input" bind:value={editName} />
</div>
<div class="form-row">
<label class="form-label" for="gr-desc">描述</label>
<input id="gr-desc" class="input" bind:value={editDesc} placeholder="留空则清除描述" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showRename = false)}>取消</button>
<button class="btn btn-primary" onclick={saveRename}>保存</button>
</div>
</Modal>
{/if}
{#if showAdd && selected !== null}
<Modal title={`向「${selected.name}」添加成员`} onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="ga-q">搜索用户(姓名 / userId / 飞书 openId)</label>
<div class="flex gap-2">
<input
id="ga-q"
class="input"
bind:value={addQuery}
placeholder="留空列出全部候选"
onkeydown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
/>
<button class="btn" onclick={searchUsers}><Icon name="search" size={13} /> 搜索</button>
</div>
<div class="section-note mt-1.5">已在本组的成员不会出现在结果里。</div>
</div>
<div class="max-h-[280px] overflow-y-auto">
{#if addSearching}
<div class="quiet px-3 py-6 text-center">搜索中…</div>
{:else if addResults.length === 0}
<div class="quiet px-3 py-6 text-center">无候选用户 · 先点「搜索」</div>
{:else}
{#each addResults as u (u.userId)}
<div class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0">
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={26} />
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{u.displayName || "(未命名)"}</div>
<div class="file-meta truncate">{u.feishuOpenId || u.userId}</div>
</div>
<button class="btn btn-sm" onclick={() => void addMember(u.userId)}>
<Icon name="plus" size={12} /> 添加
</button>
</div>
{/each}
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>关闭</button>
</div>
</Modal>
{/if}
-113
View File
@@ -1,113 +0,0 @@
<script lang="ts">
/**
* 项目修改历史 tab:展示所有文件的提交记录(新→旧)。
* 默认只显示 commit message + 作者头像 + 时间,点击可展开查看修改的文件。
*/
import { api } from "./api.js";
import { toastErr } from "./stores.js";
import type { ProjectCommitInfo, NodeDetail } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let history = $state<ProjectCommitInfo[] | null>(null);
let loadError = $state<string | null>(null);
let limit = $state(50);
let expanded = $state<Set<string>>(new Set());
async function loadHistory(): Promise<void> {
try {
const r = await api<{ history: ProjectCommitInfo[] }>(
`/database/api/projects/${node.id}/history?limit=${limit}`,
);
history = r.history;
loadError = null;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
}
}
$effect(() => {
void node.id;
void loadHistory();
});
function toggle(version: string): void {
const next = new Set(expanded);
if (next.has(version)) next.delete(version);
else next.add(version);
expanded = next;
}
function loadMore(): void {
limit += 50;
void loadHistory();
}
function authorInitial(author: string | undefined): string {
return (author ?? "?").slice(0, 1).toUpperCase();
}
</script>
<div class="panel">
<div class="mb-3 section-title">修改历史</div>
{#if history === null && loadError === null}
<div class="quiet py-5 text-center">加载中…</div>
{:else if loadError}
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if history && history.length === 0}
<div class="quiet py-5 text-center">暂无提交记录</div>
{:else if history}
<div>
{#each history as commit (commit.version)}
{@const isOpen = expanded.has(commit.version)}
<div class="border-t border-line-soft first:border-t-0">
<button
class="flex w-full items-center gap-2.5 py-3 text-left transition hover:bg-hover rounded-md px-1.5 -mx-1.5"
onclick={() => toggle(commit.version)}
aria-expanded={isOpen}
>
<!-- 头像 -->
<span
class="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent text-[12px] font-semibold text-white"
aria-hidden="true"
>{authorInitial(commit.author)}</span>
<!-- 消息与时间 -->
<div class="min-w-0 flex-1">
<p class="text-[13px] text-ink">{commit.message}</p>
<p class="mt-0.5 text-[11.5px] text-ink-3">
{#if commit.author}<span>{commit.author}</span> · {/if}{new Date(commit.committedAt).toLocaleString("zh-CN")}
</p>
</div>
<!-- 展开指示 -->
<span class="shrink-0 text-ink-3 transition {isOpen ? 'rotate-90' : ''}">
<Icon name="chevron" size={12} />
</span>
</button>
{#if isOpen}
<div class="pb-3 pl-[46px]">
<div class="flex flex-wrap gap-1">
{#each commit.files as filePath (filePath)}
<span class="inline-flex items-center gap-1 rounded-md bg-hover px-1.5 py-0.5 font-mono text-[11px] text-ink-2">
<Icon name="file" size={10} />{filePath}
</span>
{/each}
{#if commit.files.length === 0}
<span class="text-[11.5px] text-ink-3">无文件变更信息</span>
{/if}
</div>
</div>
{/if}
</div>
{/each}
</div>
{#if history.length >= limit}
<div class="mt-3 flex justify-center">
<button class="btn" onclick={loadMore}>加载更多</button>
</div>
{/if}
{/if}
</div>
-48
View File
@@ -1,48 +0,0 @@
<script lang="ts" module>
// 从已删除的 routes/adminPanels.ts 的 GROUP_ICONS 原样搬来(ADR-0029)。
export const ICONS = {
// Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录,与文件库的
// FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。
group:
"M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06",
users:
"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
plus: "M12 5v14M5 12h14",
pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z",
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z",
chevron: "m9 18 6-6-6-6",
layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5",
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2",
minus: "M5 12h14",
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3",
// Windows 资源管理器式文件浏览:文件夹/文件项与大小图标切换用。
folder: "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",
file: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z M14 2v4a2 2 0 0 0 2 2h4",
viewList: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01",
viewGrid: "M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h7v7h-7v-7Z",
arrowUp: "M12 19V5M5 12l7-7 7 7",
} as const;
export type IconName = keyof typeof ICONS;
</script>
<script lang="ts">
let { name, size = 16 }: { name: IconName; size?: number } = $props();
</script>
<svg
style="width:{size}px;height:{size}px"
class="shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><path d={ICONS[name]} /></svg>
-161
View File
@@ -1,161 +0,0 @@
<script lang="ts">
/**
* 文件库浏览器(树 + 详情 + 文件编辑栏)。
*
* 两处复用:老师端 /app(showUserFooter=true,侧栏底部带身份与退出)
* 与管理后台 /database/dashboard/library(false —— 外层壳已有身份区)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { treeVersion, bumpTree, currentNode, breadcrumb, selectedFilePath, clearSelectedFile, bumpFiles, restoreNodeId } from "./browser.js";
import type { NodeChild, NodeDetail, BreadcrumbEntry } from "./types.js";
import TreeNode from "./TreeNode.svelte";
import NodeDetailPanel from "./NodeDetailPanel.svelte";
import FileEditor from "./FileEditor.svelte";
import Modal from "./Modal.svelte";
let { showUserFooter = false }: { showUserFooter?: boolean } = $props();
let roots = $state<NodeChild[] | null>(null);
let treeError = $state<string | null>(null);
let showCreateRoot = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
async function loadRoots(): Promise<void> {
try {
const r = await api<{ nodes: NodeChild[] }>("/database/api/nodes");
roots = r.nodes;
treeError = null;
} catch (e) {
treeError = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
await loadRoots();
// 刷新后恢复之前选中的节点。
if (restoreNodeId) {
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${restoreNodeId}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${restoreNodeId}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch {
// 节点已删除或无权访问,静默忽略。
}
}
});
$effect(() => {
void $treeVersion;
void loadRoots();
});
async function createRoot(): Promise<void> {
const name = newName.trim();
if (name === "") return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: null,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateRoot = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
const initial = $derived((($me?.displayName ?? $me?.userId) ?? "U").slice(0, 1).toUpperCase());
</script>
<div class="flex min-h-0 flex-1">
<!-- 侧栏 -->
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">文件库</span>
{#if $me?.isWebsiteAdmin}
<button class="btn btn-sm" onclick={() => (showCreateRoot = true)}>
+ 根目录
</button>
{/if}
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if roots === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if treeError}
<div class="px-3 py-6 text-center text-xs text-danger">{treeError}</div>
{:else if roots.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
</div>
{:else}
{#each roots as node (node.id)}
<TreeNode {node} depth={0} />
{/each}
{/if}
</div>
{#if showUserFooter}
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<!-- 展示名优先;/me 取不到 User 行时后端已回落为 userId。 -->
<span class="flex-1 truncate text-ink" title={$me?.userId ?? ""}>{$me?.displayName ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{/if}
</aside>
<!-- 主区 -->
<main class="flex-1 overflow-y-auto">
<NodeDetailPanel />
</main>
<!-- 文件编辑器(模态框) -->
{#if $selectedFilePath && $currentNode?.kind === "PROJECT"}
<FileEditor
projectId={$currentNode.id}
path={$selectedFilePath}
role={$currentNode.role}
onchanged={bumpFiles}
onclose={clearSelectedFile}
/>
{/if}
</div>
{#if showCreateRoot}
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
<div class="form-row">
<label class="form-label" for="root-name">名称</label>
<input id="root-name" class="input" bind:value={newName} placeholder="例如:物理教研" />
</div>
<div class="form-row">
<label class="form-label" for="root-kind">类型</label>
<select id="root-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="btn btn-primary" onclick={createRoot}>创建</button>
</div>
</Modal>
{/if}
-43
View File
@@ -1,43 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { loadConfig, type AppConfig } from "./config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold tracking-wide text-ink">文件库</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">课程资源与教研文件,一处安放,随处可查</p>
{#if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a href="/app/dev-login-teacher" data-sveltekit-reload class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
-16
View File
@@ -1,16 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
</script>
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/30 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
>
<div class="w-full max-w-[440px] rounded-2xl border border-line-soft bg-panel p-6 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="mb-4 text-[15px] font-semibold">{title}</div>
{@render children()}
</div>
</div>
@@ -1,188 +0,0 @@
<script lang="ts">
import { api } from "./api.js";
import { currentNode, breadcrumb, bumpTree, clearSelectedFile, activeTab, restoreTab } from "./browser.js";
import { toastOk, toastErr } from "./stores.js";
import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import HistoryPanel from "./HistoryPanel.svelte";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
type Tab = "detail" | "files" | "history" | "grants";
const validTabs: readonly Tab[] = ["detail", "files", "history", "grants"];
let tab = $state<Tab>((validTabs.includes(restoreTab as Tab) ? restoreTab as Tab : "detail"));
/** 跟踪上一次见到的 node id,用于判断是否真正切换了节点。 */
let prevNodeId: string | null = null;
/** 首次恢复时不重置 tab。 */
let restoredOnce = restoreTab !== null;
let showCreateChild = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
let newDesc = $state("");
const node = $derived($currentNode);
const crumbs = $derived($breadcrumb);
const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;修改历史仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (node?.kind === "PROJECT") out.push(["history", "修改历史"]);
if (canManage) out.push(["grants", "授权"]);
return out;
});
$effect(() => {
const id = node?.id ?? null;
// node 还未加载或与上次相同时不做任何事。
if (id === null || id === prevNodeId) return;
prevNodeId = id;
if (restoredOnce) {
// 首次恢复(刷新后)保持 persisted tab,但要确保 tab 对当前节点有效。
restoredOnce = false;
const isProject = node?.kind === "PROJECT";
if ((tab === "files" || tab === "history") && !isProject) tab = "detail";
if (tab === "grants" && node?.role !== "MANAGE") tab = "detail";
} else {
tab = "detail";
}
clearSelectedFile();
});
// tab 变化时同步到持久化 store。
$effect(() => {
activeTab.set(tab);
});
async function createChild(): Promise<void> {
const name = newName.trim();
if (name === "" || node === null) return;
try {
await api("/database/api/nodes", {
method: "POST",
body: {
parentId: node.id,
kind: newKind,
name,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建");
showCreateChild = false;
newName = ""; newKind = "FOLDER"; newDesc = "";
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function renameNode(): Promise<void> {
if (node === null) return;
const name = prompt("新名称", node.name);
if (name === null) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "PATCH", body: { name } });
toastOk("已重命名");
bumpTree();
currentNode.update((n) => (n ? { ...n, name } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function deleteNode(): Promise<void> {
if (node === null || !confirm(`确认删除「${node.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
toastOk("已删除");
currentNode.set(null);
bumpTree();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
{#if node === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个文件夹或项目</div>
{:else}
<div class="mx-auto max-w-[880px] px-9 py-9">
<div class="mb-2 text-[12.5px] text-ink-3">
{#each crumbs as c, i (i)}
{#if i > 0}<span class="mx-1 text-line">/</span>{/if}
<span>{c.name ?? "…"}</span>
{/each}
</div>
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name}
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{node.role}</span>
</div>
<div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"}
<button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if}
{#if canManage}
<button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
<button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
{/if}
</div>
</div>
<div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
{#each tabs as [id, label] (id)}
<button
class="-mb-px border-b-2 px-3.5 py-2 text-[13px] transition {tab === id
? 'border-accent font-semibold text-ink'
: 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id)}
>{label}</button>
{/each}
</div>
{#if tab === "grants"}
<GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} />
{:else if tab === "history" && node.kind === "PROJECT"}
<HistoryPanel {node} />
{:else}
<OverviewPanel {node} />
{#if node.kind === "FOLDER"}
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
{/if}
{/if}
</div>
{/if}
{#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<div class="form-row">
<label class="form-label" for="child-name">名称</label>
<input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
</div>
<div class="form-row">
<label class="form-label" for="child-kind">类型</label>
<select id="child-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="form-row">
<label class="form-label" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="btn btn-primary" onclick={createChild}>创建</button>
</div>
</Modal>
{/if}
@@ -1,188 +0,0 @@
<script lang="ts">
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null);
let exporting = $state(false);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
/** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
$effect(() => {
void node.id;
exportJob = null;
exporting = false;
});
function openEditDesc(): void {
descDraft = node.description ?? "";
showEditDesc = true;
}
async function saveDesc(): Promise<void> {
const description = descDraft.trim();
try {
await api(`/database/api/nodes/${node.id}`, {
method: "PATCH",
body: { description: description === "" ? null : description },
});
toastOk("简介已保存");
showEditDesc = false;
const next = description === "" ? null : description;
currentNode.update((n) => (n && n.id === node.id ? { ...n, description: next } : n));
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
/**
* 导出 PDF:提交 job → 轮询 → 完成即自动触发浏览器下载。
*
* 只有一个导出目标,所以不给目标选择器 —— target 由后端 adapter 固定。
* 下载走 <a download> 而非 fetch+blob:接口是 same-origin cookie 认证,
* 浏览器直接带上会话,不需要在 JS 里搬一遍字节。
*/
async function exportPdf(): Promise<void> {
if (exporting) return;
exporting = true;
exportJob = null;
try {
const r = await api<{ jobId: string; status: string }>(`/database/api/projects/${node.id}/exports`, {
method: "POST",
body: { target: "pdf" },
});
await pollExport(r.jobId);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
exporting = false;
}
}
async function pollExport(jobId: string): Promise<void> {
for (;;) {
await new Promise((r) => setTimeout(r, 800));
let job: ExportJob;
try {
job = await api<ExportJob>(`/database/api/exports/${jobId}`);
} catch (e) {
exporting = false;
toastErr(e instanceof Error ? e.message : String(e));
return;
}
exportJob = job;
if (job.status === "DONE") {
exporting = false;
toastOk("导出完成,开始下载");
triggerDownload(`/database/api/exports/${job.id}/download`);
return;
}
if (job.status === "FAILED") {
exporting = false;
toastErr(`导出失败:${job.error ?? "未知原因"}`);
return;
}
}
}
function triggerDownload(url: string): void {
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
}
</script>
<div class="panel">
<div class="mb-4">
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
<div class="text-[13.5px] leading-7 text-ink">
{#if node.description}
{node.description}
{:else}
<span class="italic text-ink-3">暂无简介</span>
{/if}
{#if canEdit}
<button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if}
</div>
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<div>类型 <b class="font-semibold text-ink">{node.kind === "PROJECT" ? "项目" : "文件夹"}</b></div>
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleString("zh-CN")}</b></div>
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div>
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-wrap items-center gap-2.5">
<span class="quiet">独立权限</span>
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
{#if canManage}
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
{/if}
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2">
<button class="btn" onclick={exportPdf} disabled={exporting}>
<Icon name="download" size={13} />
{exporting ? "导出中…" : "导出 PDF"}
</button>
{#if exportJob?.status === "DONE"}
<span class="file-meta">
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download" download>重新下载</a>
</span>
{:else if exportJob?.status === "FAILED"}
<span class="file-meta text-danger">失败:{exportJob.error ?? "未知原因"}</span>
{/if}
</div>
{/if}
</div>
{#if showEditDesc}
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
<div class="form-row">
<label class="form-label" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="input !leading-7" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div>
</Modal>
{/if}
-11
View File
@@ -1,11 +0,0 @@
<script lang="ts">
import { toasts } from "./stores.js";
</script>
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each $toasts as t (t.id)}
<div class="max-w-[340px] rounded-lg px-4 py-2 text-sm text-white {t.kind === 'err' ? 'bg-[#7E2C26]' : 'bg-[#333230]'}">
{t.message}
</div>
{/each}
</div>
-82
View File
@@ -1,82 +0,0 @@
<script lang="ts">
import TreeNode from "./TreeNode.svelte";
import { api } from "./api.js";
import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js";
import { toastErr } from "./stores.js";
import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js";
let { node, depth }: { node: NodeChild; depth: number } = $props();
let children = $state<NodeChild[] | null>(null);
const isOpen = $derived($expanded.has(node.id));
const isSelected = $derived($currentNode?.id === node.id);
// 树刷新信号(增/删/移/重命名)→ 失效子节点缓存,展开状态下随之重载
$effect(() => {
void $treeVersion;
children = null;
});
$effect(() => {
if (isOpen && node.kind === "FOLDER" && children === null) {
api<{ nodes: NodeChild[] }>(`/database/api/nodes?parentId=${encodeURIComponent(node.id)}`)
.then((r) => (children = r.nodes))
.catch((e) => toastErr(e instanceof Error ? e.message : String(e)));
}
});
async function select(): Promise<void> {
if (node.kind === "FOLDER") toggleExpanded(node.id);
try {
const [detail, crumb] = await Promise.all([
api<{ node: NodeDetail }>(`/database/api/nodes/${node.id}`),
api<{ breadcrumb: BreadcrumbEntry[] }>(`/database/api/nodes/${node.id}/breadcrumb`),
]);
currentNode.set(detail.node);
breadcrumb.set(crumb.breadcrumb);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<div>
<div
class="tree-item flex cursor-pointer items-center gap-1 rounded-lg px-1.5 py-1.5 select-none {isSelected ? 'bg-selected' : 'hover:bg-hover'}"
role="button"
tabindex="0"
onclick={select}
onkeydown={(e) => e.key === "Enter" && select()}
>
<span class="flex h-4 w-4 shrink-0 items-center justify-center text-ink-3">
{#if node.kind === "FOLDER"}
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
{#if isOpen}<path d="M6 9l6 6 6-6z" />{:else}<path d="M9 6l6 6-6 6z" />{/if}
</svg>
{/if}
</span>
<span class="flex h-4 w-4 shrink-0 items-center justify-center {node.kind === 'PROJECT' ? 'text-ink' : 'text-ink-3'}">
{#if node.kind === "PROJECT"}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
{/if}
</span>
<span class="truncate">{node.name}</span>
{#if node.role !== "MANAGE"}
<span class="ml-auto pr-1 font-mono text-[10px] text-ink-3">{node.role}</span>
{/if}
</div>
{#if node.kind === "FOLDER" && isOpen}
<div class="ml-[15px] border-l border-guide pl-1">
{#if children === null}
<div class="px-3 py-1.5 text-xs text-ink-3"></div>
{:else}
{#each children as child (child.id)}
<TreeNode node={child} depth={depth + 1} />
{/each}
{/if}
</div>
{/if}
</div>
-49
View File
@@ -1,49 +0,0 @@
/** 与 /database/api/* 的约定一致;401 时清空会话(回到登录视图)。 */
import { me } from "./stores.js";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "ApiError";
}
}
export class UnauthenticatedError extends Error {
constructor() {
super("unauthenticated");
this.name = "UnauthenticatedError";
}
}
interface RequestOpts {
readonly method?: string;
readonly body?: unknown;
}
export async function api<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
const res = await fetch(path, {
credentials: "same-origin",
method: opts.method ?? "GET",
...(opts.body !== undefined
? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts.body) }
: {}),
});
if (res.status === 401) {
me.set(null);
throw new UnauthenticatedError();
}
if (res.status === 204) return null as T;
const text = await res.text();
const data = text === "" ? null : (JSON.parse(text) as unknown);
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | null)?.error ?? {};
throw new ApiError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err as Record<string, unknown>);
}
return data as T;
}
-79
View File
@@ -1,79 +0,0 @@
import { writable, get } from "svelte/store";
import type { BreadcrumbEntry, NodeDetail } from "./types.js";
const STORAGE_KEY = "filelib.browser";
interface PersistedState {
expanded: string[];
currentNodeId: string | null;
tab: string | null;
selectedFilePath: string | null;
}
function loadPersisted(): PersistedState {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as PersistedState;
} catch { /* ignore */ }
return { expanded: [], currentNodeId: null, tab: null, selectedFilePath: null };
}
function savePersisted(): void {
try {
const state: PersistedState = {
expanded: [...get(expanded)],
currentNodeId: get(currentNode)?.id ?? null,
tab: get(activeTab),
selectedFilePath: get(selectedFilePath),
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch { /* sessionStorage 不可用时静默忽略 */ }
}
const persisted = loadPersisted();
/** 树展开集合 / 当前选中节点 / 面包屑 / 树刷新计数。 */
export const expanded = writable<Set<string>>(new Set(persisted.expanded));
export const currentNode = writable<NodeDetail | null>(null);
export const breadcrumb = writable<BreadcrumbEntry[]>([]);
export const treeVersion = writable(0);
/** 刷新后需要恢复的节点 ID;LibraryView onMount 消费后清空。 */
export const restoreNodeId = persisted.currentNodeId;
/** 刷新后需要恢复的 tab;NodeDetailPanel 消费。 */
export const restoreTab = persisted.tab;
/** 右侧预览栏:当前选中文件路径(项目内);切换节点时清空。 */
export const selectedFilePath = writable<string | null>(persisted.selectedFilePath);
/** 文件列表刷新计数(编辑器保存/删除后 bump,列表随之重载)。 */
export const filesVersion = writable(0);
/** 当前激活的 tab(由 NodeDetailPanel 写入,持久化用)。 */
export const activeTab = writable<string | null>(persisted.tab);
// 订阅需要持久化的 store,变化时写 sessionStorage。
expanded.subscribe(() => savePersisted());
currentNode.subscribe(() => savePersisted());
selectedFilePath.subscribe(() => savePersisted());
activeTab.subscribe(() => savePersisted());
export function bumpTree(): void {
treeVersion.update((v) => v + 1);
}
export function bumpFiles(): void {
filesVersion.update((v) => v + 1);
}
export function clearSelectedFile(): void {
selectedFilePath.set(null);
}
export function toggleExpanded(id: string): void {
expanded.update((set) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
-25
View File
@@ -1,25 +0,0 @@
/**
* 前端 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;
/** 单文件上传上限(字节)。后端 `HUB_FILELIB_MAX_FILE_BYTES` 的生效值。 */
readonly maxFileBytes: number;
}
let cached: AppConfig | null = null;
export async function loadConfig(): Promise<AppConfig> {
if (cached !== null) return cached;
cached = await api<AppConfig>("/database/config");
return cached;
}
-34
View File
@@ -1,34 +0,0 @@
/**
* 会话装载:GET /database/api/me 一次,结果进 `me` store。
* 老师端与管理后台共用 —— 两处的区别只是拿到 me 之后怎么用
* (老师端未登录显示登录视图;管理后台未登录跳 /database/admin,
* 非 isWebsiteAdmin 显示无权提示)。
*/
import { get } from "svelte/store";
import { api, UnauthenticatedError } from "./api.js";
import { me, authChecked } from "./stores.js";
import type { MeResponse } from "./types.js";
/** 幂等:已检查过就不再打请求(路由间切换不重复拉取)。 */
export async function loadSession(force = false): Promise<void> {
if (get(authChecked) && !force) return;
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
}
/** 退出登录:清后端 cookie 再清前端 store。 */
export async function logout(): Promise<void> {
try {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
} catch {
/* 网络失败也照样清前端状态 */
}
me.set(null);
}
-26
View File
@@ -1,26 +0,0 @@
import { writable } from "svelte/store";
import type { MeResponse } from "./types.js";
/** 当前登录身份;null = 未登录(显示登录视图)。 */
export const me = writable<MeResponse | null>(null);
export const authChecked = writable(false);
export interface ToastItem {
readonly id: number;
readonly message: string;
readonly kind: "info" | "err";
}
let nextToastId = 1;
export const toasts = writable<ToastItem[]>([]);
export function toast(message: string, kind: ToastItem["kind"] = "info"): void {
const id = nextToastId++;
toasts.update((list) => [...list, { id, message, kind }]);
setTimeout(() => {
toasts.update((list) => list.filter((t) => t.id !== id));
}, 3600);
}
export const toastOk = (m: string): void => toast(m, "info");
export const toastErr = (m: string): void => toast(m, "err");
-160
View File
@@ -1,160 +0,0 @@
/** 与后端 /database/api/* 响应形状对齐。 */
export type NodeKind = "FOLDER" | "PROJECT";
export type Role = "VIEW" | "EDIT" | "MANAGE";
export interface NodeChild {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly role: Role;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface BreadcrumbEntry {
readonly depth: number;
readonly id: string | null;
readonly name: string | null;
readonly kind: NodeKind;
}
export interface NodeDetail {
readonly id: string;
readonly parentId: string | null;
readonly kind: NodeKind;
readonly name: string;
readonly description: string | null;
readonly role: Role;
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
readonly independentPermission: boolean;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface MeResponse {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContent {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export interface VersionInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
}
/** 项目级提交历史条目(包含受影响文件路径)。 */
export interface ProjectCommitInfo {
readonly version: string;
readonly message: string;
readonly author?: string;
readonly committedAt: string;
readonly files: readonly string[];
}
export interface ExportJob {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: string;
}
/** 成员组(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;
/** 后端解析好的展示名(USER→displayName / GROUP→组名);取不到行时回落为 principalId。 */
readonly principalName: string;
readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean;
readonly createdAt: string;
}
/** Group 选择器候选(GET /database/api/groups/search)。 */
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接。 */
readonly breadcrumb: string;
}
/** 成员选择器候选(GET /database/api/users/search)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
/** 管理后台概览统计(GET /database/api/stats)。 */
export interface DashboardStats {
readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
/** ISO 串;后端 JSON 序列化后不再是 Date。 */
readonly when: string;
}>;
}
/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
export interface OrgMember {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrgRole;
readonly createdAt: string;
}
-12
View File
@@ -1,12 +0,0 @@
<script lang="ts">
import "../app.css";
import Toasts from "$lib/Toasts.svelte";
let { children } = $props();
</script>
<div class="h-full">
{@render children()}
</div>
<Toasts />
-7
View File
@@ -1,7 +0,0 @@
/**
* 纯 SPA:关掉 SSR 与预渲染,构建产物只有一个 fallback index.html
* (adapter-static + fallback,见 svelte.config.js),由 hub 后端在
* /app 与 /database/* 两个前缀下原样送出。
*/
export const ssr = false;
export const prerender = false;
-11
View File
@@ -1,11 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// 根路径不承载界面:老师端在 /app,管理后台在 /database。
onMount(() => {
void goto("/app", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -1,22 +0,0 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后直接是文件库浏览器。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import LibraryView from "$lib/LibraryView.svelte";
onMount(loadSession);
</script>
<svelte:head><title>文件库</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<div class="flex h-full flex-col">
<LibraryView showUserFooter />
</div>
{:else}
<LoginView />
{/if}
@@ -1,11 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// /database 本身不承载界面(与旧后端 /database/admin → dashboard 的跳转一致)。
onMount(() => {
void goto("/database/dashboard", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -1,63 +0,0 @@
<script lang="ts">
/**
* 管理后台登录页(迁自后端 renderLoginPage)。
* 已登录直接跳 dashboard —— 与旧后端路由 /database/admin 的行为一致。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import { loadConfig, type AppConfig } from "$lib/config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
await loadSession();
if ($me !== null) {
void goto("/database/dashboard", { replaceState: true });
return;
}
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<svelte:head><title>Database Admin · 登录</title></svelte:head>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold text-ink">Database Admin</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">使用飞书登录以管理数据库</p>
{#if !$authChecked}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{:else if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a
href="/database/dev-login"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover"
>⚡ 一键登录管理员</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
@@ -1,108 +0,0 @@
<script lang="ts">
/**
* 管理后台外壳(迁自后端 renderDashboard 的侧栏 + 身份区)。
*
* 与旧实现的区别:六个 tab 是真 URL 路由(/database/dashboard/library 等),
* 不再是 location.hash + display:none 切换 —— 刷新不丢位置,链接可分享。
*
* 权限门:未登录跳 /database/admin;登录但非 OWNER/ADMIN(isWebsiteAdmin)
* 显示无权提示。语义与 ADR-0028 一致 —— 管理面板要求 silo org 的 OWNER/ADMIN。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import Avatar from "$lib/Avatar.svelte";
let { children } = $props();
const NAV = [
{ seg: "", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ seg: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
] as const;
const BASE = "/database/dashboard";
onMount(loadSession);
/**
* 未登录一律回登录页 —— 必须是 effect 而非 onMount 里的一次性判断:
* `logout()` 只清 store(它被老师端 /app 共用,那边 me=null 是终态而非跳转),
* 退出后这层壳会重新渲染成 me===null,若跳转只写在 onMount 就永远停在
* "跳转到登录页…"。
*/
$effect(() => {
if ($authChecked && $me === null) {
void goto("/database/admin", { replaceState: true });
}
});
function href(seg: string): string {
return seg === "" ? BASE : `${BASE}/${seg}`;
}
function isActive(seg: string): boolean {
const path = page.url.pathname.replace(/\/$/, "");
return seg === "" ? path === BASE : path === `${BASE}/${seg}`;
}
</script>
<svelte:head><title>Database Admin</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">加载中…</div>
{:else if $me === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转到登录页…</div>
{:else if !$me.isWebsiteAdmin}
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[420px] rounded-2xl border border-line-soft bg-panel p-9 text-center shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<h2 class="mb-2 text-lg font-semibold text-ink">无权访问管理后台</h2>
<p class="mb-6 text-[13px] text-ink-3">
当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。
</p>
<a href="/app" class="btn btn-primary justify-center">前往文件库</a>
<button class="btn mt-3 w-full justify-center" onclick={logout}>退出登录</button>
</div>
</div>
{:else}
<div class="flex h-full">
<aside class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="border-b border-line-soft px-4 py-4">
<span class="text-[15px] font-semibold text-ink">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-0.5 p-2.5">
{#each NAV as item (item.seg)}
{@const active = isActive(item.seg)}
<a
href={href(item.seg)}
class="flex items-center gap-2.5 rounded-[10px] px-3.5 py-2 text-[13px] transition"
class:bg-selected={active}
class:text-ink={active}
class:font-semibold={active}
class:text-ink-3={!active}
class:hover:bg-hover={!active}
>
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={item.icon} /></svg>
{item.label}
</a>
{/each}
</nav>
<div class="m-2.5 flex items-center gap-2.5 border-t border-line-soft px-3 py-2.5">
<Avatar displayName={$me.displayName} userId={$me.userId} avatarUrl={$me.avatarUrl} size={26} />
<p class="min-w-0 flex-1 truncate text-[12.5px] text-ink" title={$me.userId}>{$me.displayName}</p>
<button class="btn !px-2.5 !py-[3px] !text-[11px]" onclick={logout}>退出</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
{@render children()}
</div>
</div>
{/if}
@@ -1,67 +0,0 @@
<script lang="ts">
/** 概览(迁自后端 renderDashboard 的统计卡片 + 最近活动)。数据走 GET /database/api/stats。 */
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import type { DashboardStats } from "$lib/types.js";
let stats = $state<DashboardStats | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
stats = await api<DashboardStats>("/database/api/stats");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
const cards = $derived([
{ label: "文件夹", value: stats?.folders },
{ label: "项目", value: stats?.projects },
{ label: "文件", value: stats?.files },
{ label: "活跃授权", value: stats?.grants },
]);
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">概览</h1>
<p class="mb-5 text-[11.5px] text-ink-3">文件库实时数据</p>
{#if error}
<div class="panel text-[12.5px] text-danger">{error}</div>
{:else}
<div class="grid grid-cols-4 gap-4">
{#each cards as card (card.label)}
<div class="panel !px-5 !py-[18px]">
<p class="text-[12.5px] text-ink-3">{card.label}</p>
<p class="mt-1.5 text-[28px] font-semibold text-ink">{card.value ?? "—"}</p>
</div>
{/each}
</div>
<div class="panel mt-[18px]">
<h2 class="mb-2 text-[13.5px] font-semibold text-ink">最近活动</h2>
{#if stats === null}
<div class="quiet py-6 text-center">加载中…</div>
{:else if stats.recent.length === 0}
<div class="quiet py-[26px] text-center">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>
{:else}
{#each stats.recent as row (row.action + row.when + row.label)}
<div class="flex items-center gap-3 border-t border-line-soft py-2.5 text-[13px]">
<span class="tag shrink-0">{row.action}</span>
<span class="truncate text-ink">{row.label}</span>
<span class="ml-auto shrink-0 text-[11.5px] text-ink-3">{row.actor} · {fmtWhen(row.when)}</span>
</div>
{/each}
{/if}
</div>
{/if}
</section>
@@ -1,10 +0,0 @@
<script lang="ts">
/** Group 管理 tab —— MemberGroup 嵌套树(ADR-0028)。
* 外框 padding/overflow 对齐旧 `#tab-groups`(padding:20px;overflow:hidden):
* 两栏各自内部滚动,外层不滚。 */
import GroupAdmin from "$lib/GroupAdmin.svelte";
</script>
<div class="min-h-0 flex-1 overflow-hidden p-5">
<GroupAdmin />
</div>
@@ -1,8 +0,0 @@
<script lang="ts">
/** 文件库 tab —— 与老师端 /app 同一个浏览器组件,区别只在侧栏身份区由外壳提供。 */
import LibraryView from "$lib/LibraryView.svelte";
</script>
<div class="flex min-h-0 flex-1 flex-col">
<LibraryView />
</div>
@@ -1,4 +0,0 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
</section>
@@ -1,4 +0,0 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">设置</h1>
<p class="text-[12.5px] text-ink-3">设置功能建设中</p>
</section>
@@ -1,168 +0,0 @@
<script lang="ts">
/**
* 用户管理(迁自后端 adminPanels.ts renderUsersPanel)。
*
* 用户 = silo org 的成员,走平台层 /api/org/:slug/members(见 src/admin/routes/membersRoutes.ts)。
* 与 Group 管理是两套体系:MemberGroup 是全局主体、不归属 org(ADR-0028),
* 这里管的是 org 成员与其角色。
*/
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import { loadConfig } from "$lib/config.js";
import { toastOk, toastErr } from "$lib/stores.js";
import type { OrgMember, OrgRole } from "$lib/types.js";
const ROLE_LABEL: Record<OrgRole, string> = {
OWNER: "所有者",
ADMIN: "管理员",
MEMBER: "普通老师",
};
const ROLES: readonly OrgRole[] = ["OWNER", "ADMIN", "MEMBER"];
let orgSlug = $state<string | null>(null);
let members = $state<OrgMember[] | null>(null);
let error = $state<string | null>(null);
let newOpenId = $state("");
let newName = $state("");
let newRole = $state<OrgRole>("MEMBER");
let adding = $state(false);
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
async function load(): Promise<void> {
if (base === null) return;
try {
const r = await api<{ members: OrgMember[] }>(`${base}/members`);
members = r.members;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
try {
orgSlug = (await loadConfig()).orgSlug;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
async function addMember(): Promise<void> {
const feishuOpenId = newOpenId.trim();
if (feishuOpenId === "") {
toastErr("请填写用户 openId");
return;
}
if (base === null) return;
adding = true;
try {
const displayName = newName.trim();
await api(`${base}/members`, {
method: "POST",
body: { feishuOpenId, role: newRole, ...(displayName !== "" ? { displayName } : {}) },
});
newOpenId = "";
newName = "";
toastOk("已添加");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
adding = false;
}
}
async function setRole(userId: string, role: string): Promise<void> {
if (base === null) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: { role } });
toastOk("角色已更新");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
await load();
}
}
async function revoke(userId: string, displayName: string): Promise<void> {
if (base === null) return;
if (!confirm(`移除成员「${displayName || userId}」?`)) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}/revoke`, { method: "POST" });
toastOk("已移除");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-4 text-lg font-semibold text-ink">用户管理</h1>
<div class="max-w-[880px]">
<div class="panel mb-3.5">
<div class="section-title mb-2.5">添加成员</div>
<div class="flex flex-wrap items-center gap-2">
<input class="input min-w-0 flex-[2]" placeholder="用户 openId(飞书 ou_ 开头)" bind:value={newOpenId} />
<input class="input min-w-0 flex-1" placeholder="显示名(可选)" bind:value={newName} />
<select class="select !w-[130px]" bind:value={newRole}>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addMember} disabled={adding}>
{adding ? "添加中…" : "添加"}
</button>
</div>
</div>
<div class="panel">
<div class="section-title mb-2.5">成员列表</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if members === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if members.length === 0}
<div class="quiet py-[18px] text-center">暂无成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m (m.userId)}
<tr>
<td class="text-ink">{m.displayName || m.userId}</td>
<td class="file-meta">{m.userId}</td>
<td>
<select
class="select !w-[110px] !px-2 !py-[3px] !text-xs"
value={m.role}
onchange={(e) => setRole(m.userId, e.currentTarget.value)}
>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
</td>
<td class="text-right">
<button class="link-danger" onclick={() => revoke(m.userId, m.displayName)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</section>
-35
View File
@@ -1,35 +0,0 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* 老师端 /app 与管理后台 /database/* 共用这一份 SPA 构建产物,由 hub 后端静态托管
* (见 hub/src/database/static.ts)。服务端不渲染任何页面,只提供 /database/api/*。
*
* 两个关键配置:
*
* - `appDir: '_filelib'` —— 默认 `_app` 会与 admin-web 在同一个 Fastify 实例上注册的
* 根 `/_app/*` 资源路由撞车(见 hub/src/admin/static.ts),Fastify 重复路由会直接
* 在启动时抛错。改名后两套 SPA 的资源路径互不干扰。
*
* - `paths.relative: false` —— 同一份 index.html 会在不同深度的 URL 下被送出
* (`/app`、`/database/dashboard/users`),相对资源路径会解析错。必须用绝对路径。
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
appDir: '_filelib',
paths: {
base: '',
relative: false,
},
},
};
export default config;
-17
View File
@@ -1,17 +0,0 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}
}
-28
View File
@@ -1,28 +0,0 @@
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import wasm from "vite-plugin-wasm";
import { defineConfig } from "vite";
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管。
// 开发时 vite dev(:5173)把 API/认证/一键登录请求代理到后端(:8788);
// 页面路由全部由 SvelteKit 客户端路由处理,后端不参与。
const backend = "http://127.0.0.1:8788";
export default defineConfig({
plugins: [wasm(), 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,
},
},
});
+12 -207
View File
@@ -1,19 +1,18 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.35",
"dependencies": { "dependencies": {
"@alicloud/credentials": "^2.4.5", "@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15", "@alicloud/docmind-api20220711": "^1.4.15",
"@alicloud/tea-util": "^1.4.11", "@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"@larksuiteoapi/node-sdk": "^1.70.0", "@larksuiteoapi/node-sdk": "^1.70.0",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"ai": "^7.0.16", "ai": "^7.0.16",
@@ -903,22 +902,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/ajv-compiler": { "node_modules/@fastify/ajv-compiler": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
@@ -1050,83 +1033,6 @@
"ipaddr.js": "^2.1.0" "ipaddr.js": "^2.1.0"
} }
}, },
"node_modules/@fastify/send": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"escape-html": "~1.0.3",
"fast-decode-uri-component": "^1.0.1",
"http-errors": "^2.0.0",
"mime": "^3"
}
},
"node_modules/@fastify/static": {
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.2.tgz",
"integrity": "sha512-G/g18cG9tLutT/OVyN1AIsHIl9L1UwmJ+S3dkyhVpplIx0nEMicd7RGQ+uJLyhKKF4a3tTcQydccn3Mop1fX+Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
},
"node_modules/@fastify/static/node_modules/content-disposition": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/@fastify/static/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.14", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
@@ -1162,15 +1068,6 @@
"ws": "^8.19.0" "ws": "^8.19.0"
} }
}, },
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -2037,15 +1934,6 @@
"proxy-from-env": "^2.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -2085,18 +1973,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2378,6 +2254,7 @@
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -2570,7 +2447,8 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "3.0.3", "version": "3.0.3",
@@ -3069,23 +2947,6 @@
"giget": "dist/cli.mjs" "giget": "dist/cli.mjs"
} }
}, },
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -3152,6 +3013,7 @@
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"depd": "~2.0.0", "depd": "~2.0.0",
"inherits": "~2.0.4", "inherits": "~2.0.4",
@@ -3234,7 +3096,8 @@
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC",
"peer": true
}, },
"node_modules/ini": { "node_modules/ini": {
"version": "1.3.8", "version": "1.3.8",
@@ -3683,15 +3546,6 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lru-cache": {
"version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -3734,18 +3588,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -3767,30 +3609,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/moment": { "node_modules/moment": {
"version": "2.30.1", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -3974,22 +3792,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -4568,7 +4370,8 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC",
"peer": true
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
@@ -4767,6 +4570,7 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
@@ -4854,6 +4658,7 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.6" "node": ">=0.6"
} }
+4 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.36", "version": "0.0.35",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -12,7 +12,6 @@
"@alicloud/tea-util": "^1.4.11", "@alicloud/tea-util": "^1.4.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"@larksuiteoapi/node-sdk": "^1.70.0", "@larksuiteoapi/node-sdk": "^1.70.0",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"ai": "^7.0.16", "ai": "^7.0.16",
@@ -31,10 +30,10 @@
"axios": "1.18.1" "axios": "1.18.1"
} }
}, },
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).", "description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"scripts": { "scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts", "dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build && npm run filelib:build", "build": "tsc -p tsconfig.json && npm run admin:build",
"start": "npm run prisma:migrate && node dist/server.js", "start": "npm run prisma:migrate && node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit", "check": "tsc -p tsconfig.json --noEmit",
"audit:production": "npm audit --omit=dev --audit-level=high", "audit:production": "npm audit --omit=dev --audit-level=high",
@@ -49,8 +48,6 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"admin:dev": "npm run dev --prefix admin-web", "admin:dev": "npm run dev --prefix admin-web",
"admin:build": "npm run build --prefix admin-web", "admin:build": "npm run build --prefix admin-web"
"filelib:dev": "npm run dev --prefix filelib-web",
"filelib:build": "npm run build --prefix filelib-web"
} }
} }
@@ -1,6 +1,6 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER} -- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/ -- model: the platform administration control plane is a separate identity/session/
-- audit surface, intentionally not built -- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime -- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
-- reader — no guard, route, or service queries it for an authorization decision — -- reader — no guard, route, or service queries it for an authorization decision —
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships. -- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
@@ -1,92 +0,0 @@
-- File library (文件库) — 语义锚定:仓库根《文件库-接口契约.md》、.omo/文件库-开工计划.md (D11D19)。
-- 本地无 PG 时手写;有 PG 后可用 `prisma migrate diff` 核对与 schema 的一致性。
-- CreateEnum
CREATE TYPE "FileLibNodeKind" AS ENUM ('FOLDER', 'PROJECT');
CREATE TYPE "FileLibProvisionStatus" AS ENUM ('PROVISIONING', 'READY', 'FAILED');
CREATE TYPE "FileLibRole" AS ENUM ('VIEW', 'EDIT', 'MANAGE');
CREATE TYPE "FileLibPrincipalType" AS ENUM ('USER', 'GROUP');
CREATE TYPE "FileLibExportStatus" AS ENUM ('QUEUED', 'RUNNING', 'DONE', 'FAILED');
-- CreateTable
CREATE TABLE "FileLibNode" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"kind" "FileLibNodeKind" NOT NULL,
"name" TEXT NOT NULL,
"nameLower" TEXT NOT NULL,
"pathIds" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"provisionStatus" "FileLibProvisionStatus" NOT NULL DEFAULT 'READY',
"storageDir" TEXT,
"deletedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibNode_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FileLibGrant" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"principalType" "FileLibPrincipalType" NOT NULL,
"principalId" TEXT NOT NULL,
"role" "FileLibRole" NOT NULL,
"isCreatorGrant" BOOLEAN NOT NULL DEFAULT false,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
CONSTRAINT "FileLibGrant_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "FileLibProjectSettings" (
"nodeId" TEXT NOT NULL,
"independentPermissionsEnabled" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "FileLibProjectSettings_pkey" PRIMARY KEY ("nodeId")
);
CREATE TABLE "FileLibExportJob" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"nodeId" TEXT NOT NULL,
"target" TEXT NOT NULL,
"params" JSONB NOT NULL,
"status" "FileLibExportStatus" NOT NULL DEFAULT 'QUEUED',
"downloadUrl" TEXT,
"error" TEXT,
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FileLibExportJob_pkey" PRIMARY KEY ("id")
);
-- CreateIndex (schema-declared)
CREATE INDEX "FileLibNode_organizationId_parentId_deletedAt_idx" ON "FileLibNode"("organizationId", "parentId", "deletedAt");
CREATE INDEX "FileLibNode_organizationId_pathIds_idx" ON "FileLibNode"("organizationId", "pathIds");
CREATE INDEX "FileLibNode_creatorId_idx" ON "FileLibNode"("creatorId");
CREATE INDEX "FileLibGrant_organizationId_revokedAt_idx" ON "FileLibGrant"("organizationId", "revokedAt");
CREATE INDEX "FileLibGrant_principalType_principalId_revokedAt_idx" ON "FileLibGrant"("principalType", "principalId", "revokedAt");
CREATE INDEX "FileLibGrant_nodeId_revokedAt_idx" ON "FileLibGrant"("nodeId", "revokedAt");
CREATE INDEX "FileLibExportJob_organizationId_status_idx" ON "FileLibExportJob"("organizationId", "status");
-- D14:活跃兄弟节点大小写不敏感唯一。root 的 parentId 为 NULL,用 COALESCE 归入同一键空间。
CREATE UNIQUE INDEX "FileLibNode_active_sibling_name_key"
ON "FileLibNode"("organizationId", COALESCE("parentId", ''), "nameLower")
WHERE "deletedAt" IS NULL;
-- 契约 2.3:同一节点上同一 principal 至多一条活跃授权(Postgres 原生 UNIQUE 无法约束 NULL revokedAt)。
CREATE UNIQUE INDEX "FileLibGrant_active_unique"
ON "FileLibGrant"("nodeId", "principalType", "principalId")
WHERE "revokedAt" IS NULL;
-- AddForeignKey
ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibNode" ADD CONSTRAINT "FileLibNode_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "FileLibNode"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "FileLibGrant" ADD CONSTRAINT "FileLibGrant_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibProjectSettings" ADD CONSTRAINT "FileLibProjectSettings_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "FileLibExportJob" ADD CONSTRAINT "FileLibExportJob_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "FileLibNode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -1,70 +0,0 @@
-- 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;
@@ -1,2 +0,0 @@
-- 为 FileLibNode 加简介字段,老师在创建/概览页填写。
ALTER TABLE "FileLibNode" ADD COLUMN "description" TEXT;
+4 -191
View File
@@ -1,6 +1,6 @@
// Prisma schema for Curriculum Project Hub. // Prisma schema for Curriculum Project Hub.
// //
// Aligns to ADR-0001..0004, 0017. Key divergences from the // Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate: // legacy teaching-material-host-service schema, each deliberate:
// //
// - AgentSession is provider/model-bound. Provider runtime cursors such as // - AgentSession is provider/model-bound. Provider runtime cursors such as
@@ -11,8 +11,8 @@
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed // - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table. // user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy. // - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is // - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// open — add states without a schema migration war). // completeness OPEN — add states without a schema migration war).
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
@@ -50,7 +50,6 @@ model Organization {
projectGroupBindings ProjectGroupBinding[] projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[] projectSearchDocuments ProjectSearchDocument[]
fileLibNodes FileLibNode[]
@@index([status]) @@index([status])
} }
@@ -62,7 +61,7 @@ enum OrganizationStatus {
} }
/// Org-scoped membership role. Distinct from project PermissionRole and from /// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023), /// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025). /// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership { model OrganizationMembership {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -194,7 +193,6 @@ model User {
heldLocks ProjectAgentLock[] @relation("lockHolder") heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator") feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
teamMemberships TeamMembership[] teamMemberships TeamMembership[]
memberGroupMemberships MemberGroupMembership[]
externalPrincipalMemberships ExternalPrincipalMembership[] externalPrincipalMemberships ExternalPrincipalMembership[]
permissionGrants PermissionGrant[] @relation("grantCreator") permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator") roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
@@ -395,67 +393,6 @@ model TeamExternalBinding {
@@index([teamId, revokedAt]) @@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. /// Locally synchronized Feishu external principal membership.
model ExternalDirectoryConnection { model ExternalDirectoryConnection {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -986,127 +923,3 @@ model CapabilityCredentialVersion {
@@index([keyId]) @@index([keyId])
@@index([createdByUserId]) @@index([createdByUserId])
} }
// --- File library (文件库) -------------------------------------------------
//
// 独立模块,语义由仓库根《文件库-接口契约.md》(C/D 编号)与 .omo/文件库-开工计划.md
// (D11D19)锚定。与上面的 Folder/Project(hub 自己的 explorer,ADR-0021)是两套
// 体系,不复用、不互相引用。
/// 文件库目录树节点:文件夹(容器)或项目(叶子,关联 git 仓库)。
/// parentId 是树的权威关系;pathIds 是 id 编码的物化路径(派生),随 create/move
/// 在事务内维护(计划 D12;name 不入路径,rename 不重写后代)。
model FileLibNode {
id String @id
organizationId String
parentId String?
kind FileLibNodeKind
name String
/// D14:NFC+trim 的小写形式;活跃兄弟节点大小写不敏感唯一(部分唯一索引在迁移 SQL)。
nameLower String
/// id 编码物化路径,形如 "/rootId/childId/selfId"。祖先展开与前缀查询都用它。
pathIds String
/// D11:创建者不可变,自动持有 isCreatorGrant=true 的 MANAGE grant。
/// 故意不建 FK:这是不可变历史事实,不随 User 生命周期变化。
creatorId String
/// 老师可填写的简介,创建/概览页展示,通俗易懂地说明这个节点的用途。
description String?
/// 项目 provisioning 状态机(DB/git 双写协调,Metis 风险#1);文件夹恒 READY。
provisionStatus FileLibProvisionStatus @default(READY)
/// 项目仓库目录(绝对路径);文件夹为 null。
storageDir String?
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent FileLibNode? @relation("fileLibTree", fields: [parentId], references: [id], onDelete: Restrict)
children FileLibNode[] @relation("fileLibTree")
grants FileLibGrant[]
projectSettings FileLibProjectSettings?
exportJobs FileLibExportJob[]
@@index([organizationId, parentId, deletedAt])
@@index([organizationId, pathIds])
@@index([creatorId])
}
enum FileLibNodeKind {
FOLDER
PROJECT
}
enum FileLibProvisionStatus {
PROVISIONING
READY
FAILED
}
/// 文件库权限级别:MANAGE > EDIT > VIEW(契约 2.2,只取最高、无降权)。
enum FileLibRole {
VIEW
EDIT
MANAGE
}
enum FileLibPrincipalType {
USER
GROUP
}
/// 契约 2.3:grant 直接挂在节点上,最终权限 = max(个人, 递归 Group, 祖先继承)。
/// 活跃授权唯一性由迁移里的部分唯一索引保证(revokedAt IS NULL)。
model FileLibGrant {
id String @id @default(cuid())
organizationId String
nodeId String
principalType FileLibPrincipalType
principalId String
role FileLibRole
/// D11:创建者自动 grant;独立权限开关关闭时,项目级 grant 里只有它仍生效。
isCreatorGrant Boolean @default(false)
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([organizationId, revokedAt])
@@index([principalType, principalId, revokedAt])
@@index([nodeId, revokedAt])
}
/// 契约 P5/D11:项目独立权限开关。默认关闭(仅继承);关闭时项目级非创建者
/// grant 冻结不删除,重新开启即恢复。
model FileLibProjectSettings {
nodeId String @id
independentPermissionsEnabled Boolean @default(false)
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
}
enum FileLibExportStatus {
QUEUED
RUNNING
DONE
FAILED
}
/// 契约 D10:导出为异步任务。外部导出工具参数 OPEN-6,adapter 就位前先建模型。
model FileLibExportJob {
id String @id @default(cuid())
organizationId String
nodeId String
target String
params Json
status FileLibExportStatus @default(QUEUED)
downloadUrl String?
error String?
createdByUserId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
node FileLibNode @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([organizationId, status])
}
+3 -24
View File
@@ -268,30 +268,9 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
} }
}); });
// 退出登录不读 body,但调用方(curl -d、Postman、部分 HTTP 客户端)常给空 app.post("/auth/logout", async (_request, reply) => {
// POST 自动带上 Content-Type。Fastify 默认只有 JSON parser,遇到别的媒体类型 reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
// 会在解析阶段以 415 拒掉,进不到 handler —— 对一个"无输入"的端点没有意义。 return reply.status(204).send();
//
// 这里用 register 起一个封装作用域,catch-all parser 只在其中生效。
// **不要**把 parser 加到外层 app 上:admin plugin 没有 fastify-plugin 封装,
// 那样会让全站每个 POST/PUT/PATCH 都接受 form-urlencoded。而 form-urlencoded
// 是跨站 HTML form 唯一能发出的媒体类型(application/json 会触发 CORS
// preflight),"只认 JSON"本身是一层 CSRF 纵深防御,不能为了这个端点全局放掉。
await app.register(async (scope) => {
// parseAs:"string" 让 Fastify 负责读完流(否则连接不释放),这里直接丢掉内容
// —— 该端点不接受任何输入。
// "*" 只兜没有专属 parser 的媒体类型;内建 JSON parser 优先级更高,空 body
// 会被它判成 FST_ERR_CTP_EMPTY_JSON_BODY(400),所以要在本作用域内覆盖掉。
for (const mediaType of ["*", "application/json"]) {
scope.addContentTypeParser(mediaType, { parseAs: "string" }, (_request, _body, done) => {
done(null, undefined);
});
}
scope.post("/auth/logout", async (_request, reply) => {
reply.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
return reply.status(204).send();
});
}); });
app.get("/auth/feishu/complete", async (request, reply) => { app.get("/auth/feishu/complete", async (request, reply) => {
+3 -3
View File
@@ -24,10 +24,10 @@
* denied by default and re-opened only for the workspace plus named system * denied by default and re-opened only for the workspace plus named system
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The * runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* subprocess gets a minimal environment and SDK credential protection removes * subprocess gets a minimal environment and SDK credential protection removes
* provider secrets from Bash. This upholds the workspace-bounded file-op * provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* invariant (ADR-0018) without re-implementing the * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the ADR pins the invariant. * is the mechanism, the contract pins the invariant.
*/ */
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* ADR-0022 capacity dimensions. * ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by * The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
* capacity testing. This module is the single source of the dimension set shared * capacity testing. This module is the single source of the dimension set shared
* by the platform-ceiling config and the org capacity-policy service. * by the platform-ceiling config and the org capacity-policy service.
*/ */
-169
View File
@@ -1,169 +0,0 @@
# src/database/
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。
**前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/`
同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** ——
两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、
以及把构建产物托管出去。服务端不渲染任何 HTML。
后端路由:
- `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/<orgSlug>`
回调由 `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 它们。
## 开发模式:用环境变量开启一键登录
本地开发没有真实飞书 app 时,可以用环境变量开启一键登录,跳过飞书 OAuth,
直接以现有 OWNER/ADMIN 身份登入后台。**仅限开发,不是生产登录路径。**
### 怎么开
`hub/.env` 里设:
```sh
HUB_DEV_LOGIN_BYPASS="true"
```
改完重启服务(`npm run dev`,或本地手动 `npx tsx src/server.ts`)。启动日志会
打印一行 `DEV login bypass enabled: /database/dev-login ...` 作为确认。
开启后:
- `/database/config` 返回 `devLoginEnabled: true`SPA 登录页据此显示
「⚡ 一键登录管理员」按钮
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
回调完全一样的 session,然后跳到 `/database/dashboard`
### 怎么关
把值设成 `false`(或 `0` / `no` / `off`),或删掉这一行。关闭后按钮消失、
`/database/dev-login` 返回 404 —— 按钮和端点同进同退。
### 双重门禁(重要)
真正的开关是两个条件的**与**(判断在 `plugin.ts`):
```
allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
```
即:**只要 `NODE_ENV=production`,无论 `HUB_DEV_LOGIN_BYPASS` 设成什么,一键登录
都强制关闭。** 生产始终只能走真实飞书 OAuth。
> 提醒:`HUB_DEV_LOGIN_BYPASS` 是敏感开关,别把开着它的 `.env` 带到任何联网 /
> 共享环境。整个旁路逻辑自包含在本目录(`plugin.ts` + `routes/databaseRoutes.ts`),
> `src/admin` 的登录路由未受影响。
## 文件
| 文件 | 职责 |
|------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | `/database/config``/database/api/stats`、dev 旁路 + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app``/database` 两个 SPA 回退 |
| `filelib/` | 文件库领域层(见下) |
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/api/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。
## 文件库(filelib/)
独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11D19 等)
出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`,
内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN)
另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是
两套体系,不复用。**
| 文件 | 职责 |
|------|------|
| `filelib/model.ts` | 角色秩(MANAGE>EDIT>VIEW)、D14 命名规则、FileLibError |
| `filelib/permission.ts` | 纯权限 reducer(取最高/不降权/祖先继承/D11 冻结),不碰 IO |
| `filelib/treeService.ts` | 树增删改查;每个写操作同事务落审计 |
| `filelib/grantService.ts` | 授权管理 + 契约 8.1 矩阵强制 + force_adjust |
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(**仅测试用**,ADR-0030) |
| `filelib/gitVersionStore.ts` | **生产** C1 实现:一项目一 git 仓库,VersionId = commit hash(ADR-0030) |
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
| `filelib/routeShared.ts` | 路由共享件(依赖装配/错误映射/请求体校验) |
环境变量:
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
## 存储布局(ADR-0030)
- **文件夹不落盘。** `FOLDER` 节点的 `storageDir` 永为 `NULL`,磁盘上不存在任何
对应目录;树形只由 `parentId` + `pathIds`(id 编码的物化路径)表达。
- **项目扁平、以 id 命名。** 仓库就是 `<storageRoot>/<nodeId>`(nodeId 是 uuid)。
名字不进路径 —— 这是 rename 不动磁盘、也不重写后代路径的原因。
- **一项目一真 git 仓库。** `init` 建目录 + `git init`;`VersionId` 是 40 位 commit
hash;某文件的版本 = `git log -1 -- <path>`,所以一个文件的提交不会使另一个
文件的 `baseVersion` 失效(D16)。删除也是一个 commit,旧版本仍可读。
- git 用 `execFile` 调系统二进制,不引依赖。**每次调用都禁 hooks、隔离全局/系统
gitconfig、`GIT_LITERAL_PATHSPECS=1`** —— 项目仓库是老师上传的**数据**而非可信代码。
- 宿主必须有 `git`;缺失时建项目报 `provision_failed`
- 写入仅**进程内**串行化。共享 storage root 的多进程会在同一仓库上竞争 git 锁;
alpha Silo 是一 org 一进程(ADR-0025),目前不可达。
关键语义速查:
- **D8**:无权限 → 404(不泄露存在性);越权 → 403;Group 服务故障 → 503
- **D11**:creator 不可变 + 自动 MANAGE;独立权限关闭时项目级非创建者 grant 冻结
- **D12**:move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁
- **D15**:删除只打标本节点,"任一祖先已删"即整支不可见
- **8.1**:MANAGE 仅创建者可授/收;creator grant 不可动
- **审计**:一切写操作在业务事务内写 AuditEntry(同事务,失败即回滚);
文件内容写先 versionStore.commit 再审计(宁多版本,不造假审计)
## 约定(与 admin 面一致)
1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。
2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑
`requireSession` / `requireOrgRole` / `requireProjectPermission`
(都在 `../admin/auth/guards.js`)。
3. **租户隔离**ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`
不得跨 org。禁止无鉴权的数据路由。
4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`);
不要在这里 `new PrismaClient()`
## 为什么代码在 `src/` 下
`tsconfig.json` 固定 `rootDir: "src"``include: ["src/**/*.ts"]`。只有
`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch``npm run dev`)加载。放在
`src/` 之外的目录不会被构建,外部识别不到。
-79
View File
@@ -1,79 +0,0 @@
/**
* 文件库审计 sink(契约 C3 的入驻适配)。
*
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
*/
import type { Prisma } from "@prisma/client";
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
export const FILE_LIB_AUDIT_ACTIONS = {
folderCreate: "folder.create",
folderRename: "folder.rename",
folderMove: "folder.move",
folderDelete: "folder.delete",
projectCreate: "project.create",
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
independentEnable: "project.independent_permission.enable",
independentDisable: "project.independent_permission.disable",
independentChange: "project.independent_permission.change",
fileUpload: "file.upload",
fileRename: "file.rename",
fileDelete: "file.delete",
fileCommit: "file.commit",
fileConflictDetected: "file.conflict_detected",
exportRun: "export.run",
adminForceAdjust: "admin.force_adjust",
// ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
groupCreate: "group.create",
groupUpdate: "group.update",
groupDelete: "group.delete",
groupRestore: "group.restore",
groupMemberAdd: "group.member_add",
groupMemberRemove: "group.member_remove",
} as const;
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
export interface FileLibAuditEntry {
readonly action: string;
readonly actorUserId: string;
readonly organizationId: string;
readonly objectType: FileLibAuditObjectType;
readonly objectId: string;
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
readonly objectPath: string;
readonly detail?: Record<string, unknown> | undefined;
}
/**
* 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
* (需求 5.1"操作成功则日志必存在"的强保证)。
*/
export async function writeFileLibAudit(
tx: Prisma.TransactionClient,
entry: FileLibAuditEntry,
): Promise<void> {
const metadata: Record<string, unknown> = {
objectType: entry.objectType,
objectId: entry.objectId,
objectPath: entry.objectPath,
...(entry.detail ?? {}),
};
await tx.auditEntry.create({
data: {
action: entry.action,
actorUserId: entry.actorUserId,
organizationId: entry.organizationId,
metadata: metadata as Prisma.InputJsonValue,
},
});
}
-340
View File
@@ -1,340 +0,0 @@
/**
* 导出(契约 D10):异步任务 + 状态机 QUEUED → RUNNING → DONE/FAILED。
*
* ExportAdapter 是外部导出工具的 port(参数清单 OPEN-6,真身到位后替换)。
* 当前 stub 适配器产出"文件清单 manifest"文本,证明状态机端到端可跑;
* 产物存进程内存(v1 stub;生产应落对象存储/磁盘 —— 见 OPEN 清单)。
*/
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { requireAccessInTx, type FileLibActor } from "./treeService.js";
import type { FileDeps } from "./fileService.js";
const execFileAsync = promisify(execFile);
export interface ExportAdapterInput {
readonly storageDir: string;
readonly target: string;
readonly params: Record<string, unknown>;
readonly listFiles: (prefix?: string) => Promise<readonly { path: string; size: number }[]>;
readonly readFile: (path: string) => Promise<Buffer>;
}
export interface ExportArtifact {
readonly filename: string;
readonly content: Buffer;
}
export interface ExportAdapter {
readonly target: string;
run(input: ExportAdapterInput): Promise<ExportArtifact>;
}
/** stub 适配器:生成项目文件清单,端到端验证 job 状态机。OPEN-6 后换真导出工具。 */
export function createManifestStubAdapter(versionStore: FileDeps["versionStore"]): ExportAdapter {
return {
target: "manifest",
async run(input) {
const files = await input.listFiles();
const lines = [
`# Export manifest (stub adapter)`,
`target: ${input.target}`,
`storageDir: ${input.storageDir}`,
`files: ${files.length}`,
``,
...files.map((f) => `${String(f.size).padStart(10)} ${f.path}`),
];
return { filename: "manifest.txt", content: Buffer.from(lines.join("\n"), "utf8") };
},
};
}
/** `cph` 可执行文件位置;生产由 preflight 校验为绝对路径(见 deployment/preflight.ts)。 */
const CPH_BIN = process.env["CPH_BIN"] ?? "cph";
/** typst 渲染包目录;未设时由 cph 自行解析(仓库内 `render/`)。 */
const CPH_RENDER_DIR = process.env["CPH_RENDER_DIR"];
const CPH_BUILD_TIMEOUT_MS = 120_000;
/**
* 默认导出 target。
*
* UI 只给一个「导出 PDF」按钮,不让用户选 target;`student` 是 cph 自身在
* 工程文件未声明 `[targets.*]` 时的默认值(cph-check DEFAULT_TARGET),
* 与之保持一致,避免两端各有一套默认。
*/
const DEFAULT_PDF_TARGET = "student";
/**
* 真导出适配器:把项目物化到临时目录,跑 `cph build`,取回 PDF 字节。
*
* 为什么不直接在 `storageDir`(git worktree)里跑构建:那是项目的版本库工作区,
* 构建产物会变成未跟踪文件混进去,后续 `list`/`commit` 的语义会被污染。
* 物化到临时目录让构建对版本库完全无副作用,代价是一次文件拷贝。
*/
export function createCphPdfAdapter(): ExportAdapter {
return {
target: "pdf",
async run(input) {
await requireCourswareCph();
const target = typeof input.params["target"] === "string" ? input.params["target"] : DEFAULT_PDF_TARGET;
const workDir = await mkdtemp(path.join(tmpdir(), "cph-export-"));
try {
await materialize(input, workDir);
const outRel = path.join("build", `${target}.pdf`);
const args = ["build", ".", "--target", target, "-o", outRel];
if (CPH_RENDER_DIR !== undefined && CPH_RENDER_DIR !== "") {
args.unshift("--render-dir", CPH_RENDER_DIR);
}
await runCphBuild(args, workDir);
const content = await readFile(path.join(workDir, outRel));
return { filename: `${target}.pdf`, content };
} finally {
await rm(workDir, { recursive: true, force: true });
}
},
};
}
/**
* 确认 CPH_BIN 指向的是 Courseware 检查器,而不是同名的其它工具。
*
* `cph` 这个名字在 PyPI 上已被 conda 的 conda-package-handling 占用,装了
* miniconda 的机器上 PATH 里的 `cph` 就是它。直接拿它跑 `build` 会得到
* 一句 argparse 的 "invalid choice: 'build'",根本看不出是撞名 —— 所以这里先用
* `--version` 探一下,把撞名变成一条能直接终止排查的错误。
*
* 结果缓存:探测只为拦配置错误,没必要每次导出都多起一个进程。
*/
let cphIdentityCheck: Promise<void> | null = null;
function requireCourswareCph(): Promise<void> {
cphIdentityCheck ??= (async () => {
let stdout: string;
try {
({ stdout } = await execFileAsync(CPH_BIN, ["--version"], { timeout: 10_000 }));
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
throw new FileLibError(
500,
"cph_not_found",
`cph binary not found at "${CPH_BIN}" (set CPH_BIN to the Courseware cph)`,
);
}
throw new FileLibError(500, "cph_unusable", `cph --version failed at "${CPH_BIN}": ${String(err.message)}`);
}
if (!/^cph\s+\d+\.\d+\.\d+/.test(stdout.trim())) {
throw new FileLibError(
500,
"cph_wrong_binary",
`"${CPH_BIN}" is not the Courseware cph checker (--version said: ${stdout.trim().split("\n")[0] ?? ""}). ` +
`Set CPH_BIN to the Courseware cph binary.`,
);
}
})().catch((error: unknown) => {
// 不缓存失败:改完 CPH_BIN 重启前,下一次导出应该重新探测。
cphIdentityCheck = null;
throw error;
});
return cphIdentityCheck;
}
/** 把版本库当前内容写进 workDir。路径已由 versionStore 的 safeRelPath 约束。 */
async function materialize(input: ExportAdapterInput, workDir: string): Promise<void> {
const files = await input.listFiles();
if (files.length === 0) {
throw new FileLibError(409, "export_empty_project", "project has no files to export");
}
for (const f of files) {
const abs = path.join(workDir, f.path);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, await input.readFile(f.path));
}
}
/**
* 跑 `cph build`。cph 的约定是诊断走 stderr、退出码非零表示构建失败(ADR-0010),
* 所以失败时把 stderr 原样带进错误信息 —— 老师需要看到是哪个诊断挡住了导出。
*/
async function runCphBuild(args: readonly string[], cwd: string): Promise<void> {
try {
await execFileAsync(CPH_BIN, args, { cwd, timeout: CPH_BUILD_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
} catch (error) {
const err = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string };
if (err.code === "ENOENT") {
throw new FileLibError(500, "cph_not_found", `cph binary not found at "${CPH_BIN}"`);
}
const detail = (err.stderr ?? "").trim() || (err.stdout ?? "").trim() || err.message;
throw new FileLibError(422, "cph_build_failed", `cph build failed: ${detail}`);
}
}
// v1 stub 产物存储(进程内存,重启即失;生产替换为持久存储)。
const artifacts = new Map<string, ExportArtifact>();
/**
* 内存里最多保留的产物份数。
*
* 产物不做持久化也不复用 —— 每次导出都按仓库当前内容重新编译,内存副本只为
* 支撑「提交完成后那一次下载」。因此这里可以无条件淘汰最旧的:被淘汰的 job 再点
* 下载会拿到 export_not_ready,重新导出即可,不存在数据丢失。
* 没有上限的话每次导出都会永久占住一份 PDF(数百 KB 级),进程内存只增不减。
*/
const MAX_RETAINED_ARTIFACTS = 32;
/** Map 迭代顺序即插入顺序,首个 key 就是最旧的产物。 */
function retainArtifact(jobId: string, artifact: ExportArtifact): void {
artifacts.set(jobId, artifact);
while (artifacts.size > MAX_RETAINED_ARTIFACTS) {
const oldest = artifacts.keys().next();
if (oldest.done === true) break;
artifacts.delete(oldest.value);
}
}
export interface ExportDeps extends FileDeps {
readonly adapters: readonly ExportAdapter[];
}
export interface ExportJobDto {
readonly id: string;
readonly nodeId: string;
readonly target: string;
readonly status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
readonly error: string | null;
readonly createdAt: Date;
}
/** 提交导出(需 VIEW):建行(QUEUED)+ export.run 审计,同事务;异步执行。 */
export async function submitExport(
deps: ExportDeps,
actor: FileLibActor,
projectId: string,
target: string,
params: Record<string, unknown>,
): Promise<ExportJobDto> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, "VIEW"),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "export applies to projects only");
}
const adapter = deps.adapters.find((a) => a.target === target);
if (adapter === undefined) {
throw new FileLibError(400, "unknown_target", `no export adapter for target "${target}"`);
}
if (node.storageDir === null) {
throw new FileLibError(409, "project_not_ready", "project repository is not ready");
}
const jobId = randomUUID();
const job = await deps.prisma.$transaction(async (tx) => {
const created = await tx.fileLibExportJob.create({
data: {
id: jobId,
organizationId: deps.organizationId,
nodeId: node.id,
target,
params: params as never,
status: "QUEUED",
createdByUserId: actor.userId,
},
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "export_job",
objectId: jobId,
objectPath: node.pathIds,
detail: { target, params },
});
return created;
});
const storageDir = node.storageDir;
setImmediate(() => {
void runExportJob(deps, adapter, jobId, storageDir, target, params).catch(() => undefined);
});
return toDto(job);
}
async function runExportJob(
deps: ExportDeps,
adapter: ExportAdapter,
jobId: string,
storageDir: string,
target: string,
params: Record<string, unknown>,
): Promise<void> {
await deps.prisma.fileLibExportJob.update({ where: { id: jobId }, data: { status: "RUNNING" } });
try {
const artifact = await adapter.run({
storageDir,
target,
params,
listFiles: (prefix) => deps.versionStore.list(storageDir, prefix),
readFile: (path) => deps.versionStore.read(storageDir, path),
});
retainArtifact(jobId, artifact);
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "DONE", downloadUrl: `/database/api/exports/${jobId}/download` },
});
} catch (error) {
await deps.prisma.fileLibExportJob.update({
where: { id: jobId },
data: { status: "FAILED", error: String(error) },
});
}
}
export async function getExportJob(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportJobDto> {
const job = await deps.prisma.fileLibExportJob.findFirst({
where: { id: jobId, organizationId: deps.organizationId },
});
if (job === null) throw new FileLibError(404, "export_not_found", "export job not found");
// D8:对源项目无 View → 404(不泄露 job 存在性)。
await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, job.nodeId, "VIEW"));
return toDto(job);
}
export async function downloadExport(
deps: ExportDeps,
actor: FileLibActor,
jobId: string,
): Promise<ExportArtifact> {
await getExportJob(deps, actor, jobId);
const artifact = artifacts.get(jobId);
if (artifact === undefined) {
throw new FileLibError(409, "export_not_ready", "export artifact is not available");
}
return artifact;
}
function toDto(job: {
id: string;
nodeId: string;
target: string;
status: "QUEUED" | "RUNNING" | "DONE" | "FAILED";
error: string | null;
createdAt: Date;
}): ExportJobDto {
return {
id: job.id,
nodeId: job.nodeId,
target: job.target,
status: job.status,
error: job.error,
createdAt: job.createdAt,
};
}
-339
View File
@@ -1,339 +0,0 @@
/**
* 文件内容服务(Phase 3):路径安全 + 版本化文件操作 + 审计。
*
* 顺序铁律(Metis 风险#1 的落地):
* - 内容写:先 versionStore.commit(业务事实本体)→ 再 DB 事务(审计)。
* 宁多一个无审计的版本,不造一条假审计。
* - conflict:写 file.conflict_detected(冲突本身就是事件),再抛 409。
* - 读:随取随读,不写审计(需求 5.2 未列读操作)。
*/
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js";
import type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js";
import type { FileLibNode, PrismaClient } from "@prisma/client";
export const FILE_PATH_MAX_LENGTH = 512;
export const FILE_PATH_MAX_DEPTH = 32;
/** 单文件上限的出厂默认值(OPEN-5 初值)。实际生效值见 `resolveMaxFileBytes`。 */
export const FILE_CONTENT_MAX_BYTES_DEFAULT = 10 * 1024 * 1024;
/**
* 单文件字节上限的纯解析。非法值(非正整数/NaN)按缺省处理 ——
* 配置写错不该让上传静默变成 0 上限(那会把每次上传都拒掉)。
*
* 与 `resolveMaxFileBytes` 分开是有意的:带默认参数的单函数版本里,
* 显式传 undefined 会触发默认值、回到读 env,于是“没传值”和“读环境变量”
* 永远分不开,测试也会被 vitest 加载的 .env 干扰。
*/
export function parseMaxFileBytes(raw: string | undefined): number {
if (raw === undefined || raw.trim() === "") return FILE_CONTENT_MAX_BYTES_DEFAULT;
const parsed = Number(raw.trim());
if (!Number.isSafeInteger(parsed) || parsed <= 0) return FILE_CONTENT_MAX_BYTES_DEFAULT;
return parsed;
}
/**
* 生效上限:`HUB_FILELIB_MAX_FILE_BYTES` 覆盖,缺省 10MiB。
*
* 注意它与 `HUB_HTTP_BODY_LIMIT_BYTES` 是串联的:上传把内容放在 JSON body 里,
* 二进制过 base64 体积涨 4/3,所以真正的天花板是
* min(本值, bodyLimit × 3/4)。body limit 太小时本值不可达,而且报错发生在
* Fastify 解析阶段(413 Payload Too Large),根本到不了下面的 checkSize。
*/
export function resolveMaxFileBytes(): number {
return parseMaxFileBytes(process.env["HUB_FILELIB_MAX_FILE_BYTES"]);
}
const CONTROL_CHARS = /[\p{C}]/u;
const FORBIDDEN_SEGMENTS = new Set(["", ".", "..", ".git"]);
/**
* 路径安全(Metis 安全红线):NFC;拒绝反斜杠、控制字符、空段、
* "." / ".." / ".git" 段、绝对路径、超长/超深。返回规范化相对路径。
*/
export function validateFilePath(raw: string): string {
const normalized = raw.normalize("NFC");
if (normalized.length === 0 || normalized.length > FILE_PATH_MAX_LENGTH) {
throw new FileLibError(400, "invalid_path", `path must be 1..${FILE_PATH_MAX_LENGTH} characters`);
}
if (normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", "path must use '/' separators");
}
if (CONTROL_CHARS.test(normalized)) {
throw new FileLibError(400, "invalid_path", "path must not contain control characters");
}
const segments = normalized.split("/");
if (segments.length > FILE_PATH_MAX_DEPTH) {
throw new FileLibError(400, "invalid_path", `path depth exceeds ${FILE_PATH_MAX_DEPTH}`);
}
for (const segment of segments) {
if (FORBIDDEN_SEGMENTS.has(segment)) {
throw new FileLibError(400, "invalid_path", `forbidden path segment: "${segment}"`);
}
}
return normalized;
}
export interface FileDeps extends AccessDeps {
readonly prisma: PrismaClient;
readonly versionStore: VersionStore;
/** 单文件字节上限。装配处用 `resolveMaxFileBytes()` 求值,缺省即出厂值。 */
readonly maxFileBytes?: number | undefined;
}
type ProjectChain = { readonly node: FileLibNode; readonly storageDir: string };
async function requireProject(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
minRole: "VIEW" | "EDIT",
): Promise<ProjectChain> {
const { node } = await deps.prisma.$transaction(async (tx) =>
requireAccessInTx(tx, deps, actor, projectId, minRole),
);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "file operations apply to projects only");
}
if (node.provisionStatus === "PROVISIONING") {
throw new FileLibError(409, "project_not_ready", "project repository is still provisioning");
}
if (node.provisionStatus === "FAILED") {
throw new FileLibError(409, "project_not_ready", "project repository provisioning failed");
}
if (node.storageDir === null) {
throw new FileLibError(500, "storage_missing", "project has no storage directory");
}
return { node, storageDir: node.storageDir };
}
export type FileContentEncoding = "utf8" | "base64";
export interface FileContentDto {
readonly path: string;
readonly version: string;
readonly encoding: FileContentEncoding;
readonly content: string;
readonly size: number;
}
export function decodeContent(content: string, encoding: FileContentEncoding): string | Buffer {
return encoding === "base64" ? Buffer.from(content, "base64") : content;
}
function encodeContent(buffer: Buffer): { readonly encoding: FileContentEncoding; readonly content: string } {
// 粗判二进制:含 NUL 字节即按 base64 返回(需求 2.5 在线编辑仅针对文本)。
return buffer.includes(0)
? { encoding: "base64", content: buffer.toString("base64") }
: { encoding: "utf8", content: buffer.toString("utf8") };
}
function checkSize(content: string | Buffer, maxBytes: number): void {
const bytes = typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
if (bytes > maxBytes) {
throw new FileLibError(413, "file_too_large", `file exceeds ${maxBytes} bytes`);
}
}
/**
* commit message 的默认文案:`【用户名】修改了【路径】`。
* 用户名取 displayName,缺失回退 userId(权限判定从不看它)。
* 显式传 message 的调用方优先 —— 这里只填空缺。
*/
export function defaultCommitMessage(actor: FileLibActor, filePath: string): string {
const who = actor.displayName === undefined || actor.displayName.trim() === ""
? actor.userId
: actor.displayName.trim();
return `${who}】修改了【${filePath}`;
}
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
action: string,
project: ProjectChain,
filePath: string,
detail: Record<string, unknown>,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "file",
objectId: project.node.id,
objectPath: `${project.node.pathIds}:${filePath}`,
detail,
});
});
}
/* ---------------------------------------------------------------- 读操作 */
export async function listFiles(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
prefix?: string,
): Promise<readonly FileEntry[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.list(project.storageDir, prefix === undefined ? undefined : validateFilePath(prefix));
}
export async function readFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<FileContentDto> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
const { encoding, content } = encodeContent(buffer);
return { path: filePath, version, encoding, content, size: buffer.byteLength };
}
/** 原始字节下载(浏览器 save-as 用):JSON 之外的第二条读取通道,同样的 VIEW 门禁。 */
export async function readFileRaw(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
): Promise<{ readonly buffer: Buffer; readonly version: string; readonly filename: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
const [version, buffer] = await Promise.all([
deps.versionStore.head(project.storageDir, filePath),
deps.versionStore.read(project.storageDir, filePath),
]);
return { buffer, version, filename: filePath.split("/").pop() ?? "download" };
}
export async function fileHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
limit?: number,
): Promise<readonly VersionInfo[]> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.history(project.storageDir, filePath, limit);
}
/** 项目级提交历史(所有文件),VIEW 即可访问。 */
export async function projectHistory(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
limit?: number,
): Promise<readonly ProjectCommitInfo[]> {
const project = await requireProject(deps, actor, projectId, "VIEW");
return deps.versionStore.projectHistory(project.storageDir, limit);
}
export async function diffFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
from: string,
to: string,
): Promise<{ readonly diff: string }> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "VIEW");
return { diff: await deps.versionStore.diff(project.storageDir, filePath, from, to) };
}
/* ---------------------------------------------------------------- 写操作 */
export interface CommitInput {
readonly path: string;
/** null = 新建(已存在则 409);编辑时传 readFile 拿到的 version。 */
readonly baseVersion: string | null;
readonly content: string;
readonly encoding?: FileContentEncoding | undefined;
readonly message?: string | undefined;
}
/**
* 统一写入口(上传/编辑共用):先 commit,成功写 file.commit / file.upload 审计;
* 冲突写 file.conflict_detected 后抛 409(details 带 currentVersion,编辑 UI 用它拉 diff)。
*/
export async function commitFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
input: CommitInput,
): Promise<{ readonly version: string }> {
const filePath = validateFilePath(input.path);
const content = decodeContent(input.content, input.encoding ?? "utf8");
checkSize(content, deps.maxFileBytes ?? resolveMaxFileBytes());
const project = await requireProject(deps, actor, projectId, "EDIT");
// 调用方传了非空 message 则用它,否则回退默认文案。
// 空串必须当作没传:`git commit -m ""` 会以 empty commit message 失败。
const trimmedMessage = input.message?.trim();
const message = trimmedMessage === undefined || trimmedMessage === ""
? defaultCommitMessage(actor, filePath)
: trimmedMessage;
const result: CommitResult = await deps.versionStore.commit(project.storageDir, filePath, {
baseVersion: input.baseVersion,
content,
message,
// 身份:name=displayName(回退 userId),email=<userId>@域名(git 实现里拼)。
author: { userId: actor.userId, displayName: actor.displayName },
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion: input.baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(
deps,
actor,
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message },
);
return { version: result.version };
}
export async function deleteFile(
deps: FileDeps,
actor: FileLibActor,
projectId: string,
rawPath: string,
baseVersion: string,
): Promise<void> {
const filePath = validateFilePath(rawPath);
const project = await requireProject(deps, actor, projectId, "EDIT");
const result = await deps.versionStore.remove(project.storageDir, filePath, baseVersion, {
userId: actor.userId,
displayName: actor.displayName,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
baseVersion,
currentVersion: result.currentVersion,
});
throw new FileLibError(409, "version_conflict", "file was modified since baseVersion", {
currentVersion: result.currentVersion,
});
}
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion });
}
-443
View File
@@ -1,443 +0,0 @@
/**
* VersionStore(契约 C1)的真 git 实现 —— ADR-0030。
*
* 一个项目一个 git 仓库,位于 <storageRoot>/<nodeId>(nodeId 是 uuid;
* 文件夹不落盘,见 ADR-0030 Context)。VersionId = commit hash。
*
* D16 文件级版本的映射(ADR-0030 Decision):一次写只碰一个路径、只产生一个
* commit;某文件的版本 = `git log -1 -- <path>` 的 hash。因此 a.md 的提交不出现在
* b.md 的 log 里,两者 baseVersion 互不失效 —— 尽管 commit 本身是仓库级对象。
*
* 不引 npm 依赖:三条 execFile 就够,见 ADR-0030 Alternatives。
*/
import { execFile } from "node:child_process";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { FileLibError } from "./model.js";
import type {
CommitAuthor,
CommitRequest,
CommitResult,
FileEntry,
ProjectCommitInfo,
VersionId,
VersionInfo,
VersionStore,
} from "./versionStore.js";
/** 无 author 时的固定身份(ADR-0030 Consequences:不再是 undefined)。 */
const FALLBACK_AUTHOR = "filelib";
/** 提交者 email 域名:`<userId>@filelib.paradigm-edu.net`。 */
const AUTHOR_EMAIL_DOMAIN = "filelib.paradigm-edu.net";
/**
* 每次调用都带的加固(ADR-0030 Decision)。项目仓库是老师上传的**数据**,
* 不是可信代码:hooks 必须禁用,全局/系统 gitconfig 必须隔离,否则仓库内容
* 或开发者机器上的配置就能改变服务端行为。
*/
const HARDENING_ARGS = ["-c", "core.hooksPath=", "-c", "commit.gpgsign=false"] as const;
/**
* 仓库定位参数。**这两个不能省**:git 默认会沿目录树**向上**找 `.git`,
* 而 storage root 很可能就在另一个 git 仓库里(本地开发的默认值
* `hub/.filelib-repos` 就在本 repo 内)。不钉死的后果是项目目录没自己的 `.git`
* 时,所有命令默默落到**外层仓库**上 —— 轻则 `git add` 报 ignored,
* 重则把老师的文件提交进源码仓。
*/
function repoArgs(projectDir: string): readonly string[] {
return [`--git-dir=${path.join(projectDir, ".git")}`, `--work-tree=${projectDir}`];
}
const HARDENING_ENV = {
GIT_CONFIG_GLOBAL: "/dev/null",
GIT_CONFIG_SYSTEM: "/dev/null",
// 文件名永远不被重解释为 pathspec magic(`:(glob)` 等)。
GIT_LITERAL_PATHSPECS: "1",
// 仓库不得因为凭据提示卡住一个 HTTP 请求。
GIT_TERMINAL_PROMPT: "0",
} as const;
/**
* 继承来的这几个会劫持全部命令(比如 hub 自身被一个 git hook 启动时),
* 必须从子进程 env 里**删掉**而不是置空 —— 置空在 git 里的含义并不统一。
* 我们只认 repoArgs 里显式传的那一份。
*/
const STRIPPED_ENV = ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY"] as const;
function childEnv(extra: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env, ...HARDENING_ENV, ...extra };
for (const key of STRIPPED_ENV) delete env[key];
return env;
}
interface GitResult {
readonly stdout: Buffer;
readonly code: number;
readonly stderr: string;
}
/** execFile 的 args 数组形式:不拼 shell,文件名不参与命令解析。 */
function runGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, [...repoArgs(cwd), ...args], env);
}
/**
* 不钉 --git-dir 的调用。**只给 `git init` 用** —— 那一刻 `.git` 尚不存在,
* 钉上去 git 会直接报错。init 自己总是在 cwd 建仓,不会向上找。
*/
function runGitBare(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>> = {},
): Promise<GitResult> {
return execGit(cwd, args, env);
}
function execGit(
cwd: string,
args: readonly string[],
env: Readonly<Record<string, string>>,
): Promise<GitResult> {
return new Promise((resolve, reject) => {
execFile(
"git",
[...HARDENING_ARGS, ...args],
{
cwd,
encoding: "buffer",
env: childEnv(env),
maxBuffer: 64 * 1024 * 1024,
windowsHide: true,
},
(error, stdout, stderr) => {
const out = Buffer.isBuffer(stdout) ? stdout : Buffer.from(String(stdout));
const err = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr);
if (error === null) {
resolve({ stdout: out, code: 0, stderr: err });
return;
}
const code = (error as NodeJS.ErrnoException & { code?: number | string }).code;
if (code === "ENOENT") {
// 坑:spawn 的 ENOENT 有两种来源且**报错完全一样**(都是 path:"git"、
// syscall:"spawn git") —— git 真的不在 PATH 上,或者 cwd 目录不存在。
// 后者在这里是常态(DB 里有 storageDir、磁盘上却没建过,比如内存 store
// 时代留下的旧项目),必须报 repo_not_found 而不是冤枉 git 没装。
void access(cwd).then(
() => reject(new FileLibError(500, "git_missing", "git executable not found on PATH")),
() => reject(new FileLibError(404, "repo_not_found", `repository directory missing: ${cwd}`)),
);
return;
}
// 非零退出是常规控制流(文件不存在、空仓库等),交给调用点判断。
resolve({ stdout: out, code: typeof code === "number" ? code : 1, stderr: err });
},
);
});
}
async function requireGit(cwd: string, args: readonly string[], env?: Record<string, string>): Promise<Buffer> {
const res = await runGit(cwd, args, env);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
async function requireGitBare(cwd: string, args: readonly string[]): Promise<Buffer> {
const res = await runGitBare(cwd, args);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git ${args[0] ?? ""} failed: ${res.stderr.trim()}`);
}
return res.stdout;
}
/**
* S4:同仓库写操作串行化。git 的并发写会在 index.lock 上打架,
* 串行化把它变成干净的 conflict 返回值而不是锁错误。仅进程内有效(ADR-0030)。
*/
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
/**
* 仓库内相对路径的再校验。fileService.validateFilePath 已经把过一遍,
* 但 ADR-0030 把这条从卫生升级为安全边界 —— 本层不信调用方。
*/
function safeRelPath(filePath: string): string {
const normalized = filePath.normalize("NFC");
if (normalized === "" || path.isAbsolute(normalized) || normalized.includes("\\")) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
const segments = normalized.split("/");
for (const segment of segments) {
if (segment === "" || segment === "." || segment === ".." || segment === ".git") {
throw new FileLibError(400, "invalid_path", `unsafe path segment in: ${filePath}`);
}
}
// 解析后必须仍在仓库内(符号链接由 git 自身不跟随 + 此处前缀检查共同兜住)。
const resolved = path.posix.normalize(normalized);
if (resolved.startsWith("..") || path.isAbsolute(resolved)) {
throw new FileLibError(400, "invalid_path", `unsafe path: ${filePath}`);
}
return resolved;
}
/**
* 提交者身份 → git author/committer。
* name = displayName(缺失回退 userId);email = `<userId>@filelib.paradigm-edu.net`。
* email 用 userId 而不用 displayName:昵称会改,身份追溯不能跟着漂。
* name 里的换行/`<`/`>` 必须清掉 —— 它们会破坏 git 的 ident 行格式。
*/
function authorEnv(author: CommitAuthor | undefined): Record<string, string> {
const rawName = author?.displayName;
const fallback = author?.userId ?? FALLBACK_AUTHOR;
const name = (rawName === undefined || rawName.trim() === "" ? fallback : rawName.trim())
.replace(/[<>\n\r]/g, " ")
.trim();
const localPart = (author?.userId ?? FALLBACK_AUTHOR).replace(/[^\w.-]/g, "_");
const email = `${localPart}@${AUTHOR_EMAIL_DOMAIN}`;
return {
GIT_AUTHOR_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_AUTHOR_EMAIL: email,
GIT_COMMITTER_NAME: name === "" ? FALLBACK_AUTHOR : name,
GIT_COMMITTER_EMAIL: email,
};
}
export function createGitVersionStore(): VersionStore {
const serialize = createKeySerializer();
/**
* 未 init → repo_not_found。
* 不能用 `git rev-parse --git-dir`:repoArgs 已把 --git-dir 钉死,rev-parse 会
* 原样回显它而不验证存在;而不钉死时它又会向上找到外层仓库。所以直接
* 测文件系统:项目目录里必须有属于它自己的 `.git`。
*/
async function requireRepo(projectDir: string): Promise<void> {
try {
await access(path.join(projectDir, ".git"));
} catch {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
}
/** 该路径在 HEAD 上的当前版本;不存在(或从未提交)→ null。 */
async function currentVersion(projectDir: string, filePath: string): Promise<VersionId | null> {
const exists = await runGit(projectDir, ["cat-file", "-e", `HEAD:${filePath}`]);
if (exists.code !== 0) return null; // 空仓库、已删除、或从无此文件
const log = await runGit(projectDir, ["log", "-1", "--format=%H", "--", filePath]);
if (log.code !== 0) return null;
const hash = log.stdout.toString("utf8").trim();
return hash === "" ? null : hash;
}
async function headVersion(projectDir: string, filePath: string): Promise<VersionId> {
const version = await currentVersion(projectDir, filePath);
if (version === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return version;
}
/** commit id 必须存在,否则 version_not_found(而非把 git 错误透出去)。 */
async function requireCommit(projectDir: string, version: VersionId): Promise<void> {
const res = await runGit(projectDir, ["rev-parse", "--verify", "--quiet", `${version}^{commit}`]);
if (res.code !== 0) {
throw new FileLibError(404, "version_not_found", `version not found: ${version}`);
}
}
/** 提交暂存区里已备好的单个路径。返回新 commit hash。 */
async function commitPath(
projectDir: string,
filePath: string,
message: string,
author: CommitAuthor | undefined,
): Promise<VersionId> {
const env = authorEnv(author);
await requireGit(projectDir, ["commit", "--quiet", "--allow-empty", "-m", message, "--", filePath], env);
const hash = await requireGit(projectDir, ["rev-parse", "HEAD"]);
return hash.toString("utf8").trim();
}
return {
/** S7 幂等:已有仓库就不重建。 */
async init(projectDir) {
await serialize(projectDir, async () => {
await mkdir(projectDir, { recursive: true });
try {
await access(path.join(projectDir, ".git"));
return; // 已是仓库,不清空
} catch { /* 继续 init */ }
// init 用 runGitBare:此时 .git 尚不存在,钉 --git-dir 反而会让 git 报错。
await requireGitBare(projectDir, ["init", "--quiet"]);
// 默认分支名不依赖宿主 git 版本/配置(全局配置已被隔离)。
await requireGit(projectDir, ["symbolic-ref", "HEAD", "refs/heads/main"]);
});
},
async list(projectDir, prefix) {
await requireRepo(projectDir);
const res = await runGit(projectDir, ["ls-tree", "-r", "-l", "-z", "HEAD"]);
if (res.code !== 0) return []; // 空仓库(无 HEAD)
const out: FileEntry[] = [];
for (const record of res.stdout.toString("utf8").split("\0")) {
if (record === "") continue;
// 形如:"<mode> <type> <object> <size>\t<path>"
const tab = record.indexOf("\t");
if (tab === -1) continue;
const meta = record.slice(0, tab).split(/\s+/);
const entryPath = record.slice(tab + 1);
if (meta[1] !== "blob") continue;
if (prefix !== undefined && !entryPath.startsWith(prefix)) continue;
out.push({ path: entryPath, size: Number.parseInt(meta[3] ?? "0", 10) || 0 });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
await requireRepo(projectDir);
return headVersion(projectDir, safeRelPath(filePath));
},
async read(projectDir, filePath, at) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
if (at === undefined) {
await headVersion(projectDir, rel); // 存在性 → 404 file_not_found
const res = await runGit(projectDir, ["show", `HEAD:${rel}`]);
if (res.code !== 0) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
return res.stdout;
}
await requireCommit(projectDir, at);
const res = await runGit(projectDir, ["show", `${at}:${rel}`]);
if (res.code !== 0) {
// commit 存在但该版本里没有这个路径。
throw new FileLibError(404, "version_not_found", `version not found: ${rel}@${at}`);
}
return res.stdout;
},
async commit(projectDir, filePath, req: CommitRequest): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
// S2:baseVersion=null 表新建,已存在即 conflict;
// S1:否则要求 baseVersion 精确等于当前版本。
if (req.baseVersion === null) {
if (current !== null) return { status: "conflict", currentVersion: current };
} else if (req.baseVersion !== current) {
return { status: "conflict", currentVersion: current ?? req.baseVersion };
}
const abs = path.join(projectDir, rel);
await mkdir(path.dirname(abs), { recursive: true });
await writeFile(abs, req.content);
await requireGit(projectDir, ["add", "--", rel]);
const message = req.message ?? (req.baseVersion === null ? `create ${rel}` : `update ${rel}`);
const version = await commitPath(projectDir, rel, message, req.author);
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion, author): Promise<CommitResult> {
const rel = safeRelPath(filePath);
return serialize(projectDir, async (): Promise<CommitResult> => {
await requireRepo(projectDir);
const current = await currentVersion(projectDir, rel);
if (current === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${rel}`);
}
if (baseVersion !== current) return { status: "conflict", currentVersion: current };
await requireGit(projectDir, ["rm", "--quiet", "--", rel]);
const version = await commitPath(projectDir, rel, `remove ${rel}`, author);
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
await requireCommit(projectDir, from);
await requireCommit(projectDir, to);
const res = await runGit(projectDir, ["diff", from, to, "--", rel]);
if (res.code !== 0) {
throw new FileLibError(500, "git_failed", `git diff failed: ${res.stderr.trim()}`);
}
return res.stdout.toString("utf8");
},
async history(projectDir, filePath, limit) {
await requireRepo(projectDir);
const rel = safeRelPath(filePath);
const args = ["log", "--format=%H%x1f%an%x1f%aI%x1f%s%x1e"];
if (limit !== undefined) args.push(`-${limit}`);
args.push("--", rel);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: VersionInfo[] = [];
for (const record of res.stdout.toString("utf8").split("\x1e")) {
const line = record.trim();
if (line === "") continue;
const [version, author, committedAt, message] = line.split("\x1f");
if (version === undefined) continue;
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
});
}
return out; // git log 已是新→旧
},
async projectHistory(projectDir, limit) {
await requireRepo(projectDir);
// %x1e 放在 format 开头作为每条记录的分隔符;--name-only 的文件列表跟在 format 行之后。
const args = ["log", "--format=%x1e%H%x1f%an%x1f%aI%x1f%s", "--name-only"];
if (limit !== undefined) args.push(`-${limit}`);
const res = await runGit(projectDir, args);
if (res.code !== 0) return []; // 空仓库
const out: ProjectCommitInfo[] = [];
// split 按 \x1e 分块,第一个空块跳过。
for (const block of res.stdout.toString("utf8").split("\x1e")) {
const trimmed = block.trim();
if (trimmed === "") continue;
const lines = trimmed.split("\n");
const header = lines[0];
if (header === undefined) continue;
const [version, author, committedAt, message] = header.split("\x1f");
if (version === undefined) continue;
// header 之后的非空行是受影响的文件路径。
// git 对含特殊字符的路径加双引号,去掉外层引号即可。
const files = lines.slice(1)
.map((l) => l.trim())
.filter((l) => l !== "")
.map((l) => (l.startsWith('"') && l.endsWith('"') ? l.slice(1, -1) : l));
out.push({
version,
message: message ?? "",
author: author === undefined || author === "" ? undefined : author,
committedAt: committedAt ?? "",
files,
});
}
return out;
},
};
}
-356
View File
@@ -1,356 +0,0 @@
/**
* 授权管理(契约 8.1 矩阵的服务端强制)。
*
* 矩阵:
* - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动
* - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者
* - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡)
* - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计
*
* D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。
*/
import { Prisma } from "@prisma/client";
import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client";
import { FileLibError, type FileLibRole } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import {
requireAccessInTx,
type AccessDeps,
type FileLibActor,
type InitialGrant,
} from "./treeService.js";
export interface GrantDto {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
/**
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
*/
readonly principalName: string;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
readonly createdAt: Date;
}
function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
return {
id: grant.id,
principalType: grant.principalType,
principalId: grant.principalId,
principalName: principalName ?? grant.principalId,
role: grant.role,
isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt,
};
}
type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient };
/**
* 批量解析 principal 展示名(两条 IN 查询,不做 N+1)。
* 组不按 archivedAt 过滤:已归档组的历史授权仍要能显示出名字来给管理员收回。
*/
async function resolvePrincipalNames(
tx: Tx | PrismaClient,
grants: readonly FileLibGrant[],
): Promise<ReadonlyMap<string, string>> {
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
const [users, groups] = await Promise.all([
userIds.length === 0
? Promise.resolve([])
: tx.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }),
groupIds.length === 0
? Promise.resolve([])
: tx.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }),
]);
const names = new Map<string, string>();
for (const u of users) names.set(`USER:${u.id}`, u.displayName);
for (const g of groups) names.set(`GROUP:${g.id}`, g.name);
return names;
}
async function toDtosWithNames(tx: Tx | PrismaClient, grants: readonly FileLibGrant[]): Promise<readonly GrantDto[]> {
const names = await resolvePrincipalNames(tx, grants);
return grants.map((g) => toDto(g, names.get(`${g.principalType}:${g.principalId}`)));
}
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
async function requireManage(
deps: Deps,
actor: FileLibActor,
nodeId: string,
tx?: Tx,
): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> {
if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE");
return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE"));
}
/** 列出节点活跃授权(需 MANAGE)。 */
export async function listGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly GrantDto[]> {
await requireManage(deps, actor, nodeId);
const grants = await deps.prisma.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return toDtosWithNames(deps.prisma, grants);
}
export interface PutGrantsResult {
readonly granted: number;
readonly updated: number;
readonly grants: readonly GrantDto[];
}
/**
* 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update);
* 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。
*/
export async function putGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const isCreator = node.creatorId === actor.userId;
for (const item of items) {
if (item.role === "MANAGE" && !isCreator) {
throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE");
}
if (item.principalType === "USER" && item.principalId === node.creatorId) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
}
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
});
}
/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */
export async function revokeGrant(
deps: Deps,
actor: FileLibActor,
nodeId: string,
grantId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
const grant = await tx.fileLibGrant.findFirst({
where: { id: grantId, nodeId: node.id, revokedAt: null },
});
if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found");
if (grant.isCreatorGrant) {
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
}
if (grant.role === "MANAGE" && node.creatorId !== actor.userId) {
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
}
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
});
});
}
/**
* 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免;
* 每一条变更都落 admin.force_adjust 审计(高危留痕)。
*/
export async function forceAdjustGrants(
deps: Deps,
actor: FileLibActor,
nodeId: string,
items: readonly InitialGrant[],
): Promise<PutGrantsResult> {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "force adjust requires website administrator");
}
validateGrantItems(items);
return deps.prisma.$transaction(async (tx) => {
const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
let granted = 0;
let updated = 0;
for (const item of items) {
const existing = await tx.fileLibGrant.findFirst({
where: {
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
revokedAt: null,
},
});
if (existing !== null) {
if (existing.role !== item.role) {
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
updated += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "update",
principalType: item.principalType,
principalId: item.principalId,
from: existing.role,
to: item.role,
});
}
} else {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: node.id,
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
createdByUserId: actor.userId,
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
change: "grant",
principalType: item.principalType,
principalId: item.principalId,
role: item.role,
});
}
}
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
});
}
/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
export async function setIndependentPermission(
deps: Deps,
actor: FileLibActor,
nodeId: string,
enabled: boolean,
): Promise<{ readonly enabled: boolean }> {
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireManage(deps, actor, nodeId, tx);
if (node.kind !== "PROJECT") {
throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
}
const current = await tx.fileLibProjectSettings.findUnique({
where: { nodeId: node.id },
select: { independentPermissionsEnabled: true },
});
if ((current?.independentPermissionsEnabled ?? false) === enabled) {
return { enabled }; // 状态未变:空操作,不产生审计
}
await tx.fileLibProjectSettings.upsert({
where: { nodeId: node.id },
update: { independentPermissionsEnabled: enabled },
create: { nodeId: node.id, independentPermissionsEnabled: enabled },
});
await writeFileLibAudit(tx, {
action: enabled
? FILE_LIB_AUDIT_ACTIONS.independentEnable
: FILE_LIB_AUDIT_ACTIONS.independentDisable,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "project",
objectId: node.id,
objectPath: node.pathIds,
detail: { enabled },
});
return { enabled };
});
}
function validateGrantItems(items: readonly InitialGrant[]): void {
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
const seen = new Set<string>();
for (const item of items) {
if (item.principalType !== "USER" && item.principalType !== "GROUP") {
throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`);
}
if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") {
throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`);
}
if (item.principalId.trim() === "") {
throw new FileLibError(400, "invalid_request", "principalId must not be empty");
}
const key = `${item.principalType}:${item.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`);
seen.add(key);
}
}
async function audit(
tx: Prisma.TransactionClient,
deps: Deps,
actor: FileLibActor,
action: string,
nodeId: string,
pathIds: string,
detail: Record<string, unknown>,
): Promise<void> {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: nodeId,
objectPath: pathIds,
detail,
});
}
-52
View File
@@ -1,52 +0,0 @@
/**
* GroupResolver port(契约 C2)。
*
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
* 调用方只依赖此 port,不换调用点。
*/
import type { PrismaClient } from "@prisma/client";
export interface GroupResolver {
resolveMemberGroupIds(userId: string): Promise<readonly string[]>;
}
/**
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
* (以及潜在的迁移对照),新代码不要使用。
*
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
*/
export function createTeamGroupResolver(
prisma: PrismaClient,
organizationId: string,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
const memberships = await prisma.teamMembership.findMany({
where: {
userId,
revokedAt: null,
team: { organizationId, archivedAt: null },
},
select: { teamId: true },
});
return memberships.map((m) => m.teamId);
},
};
}
/** 单测 mock:静态 用户→组 映射。 */
export function createStaticGroupResolver(
map: Readonly<Record<string, readonly string[]>>,
): GroupResolver {
return {
async resolveMemberGroupIds(userId) {
return map[userId] ?? [];
},
};
}
@@ -1,49 +0,0 @@
/**
* GroupResolver 的 HTTP 实现(契约 C2,Group 团队服务到位后启用,
* 经 HUB_GROUP_SERVICE_URL 配置)。
*
* 语义红线:
* - 失败 → FileLibError(503, group_unavailable)。依赖故障不是"无权限",
* 绝不伪装成 404/403(计划 D13)。
* - 我方绝不自己推祖先:返回什么用什么,不在本地补逻辑。
*/
import { FileLibError } from "./model.js";
import type { GroupResolver } from "./groupResolver.js";
export interface HttpGroupResolverConfig {
readonly baseUrl: string;
readonly timeoutMs?: number;
/** 测试可注入假 fetch;生产用全局 fetch。 */
readonly fetchFn?: typeof fetch;
}
export function createHttpGroupResolver(config: HttpGroupResolverConfig): GroupResolver {
const timeoutMs = config.timeoutMs ?? 2_000;
const fetchFn = config.fetchFn ?? fetch;
return {
async resolveMemberGroupIds(userId) {
const url = `${config.baseUrl.replace(/\/$/, "")}/groups/resolve-member-groups?userId=${encodeURIComponent(userId)}`;
let response: Response;
try {
response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
} catch (error) {
throw new FileLibError(503, "group_unavailable", `group service unreachable: ${String(error)}`);
}
if (!response.ok) {
throw new FileLibError(503, "group_unavailable", `group service returned ${response.status}`);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new FileLibError(503, "group_unavailable", "group service returned malformed JSON");
}
const groupIds = (body as { groupIds?: unknown }).groupIds;
if (!Array.isArray(groupIds) || groupIds.some((id) => typeof id !== "string")) {
throw new FileLibError(503, "group_unavailable", "group service returned malformed payload");
}
return groupIds as readonly string[];
},
};
}
-49
View File
@@ -1,49 +0,0 @@
/**
* 文件库 HTTP 门禁(契约 C4 的入驻适配)。
*
* 身份链:hub session(飞书 OAuth / dev bypass)→ silo org membership。
* 网站管理员 = org 的 OWNER/ADMIN(D19:仅 root 创建与 force_adjust 特权,
* 不给内容读旁路);普通成员 = 任何活跃 membership;非成员 = 403。
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import { requireSession, sendError } from "../../admin/auth/guards.js";
import type { FileLibActor } from "./treeService.js";
export interface FileLibGuardDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
/** 文件库归属的 silo org(ADR-0020/0025)。 */
readonly organizationId: string;
}
const WEBSITE_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADMIN"];
/** 每个 /database/api/* 端点第一行调它;返回 null 时响应已发出,fail closed。 */
export async function requireFileLibActor(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibGuardDeps,
): Promise<FileLibActor | null> {
const auth = await requireSession(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
});
if (auth === null) return null;
const membership = await deps.prisma.organizationMembership.findFirst({
where: { organizationId: deps.organizationId, userId: auth.user.id, revokedAt: null },
select: { role: true },
});
if (membership === null) {
await sendError(reply, 403, "forbidden", "not a member of this organization");
return null;
}
return {
userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
displayName: auth.user.displayName,
};
}
@@ -1,35 +0,0 @@
/**
* 默认 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))];
},
};
}
@@ -1,607 +0,0 @@
/**
* 成员组(MemberGroup)管理服务(ADR-0028)。
*
* 语义锚定:
* - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org
* (deps.organizationId)—— MemberGroup 无 orgId,审计沿用文件库 sink(决策4)。
* - 权限门禁:创建/删除/成员增删仅网站管理员(silo org OWNER/ADMIN);
* 搜索(授权选择器)不限管理员 —— 选组授权是 Manage 持有者的能力(决策2)。
* - 软删除:archivedAt 打标;删组级联软删整棵子树(闭包 ancestorId=G);
* 闭包/成员行保留,list/解析按 archivedAt 过滤(决策4)。
* - 闭包维护:仅 create —— 插 (G,G,0),再对 parent P 插
* (a.ancestorId, G, a.depth+1) for a in closure where descendantId=P。
* v1 不支持 reparent(决策5)。
*
* 与 hub Team 不同:成员是全局用户,不要求 org membership;按 userId 或
* User.feishuOpenId(全局 @unique)解析。
*/
import type { PrismaClient, Prisma } from "@prisma/client";
import { FileLibError } from "./model.js";
import type { FileLibActor } from "./treeService.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
export interface MemberGroupServiceDeps {
readonly prisma: PrismaClient;
/** silo org id —— 仅用于审计归属(MemberGroup 全局无 orgId,决策4)。 */
readonly organizationId: string;
}
export interface MemberGroupDto {
readonly id: string;
readonly parentId: string | null;
readonly name: string;
readonly description: string | null;
/** 到根的边数(根 = 0);由闭包行数推导。 */
readonly depth: number;
readonly memberCount: number;
/** 软删标记(决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 */
readonly archivedAt: Date | null;
}
export interface MemberGroupMemberDto {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
/** 加入本组时间(membership.createdAt),用于成员表排序/展示。 */
readonly joinedAt: Date;
}
/** 成员选择器候选(加成员弹窗搜索用)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接;无祖先时即自身名。 */
readonly breadcrumb: string;
}
export interface CreateMemberGroupInput {
readonly name: string;
readonly description?: string | undefined;
readonly parentId?: string | null | undefined;
}
export interface AddMemberInput {
readonly userId?: string | undefined;
readonly feishuOpenId?: string | undefined;
}
/** 改名/改描述(决策6)。字段缺省 = 不动;description 传空串 = 清空。 */
export interface UpdateMemberGroupInput {
readonly name?: string | undefined;
readonly description?: string | undefined;
}
type Tx = Prisma.TransactionClient;
/* ---------------------------------------------------------------- 内部工具 */
/** 管理门禁:非网站管理员一律 403(决策2)。 */
function requireAdmin(actor: FileLibActor): void {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "group management requires website administrator");
}
}
/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */
function normalizeGroupName(raw: string): string {
const name = raw.trim();
if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty");
if (name.length > 100) throw new FileLibError(400, "invalid_request", "group name too long (max 100)");
return name;
}
async function requireActiveGroup(
client: PrismaClient | Tx,
groupId: string,
): Promise<{ readonly id: string; readonly name: string }> {
const group = await client.memberGroup.findFirst({
where: { id: groupId, archivedAt: null },
select: { id: true, name: true },
});
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
return group;
}
/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */
async function resolveUser(
tx: Tx,
input: AddMemberInput,
): Promise<{
readonly id: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}> {
const select = { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } as const;
if (input.userId !== undefined && input.userId !== "") {
const user = await tx.user.findUnique({ where: { id: input.userId }, select });
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.userId}`);
return user;
}
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
const user = await tx.user.findUnique({
where: { feishuOpenId: input.feishuOpenId },
select,
});
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.feishuOpenId}`);
return user;
}
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
}
/* ---------------------------------------------------------------- 公共操作 */
/**
* 创建成员组(建根 / 建子)。仅网站管理员。事务内维护闭包。
* parentId 给定时校验其活跃存在;闭包:插自身 depth0 + 继承 parent 的祖先。
*/
export async function createMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
input: CreateMemberGroupInput,
): Promise<MemberGroupDto> {
requireAdmin(actor);
const name = normalizeGroupName(input.name);
const description = input.description?.trim() || null;
const parentId = input.parentId ?? null;
return deps.prisma.$transaction(async (tx) => {
let parentClosure: { ancestorId: string; depth: number }[] = [];
if (parentId !== null) {
const parent = await tx.memberGroup.findFirst({
where: { id: parentId, archivedAt: null },
select: { id: true },
});
if (parent === null) throw new FileLibError(404, "group_not_found", "parent group not found");
parentClosure = await tx.memberGroupClosure.findMany({
where: { descendantId: parentId },
select: { ancestorId: true, depth: true },
});
}
const group = await tx.memberGroup.create({
data: { name, parentId, ...(description !== null ? { description } : {}) },
select: { id: true, parentId: true, name: true, description: true },
});
// 闭包维护:自身 depth0,再继承 parent 的每个祖先(depth+1)。
await tx.memberGroupClosure.create({
data: { ancestorId: group.id, descendantId: group.id, depth: 0 },
});
if (parentClosure.length > 0) {
await tx.memberGroupClosure.createMany({
data: parentClosure.map((a) => ({
ancestorId: a.ancestorId,
descendantId: group.id,
depth: a.depth + 1,
})),
});
}
// parent 的闭包行数 = parent.depth + 1 = 新组 depth(闭包不变量)。
const depth = parentClosure.length;
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupCreate,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { name, parentId },
});
return {
id: group.id,
parentId: group.parentId,
name: group.name,
description: group.description,
depth,
memberCount: 0,
archivedAt: null,
};
});
}
/**
* 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1
* 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。
*/
export async function updateMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
input: UpdateMemberGroupInput,
): Promise<MemberGroupDto> {
requireAdmin(actor);
if (input.name === undefined && input.description === undefined) {
throw new FileLibError(400, "invalid_request", "name or description is required");
}
const name = input.name === undefined ? undefined : normalizeGroupName(input.name);
return deps.prisma.$transaction(async (tx) => {
await requireActiveGroup(tx, groupId);
const group = await tx.memberGroup.update({
where: { id: groupId },
data: {
...(name !== undefined ? { name } : {}),
...(input.description !== undefined
? { description: input.description.trim() || null }
: {}),
},
select: { id: true, parentId: true, name: true, description: true },
});
// depth 由闭包行数推导(与 listMemberGroups 同一不变量);update 不改闭包。
const closureCount = await tx.memberGroupClosure.count({ where: { descendantId: groupId } });
const memberCount = await tx.memberGroupMembership.count({
where: { groupId, revokedAt: null },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: {
...(name !== undefined ? { name } : {}),
...(input.description !== undefined ? { description: group.description } : {}),
},
});
return {
id: group.id,
parentId: group.parentId,
name: group.name,
description: group.description,
depth: closureCount - 1,
memberCount,
archivedAt: null, // requireActiveGroup 已保证是活跃组
};
});
}
/**
* 软删除成员组:级联软删整棵子树(闭包 ancestorId=G 的全部活跃 descendant)。
* 闭包/成员行保留;list/解析按 archivedAt 过滤,整支立即停止贡献权限。
*/
export async function deleteMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
): Promise<{ readonly archivedCount: number }> {
requireAdmin(actor);
return deps.prisma.$transaction(async (tx) => {
const group = await requireActiveGroup(tx, groupId);
const subtree = await tx.memberGroupClosure.findMany({
where: { ancestorId: groupId },
select: { descendantId: true },
});
const ids = subtree.map((r) => r.descendantId);
const now = new Date();
const result = await tx.memberGroup.updateMany({
where: { id: { in: ids }, archivedAt: null },
data: { archivedAt: now },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { name: group.name, archivedCount: result.count },
});
return { archivedCount: result.count };
});
}
/**
* 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7):
* - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。
* - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、
* depth 推导(闭包行数)与"祖先必活跃"的前提脱节。
* - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次
* 恢复意外把整支历史组全部重新授权。
* 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。
*/
export async function restoreMemberGroup(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
): Promise<{ readonly restoredCount: number }> {
requireAdmin(actor);
return deps.prisma.$transaction(async (tx) => {
const group = await tx.memberGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, archivedAt: true },
});
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
if (group.archivedAt === null) {
throw new FileLibError(409, "not_archived", "group is not archived");
}
// 自身 + 祖先(闭包 descendantId=G 含 depth0 自身),只挑已归档的解标。
const chain = await tx.memberGroupClosure.findMany({
where: { descendantId: groupId },
select: { ancestorId: true },
});
const ids = chain.map((r) => r.ancestorId);
const result = await tx.memberGroup.updateMany({
where: { id: { in: ids }, archivedAt: { not: null } },
data: { archivedAt: null },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { name: group.name, restoredCount: result.count },
});
return { restoredCount: result.count };
});
}
/**
* 组扁平列表(前端自行按 parentId/depth 拼树);仅网站管理员。
* includeArchived=true 时连已归档组一并返回(带 archivedAt 标记),供后台展示/恢复;
* 默认只返回活跃组 —— 权限相关的调用方一律走默认。
*/
export async function listMemberGroups(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
includeArchived = false,
): Promise<readonly MemberGroupDto[]> {
requireAdmin(actor);
const groups = await deps.prisma.memberGroup.findMany({
where: includeArchived ? {} : { archivedAt: null },
orderBy: { name: "asc" },
select: { id: true, parentId: true, name: true, description: true, archivedAt: true },
});
if (groups.length === 0) return [];
const ids = groups.map((g) => g.id);
// depth:每个组的闭包行数(自身 + 祖先)- 1。级联软删保证活跃组的祖先必活跃。
const closure = await deps.prisma.memberGroupClosure.findMany({
where: { descendantId: { in: ids } },
select: { descendantId: true },
});
const closureCount = new Map<string, number>();
for (const row of closure) {
closureCount.set(row.descendantId, (closureCount.get(row.descendantId) ?? 0) + 1);
}
const counts = await deps.prisma.memberGroupMembership.groupBy({
by: ["groupId"],
where: { groupId: { in: ids }, revokedAt: null },
_count: { _all: true },
});
const countByGroup = new Map(counts.map((c) => [c.groupId, c._count._all]));
return groups.map((g) => ({
id: g.id,
parentId: g.parentId,
name: g.name,
description: g.description,
depth: (closureCount.get(g.id) ?? 1) - 1,
memberCount: countByGroup.get(g.id) ?? 0,
archivedAt: g.archivedAt,
}));
}
/**
* 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在,
* 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。
*/
export async function listMembers(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
): Promise<readonly MemberGroupMemberDto[]> {
requireAdmin(actor);
const exists = await deps.prisma.memberGroup.findUnique({
where: { id: groupId },
select: { id: true },
});
if (exists === null) throw new FileLibError(404, "group_not_found", "group not found");
const rows = await deps.prisma.memberGroupMembership.findMany({
where: { groupId, revokedAt: null },
select: {
createdAt: true,
user: { select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } },
},
orderBy: { createdAt: "asc" },
});
return rows.map((r) => ({
userId: r.user.id,
displayName: r.user.displayName,
feishuOpenId: r.user.feishuOpenId,
avatarUrl: r.user.avatarUrl,
joinedAt: r.createdAt,
}));
}
/** 加成员(userId 或 feishuOpenId 解析);已是活跃成员 → 409。仅网站管理员。 */
export async function addMember(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
input: AddMemberInput,
): Promise<MemberGroupMemberDto> {
requireAdmin(actor);
return deps.prisma.$transaction(async (tx) => {
const group = await requireActiveGroup(tx, groupId);
const user = await resolveUser(tx, input);
const existing = await tx.memberGroupMembership.findFirst({
where: { groupId: group.id, userId: user.id, revokedAt: null },
select: { id: true },
});
if (existing !== null) {
throw new FileLibError(409, "already_member", "user is already a member of this group");
}
const created = await tx.memberGroupMembership.create({
data: { groupId: group.id, userId: user.id },
select: { createdAt: true },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { userId: user.id },
});
return {
userId: user.id,
displayName: user.displayName,
feishuOpenId: user.feishuOpenId,
avatarUrl: user.avatarUrl,
joinedAt: created.createdAt,
};
});
}
/** 移成员(软删 revokedAt);不在组 → 404。仅网站管理员。 */
export async function removeMember(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
groupId: string,
userId: string,
): Promise<void> {
requireAdmin(actor);
await deps.prisma.$transaction(async (tx) => {
const group = await requireActiveGroup(tx, groupId);
const membership = await tx.memberGroupMembership.findFirst({
where: { groupId: group.id, userId, revokedAt: null },
select: { id: true },
});
if (membership === null) throw new FileLibError(404, "member_not_found", "group member not found");
await tx.memberGroupMembership.update({
where: { id: membership.id },
data: { revokedAt: new Date() },
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "group",
objectId: group.id,
objectPath: group.id,
detail: { userId },
});
});
}
/**
* 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2)
* —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大
* 已有能力面,只是把"盲敲 id"变成"搜索选择"。
* excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。
*/
export async function searchUsers(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
q: string,
excludeGroupId?: string,
limit = 20,
): Promise<readonly UserSearchResult[]> {
requireAdmin(actor);
const keyword = q.trim();
let excludeIds: string[] = [];
if (excludeGroupId !== undefined && excludeGroupId !== "") {
const rows = await deps.prisma.memberGroupMembership.findMany({
where: { groupId: excludeGroupId, revokedAt: null },
select: { userId: true },
});
excludeIds = rows.map((r) => r.userId);
}
const users = await deps.prisma.user.findMany({
where: {
...(excludeIds.length > 0 ? { id: { notIn: excludeIds } } : {}),
...(keyword === ""
? {}
: {
OR: [
{ displayName: { contains: keyword, mode: "insensitive" as const } },
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
],
}),
},
take: limit,
orderBy: { displayName: "asc" },
select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true },
});
return users.map((u) => ({
userId: u.id,
displayName: u.displayName,
feishuOpenId: u.feishuOpenId,
avatarUrl: u.avatarUrl,
}));
}
/**
* 授权选择器搜索(契约 C2 /groups/search)。**不限管理员**(决策2)。
* 活跃组按名过滤,breadcrumb 由活跃祖先链按 depth 排序拼成。
*/
export async function searchMemberGroups(
deps: MemberGroupServiceDeps,
q: string,
limit = 20,
): Promise<readonly MemberGroupSearchResult[]> {
const keyword = q.trim();
const groups = await deps.prisma.memberGroup.findMany({
where: {
archivedAt: null,
...(keyword === "" ? {} : { name: { contains: keyword, mode: "insensitive" as const } }),
},
take: limit,
orderBy: { name: "asc" },
select: { id: true, name: true },
});
if (groups.length === 0) return [];
const ids = groups.map((g) => g.id);
// 祖先链(仅活跃祖先);depth 越大越靠根。
const closure = await deps.prisma.memberGroupClosure.findMany({
where: { descendantId: { in: ids }, ancestor: { archivedAt: null } },
select: { descendantId: true, ancestorId: true, depth: true },
});
const ancestorIds = [...new Set(closure.map((c) => c.ancestorId))];
const names = await deps.prisma.memberGroup.findMany({
where: { id: { in: ancestorIds } },
select: { id: true, name: true },
});
const nameById = new Map(names.map((n) => [n.id, n.name]));
const chainByGroup = new Map<string, { ancestorId: string; depth: number }[]>();
for (const row of closure) {
const arr = chainByGroup.get(row.descendantId) ?? [];
arr.push({ ancestorId: row.ancestorId, depth: row.depth });
chainByGroup.set(row.descendantId, arr);
}
return groups.map((g) => {
const chain = (chainByGroup.get(g.id) ?? []).slice().sort((a, b) => b.depth - a.depth);
const breadcrumb = chain
.map((c) => nameById.get(c.ancestorId) ?? "")
.filter((s) => s !== "")
.join(" / ");
return { id: g.id, name: g.name, breadcrumb: breadcrumb || g.name };
});
}
-59
View File
@@ -1,59 +0,0 @@
/**
* 文件库领域基础:角色秩(D14)
*
* 语义锚定:仓库根-.md(2.2 / D8 / D14 )
* .omo/-.md, hub
* Folder/Project/PermissionGrant
*/
export const FILE_LIB_ROLES = ["VIEW", "EDIT", "MANAGE"] as const;
export type FileLibRole = (typeof FILE_LIB_ROLES)[number];
/** 契约 2.2:MANAGE > EDIT > VIEW,严格全序。 */
export const ROLE_RANK: Record<FileLibRole, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
export function roleAtLeast(role: FileLibRole, min: FileLibRole): boolean {
return ROLE_RANK[role] >= ROLE_RANK[min];
}
/** 业务错误。statusCode 由路由层映射为 HTTP 响应(D8 语义在此层只表达为 code)。 */
export class FileLibError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
/** 结构化附加信息(如 409 时的 currentVersion),路由层并入错误响应。 */
readonly details?: Record<string, unknown>,
) {
super(message);
this.name = "FileLibError";
}
}
export const NODE_NAME_MAX_LENGTH = 128;
/** D14:禁 `/`;反斜杠同样禁止(它会变成存储路径的分隔符,且易用于伪装)。控制字符禁。 */
const FORBIDDEN_NAME_CHARS = /[/\\\p{C}]/u;
/**
* D14:NFC + trim,
* FileLibError(400, "invalid_name"),
*/
export function normalizeNodeName(raw: string): string {
const name = raw.normalize("NFC").trim();
if (name.length === 0) {
throw new FileLibError(400, "invalid_name", "name must not be empty");
}
if (name.length > NODE_NAME_MAX_LENGTH) {
throw new FileLibError(400, "invalid_name", `name exceeds ${NODE_NAME_MAX_LENGTH} characters`);
}
if (FORBIDDEN_NAME_CHARS.test(name)) {
throw new FileLibError(400, "invalid_name", "name must not contain '/', '\\' or control characters");
}
return name;
}
/** D14:活跃兄弟节点大小写不敏感唯一的比较键(DB 层另有部分唯一索引兜底)。 */
export function nameKey(normalizedName: string): string {
return normalizedName.toLowerCase();
}
-74
View File
@@ -1,74 +0,0 @@
/**
* reducer( P6 / D11 / D8)
*
* (Metis ): "已解析好的" grant
* , DB / treeService
*
*/
import type { FileLibRole } from "./model.js";
import { ROLE_RANK } from "./model.js";
export interface FileLibGrantFact {
readonly nodeId: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
}
export interface EffectiveRoleInput {
/** 目标节点(self)。 */
readonly nodeId: string;
readonly nodeKind: "FOLDER" | "PROJECT";
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
readonly ancestorIds: readonly string[];
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
readonly independentPermissionsEnabled: boolean;
readonly userId: string;
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
readonly groupIds: readonly string[];
/** self ancestors 上的全部活跃 grant(revokedAt 已由获取层过滤)。 */
readonly grants: readonly FileLibGrantFact[];
}
/**
* P6:effective(user, R) = max { grant.role | s {user} groups*(user),
* r {R} ancestors(R) }; null()
* "个人权限不能降权" max ,
*
* D11:目标为 PROJECT ,( self ) grant
* ; grant(isCreatorGrant)
* grant
*/
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
const groups = new Set(input.groupIds);
const freezeProjectGrants =
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
let best: FileLibRole | null = null;
for (const grant of input.grants) {
if (!onChain.has(grant.nodeId)) continue;
if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
}
return best;
}
/**
* D8 :
* - (effective === null) "not_found"( 404,);
* - "forbidden"( 403)
*/
export type AccessVerdict =
| { readonly allowed: true; readonly role: FileLibRole }
| { readonly allowed: false; readonly reason: "not_found" | "forbidden" };
export function checkAccess(effective: FileLibRole | null, min: FileLibRole): AccessVerdict {
if (effective === null) return { allowed: false, reason: "not_found" };
if (ROLE_RANK[effective] < ROLE_RANK[min]) return { allowed: false, reason: "forbidden" };
return { allowed: true, role: effective };
}
-91
View File
@@ -1,91 +0,0 @@
/**
* /database/api/* :actor
* ( admin ):;guard fail closed; scope silo org
*/
import type { FastifyReply, FastifyRequest } from "fastify";
import { Prisma } from "@prisma/client";
import type { PrismaClient } from "@prisma/client";
import { FileLibError } from "./model.js";
import { requireFileLibActor } from "./guards.js";
import type { FileLibActor, TreeServiceDeps } from "./treeService.js";
import type { GroupResolver } from "./groupResolver.js";
import type { VersionStore } from "./versionStore.js";
import type { ExportAdapter } from "./exportService.js";
export interface FileLibRouteDeps {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly organizationId: string;
readonly storageRoot: string;
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
readonly exportAdapters: readonly ExportAdapter[];
/** 单文件字节上限(`HUB_FILELIB_MAX_FILE_BYTES`)。 */
readonly maxFileBytes: number;
}
/** 组装 treeService 依赖(路由处理内直接使用)。 */
export function treeDeps(deps: FileLibRouteDeps): TreeServiceDeps {
return {
prisma: deps.prisma,
groupResolver: deps.groupResolver,
versionStore: deps.versionStore,
organizationId: deps.organizationId,
storageRoot: deps.storageRoot,
};
}
/** 端点第一行调用;null = 响应已发(401/403),fail closed。 */
export async function actorOrNull(
request: FastifyRequest,
reply: FastifyReply,
deps: FileLibRouteDeps,
): Promise<FileLibActor | null> {
return requireFileLibActor(request, reply, {
prisma: deps.prisma,
sessionSecret: deps.sessionSecret,
organizationId: deps.organizationId,
});
}
/** 统一错误出口:FileLibError → 语义码;P2002 → 409;其余 → 500(不泄露内部)。 */
export async function sendRouteError(reply: FastifyReply, error: unknown): Promise<void> {
if (error instanceof FileLibError) {
await reply.status(error.statusCode).send({
error: { code: error.code, message: error.message, ...(error.details ?? {}) },
});
return;
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
await reply.status(409).send({ error: { code: "conflict", message: "uniqueness conflict" } });
return;
}
reply.log.error({ err: error }, "filelib route: unexpected error");
await reply.status(500).send({ error: { code: "internal", message: "internal error" } });
}
/** 请求体轻量校验(抛 FileLibError 400)。 */
export function bodyObject(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new FileLibError(400, "invalid_request", "request body must be a JSON object");
}
return body as Record<string, unknown>;
}
export function requireString(obj: Record<string, unknown>, key: string): string {
const value = obj[key];
if (typeof value !== "string" || value === "") {
throw new FileLibError(400, "invalid_request", `missing or invalid field: ${key}`);
}
return value;
}
export function optionalString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw new FileLibError(400, "invalid_request", `invalid field: ${key}`);
}
return value;
}
-606
View File
@@ -1,606 +0,0 @@
/**
* (Phase 1 ,Phase 2 )
*
* :
* - D8 404 ; 403(loadChain / requireAccess)
* - D11 creator + MANAGE grant; permission.ts
* - D12 move = MANAGE + EDIT+, + pg
* - D14 (model.ts)+ (DB )
* - D15 ;"任一祖先已删"
* - D17 breadcrumb View ,
* - 树表示:parentId ;pathIds id (name ,
* rename ;move )
*
* (D19)= silo org OWNER/ADMIN( C4 ): root
* force_adjust ,穿
* effectiveRole, admin
*/
import { randomUUID } from "node:crypto";
import path from "node:path";
import { Prisma } from "@prisma/client";
import type { PrismaClient, FileLibNode } from "@prisma/client";
import {
FileLibError,
nameKey,
normalizeNodeName,
type FileLibRole,
} from "./model.js";
import { checkAccess, effectiveRole } from "./permission.js";
import type { GroupResolver } from "./groupResolver.js";
import type { VersionStore } from "./versionStore.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
export interface FileLibActor {
readonly userId: string;
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
readonly isWebsiteAdmin: boolean;
/**
* () commit message ;
* userId退 userId
*/
readonly displayName?: string | undefined;
}
export interface TreeServiceDeps {
readonly prisma: PrismaClient;
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
/** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */
readonly organizationId: string;
/** 项目 git 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
readonly storageRoot: string;
}
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
export interface InitialGrant {
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
}
type Tx = Prisma.TransactionClient;
interface Chain {
readonly node: FileLibNode;
/** 根在前、直接父在后;不含 node 自身。 */
readonly ancestors: readonly FileLibNode[];
}
/* ---------------------------------------------------------------- 内部工具 */
/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */
function ancestorIdsOf(node: FileLibNode): string[] {
return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1);
}
/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */
async function loadVisibleChain(
tx: Tx,
organizationId: string,
nodeId: string,
): Promise<Chain> {
const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } });
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
const ancestorIds = ancestorIdsOf(node);
const ancestors = ancestorIds.length === 0
? []
: await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } });
if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) {
// D15:已删子树对外"不存在"(D8 不泄露)。
throw new FileLibError(404, "node_not_found", "node not found");
}
const byId = new Map(ancestors.map((a) => [a.id, a]));
const ordered = ancestorIds
.map((id) => byId.get(id))
.filter((a): a is FileLibNode => a !== undefined);
return { node, ancestors: ordered };
}
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
async function resolveRole(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
chain: Chain,
): Promise<FileLibRole | null> {
const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id];
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
let independentPermissionsEnabled = false;
if (chain.node.kind === "PROJECT") {
const settings = await tx.fileLibProjectSettings.findUnique({
where: { nodeId: chain.node.id },
select: { independentPermissionsEnabled: true },
});
independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
}
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return effectiveRole({
nodeId: chain.node.id,
nodeKind: chain.node.kind,
ancestorIds: chain.ancestors.map((a) => a.id),
independentPermissionsEnabled,
userId: actor.userId,
groupIds,
grants,
});
}
/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */
async function requireAccess(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
nodeId: string,
minRole: FileLibRole,
): Promise<Chain & { readonly role: FileLibRole }> {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const role = await resolveRole(tx, deps, actor, chain);
const verdict = checkAccess(role, minRole);
if (!verdict.allowed) {
throw verdict.reason === "not_found"
? new FileLibError(404, "node_not_found", "node not found")
: new FileLibError(403, "forbidden", `requires ${minRole}`);
}
return { ...chain, role: verdict.role };
}
/**
* (grantService ) tx 内门禁:在调用方自己的事务里做
* ,, check-tx / write-tx
*/
export async function requireAccessInTx(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
nodeId: string,
minRole: FileLibRole,
): Promise<Chain & { readonly role: FileLibRole }> {
return requireAccess(tx, deps, actor, nodeId, minRole);
}
/** P2002(活跃兄弟名部分唯一索引)→ 409。 */
function rethrowNameConflict(error: unknown, name: string): never {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`);
}
throw error;
}
function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string {
const table = kind === "PROJECT"
? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete }
: { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete };
return table[verb];
}
function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void {
const seen = new Set<string>();
for (const grant of grants) {
const key = `${grant.principalType}:${grant.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`);
seen.add(key);
if (grant.principalType === "USER" && grant.principalId === actor.userId) {
throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant");
}
// v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。
}
}
/* ---------------------------------------------------------------- 公共操作 */
export interface CreateNodeInput {
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly description?: string | undefined;
readonly grants?: readonly InitialGrant[] | undefined;
}
/**
* /root ( 2.1); root EDIT+
* creator MANAGE(D11); provisioning 状态机:PROVISIONING init READY
*/
export async function createNode(
deps: TreeServiceDeps,
actor: FileLibActor,
input: CreateNodeInput,
): Promise<FileLibNode> {
const name = normalizeNodeName(input.name);
const initialGrants = input.grants ?? [];
validateInitialGrants(actor, initialGrants);
const id = randomUUID();
let pathIds: string;
let storageDir: string | null = null;
const created = await deps.prisma.$transaction(async (tx) => {
if (input.parentId === null) {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "root creation requires website administrator");
}
pathIds = `/${id}`;
} else {
const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT");
if (parent.node.kind !== "FOLDER") {
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
}
pathIds = `${parent.node.pathIds}/${id}`;
}
if (input.kind === "PROJECT") {
// ADR-0030:项目扁平居于同一根下、以 uuid 命名;名字不进路径(所以 rename
// 不动磁盘)。FOLDER 永不赋值 —— 文件夹不落盘,只存在于 DB 的 parentId/pathIds。
storageDir = path.join(deps.storageRoot, id);
}
let node: FileLibNode;
try {
node = await tx.fileLibNode.create({
data: {
id,
organizationId: deps.organizationId,
parentId: input.parentId,
kind: input.kind,
name,
nameLower: nameKey(name),
pathIds,
creatorId: actor.userId,
provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY",
storageDir,
...(input.description !== undefined && input.description.trim() !== ""
? { description: input.description.trim() }
: {}),
},
});
} catch (error) {
rethrowNameConflict(error, name);
}
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: id,
principalType: "USER",
principalId: actor.userId,
role: "MANAGE",
isCreatorGrant: true,
createdByUserId: actor.userId,
},
});
for (const grant of initialGrants) {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: id,
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
createdByUserId: actor.userId,
},
});
}
if (input.kind === "PROJECT") {
await tx.fileLibProjectSettings.create({
data: { nodeId: id, independentPermissionsEnabled: false },
});
}
await writeFileLibAudit(tx, {
action: nodeAction(input.kind, "Create"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: input.kind === "PROJECT" ? "project" : "folder",
objectId: id,
objectPath: pathIds,
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
});
for (const grant of initialGrants) {
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: id,
objectPath: pathIds,
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
});
}
return node;
});
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
// ADR-0030:这一步真的建目录并 `git init`;宿主无 git 则此处 provision_failed。
if (input.kind === "PROJECT" && storageDir !== null) {
try {
await deps.versionStore.init(storageDir);
return await deps.prisma.fileLibNode.update({
where: { id: created.id },
data: { provisionStatus: "READY" },
});
} catch (error) {
await deps.prisma.fileLibNode
.update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } })
.catch(() => undefined);
throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`);
}
}
return created;
}
/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */
export async function renameNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
rawName: string,
): Promise<FileLibNode> {
const name = normalizeNodeName(rawName);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
let updated: FileLibNode;
try {
updated = await tx.fileLibNode.update({
where: { id: node.id },
data: { name, nameLower: nameKey(name) },
});
} catch (error) {
rethrowNameConflict(error, name);
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Rename"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { from: node.name, to: name },
});
return updated;
});
}
/**
* (D12): MANAGE + EDIT+( root );
* + org ; pathIds
*/
export async function moveNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
newParentId: string | null,
): Promise<FileLibNode> {
return deps.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`;
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
if (node.parentId === newParentId) return node;
let newPathIds: string;
if (newParentId === null) {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "moving to root requires website administrator");
}
newPathIds = `/${node.id}`;
} else {
const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT");
if (parent.node.kind !== "FOLDER") {
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
}
if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) {
throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree");
}
newPathIds = `${parent.node.pathIds}/${node.id}`;
}
const oldPrefix = node.pathIds;
let updated: FileLibNode;
try {
updated = await tx.fileLibNode.update({
where: { id: node.id },
data: { parentId: newParentId, pathIds: newPathIds },
});
// 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。
await tx.$executeRaw`
UPDATE "FileLibNode"
SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int)
WHERE "organizationId" = ${deps.organizationId}
AND "pathIds" LIKE ${oldPrefix + "/%"}
`;
} catch (error) {
rethrowNameConflict(error, node.name);
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Move"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: newPathIds,
detail: { fromParentId: node.parentId, toParentId: newParentId },
});
return updated;
});
}
/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */
export async function softDeleteNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Delete"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name },
});
});
}
/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */
export async function getEffectiveRole(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<FileLibRole | null> {
return deps.prisma.$transaction(async (tx) => {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const role = await resolveRole(tx, deps, actor, chain);
if (role === null) throw new FileLibError(404, "node_not_found", "node not found");
return role;
});
}
export interface BreadcrumbEntry {
readonly depth: number;
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
readonly id: string | null;
readonly name: string | null;
readonly kind: "FOLDER" | "PROJECT";
}
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
export async function breadcrumb(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly BreadcrumbEntry[]> {
return deps.prisma.$transaction(async (tx) => {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const selfRole = await resolveRole(tx, deps, actor, chain);
if (checkAccess(selfRole, "VIEW").allowed !== true) {
throw new FileLibError(404, "node_not_found", "node not found");
}
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const chainNodes = [...chain.ancestors, chain.node];
const chainIds = chainNodes.map((n) => n.id);
const allGrants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
const settings = chain.node.kind === "PROJECT"
? await tx.fileLibProjectSettings.findUnique({
where: { nodeId: chain.node.id },
select: { independentPermissionsEnabled: true },
})
: null;
return chainNodes.map((current, depth) => {
const role = effectiveRole({
nodeId: current.id,
nodeKind: current.kind,
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
independentPermissionsEnabled:
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
userId: actor.userId,
groupIds,
grants: allGrants,
});
const visible = role !== null;
return {
depth,
id: visible ? current.id : null,
name: visible ? current.name : null,
kind: current.kind,
};
});
});
}
export interface ChildNodeDto {
readonly id: string;
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly role: FileLibRole;
readonly createdAt: Date;
readonly updatedAt: Date;
}
/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */
export async function listChildren(
deps: TreeServiceDeps,
actor: FileLibActor,
parentId: string | null,
): Promise<readonly ChildNodeDto[]> {
return deps.prisma.$transaction(async (tx) => {
let parentAncestorIds: string[] = [];
if (parentId !== null) {
const parent = await requireAccess(tx, deps, actor, parentId, "VIEW");
parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id];
}
const children = await tx.fileLibNode.findMany({
where: { organizationId: deps.organizationId, parentId, deletedAt: null },
orderBy: [{ kind: "asc" }, { nameLower: "asc" }],
});
if (children.length === 0) return [];
// D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。
const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)];
const allGrants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
const settingsRows = projectIds.length === 0
? []
: await tx.fileLibProjectSettings.findMany({
where: { nodeId: { in: projectIds } },
select: { nodeId: true, independentPermissionsEnabled: true },
});
const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const out: ChildNodeDto[] = [];
for (const child of children) {
const role = effectiveRole({
nodeId: child.id,
nodeKind: child.kind,
ancestorIds: parentAncestorIds,
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
userId: actor.userId,
groupIds,
grants: allGrants,
});
if (role === null) continue;
out.push({
id: child.id,
parentId: child.parentId,
kind: child.kind,
name: child.name,
role,
createdAt: child.createdAt,
updatedAt: child.updatedAt,
});
}
return out;
});
}
/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */
export async function updateNodeDescription(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
description: string | null,
): Promise<FileLibNode> {
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT");
return tx.fileLibNode.update({
where: { id: node.id },
data: { description: description?.trim() || null },
});
});
}
-335
View File
@@ -1,335 +0,0 @@
/**
* VersionStore port( C1)+ ****
*
* `gitVersionStore.ts`( git ,VersionId = commit hash),
* ADR-0030 store;
* VersionId (`v1`/`v2`),****,
* 线("Mock 保真红线"):(S1)baseVersion=null (S2)
* init (S7) projectDir (S4)(D16)
*
* 持久化: persistPath (JSON),ADR-0030
* git, JSON
*/
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { FileLibError } from "./model.js";
export type VersionId = string;
/**
* `userId` (),`displayName`
* git `name <userId@域名>`(ADR-0030)
*/
export interface CommitAuthor {
readonly userId: string;
readonly displayName?: string | undefined;
}
export interface CommitRequest {
/** 编辑起始版本;null 表示新建文件(已存在则 conflict,S2)。 */
readonly baseVersion: VersionId | null;
readonly content: string | Buffer;
readonly message?: string | undefined;
readonly author?: CommitAuthor | undefined;
}
export type CommitResult =
| { readonly status: "ok"; readonly version: VersionId }
| { readonly status: "conflict"; readonly currentVersion: VersionId };
export interface VersionInfo {
readonly version: VersionId;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
}
/** 项目级提交历史条目(包含受影响文件路径)。 */
export interface ProjectCommitInfo {
readonly version: VersionId;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
/** 本次提交修改的文件路径列表。 */
readonly files: readonly string[];
}
export interface FileEntry {
readonly path: string;
readonly size: number;
}
export interface VersionStore {
init(projectDir: string): Promise<void>;
list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
head(projectDir: string, filePath: string): Promise<VersionId>;
read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>;
commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
/** 删除也是一次提交,所以同样带提交者身份。 */
remove(
projectDir: string,
filePath: string,
baseVersion: VersionId,
author?: CommitAuthor,
): Promise<CommitResult>;
diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>;
history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
/** 项目级提交历史(所有文件),新→旧排列。 */
projectHistory(projectDir: string, limit?: number): Promise<ProjectCommitInfo[]>;
}
interface StoredVersion {
readonly version: VersionId;
readonly content: Buffer;
readonly message: string;
readonly author: string | undefined;
readonly committedAt: string;
readonly deleted: boolean;
}
interface Repo {
/** 每文件一条版本链(D16:版本是文件级的,互不干扰)。 */
readonly files: Map<string, StoredVersion[]>;
counter: number;
}
/** S4:同 projectDir 的写操作经 per-repo promise 链串行化。 */
function createKeySerializer(): <T>(key: string, fn: () => Promise<T>) => Promise<T> {
const tails = new Map<string, Promise<unknown>>();
return <T>(key: string, fn: () => Promise<T>): Promise<T> => {
const prev = tails.get(key) ?? Promise.resolve();
const next = prev.then(fn, fn);
tails.set(key, next.catch(() => undefined));
return next;
};
}
function toBuffer(content: string | Buffer): Buffer {
return typeof content === "string" ? Buffer.from(content, "utf8") : content;
}
/** VersionInfo.author 是展示字符串;语义与 git 实现对齐(取 displayName,回退 userId)。 */
function authorLabel(author: CommitAuthor | undefined): string | undefined {
if (author === undefined) return undefined;
const name = author.displayName;
return name === undefined || name.trim() === "" ? author.userId : name.trim();
}
/** 极简 unified-diff(mock 保真够用;真包的 diff 以版本团队为准)。 */
function naiveDiff(fromText: string, toText: string): string {
const a = fromText.split("\n");
const b = toText.split("\n");
const out: string[] = ["--- a", "+++ b"];
const max = Math.max(a.length, b.length);
for (let i = 0; i < max; i += 1) {
const al = a[i];
const bl = b[i];
if (al === bl) {
if (al !== undefined) out.push(` ${al}`);
} else {
if (al !== undefined) out.push(`-${al}`);
if (bl !== undefined) out.push(`+${bl}`);
}
}
return out.join("\n");
}
export function createInMemoryVersionStore(persistPath?: string): VersionStore {
const repos = new Map<string, Repo>();
const serialize = createKeySerializer();
/* ---------------- 开发期快照持久化(语义不变,仅防重启丢失) ---------------- */
interface PersistedVersion extends Omit<StoredVersion, "content"> {
content: string; // base64
}
function loadPersisted(): void {
if (persistPath === undefined) return;
try {
const raw = JSON.parse(readFileSync(persistPath, "utf8")) as {
repos: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }>;
};
for (const [dir, repo] of Object.entries(raw.repos)) {
const files = new Map<string, StoredVersion[]>();
for (const [filePath, versions] of Object.entries(repo.files)) {
files.set(filePath, versions.map((v) => ({ ...v, content: Buffer.from(v.content, "base64") })));
}
repos.set(dir, { files, counter: repo.counter });
}
} catch { /* 无快照或损坏 → 空库起步(开发语义) */ }
}
function savePersisted(): void {
if (persistPath === undefined) return;
const out: Record<string, { counter: number; files: Record<string, PersistedVersion[]> }> = {};
for (const [dir, repo] of repos) {
const files: Record<string, PersistedVersion[]> = {};
for (const [filePath, versions] of repo.files) {
files[filePath] = versions.map((v) => ({ ...v, content: v.content.toString("base64") }));
}
out[dir] = { counter: repo.counter, files };
}
try {
mkdirSync(path.dirname(persistPath), { recursive: true });
const tmp = `${persistPath}.tmp`;
writeFileSync(tmp, JSON.stringify({ repos: out }), "utf8");
renameSync(tmp, persistPath);
} catch { /* 快照失败不影响开发使用 */ }
}
loadPersisted();
function requireRepo(projectDir: string): Repo {
const repo = repos.get(projectDir);
if (repo === undefined) {
throw new FileLibError(404, "repo_not_found", `repository not initialized: ${projectDir}`);
}
return repo;
}
function liveVersion(repo: Repo, filePath: string): StoredVersion {
const chain = repo.files.get(filePath);
const latest = chain?.[chain.length - 1];
if (chain === undefined || latest === undefined || latest.deleted) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
return latest;
}
function findVersion(repo: Repo, filePath: string, version: VersionId): StoredVersion {
const found = repo.files.get(filePath)?.find((v) => v.version === version);
if (found === undefined) {
throw new FileLibError(404, "version_not_found", `version not found: ${filePath}@${version}`);
}
return found;
}
return {
async init(projectDir) {
await serialize(projectDir, async () => {
// S7:幂等,重复调用不报错、不重建。
const created = repos.get(projectDir) === undefined;
repos.set(projectDir, repos.get(projectDir) ?? { files: new Map(), counter: 0 });
if (created) savePersisted();
});
},
async list(projectDir, prefix) {
const repo = requireRepo(projectDir);
const out: FileEntry[] = [];
for (const [path, chain] of repo.files) {
const latest = chain[chain.length - 1];
if (latest === undefined || latest.deleted) continue;
if (prefix !== undefined && !path.startsWith(prefix)) continue;
out.push({ path, size: latest.content.byteLength });
}
return out.sort((a, b) => a.path.localeCompare(b.path));
},
async head(projectDir, filePath) {
return liveVersion(requireRepo(projectDir), filePath).version;
},
async read(projectDir, filePath, at) {
const repo = requireRepo(projectDir);
if (at !== undefined) return findVersion(repo, filePath, at).content;
return liveVersion(repo, filePath).content;
},
async commit(projectDir, filePath, req) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
// S2:新建(baseVersion null)要求文件当前不存在;否则要求 baseVersion 精确等于当前版本(S1)。
if (req.baseVersion === null) {
if (currentVersion !== null) return { status: "conflict", currentVersion };
} else if (req.baseVersion !== currentVersion) {
return {
status: "conflict",
currentVersion: currentVersion ?? req.baseVersion,
};
}
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: toBuffer(req.content),
message: req.message ?? `commit ${version}`,
author: authorLabel(req.author),
committedAt: new Date().toISOString(),
deleted: false,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async remove(projectDir, filePath, baseVersion, author) {
return serialize(projectDir, async (): Promise<CommitResult> => {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const latest = chain[chain.length - 1];
const currentVersion = latest !== undefined && !latest.deleted ? latest.version : null;
if (currentVersion === null) {
throw new FileLibError(404, "file_not_found", `file not found: ${filePath}`);
}
if (baseVersion !== currentVersion) return { status: "conflict", currentVersion };
repo.counter += 1;
const version = `v${repo.counter}`;
chain.push({
version,
content: Buffer.alloc(0),
message: `remove ${filePath}`,
author: authorLabel(author),
committedAt: new Date().toISOString(),
deleted: true,
});
repo.files.set(filePath, chain);
savePersisted();
return { status: "ok", version };
});
},
async diff(projectDir, filePath, from, to) {
const repo = requireRepo(projectDir);
const a = findVersion(repo, filePath, from);
const b = findVersion(repo, filePath, to);
return naiveDiff(a.content.toString("utf8"), b.content.toString("utf8"));
},
async history(projectDir, filePath, limit) {
const repo = requireRepo(projectDir);
const chain = repo.files.get(filePath) ?? [];
const infos: VersionInfo[] = chain.map((v) => ({
version: v.version,
message: v.message,
author: v.author,
committedAt: v.committedAt,
}));
const ordered = infos.reverse();
return limit !== undefined ? ordered.slice(0, limit) : ordered;
},
async projectHistory(projectDir, limit) {
const repo = requireRepo(projectDir);
// 汇集所有文件的所有版本,按提交时间新→旧排序。
const all: ProjectCommitInfo[] = [];
for (const [filePath, chain] of repo.files) {
for (const v of chain) {
all.push({
version: v.version,
message: v.message,
author: v.author,
committedAt: v.committedAt,
files: [filePath],
});
}
}
all.sort((a, b) => b.committedAt.localeCompare(a.committedAt));
return limit !== undefined ? all.slice(0, limit) : all;
},
};
}
-50
View File
@@ -1,50 +0,0 @@
/**
* Registers the `/database/*` HTTP surface onto the Fastify app.
*
* Single public entry point (mirrors src/admin/plugin.ts). hub.ts calls
* registerDatabasePlugin() so the routes are recognized at startup.
*
* Register AFTER registerAdminPlugin: it relies on the @fastify/cookie parser
* and the /auth/feishu/* login routes that the admin plugin adds.
*
* The dev login bypass is self-contained in THIS module: the flag is read here
* (not threaded from hub.ts) and only ever enables the `/database/dev-login`
* route below. Double-gated: NODE_ENV must not be production AND
* HUB_DEV_LOGIN_BYPASS must be truthy. Production always requires real Feishu
* OAuth.
*/
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;
readonly sessionSecret: string;
readonly siloOrganizationSlug: string;
}
const FALSY = new Set(["", "0", "false", "no", "off"]);
export async function registerDatabasePlugin(
app: FastifyInstance,
config: DatabasePluginConfig,
): Promise<void> {
const allowDevLoginBypass =
process.env["NODE_ENV"] !== "production" &&
!FALSY.has((process.env["HUB_DEV_LOGIN_BYPASS"] ?? "").trim().toLowerCase());
if (allowDevLoginBypass) {
app.log.warn("DEV login bypass enabled: /database/dev-login mints a session without Feishu OAuth");
}
await registerDatabaseRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
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);
}
-251
View File
@@ -1,251 +0,0 @@
/**
* `/database/*` route aggregator.
*
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves.
*
* 标准前后端分离:本文件** HTML** tab
* `hub/filelib-web` SvelteKit SPA ( /app /database,
* ../static.ts / registerDatabaseSpa) SPA JSON/:
*
* /database/config unauthenticated bootstrap: silo org slug + dev toggle
* /database/api/stats ( + silo org OWNER/ADMIN)
* /database/dev-login DEV ONLY bypass, registered only when the flag is on
*
* The dev bypass (/database/dev-login) is self-contained here and gated by
* 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 } 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 { createGitVersionStore } from "../filelib/gitVersionStore.js";
import { resolveMaxFileBytes } from "../filelib/fileService.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
/** HMAC secret for the signed session cookie — reused from the admin plane. */
readonly sessionSecret: string;
/** 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;
}
export async function registerDatabaseRoutes(
app: FastifyInstance,
config: DatabaseRouteConfig,
): Promise<void> {
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
let filelibDepsForStats: FileLibRouteDeps | null = null;
// 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
// 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册,
// 两处并存是为了兼容既有前端调用点。
app.get("/database/config", async () => ({
orgSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
// 上传上限由后端下发,前端不再写死 —— 两处各自硬编码会随配置漂。
maxFileBytes: resolveMaxFileBytes(),
}));
// 概览页统计。登录 + 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
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
// session guard behaves identically. Never registered in production.
if (config.allowDevLoginBypass) {
app.get("/database/dev-login", async (_request, reply) => {
const membership = await config.prisma.organizationMembership.findFirst({
where: {
revokedAt: null,
role: { in: ["OWNER", "ADMIN"] },
organization: { status: "ACTIVE" },
},
select: { userId: true, organizationId: true },
orderBy: { createdAt: "asc" },
});
if (membership === null) {
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
}
const identity = await config.prisma.feishuUserIdentity.findFirst({
where: {
userId: membership.userId,
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
},
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
});
if (identity === null) {
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
}
const token = signSession(
{
userId: membership.userId,
feishuIdentityId: identity.id,
feishuConnectionId: identity.connectionId,
feishuOrganizationId: identity.connection.organizationId,
},
config.sessionSecret,
);
// Local dev is http://127.0.0.1, so secure:false. This route only ever
// runs outside production (double-gated in ./plugin.ts).
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: false,
maxAge: 7 * 24 * 60 * 60,
});
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
return reply.redirect("/database/dashboard");
});
}
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
// 依赖装配:VersionStore 是真 git —— 一项目一仓库 <storageRoot>/<nodeId>,
// VersionId = commit hash(ADR-0030);
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
const siloOrg = await config.prisma.organization.findUnique({
where: { slug: config.siloOrganizationSlug },
select: { id: true },
});
if (siloOrg === null) {
app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered");
return;
}
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
const versionStore = createGitVersionStore();
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
const filelibDeps: FileLibRouteDeps = {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
organizationId: siloOrg.id,
storageRoot,
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
? createMemberGroupResolver(config.prisma)
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
versionStore,
exportAdapters: [createCphPdfAdapter(), createManifestStubAdapter(versionStore)],
maxFileBytes: resolveMaxFileBytes(),
};
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
siloOrganizationSlug: config.siloOrganizationSlug,
allowDevLoginBypass: config.allowDevLoginBypass,
});
// 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。
// SPA 化后目标是真路由(不再是 #library 锚点)。
app.get("/database/library", async (_request, reply) =>
reply.redirect("/database/dashboard/library"),
);
filelibDepsForStats = filelibDeps;
}
/* ------------------------------------------------------------ 概览页统计 */
interface DashboardStats {
readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
readonly when: Date;
}>;
}
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
async function loadDashboardStats(
prisma: PrismaClient,
deps: FileLibRouteDeps,
): Promise<DashboardStats> {
const organizationId = deps.organizationId;
const [folders, projects, grants] = await Promise.all([
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }),
prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }),
]);
// 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计)
const readyProjects = await prisma.fileLibNode.findMany({
where: {
organizationId, kind: "PROJECT", deletedAt: null,
provisionStatus: "READY", storageDir: { not: null },
},
select: { storageDir: true },
});
let files = 0;
for (const project of readyProjects) {
if (project.storageDir === null) continue;
try {
files += (await deps.versionStore.list(project.storageDir)).length;
} catch { /* repo 缺失(如重启未恢复)不计 */ }
}
const entries = await prisma.auditEntry.findMany({
where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } },
orderBy: { createdAt: "desc" },
take: 8,
});
const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))];
const users = actorIds.length === 0
? []
: await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } });
const nameById = new Map(users.map((u) => [u.id, u.displayName]));
const recent = entries.map((entry) => {
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
const label =
(typeof meta["name"] === "string" ? meta["name"] : undefined) ??
(typeof meta["to"] === "string" ? meta["to"] : undefined) ??
(typeof meta["path"] === "string" ? meta["path"] : undefined) ??
(typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : "");
return {
action: entry.action,
actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown",
label,
when: entry.createdAt,
};
});
return { folders, projects, files, grants, recent };
}
-257
View File
@@ -1,257 +0,0 @@
/**
* /database/api/* ( 9.3/9.4)
* 冲突流:POST commits 200 {version} 409 {currentVersion}( UI diff )
*/
import type { FastifyInstance, FastifyRequest } from "fastify";
import {
commitFile,
deleteFile,
diffFile,
fileHistory,
listFiles,
projectHistory,
readFile,
readFileRaw,
type FileContentEncoding,
} from "../filelib/fileService.js";
import {
createManifestStubAdapter,
downloadExport,
getExportJob,
submitExport,
} from "../filelib/exportService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerFileRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const fileDeps = {
prisma: deps.prisma,
organizationId: deps.organizationId,
groupResolver: deps.groupResolver,
versionStore: deps.versionStore,
maxFileBytes: deps.maxFileBytes,
};
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
function pathParam(request: FastifyRequest): string {
const path = (request.query as { path?: string }).path;
if (path === undefined) throw new FileLibError(400, "invalid_request", "missing query: path");
return path;
}
function encodingParam(raw: unknown): FileContentEncoding {
if (raw === undefined || raw === "utf8") return "utf8";
if (raw === "base64") return "base64";
throw new FileLibError(400, "invalid_request", "encoding must be utf8 or base64");
}
app.get("/database/api/projects/:id/files", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const prefix = (request.query as { prefix?: string }).prefix;
return { files: await listFiles(fileDeps, actor, id, prefix) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return await readFile(fileDeps, actor, id, pathParam(request));
} catch (error) {
return sendRouteError(reply, error);
}
});
// 原始字节下载(Content-Disposition: attachment;浏览器直接 save-as)
app.get("/database/api/projects/:id/file/raw", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const raw = await readFileRaw(fileDeps, actor, id, pathParam(request));
return reply
.header("Content-Disposition",
`attachment; filename="${encodeURIComponent(raw.filename)}"; filename*=UTF-8''${encodeURIComponent(raw.filename)}`)
.header("X-Content-Version", raw.version)
.type("application/octet-stream")
.send(raw.buffer);
} catch (error) {
return sendRouteError(reply, error);
}
});
// 新建/上传(baseVersion 恒 null;已存在 → 409)
app.put("/database/api/projects/:id/file", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const result = await commitFile(fileDeps, actor, id, {
path: requireString(body, "path"),
baseVersion: null,
content: requireString(body, "content"),
encoding: encodingParam(body["encoding"]),
message: optionalString(body, "message"),
});
return reply.status(201).send(result);
} catch (error) {
return sendRouteError(reply, error);
}
});
// 提交编辑(乐观并发;409 → details.currentVersion + file.conflict_detected 审计)
app.post("/database/api/projects/:id/file/commits", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const baseVersion = body["baseVersion"];
if (typeof baseVersion !== "string" || baseVersion === "") {
throw new FileLibError(400, "invalid_request", "baseVersion must be a non-empty string");
}
return await commitFile(fileDeps, actor, id, {
path: requireString(body, "path"),
baseVersion,
content: requireString(body, "content"),
encoding: encodingParam(body["encoding"]),
message: optionalString(body, "message"),
});
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/projects/:id/file", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
await deleteFile(fileDeps, actor, id, pathParam(request), requireString(body, "baseVersion"));
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file/diff", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const query = request.query as { from?: string; to?: string };
if (query.from === undefined || query.to === undefined) {
throw new FileLibError(400, "invalid_request", "missing query: from / to");
}
return await diffFile(fileDeps, actor, id, pathParam(request), query.from, query.to);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/file/history", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const rawLimit = (request.query as { limit?: string }).limit;
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
}
return { history: await fileHistory(fileDeps, actor, id, pathParam(request), limit) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/projects/:id/history", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const rawLimit = (request.query as { limit?: string }).limit;
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
}
return { history: await projectHistory(fileDeps, actor, id, limit) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 导出(D10 异步) */
app.post("/database/api/projects/:id/exports", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const params = body["params"];
if (params !== undefined && (typeof params !== "object" || params === null || Array.isArray(params))) {
throw new FileLibError(400, "invalid_request", "params must be an object");
}
const job = await submitExport(
exportDeps,
actor,
id,
requireString(body, "target"),
(params as Record<string, unknown> | undefined) ?? {},
);
return reply.status(202).send({ jobId: job.id, status: job.status });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/exports/:jobId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { jobId } = request.params as { jobId: string };
return await getExportJob(exportDeps, actor, jobId);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/exports/:jobId/download", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { jobId } = request.params as { jobId: string };
const artifact = await downloadExport(exportDeps, actor, jobId);
// PDF 报真实 MIME,浏览器才能内联预览/正确命名;其余产物保守为二进制流。
const contentType = artifact.filename.toLowerCase().endsWith(".pdf")
? "application/pdf"
: "application/octet-stream";
return reply
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
.type(contentType)
.send(artifact.content);
} catch (error) {
return sendRouteError(reply, error);
}
});
}
-301
View File
@@ -1,301 +0,0 @@
/**
* /database/api/* ( 9.1/9.2 + C2 )
* 约定:绝对路径;actorOrNull ; filelib ; sendRouteError
*/
import type { FastifyInstance } from "fastify";
import {
breadcrumb,
createNode,
getEffectiveRole,
listChildren,
moveNode,
renameNode,
softDeleteNode,
updateNodeDescription,
type FileLibActor,
type InitialGrant,
} from "../filelib/treeService.js";
import {
forceAdjustGrants,
listGrants,
putGrants,
revokeGrant,
setIndependentPermission,
} from "../filelib/grantService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
treeDeps,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerFileLibRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const tree = treeDeps(deps);
const grantDeps = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
/* ------------------------------------------------------------ 身份 */
// 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。
// displayName/avatarUrl 供侧栏身份区显示 —— 页面不再服务端渲染(ADR-0029),
// 旧 renderDashboard 在 handler 里查 Prisma 拿到的名字,现在必须由这里带出去,
// 否则前端只有 userId 可显示。
app.get("/database/api/me", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { displayName: true, avatarUrl: true },
});
return {
userId: actor.userId,
isWebsiteAdmin: actor.isWebsiteAdmin,
displayName: user?.displayName ?? actor.userId,
avatarUrl: user?.avatarUrl ?? null,
};
});
/* ------------------------------------------------------------ 树节点 */
app.get("/database/api/nodes", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const query = request.query as { parentId?: string };
const parentId = query.parentId === undefined || query.parentId === "" ? null : query.parentId;
return { nodes: await listChildren(tree, actor, parentId) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/nodes", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const parentIdRaw = body["parentId"];
if (parentIdRaw !== null && typeof parentIdRaw !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
const kindRaw = requireString(body, "kind");
if (kindRaw !== "FOLDER" && kindRaw !== "PROJECT") {
throw new FileLibError(400, "invalid_request", "kind must be FOLDER or PROJECT");
}
const grants = parseGrants(body["grants"]);
const description = optionalString(body, "description");
const node = await createNode(tree, actor, {
parentId: parentIdRaw,
kind: kindRaw,
name: requireString(body, "name"),
description: description || undefined,
grants,
});
return reply.status(201).send({ node });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/nodes/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const role = await getEffectiveRole(tree, actor, id); // 无权限即 404(D8)
const node = await deps.prisma.fileLibNode.findFirst({
where: { id, organizationId: deps.organizationId },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
const settings = node.kind === "PROJECT"
? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } })
: null;
return {
node: {
id: node.id,
parentId: node.parentId,
kind: node.kind,
name: node.name,
description: node.description,
role,
provisionStatus: node.provisionStatus,
independentPermission: settings?.independentPermissionsEnabled ?? false,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
},
};
} catch (error) {
return sendRouteError(reply, error);
}
});
app.patch("/database/api/nodes/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const name = optionalString(body, "name");
const hasParent = Object.prototype.hasOwnProperty.call(body, "parentId");
const hasDesc = Object.prototype.hasOwnProperty.call(body, "description");
if (name === undefined && !hasParent && !hasDesc) {
throw new FileLibError(400, "invalid_request", "nothing to update (name? parentId? description?)");
}
let node;
if (name !== undefined) node = await renameNode(tree, actor, id, name);
if (hasParent) {
const parentId = body["parentId"];
if (parentId !== null && typeof parentId !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
node = await moveNode(tree, actor, id, parentId);
}
if (hasDesc) {
const d = body["description"];
if (d !== null && typeof d !== "string") {
throw new FileLibError(400, "invalid_request", "description must be a string or null");
}
node = await updateNodeDescription(tree, actor, id, typeof d === "string" ? d : null);
}
return { node };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/nodes/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
await softDeleteNode(tree, actor, id);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/nodes/:id/breadcrumb", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { breadcrumb: await breadcrumb(tree, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.get("/database/api/nodes/:id/effective-permission", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { role: await getEffectiveRole(tree, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 授权 */
app.get("/database/api/nodes/:id/grants", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { grants: await listGrants(grantDeps, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.put("/database/api/nodes/:id/grants", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const items = parseGrants(body["grants"]);
if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants");
return await putGrants(grantDeps, actor, id, items);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/nodes/:id/grants/:grantId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id, grantId } = request.params as { id: string; grantId: string };
await revokeGrant(grantDeps, actor, id, grantId);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
// D19 高危通道:网站管理员凭 id 强制调整,全部留 admin.force_adjust 审计。
app.put("/database/api/admin/nodes/:id/grants", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const items = parseGrants(body["grants"]);
if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants");
return await forceAdjustGrants(grantDeps, actor, id, items);
} catch (error) {
return sendRouteError(reply, error);
}
});
app.put("/database/api/projects/:id/independent-permission", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
if (typeof body["enabled"] !== "boolean") {
throw new FileLibError(400, "invalid_request", "enabled must be a boolean");
}
return await setIndependentPermission(grantDeps, actor, id, body["enabled"]);
} catch (error) {
return sendRouteError(reply, error);
}
});
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
}
function parseGrants(raw: unknown): InitialGrant[] | undefined {
if (raw === undefined) return undefined;
if (!Array.isArray(raw)) throw new FileLibError(400, "invalid_request", "grants must be an array");
return raw.map((item) => {
if (typeof item !== "object" || item === null) {
throw new FileLibError(400, "invalid_request", "grant entries must be objects");
}
const grant = item as Record<string, unknown>;
const principalType = grant["principalType"];
if (principalType !== "USER" && principalType !== "GROUP") {
throw new FileLibError(400, "invalid_request", "grant.principalType must be USER or GROUP");
}
const role = grant["role"];
if (role !== "VIEW" && role !== "EDIT" && role !== "MANAGE") {
throw new FileLibError(400, "invalid_request", "grant.role must be VIEW, EDIT or MANAGE");
}
if (typeof grant["principalId"] !== "string" || grant["principalId"] === "") {
throw new FileLibError(400, "invalid_request", "grant.principalId must be a non-empty string");
}
return { principalType, principalId: grant["principalId"], role };
});
}
@@ -1,196 +0,0 @@
/**
* /database/api/groups/* (ADR-0028)
* 约定:绝对路径;actorOrNull fail closed; memberGroupService;
* sendRouteError
*
* (CRUD + ) service ;
* ( Manage ,2)
*/
import type { FastifyInstance } from "fastify";
import {
addMember,
createMemberGroup,
deleteMemberGroup,
listMemberGroups,
listMembers,
removeMember,
restoreMemberGroup,
searchMemberGroups,
searchUsers,
updateMemberGroup,
} from "../filelib/memberGroupService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerMemberGroupRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId };
/* ------------------------------------------------------------ 搜索(授权选择器) */
// 契约 C2 /groups/search:活跃组 + breadcrumb。**非管理员可调**(决策2)。
// 注:必须先于 "/database/api/groups" 之类的段前缀之外单独成路径,Fastify
// 静态路由不会 shadow,顺序无关;此处与其它端点平级注册。
app.get("/database/api/groups/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const q = (request.query as { q?: string }).q ?? "";
return { groups: await searchMemberGroups(svc, q) };
} catch (error) {
return sendRouteError(reply, error);
}
});
// 成员选择器:搜全局用户。仅管理员(service 层门禁)。
// excludeGroupId 过滤掉该组已有活跃成员。
app.get("/database/api/users/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const query = request.query as { q?: string; excludeGroupId?: string };
return { users: await searchUsers(svc, actor, query.q ?? "", query.excludeGroupId) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 组 CRUD */
// includeArchived=1 时连已归档组一并返回(带 archivedAt),供后台展示/恢复。
app.get("/database/api/groups", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const raw = (request.query as { includeArchived?: string }).includeArchived;
const includeArchived = raw === "1" || raw === "true";
return { groups: await listMemberGroups(svc, actor, includeArchived) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/groups", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const parentIdRaw = body["parentId"];
if (parentIdRaw !== undefined && parentIdRaw !== null && typeof parentIdRaw !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
const group = await createMemberGroup(svc, actor, {
name: requireString(body, "name"),
description: optionalString(body, "description"),
parentId: parentIdRaw === undefined ? null : parentIdRaw,
});
return reply.status(201).send({ group });
} catch (error) {
return sendRouteError(reply, error);
}
});
// 改名 / 改描述(决策6)。不接受 parentId —— reparent 仍不在 v1(决策5)。
app.patch("/database/api/groups/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
if (body["parentId"] !== undefined) {
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
}
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
// (它把 "" 也归为 undefined)。
const descRaw = body["description"];
if (descRaw !== undefined && descRaw !== null && typeof descRaw !== "string") {
throw new FileLibError(400, "invalid_request", "description must be a string");
}
const group = await updateMemberGroup(svc, actor, id, {
name: optionalString(body, "name"),
description: descRaw === undefined || descRaw === null ? undefined : descRaw,
});
return { group };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/groups/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const result = await deleteMemberGroup(svc, actor, id);
return { archivedCount: result.archivedCount };
} catch (error) {
return sendRouteError(reply, error);
}
});
// 恢复(取消归档)。与删除不对称:只恢复该组 + 已归档祖先链,不动子树(决策7)。
app.post("/database/api/groups/:id/restore", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const result = await restoreMemberGroup(svc, actor, id);
return { restoredCount: result.restoredCount };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 成员 */
app.get("/database/api/groups/:id/members", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { members: await listMembers(svc, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/groups/:id/members", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const userId = optionalString(body, "userId");
const feishuOpenId = optionalString(body, "feishuOpenId");
if (userId === undefined && feishuOpenId === undefined) {
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
}
const member = await addMember(svc, actor, id, { userId, feishuOpenId });
return reply.status(201).send({ member });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/groups/:id/members/:userId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id, userId } = request.params as { id: string; userId: string };
await removeMember(svc, actor, id, userId);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
}
-106
View File
@@ -1,106 +0,0 @@
/**
* ():
* GET /database/api/login-info (org slug / dev )
* GET /app/dev-login-teacher DEV ONLY ()
*
* `/app/*` SPA 退 ../static.ts
* `/database/*` filelib-web ,
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
export interface TeacherAppConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
/** 飞书 OAuth 链接按 silo org slug 构造。 */
readonly siloOrganizationSlug: string;
/** DEV ONLY(双重门禁,见 database/plugin.ts):一键登录端点与按钮同进同退。 */
readonly allowDevLoginBypass: boolean;
}
export async function registerTeacherApp(
app: FastifyInstance,
config: TeacherAppConfig,
): Promise<void> {
// 登录页配置(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
app.get("/database/api/login-info", async () => ({
orgSlug: config.siloOrganizationSlug,
devLoginEnabled: config.allowDevLoginBypass,
}));
if (!config.allowDevLoginBypass) return;
registerDevLogins(app, config);
}
/** DEV ONLY:( plugin.ts)
* /database/admin */
function registerDevLogins(app: FastifyInstance, config: TeacherAppConfig): void {
app.get("/app/dev-login-teacher", async (_request, reply) => {
const prisma = config.prisma;
const organization = await prisma.organization.findFirst({
where: { status: "ACTIVE" },
select: { id: true },
});
if (organization === null) {
return reply.status(404).send({ error: { code: "no_org", message: "no active organization" } });
}
let membership = await prisma.organizationMembership.findFirst({
where: { organizationId: organization.id, role: "MEMBER", revokedAt: null },
select: { userId: true, organizationId: true },
});
if (membership === null) {
const teacher = await prisma.user.upsert({
where: { feishuOpenId: "ou_dev_teacher" },
update: {},
create: { feishuOpenId: "ou_dev_teacher", displayName: "测试老师" },
});
await prisma.organizationMembership.create({
data: { organizationId: organization.id, userId: teacher.id, role: "MEMBER" },
});
membership = { userId: teacher.id, organizationId: organization.id };
}
const connection = await prisma.organizationFeishuApplicationConnection.findFirst({
where: { organizationId: membership.organizationId, status: "ACTIVE" },
select: { id: true, organizationId: true },
});
if (connection === null) {
return reply.status(404).send({ error: { code: "no_connection", message: "no active Feishu connection for org" } });
}
let identity = await prisma.feishuUserIdentity.findFirst({
where: { userId: membership.userId, connectionId: connection.id },
select: { id: true, connectionId: true },
});
if (identity === null) {
identity = await prisma.feishuUserIdentity.create({
data: { connectionId: connection.id, userId: membership.userId, openId: "ou_dev_teacher" },
select: { id: true, connectionId: true },
});
}
setSessionCookie(reply, config.sessionSecret, membership.userId, identity.id, identity.connectionId, connection.organizationId);
reply.log.warn({ userId: membership.userId }, "DEV teacher-app login bypass (regular teacher) used");
return reply.redirect("/app");
});
}
function setSessionCookie(
reply: { setCookie: (name: string, value: string, opts: Record<string, unknown>) => void },
secret: string,
userId: string,
feishuIdentityId: string,
feishuConnectionId: string,
feishuOrganizationId: string,
): void {
const token = signSession({ userId, feishuIdentityId, feishuConnectionId, feishuOrganizationId }, secret);
reply.setCookie(SESSION_COOKIE_NAME, token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: false,
maxAge: 7 * 24 * 60 * 60,
});
}
-71
View File
@@ -1,71 +0,0 @@
/**
* 前端静态托管:老师端 `/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<void> {
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<FastifyReply> =>
reply.type("text/html; charset=utf-8").send(indexHtml);
app.get("/app", sendIndex);
app.get("/app/*", sendIndex);
app.get("/database", sendIndex);
app.get("/database/*", sendIndex);
}
-1
View File
@@ -250,7 +250,6 @@ async function initializeSilo(
data: { data: {
organizationId: input.organization.id, organizationId: input.organization.id,
name: "Inbox", name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000", sortKey: "000000",
}, },
}); });

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