# 文件库系统 · 接口契约与决策（v0.1 我方提案）

> **读者**：版本团队、Group 团队、审计团队、平台方、编辑团队、产品。
> **用法**：本文档是我方（文件库）提出的对齐基线。所有决策带 `D-x` 编号、所有契约带 `C-x` 编号，评审时请按编号引用（"D5 我们不同意，建议……"）。评审通过后本文件冻结，各方照此实现；任何变更走文档修订，不接受口头对齐。
> **状态**：v0.1 提案，未冻结。

---

## 1. 系统边界与分工

| 子系统 | 负责方 | 与我方关系 |
|---|---|---|
| 文件库（目录树 / 权限引擎 / 内容存储 / 对外 API / 前端） | **我方** | — |
| 版本能力（git 化、commit、diff、历史） | 版本团队 | 交付 **npm 工具包**，我方在自有存储上集成（见 C1） |
| Group 系统（全局嵌套组） | Group 团队 | 我方**实时消费**其查询接口（见 C2） |
| 审计系统（存储 / 查询 / 保留） | 审计团队 | 我方**上报事件**（见 C3）；查询界面归审计方 |
| 平台身份（登录 / 角色） | 平台方 | 我方验签消费角色（见 C4） |
| 在线编辑与冲突合并 UI（需求 2.5） | 编辑团队 | **消费我方文件 API**（见第 9 节） |
| 导出工具 | 已有工具 | 我方按需传参调用（参数清单 OPEN-6） |

**内容归属**：文件内容（git 仓库）物理存储在文件库自己的服务器上，由我方管理；版本团队不持有任何数据，只交付代码。

## 2. 总体架构

```
编辑团队(2.5 UI) ──┐
其他消费方        ──┤ HTTP + JWT
                   ▼
        ┌─────────────────────┐
        │   文件库服务(我方)  │
        │  目录树 + 权限引擎   │──── 实时查询 ──▶ Group 系统(C2)
        │  文件 API + 授权 API │──── 验签 JWT ──▶ 平台身份(C4)
        └─────────┬───────────┘
                  │ import(函数调用)
                  ▼
        ┌─────────────────────┐      outbox 中继(at-least-once)
        │ 版本工具包(C1,npm)  │         ┌──────────────────────▶ 审计系统(C3)
        │ 操作我方磁盘上的     │         │
        │ git 仓库(内容归我方)│         ▼
        └─────────────────────┘      我方 DB:业务表 + outbox 表(同事务)
```

## 3. 决策总表

| 编号 | 决策 | 一句话理由 | 影响方 |
|---|---|---|---|
| D1 | 文件库后端 TypeScript + Fastify + Prisma(PostgreSQL)；前端 React | 与平台技术心智一致 | 我方 |
| D2 | 文件内容物理存储在文件库服务器，版本能力以 npm 包交付 | 需求 2.1"项目初始化为 Git 仓库"，仓库是项目的存储 | 版本团队 |
| D3 | 版本包冲突用**返回值**表达，异常仅用于系统错误 | 冲突是正常业务流，不是故障 | 版本、编辑 |
| D4 | Group 查询**实时调用、不缓存** | 需求 3.3"成员变更实时生效" | Group |
| D5 | 审计走"本地 outbox + 后台中继"满足"同事务或可靠消息" | 跨服务做不到真同事务，outbox 是最简单的可靠方案 | 审计 |
| D6 | 平台身份用 JWT(RS256)验签，平台角色 `platform.admin` 映射网站管理员 | 无状态、无需每次回调平台 | 平台 |
| D7 | 权限管理规则按第 8 节补齐（2.4 空洞的我方版本） | 需求 2.4 为空，必须先有规则才能写码 | 产品确认 |
| D8 | 无 View 权限时 API 返回 **404**（不泄露存在性）；有 View 但操作越权返回 403 | 防资源探测 | 所有消费方 |
| D9 | 软删除：删除即打标隐藏，所有 API 默认不可见 | 需求 2.6/3.3 | 我方 |
| D10 | 导出为**异步任务**（提交返回 job，轮询取结果） | 导出耗时不确定，同步会超时 | 编辑/前端 |

---

## 4. C1 · 版本工具包契约（给版本团队）

**交付形态**：npm 包（TypeScript，自带类型定义），运行在文件库后端进程内。实现技术（isomorphic-git / nodegit / 其他）由版本团队自选，**本契约只约束接口与语义**。

### 4.1 接口签名

