Files
curriculum-project-hub/文件库-接口契约.md
T

305 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 文件库系统 · 接口契约与决策(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 + JWTC4);统一错误信封 `{ "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 | 网站管理员强制调整的前端入口(我方做还是平台做)、审计查询页是否嵌入文件库前端 | 产品 |