16 KiB
文件库系统 · 接口契约与决策(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 接口签名
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"同事务或可靠消息")
- 我方在每个关键写操作的同一数据库事务内,向本地
audit_outbox表写入事件; - 后台中继进程读取 outbox,POST 到审计系统
POST /audit/events,at-least-once,失败重试; - 审计系统按
eventId幂等去重; - 中继只负责送达,业务操作不因审计系统不可用而失败(但事件绝不丢)。
6.2 事件信封(补齐需求 5.3 空缺的字段定义)
{
"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 · 平台身份契约(给平台方)
- 调用方在
Authorization: Bearer <JWT>中携带平台签发的令牌(RS256);平台通过 JWKS endpoint 分发公钥,我方只做验签 + 过期检查,不回调平台、不建用户表; - claims 约定:
sub(用户 id,全局唯一)、roles(平台角色数组)、exp、iss; - 角色映射:
roles含platform.admin→ 文件库"网站管理员";其余合法令牌 → 普通用户;无令牌/验签失败/过期 →401; - 网站管理员权限范围:创建根目录、强制权限调整(必审计)、以及平台方赋予的其他高危操作;不隐式穿透各节点的业务权限(要管理某子树须被显式授权或走强制调整并留痕);
- 前端登录跳转平台 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 | 网站管理员强制调整的前端入口(我方做还是平台做)、审计查询页是否嵌入文件库前端 | 产品 |