```typescript
export type VersionId = string; // 不透明字符串,调用方不得解析(当前为 git commit hash)

export interface CommitRequest {
  /** 编辑的起始版本;null 表示新建文件 */
  baseVersion: VersionId | null;
  content: string | Buffer;     // 文本用 string(UTF-8),二进制用 Buffer
  message?: string;             // 缺省由包自动生成
  author?: string;              // 操作者标识,写入版本记录
}

export type CommitResult =
  | { status: "ok"; version: VersionId }
  | { status: "conflict"; currentVersion: VersionId };

export interface VersionInfo {
  version: VersionId;
  message: string;
  author?: string;
  committedAt: string;          // ISO 8601
}

export interface FileEntry { path: string; size: number; }

export interface VersionStore {
  init(projectDir: string): Promise<void>;                                   // S7: 幂等
  list(projectDir: string, prefix?: string): Promise<FileEntry[]>;
  head(projectDir: string, filePath: string): Promise<VersionId>;            // 文件不存在 → FileNotFoundError
  read(projectDir: string, filePath: string, at?: VersionId): Promise<Buffer>; // 缺省读最新
  commit(projectDir: string, filePath: string, req: CommitRequest): Promise<CommitResult>;
  remove(projectDir: string, filePath: string, baseVersion: VersionId): Promise<CommitResult>;
  diff(projectDir: string, filePath: string, from: VersionId, to: VersionId): Promise<string>; // unified diff
  history(projectDir: string, filePath: string, limit?: number): Promise<VersionInfo[]>;
}
```

### 4.2 语义规则

| 编号 | 规则 |
|---|---|
| S1 | **冲突不是异常**。`baseVersion` 落后于当前版本时返回 `{status:"conflict"}`；异常仅用于 IO 故障、仓库损坏等系统错误 |
| S2 | `baseVersion: null` = 新建；路径已存在时返回 conflict |
| S3 | 二进制文件与文本同样版本化；`diff` 仅保证对文本有意义，二进制可返回占位说明 |
| S4 | 包必须保证**同一 projectDir 的写操作（commit/remove）串行化**，调用方可并发调用 |
| S5 | 存储布局不透明：我方不直接读写仓库目录，一切经包接口 |
| S6 | 在线编辑仅针对文本文件（需求 2.5）；二进制材料走上传/下载 |
| S7 | `init` 幂等，重复调用不报错、不重建 |
| S8 | 规模假设：单项目文件数千级、单文件 10MB 以内，超出另行对齐 |

---

## 5. C2 · Group 查询契约（给 Group 团队)

**形态**：HTTP + JSON。我方只读，不写 Group 系统任何数据（需求 3.3）。

### 5.1 接口

```
GET /groups/resolve-member-groups?userId={userId}
  → { "groupIds": ["g1","g2",...] }
  # 权限计算专用:用户直接所属的全部 group + 这些 group 的所有祖先 group,去重。
  # 方向是"向祖先"收集(需求 3.2:权限沿 group 树向下传递)。

GET /groups/search?q={keyword}&limit={n}
  → [{ "id":"g1","name":"物理教研组","breadcrumb":"总部 / 教研 / 物理" }]
  # 前端授权选择器用。

GET /groups/{groupId}
  → { "id":"g1","name":"物理教研组","parentId":"g0","status":"active" }
```

### 5.2 语义规则

| 编号 | 规则 |
|---|---|
| G1 | `groupId` 为不透明、全局唯一字符串 |
| G2 | resolve 只含**祖先方向**，不含子孙；用户无所属时返回空数组 |
| G3 | 软删除的 group 不出现在任何接口结果中 |
| G4 | **实时性**：我方每次权限计算都实时调用 resolve，不缓存；Group 方成员变更须即刻在 resolve 结果中可见（需求 3.3） |
| G5 | 文件夹/项目的授权记录只存在文件库，Group 系统不感知（需求 3.3） |
| G6 | 可用性：resolve 位于我方**每一次受保护请求**的权限计算路径上，其可用性即文件库可用性 → 需要 Group 方给出延迟与可用性承诺（OPEN-1） |

---

## 6. C3 · 审计上报契约（给审计团队）

### 6.1 机制（满足需求 5.1"同事务或可靠消息"）

1. 我方在每个关键写操作的**同一数据库事务**内，向本地 `audit_outbox` 表写入事件；
2. 后台中继进程读取 outbox，POST 到审计系统 `POST /audit/events`，**at-least-once**，失败重试；
3. 审计系统按 `eventId` **幂等去重**；
4. 中继只负责送达，业务操作不因审计系统不可用而失败（但事件绝不丢）。

