# 素材库接入指南（WebStoreHouse 用户素材库 /api/vault + 官方 SDK mkvault-1.js）

托管项目可以给**每个登录用户**存一个素材库：条目是任意 JSON 对象（每条 ≤256KB，单库 ≤2 万条），
二进制（图片/音频/视频）单独上传拿 `fileId`，再把 `fileId` 写进条目里引用。它补的是 mksync 的两个短板：
整档 256KB 装不下大量长文本（日记、卡片、作品集）；二进制根本放不进 JSON。

**必须登录**——未登录所有接口 401（SDK 抛 `err.code === 'needs_login'`）。只想存进度/设置用 `mksync-1.js` 就够了，
它未登录也能降级本地；素材库没有本地降级。

真相源：`~/.claude/skills/publish-web/vault-api.md`（cutover 后）；公开副本 `/sdk/vault-api.md`。类型：`/sdk/mkvault.d.ts`。

## 30 秒接入

```html
<script src="/sdk/mkvault-1.js?v=dev"></script>   <!-- ?v= 由 publish.py 发布时注入时间戳 -->
<script>
if (window.MKVault) (async () => {                  // 线上站点部署本 SDK 前引用会 404，守卫一下
  const vault = MKVault.open({ slug: 'myapp' });    // 线上从 /p/<id>/ 推断 ns；本地开发落 myapp-devlocal
  try {
    render(await vault.items());                    // 内存缓存 + ETag：没变化服务端回 304，直接用缓存

    const f = await vault.upload(fileInput.files[0], {
      downscale: { maxSide: 2048, quality: 0.85 },  // 仅图片：缩边 + 转 WebP（Safari 退 JPEG），GIF 跳过
      onProgress: p => bar.style.width = (p * 100) + '%',
    });
    const id = MKVault.newId();
    await vault.put(id, { title: '水墨山', tags: ['水墨'], media: [{ fileId: f.fileId, kind: f.kind }] });
    img.src = vault.fileUrl(f.fileId);              // 同源相对地址，<img>/<audio>/<video> 自带登录 cookie
  } catch (e) {
    if (e.code === 'needs_login') showLoginHint(); // 用 MKApp.login() 或 MKSync 的 login() 引导
    else toast(e.message);
  }
})();
</script>
```

## API

**`MKVault`（全局）**

| 方法 | 说明 |
|---|---|
| `open(ns?)` / `open({slug?, ns?})` → 实例 | ns 推断与 mksync **完全一致**：显式 ns > `/p/<id>/` 小写 > `<slug>-devlocal`；都没有时同步抛 `no_ns` |
| `newId()` | 生成合法条目 id（时间前缀 + 随机，按 id 排序约等于按创建时间） |
| `usage()` → `{bytes, quota}` | 当前用户跨全部 ns 的总用量 |
| `limits()` → `{maxItemBytes, maxImage, maxAudio, maxVideo, quota, maxItems}` \| `null` | 来自 `/api/site` 的 `vault` 字段，前端别写死第二份 |

**实例**

| 方法 | 说明 |
|---|---|
| `items({force?})` → `Item[]` | 全部条目（服务端无序，自行排序）。第二次起带 `If-None-Match`，304 返回缓存副本；并发调用合并成一个请求 |
| `get(id)` | 从缓存同步取；未拉过列表或不存在为 `null` |
| `put(id, obj, {ifMatch?})` → `{rev, item}` | 整条覆盖写。服务端以 URL 的 `id` 写回 `item.id`。`ifMatch` 见下文 |
| `del(id)` → `{ok, files}` | 删条目，**服务端顺带删它 `media[].fileId` / `media[].thumbId` 引用的文件** |
| `upload(file, {onProgress?, downscale?, signal?})` → `{fileId, kind, mime, bytes}` | multipart 字段 `file`，XHR（为了上传进度）。`downscale: true` = 默认 2048 / 0.85 |
| `fileUrl(fileId)` | `/api/vault/<ns>/files/<fileId>`（`fileId` 不合法返回 `null`） |
| `delFile(fileId)` | 删孤儿文件（没被任何条目 media 引用、或换图后旧图） |
| `usage()` / `limits()` / `ns` | 同全局 |

## 并发：ifMatch

列表接口不下发单条 rev，但条目文件字节就是 `JSON.stringify(条目)`，SDK 可以对缓存条目**现算** sha256（需 https 的 `crypto.subtle`）。