### 6.2 事件信封（补齐需求 5.3 空缺的字段定义）

```json
{
  "eventId": "01J…",              // 我方生成的唯一 id,幂等键
  "schemaVersion": 1,
  "occurredAt": "2026-07-21T10:00:00.000Z",
  "sourceService": "filelib",
  "actor": { "userId": "u123", "platformRole": "teacher" },
  "action": "permission.grant",   // 见 6.3 词汇表
  "object": { "type": "folder", "id": "n456", "path": "/物理/必修一" },
  "result": "success",            // success | failure
  "errorCode": null,
  "detail": { /* 按 action 而定的负载,如 grant 的 {principalType, principalId, role} */ }
}
```

### 6.3 action 词汇表（文件库范围，对照需求 5.2）

| action | 需求 5.2 对应 |
|---|---|
| `folder.create` / `folder.rename` / `folder.move` / `folder.delete` | 创建/删除/移动/重命名文件夹 |
| `project.create` / `project.rename` / `project.move` / `project.delete` | 同上（项目） |
| `permission.grant` / `permission.update` / `permission.revoke` | 权限变更（detail 含个人/Group、Manage/Edit/View） |
| `project.independent_permission.enable` / `.disable` / `.change` | 项目独立权限开启/关闭/变更 |
| `file.upload` / `file.rename` / `file.delete` | 材料文件管理 |
| `file.commit` | 文件编辑提交（含冲突合并后提交；编辑经我方 API 落盘，故由我方上报） |
| `file.conflict_detected` | 冲突检测发生（detail 含起始版本与冲突版本） |
| `export.run` | 导出操作 |
| `admin.force_adjust` | 网站管理员强制权限调整（高危） |

Group 相关动作（创建/删除/成员变更/嵌套变更）由 **Group 系统自行上报**；归档类动作待归档功能定案后补充（OPEN-4）。

### 6.4 边界

- 审计的存储、防篡改、保留策略（需求 5.5 ≥180 天）、查询接口与查询界面，全部归审计系统；
- 文件库前端**不内嵌**审计查询页（若产品要求嵌入，OPEN-8 再议）。

---

## 7. C4 · 平台身份契约（给平台方）

1. 调用方在 `Authorization: Bearer <JWT>` 中携带平台签发的令牌（RS256）；平台通过 JWKS endpoint 分发公钥，我方只做验签 + 过期检查，**不回调平台、不建用户表**；
2. claims 约定：`sub`（用户 id，全局唯一）、`roles`（平台角色数组）、`exp`、`iss`；
3. **角色映射**：`roles` 含 `platform.admin` → 文件库"网站管理员"；其余合法令牌 → 普通用户；无令牌/验签失败/过期 → `401`；
4. 网站管理员权限范围：创建根目录、强制权限调整（必审计）、以及平台方赋予的其他高危操作；**不隐式穿透**各节点的业务权限（要管理某子树须被显式授权或走强制调整并留痕）；
5. 前端登录跳转平台 SSO，具体流程由前端与平台方另行对齐。

---

## 8. 权限规则 · 2.4 补齐版（D7，产品确认后冻结）

### 8.1 创建者与授权矩阵

| 角色 | 能授/改/收的级别 | 限制 |
|---|---|---|
| **节点创建者** | Manage / Edit / View | 自身 Manage 不可被收回（转让 OPEN-3） |
| **Manage 持有者** | Edit / View | **不可**授予 Manage；**不可**修改/收回创建者的任何权限 |
| **Edit / View** | 无授权能力 | — |
| **网站管理员** | 任意（走 `admin.force_adjust`，必审计） | 不属于日常授权路径 |

### 8.2 各级别能力清单

| 能力 | View | Edit | Manage |
|---|:---:|:---:|:---:|
| 浏览树 / 读文件 / 下载 | ✓ | ✓ | ✓ |
| 导出 | ✓ | ✓ | ✓ |
| 创建子文件夹/项目（需求：父级 Edit+） | | ✓ | ✓ |
| 编辑文本文件 / 上传 / 删改项目内材料 | | ✓ | ✓ |
| 重命名 / 移动 / 删除**本节点** | | | ✓ |
| 授权管理（按 8.1 矩阵） | | | ✓ |
| 项目独立权限开关 | | | ✓ |

### 8.3 其余规则