| `ifMatch` | 行为 |
|---|---|
| 省略 / `'*'` | 后写覆盖（单设备编辑够用） |
| `true` | 用本实例已知的 rev：上次 `put` 成功或 409 带回的 → 否则对缓存条目现算 → 列表里没有则视为 `'0'`。没拉过列表也没写过该条目时 reject `no_rev` |
| `'0'` | 只许新建：条目已存在即冲突 |
| 其他字符串 | 原样作为 `If-Match` |

冲突时 reject `err.code === 'conflict'`，`err.rev` 是服务端当前 rev（`'0'` = 已被删），`err.item` 是服务端当前条目；
SDK 已把缓存对齐到服务端版本，并记住这个 rev——合并后再 `put(id, merged, {ifMatch: true})` 即可。

## 错误码（`err.code`，另有 `err.status`）

`needs_login`(401) · `conflict`(409) · `ns_owner_conflict`(403，该 ns 已被别的项目认领) · `quota`(507，3GB 或条目数满) ·
`too_large`(413，单条 JSON 或单文件超限) · `bad_type`(415，只认魔数：WEBP/JPEG/PNG/GIF、MP3/M4A/OGG/WAV/FLAC、MP4/MOV/WEBM) ·
`not_found` · `network`(status 0) · `aborted` · 客户端自检 `bad_id` / `bad_item` / `bad_file` / `no_rev` · 其他 `http_<status>`。

## 限额（`/api/site` 的 `vault` 字段同步下发）

| 项 | 值 |
|---|---|
| 单条 JSON | 256KB |
| 单库条目数 | 20000 |
| 单个图片 / 音频 / 视频 | 10MB / 30MB / 80MB（视频受 Cloudflare 免费版单请求 <100MB 约束） |
| 每用户总空间（条目 + 文件，跨全部 ns） | 3GB |

## 注意事项

- **ns 归属**：某个 ns 第一次被写入时由来源页面 `/p/<id>/` 认领（服务端看 Referer），之后别的项目读写一律 403
  `ns_owner_conflict`。SDK 的 fetch 显式 `referrerPolicy: 'same-origin'`，压过页面级 `no-referrer`；
  但 **XHR 上传设不了 referrerPolicy**——页面若设了 no-referrer，上传照常成功，只是那次不认领归属。
- **文件 URL 只对本人有效**（需要登录 cookie），不能当公开外链分享、也不能给别的账号看。
- `fileId` 内容永不改写，浏览器按 `immutable` 缓存一年：换图就上传新文件、改条目引用、`delFile` 旧的。
- 删条目只连带删 `media[]` 里的 `fileId/thumbId`；放在别的字段里的 fileId 要自己 `delFile`。
- 压缩是优化不是门槛：解码/编码任何一步失败都原样上传。没缩尺寸且重编码没变小时保留原图。
  Safari 不支持编码 WebP（`toBlob` 静默给 PNG），SDK 按实际 `blob.type` 判断后退 JPEG——会丢透明通道。
- `items()` 返回的是缓存的**副本数组**，元素是缓存对象本身：要改先拷贝，改完 `put`。

## 本地开发

- 素材库要登录且同源，`file://` 与第三方开发服务器上所有请求都会失败（`network` / `http_404`）。
- 本地联调：起本机 WebStoreHouse，`ln -s <构建目录> data/projects/<name>dev`，`env -u PUBLIC_URL npm start`，
  打开 `http://localhost:4321/p/<name>dev/`，ns 就是 `<name>dev`，与线上隔离；测完删软链接。
- 线上站点部署本 SDK 之前就发布的项目：`/sdk/mkvault-1.js` 会 404，务必 `if (window.MKVault)` 守卫。

## 底层 HTTP（不用 SDK 的项目 & 排错用）

全部需登录 cookie；`<ns>` 为 `[a-z0-9][a-z0-9_-]{0,31}`。

| 请求 | 响应 |
|---|---|
| `GET /api/vault/<ns>/items`（可带 `If-None-Match`） | `{rev, items}` + `ETag`；未变 304 |
| `PUT /api/vault/<ns>/items/<id>`（JSON，可带 `If-Match`） | `{ok, rev}`；冲突 409 `{error, rev, item}` |
| `DELETE /api/vault/<ns>/items/<id>` | `{ok, files}` |
| `POST /api/vault/<ns>/files`（multipart，字段 `file`） | `{fileId, kind, mime, bytes}` |
| `GET /api/vault/<ns>/files/<fileId>` | 文件本体，支持 Range（视频拖进度条） |
| `DELETE /api/vault/<ns>/files/<fileId>` | `{ok}` |
| `GET /api/vault/usage` | `{bytes, quota}` |