- **P3 根目录**：仅网站管理员可创建；创建时必须指定 ≥1 名 Manage 持有者（可以不是创建者本人），保证每棵子树都有权限链起点；
- **P4 空权限创建**：允许；此时仅创建者可见可用；
- **P5 项目独立权限**：默认关闭（仅继承父链）；开启后项目级授权参与 max 计算；关闭时项目级授权**冻结不删除**，重新开启即恢复；
- **P6 计算规则**（与需求 2.3 一致的形式化）：

  `effective(user, R) = max { grant.role | grant ∈ grants(s, r), s ∈ {user} ∪ resolveGroups(user), r ∈ {R} ∪ ancestors(R) }`，无 grant → 无权限。个人低权限**不构成降权**（只取最高，不做减法）；
- **P7 可见性**：对某节点无任何权限的用户，该节点对其不可见（API 行为见 D8）。

---

## 9. 文件库对外 API（消费方：编辑团队及其他）

**约定**：REST + JSON + JWT（C4）；统一错误信封 `{ "error": { "code", "message", "details?" } }`；D8 可见性语义（无 View → 404；越权操作 → 403）。

### 9.1 树与节点

```
GET    /nodes?parentId={id}          # 列子节点(缺省列根);仅返回有 View 的
POST   /nodes                        # 创建文件夹/项目 {parentId, type, name, grants?[]}
                                     #   parentId=null 仅网站管理员;type=project 时自动调 C1.init
GET    /nodes/{id}                   # 节点详情 + 我的 effective 权限 + breadcrumb
PATCH  /nodes/{id}                   # 重命名/移动 {name?, parentId?} (需本节点 Manage)
DELETE /nodes/{id}                   # 软删除(需 Manage)
```

### 9.2 授权

```
GET    /nodes/{id}/grants            # 授权列表(需 Manage)
PUT    /nodes/{id}/grants            # 批量授予/修改 [{principalType:user|group, principalId, role}]
DELETE /nodes/{id}/grants/{grantId}  # 收回
GET    /nodes/{id}/effective-permission  # 当前用户在此节点的生效权限(自查)
PUT    /projects/{id}/independent-permission  # {enabled: bool} 独立权限开关
```

### 9.3 文件内容（编辑团队的主接口）

```
GET    /projects/{id}/files?prefix=              # 文件清单
GET    /projects/{id}/files/{path}               # → {content, version} 编辑起手式:拿到起始版本
PUT    /projects/{id}/files/{path}               # 新建/上传 {content} (需 Edit)
POST   /projects/{id}/files/{path}/commits       # 提交编辑 {baseVersion, content, message?}
                                                 # → 200 {version} | 409 {currentVersion}
GET    /projects/{id}/files/{path}/diff?from=&to= # 冲突时取差异(unified diff)
GET    /projects/{id}/files/{path}/history       # 版本历史
DELETE /projects/{id}/files/{path}               # {baseVersion} (需 Edit)
```

**冲突合并流程（配合编辑团队 2.5）**：编辑 UI 用 `GET files/{path}` 记录 `version` → 用户编辑 → `POST commits` 带 `baseVersion` → 若 `409`，UI 用 `diff?from=base&to=current` 拉取差异，展示三方合并区 → 用户写出最终内容后再次 `POST commits`（baseVersion 换为 currentVersion）。

### 9.4 导出

```
POST /projects/{id}/exports {target, params}  → {jobId}   # D10 异步
GET  /exports/{jobId}                         → {status, downloadUrl?}
```

---

## 10. OPEN 清单（需对方/产品确认，不阻塞我方开工）

| 编号 | 事项 | 等谁 |
|---|---|---|
| OPEN-1 | Group resolve 的延迟/可用性 SLA 数值 | Group 团队 |
| OPEN-2 | 审计事件投递的 endpoint、鉴权方式、保留期确认（需求建议 ≥180 天） | 审计团队 |
| OPEN-3 | 创建者离职/转让后 Manage 链如何处理 | 产品 |
| OPEN-4 | 归档功能（需求已划线"暂时未定"） | 产品 |
| OPEN-5 | 二进制材料的大小上限与在线预览诉求 | 产品 |
| OPEN-6 | 导出工具的参数清单（需求原文"待对接时确认"） | 导出工具方 |
| OPEN-7 | 软删除的恢复入口、"后台标签"管理界面归属 | 产品 |
| OPEN-8 | 网站管理员强制调整的前端入口（我方做还是平台做）、审计查询页是否嵌入文件库前端 | 产品 |
