Glymonir HTTP API
面向第三方 / cron 任务的实时参考文档。后端每个挂了
@RequireScope的 endpoint 都应该在本文里有对应章节。新增带 scope 的 endpoint 时, 在合适章节追加 request 形状、response 形状和一段 curl 示例;新增或 重命名 scope 时,同步更新下面的 Scopes 表。
Base URL https://<host>/api · 本地开发: http://localhost:8123/api
版本状态:V1 — 覆盖图片采集。后续版本会陆续加入图片读取 API、搜索、 通知。Scope 字符串一旦发布永久不变,详见下方稳定性保证章节。
用 CLI 快速上手
绝大多数"用脚本上传一张图"场景下,官方 Node CLI 是最快的路径 —— 一条命令搞定,不用自己写预处理:
export GLYMONIR_API_KEY=gly_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 零安装:
npx glymonir-cli upload photo.jpg
# 上传到指定 library:
npx glymonir-cli upload --library 1234 photo.jpg
# 附带元数据 + 批量:
npx glymonir-cli upload \
--name "Mt. Fuji at sunrise" --tags mountain,sunrise \
*.jpgCLI 本地完成网页端 imagePreprocess.ts 做的所有事(thumb + preview WebP、512×512 embedding JPEG、SHA-256 去重),然后调本文档下方 的 /picture/upload/r2/* endpoint。源码 + 文档在 tools/glymonir-cli/。
要更细粒度的控制 / 不用 Node,直接看下面的 HTTP API。
目录
认证
进入 API 有两条路径,endpoint 不关心请求走的是哪条 —— 只要解析后的 用户拥有所需权限即可。
| 路径 | 来源 | 有效期 | 适用场景 |
|---|---|---|---|
| Supabase JWT | supabase.auth 浏览器会话 | 1 小时,可刷新 | 浏览器 UI、所有交互式场景 |
| API 密钥 | POST /user/api-keys(本文档) | 直到撤销 / 过期 | Cron、Workers、服务器到服务器调用 |
两者用同一个 header:
Authorization: Bearer <token>服务端的 ApiKeyAuthFilter 先跑,识别 gly_live_ 前缀的 token; 其它一律交给 JwtAuthFilter。所以同一个 endpoint 同时给浏览器和 机器调用都没问题。
API 密钥代理用户身份
密钥的有效权限 = (持有人的角色权限) ∩ (密钥被授予的 scope)。 即使 scope 字符串匹配,密钥也无法访问超出持有人本身角色 / library 权限的 endpoint。admin:* 和 gallery:upload 这两个 scope 只有管理员能授予。
Token 格式
gly_live_<32 个随机字符>32 字符随机串从一个 56 符号字母表中抽取,显式排除了 0、O、 1、l、I(避免复制粘贴误读产生另一个有效 token)。约 186 bits 熵。
服务端只存 SHA-256(token),没有恢复流程。丢了就吊销 + 重建。完整明文仅在 POST /user/api-keys 的响应里返回一次, 之后 API 永远只返回 13 字符的展示前缀(如 gly_live_a8K9x)。
Scopes
| Scope | 谁可以授予 | 涵盖范围 |
|---|---|---|
gallery:read | 任何登录用户(受发行准入限制) | 批量读取公开 gallery —— 列表 / 单图全字段(名称、描述、标签、颜色、尺寸、R2 URL)+ 1024 维 CLIP 向量。见下方 /api/gallery/*。 |
gallery:upload | 仅管理员 | 通过 R2 两阶段流程上传图到公开 gallery + 管理员批量 URL 采集。 |
admin:* | 仅管理员 | 资源前缀通配符 —— 所有 admin:xxx endpoint(回收站清理、批量采集诊断、用户封禁等)。不是全局通配符,不覆盖 gallery:upload 或 gallery:read。 |
发行准入门槛:POST /user/api-keys 要求调用者当前订阅为 Pro 或角色为 admin。Free / Starter 用户被拒,返回 40101 + 升级提 示。Admin 角色自动绕开订阅档位检查("effective plan = UNLIMITED")。
通配规则:授予 a:b:* 匹配所有 a:b:<任意值>;授予 * 匹 配一切。如果将来确实需要超级密钥,授予 *(目前未在公开目录中, 请谨慎控制)。
遗留别名:2026-05-25 之前发行的密钥可能仍带 picture:upload 这个被弃用的 scope。服务端把 picture:upload 视作等价于 gallery:upload,老密钥继续可用;新密钥不能再申请 picture:upload。
POST /user/api-keys 创建时可授予的 scope 集合会按 角色 + 订阅 档 过滤。调 GET /user/api-keys/available-scopes 可查到当前用户 具体能申请哪些 scope —— 返回空列表 = 当前账号无资格创建 API 密钥 (很可能是 Free / Starter 计划)。
响应封装
每个 JSON endpoint 都用如下结构包裹返回值:
{
"code": 0,
"data": { /* endpoint 自己的数据 */ },
"message": "ok"
}code: 0 = 成功;非零 code = 业务错误,data 为 null。详见 下方错误码。SSE 端点(/picture/upload/batch/selected)是例 外,直接流式返回事件,不裹封装。
错误码
| Code | 含义 | 典型触发场景 |
|---|---|---|
0 | 成功 | — |
40000 | 请求参数错误 | 字段缺失 / 格式错误,body 校验未通过 |
40100 | 未登录 | 密钥缺失 / 错误 / 已撤销 / 已过期。这四种情况返回完全相同的错误,调用方无法通过响应区分(防探测)。 |
40101 | 权限不足 / scope 不匹配 | 已认证,但密钥不带 endpoint 所需的 scope。例如:"API key missing required scope: gallery:read" |
40300 | 用户层权限拒绝 | 用户角色 / library 权限校验失败(如调用方对目标 library 没有 PICTURE_UPLOAD 权限) |
40400 | 资源不存在 | 资源不存在 / 不在公开 gallery / 未通过审核 —— API 三种情况合并为同一个 404,防止用来探测私有状态。 |
42301 | 嵌入向量未就绪(相似搜索) | 图片刚上传,/similar/list 还没生成嵌入。前端会回退到基于标签的推荐 |
42500 | 嵌入向量未就绪(gallery API) | /api/gallery/picture/{id}/embedding —— 图片存在但 CLIP 向量还没算出来。等几分钟重试。 |
42900 | 速率限制 | 每密钥配额耗尽。响应附带 HTTP Retry-After header(单位:秒)。默认 Pro/user = 60 req/min 稳定速率 + 100 token burst;admin 无限。 |
50000 | 服务端异常 | Bug 或下游故障 |
HTTP 状态码标准:200 表示成功,4xx/5xx 对应错误类。永远要看 body 里的 code —— HTTP 200 + code != 0 仍然是业务错误。
API 密钥管理
下面这五个端点都在 /user/api-keys 路径下,只要登录用户即可调用 (Supabase JWT 可以;带相应 scope 的 API 密钥理论上也可以,但 V1 没为"自管理"开放 scope,所以请用 JWT)。
POST /user/api-keys — 创建
请求体:
{
"name": "ingest-worker-2026-05",
"scopes": ["gallery:read"],
"description": "Cloudflare Worker cron, Wikimedia CC0 ingest",
"expiresInDays": 365
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
name | string | 是 | 1–255 字符,UI 列表展示用 |
scopes | string[] | 是 | 每项必须在目录中 且 调用方有权授予 |
description | string | 否 | 自由备注 |
expiresInDays | int | 否 | 不传 / 0 表示永不过期 |
成功响应(code: 0):
{
"data": {
"plaintext": "gly_live_...",
"key": {
"id": "1234567890",
"name": "ingest-worker-2026-05",
"prefix": "gly_live_a8K9",
"scopes": ["gallery:read"],
"expiresAt": "2027-05-04T00:00:00Z",
"revokedAt": null,
"lastUsedAt": null,
"lastUsedIp": null,
"totalRequests": 0,
"createTime": "2026-05-04T13:02:11Z",
"description": "Cloudflare Worker cron, Wikimedia CC0 ingest"
}
}
}plaintext 只返回这一次,立刻保存到安全位置。
curl 示例:
curl -X POST https://<host>/api/user/api-keys \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"name":"ingest-worker-2026-05","scopes":["gallery:read"]}'GET /user/api-keys — 列表
列出当前调用方自己的密钥(包括已撤销的,带 revokedAt)。分页。
GET /user/api-keys?current=1&pageSize=20响应结构:标准 MyBatis-Plus Page<ApiKeyVO> —— 包含 records[]、 total、current、size。keyHash 永远不返回。
POST /user/api-keys/{id}/revoke — 撤销
幂等操作。第二次调用一个已撤销的密钥仍然返回 true —— 服务端 故意不区分"刚刚撤销"和"早就撤销了",防止好奇的调用方用响 应差异探测状态。已撤销的密钥下一次使用时被拒绝(返回 40100)。
curl -X POST https://<host>/api/user/api-keys/1234567890/revoke \
-H "Authorization: Bearer <jwt>"POST /user/api-keys/update — 更新元数据
只能改 name 和 description。scopes 故意不可变 —— 如果 需要不同的 scope 集合,创建新密钥并撤销旧的。这是为了防止权限 慢慢扩张("permission creep")。
{
"id": "1234567890",
"name": "ingest-worker-rotated",
"description": "rotated 2026-05-04, original token compromised"
}GET /user/api-keys/available-scopes — 可授予的 scope 目录
返回当前调用方有权授予的 scope 列表。前端用它渲染创建密钥对话框 里的复选框。脚本里也可以用它在运行时发现新增的 scope —— 这个目 录会随版本逐步扩充。
{
"data": [
{
"value": "gallery:read",
"label": "Read public gallery",
"description": "Bulk-read public gallery pictures + metadata + 1024-d CLIP embedding vectors.",
"requiredRole": "user"
}
]
}Gallery 读取(gallery:read)
下面三个 endpoint 为面向程序的 gallery 消费方设计(Pro 用户 + 管 理员)。只返回通过审核(PASS)的公开 gallery 图;库 / 私有 library / REVIEWING / REJECTED 一律返回 40400,与调用方无关。
GET /api/gallery/pictures — 列表
按时间倒序游标分页。游标稳定 —— 即使中间有新图入库,昨天拿到的 "第 2 页"依然是同一批结果。
参数:
| 名称 | 类型 | 默认 | 说明 |
|---|---|---|---|
cursor | string | — | 不透明游标。首次请求不传;后续传上一次响应的 nextCursor。 |
limit | int | 50 | 每页大小,clamp 到 [1, 200]。 |
响应 data:
{
"items": [
{
"id": "1234567890",
"sha256": "abc...",
"name": "Mt. Fuji at sunrise",
"format": "jpg",
"width": 4032, "height": 3024,
"sizeBytes": 5242880,
"originalUrl": "https://cdn.example.com/photos/...jpg",
"thumbUrl": "https://cdn.example.com/cdn-cgi/image/.../...webp",
"createTime": "2026-05-25T12:00:00Z"
}
],
"nextCursor": "MTcxNjY0MjQwMHwxMjM0NTY3ODkw",
"limit": 50
}nextCursor 缺省 = 已到末页。
GET /api/gallery/picture/{id} — 详情
完整元数据 + R2 URL。CLIP 向量拆到下一个 endpoint(4 KB,单独取 更划算)。
响应 data:
{
"id": "1234567890",
"sha256": "abc...",
"name": "Mt. Fuji at sunrise",
"introduction": "Captured 2024-09-15 from Hakone.",
"tags": ["mountain","sunrise","japan"],
"userId": "987",
"createTime": "2026-05-25T12:00:00Z",
"format": "jpg", "width": 4032, "height": 3024, "sizeBytes": 5242880,
"picPalette": "[{\"hex\":\"#a36b3f\",\"ratio\":0.42,\"lab\":[...]}]",
"picMosaicLab": "[[L,a,b], [L,a,b], ... 25 entries]",
"originalUrl": "https://cdn.example.com/photos/...jpg",
"thumbUrl": "https://cdn.example.com/cdn-cgi/image/.../...webp",
"previewUrl": "https://cdn.example.com/cdn-cgi/image/.../...webp"
}40400(code 40400):id 不存在 / 图属于私有 library / 未通过审核 —— 合并为同一个 404,API 表面不可探测。
GET /api/gallery/picture/{id}/embedding — 向量
{
"id": "1234567890",
"embeddingModel": "jina-clip-v2",
"dimensions": 1024,
"embedding": [0.0123, -0.0456, ...],
"computedAt": "2026-05-25T12:00:01Z"
}状态:
code 0—— 向量就绪,在data.embeddingcode 42500—— 图存在但向量还没算,data.embedding为null。 几分钟后重试。code 40400—— 同上 404 合并语义。
向量已 L2-normalised(norm=1.0),所以点积 = cosine 相似度。
图片上传(gallery:upload)
下面三个 endpoint 都需要带 gallery:upload(仅管理员)的 API 密钥,且 解析出来的用户对目标 library 拥有 PICTURE_UPLOAD 权限(目标是公开 gallery 时不需要 library 权限)。密钥层 → 用户层 → library 层,三道 权限闸门叠加生效。
上传契约(下面所有 endpoint 一视同仁,admin 不旁路):
| 规则 | 取值 | 作用范围 |
|---|---|---|
| 格式白名单 | image/jpeg / image/png / image/webp | 所有上传。GIF / TIFF / HEIC / SVG 一律拒绝。 |
| 单文件上限 | ≤ 50 MB | 所有上传、所有目的地。 |
| 公开 gallery 下限 | ≥ 1 MB | 不传 libraryId(或显式置 null)。质量底线,防止低分辨率小图污染浏览面板。 |
| library 下限 | 无 | 传 libraryId 指向某个 library。用户私有存储,50 KB 的图标也是合法内容。 |
| 管理员 URL 批量采集 | ≥ 1 MB 且 ≤ 50 MB | 与 gallery 一致 —— /picture/upload/batch/selected 永远目标公开 gallery。 |
违反契约返回 code: 40000(PARAMS_ERROR),message 会指明 触发的具体规则("File size exceeds 50 MB cap..." 或 "Public gallery uploads must be at least 1 MB...")。常量由 PictureUploadSizeIT 锁死,不会被静悄悄改动。
三变体上传模型
每张图(无论是上传到公共 gallery 还是 library)在 R2 里会存最多 4 个对象。 客户端(浏览器 / CLI / 你的代码)需要在调 check 之前就把三个变体生成好。
| 对象 | Slot 名 | 是否必须 | 服务端期望的格式 |
|---|---|---|---|
| 原图 | original | 是 | 用户上传的原始格式(JPEG / PNG / WebP) |
| 缩略图 | thumb | 是 | WebP,最长边 1280px,质量 ~76 |
| 预览图 | preview | 是 | WebP,最长边 3200px,质量 ~80 |
| Embedding | embedding | 可选 | JPEG,精确 512×512,黑色 padding 保持原始宽高比,质量 ~85。喂给 Jina CLIP 做相似度搜索。生成不出来可以省,管理员后台有补传流程。 |
客户端对每个变体算 (sha256, 字节大小, content-type) 三元组,在 check 请求里一次性提交。服务端对每个还没存过的变体 slot 各 签发一个 presigned PUT URL(全部命中去重 = 所有 slot 返回 null, 客户端直接跳到 finalize)。
参考实现见 tools/glymonir-cli/src/preprocess.ts。 不想自己复现,用上面的 CLI 快速上手那一节里的 CLI,它会全帮你做好。
finalize 的美学元数据
除了那 4 个 R2 对象之外,picture 行还携带一些客户端通常在上传时 顺手算好的元数据。全部可选 —— 你不传,图也能存进去,只是 UI 质量会降级。
| 字段 | 含义 | 传 null 的后果 |
|---|---|---|
width, height | 原图像素尺寸 | 强烈推荐 —— 列表布局靠宽高比排版 |
thumbhash | base64 LQIP 占位图,约 30 字节(用 thumbhash 库生成) | 瀑布流在 thumb 加载完之前显示纯灰色占位 |
aveColorPalette | JSON 字符串:Top-5 Lab K-means 调色板,[{hex,ratio,lab}, ...] | 列表卡片背景渐变退化为默认灰 |
aveMosaicLab | JSON 字符串:5×5 Lab 空间网格,[[L,a,b], ... ×25] | "photo-mosaic" 相似搜索无法索引这张图 |
exif | JSON 字符串:EXIF blob(机身 / 镜头 / 光圈 / 快门 / ISO / GPS) | 详情页隐藏 EXIF 区块 |
CLI 用 sharp + thumbhash + 本地 Lab K-means 把这些全算了。 纯 HTTP 接入方算不出来,传 null 即可。
POST /picture/upload/r2/check — 阶段一
客户端本地算原图和 3 个变体的 sha256,服务端做去重检测,然后对每个 未存过的 slot 返回一个 presigned PUT URL。
请求体 —— 除 embedding 外都是必填:
{
"sha256": "<原始文件的 64 字符 hex sha256>",
"size": 524288,
"ext": "jpg",
"contentType": "image/jpeg",
"thumb": {
"sha256": "<thumb WebP 字节的 sha256>",
"size": 18432,
"contentType": "image/webp"
},
"preview": {
"sha256": "<preview WebP 字节的 sha256>",
"size": 184320,
"contentType": "image/webp"
},
"embedding": {
"sha256": "<512×512 embedding JPEG 字节的 sha256>",
"size": 32768,
"contentType": "image/jpeg"
},
"libraryId": null
}| 字段 | 必填? | 说明 |
|---|---|---|
sha256 | 是 | 16 进制小写 64 字符,用于 blob 去重 |
size | 是 | 原图字节数。服务端在签发 URL 前会按 1 MB / 50 MB 上下限校验 |
ext | 是 | 小写,不带点 —— jpg / png / webp |
contentType | 是 | 必须是 image/jpeg / image/png / image/webp 之一 |
thumb | 是 | {sha256, size, contentType}。变体不跨图去重(R2 key 由原图 sha256 推导) |
preview | 是 | 同 thumb |
embedding | 可选 | 同上结构。生成不出来传 null 或省略 |
libraryId | 可选 | null(或省略)= 公共 gallery;整数 = 上传到指定 library |
响应:
{
"code": 0,
"data": {
"dedupe": false,
"blobId": null,
"original": { "uploadUrl": "...", "requiredHeaders": {"Content-Type": "image/jpeg"}, "stagingKey": "staging/<uuid>.jpg" },
"thumb": { "uploadUrl": "...", "requiredHeaders": {"Content-Type": "image/webp"}, "stagingKey": "thumb/<sha>.webp" },
"preview": { "uploadUrl": "...", "requiredHeaders": {"Content-Type": "image/webp"}, "stagingKey": "preview/<sha>.webp" },
"embedding": { "uploadUrl": "...", "requiredHeaders": {"Content-Type": "image/jpeg"}, "stagingKey": "embedding/<sha>.jpg" },
"expiresInSeconds": 900
}
}| 字段 | 含义 |
|---|---|
dedupe | 原图 sha256 命中已有 blob 时为 true。即使命中,单个变体 slot 仍可能非 null(blob 缺该变体) |
blobId | 命中去重时给出已存在的 picture_blob.id,新文件为 null。命中时把这个 id 透传到 finalize |
original / thumb / preview / embedding | 该变体已有 → null(别 PUT);否则给出 presigned slot,见下表 |
expiresInSeconds | 本次响应里所有 uploadUrl 的 TTL,默认 900(15 分钟) |
每个非 null slot 结构:
| Slot 字段 | 含义 |
|---|---|
uploadUrl | R2 预签名 URL。直接 PUT 字节过去 |
requiredHeaders | 必须在 PUT 时原样回填的 header map,否则 R2 报 SignatureDoesNotMatch。一定包含对应的 Content-Type |
stagingKey | R2 对象 key。原图是 staging/(finalize 时升级为永久);变体是最终 key —— PUT 两次幂等覆盖。透传到 finalize |
阶段 1.5 —— PUT 到 presigned URL
对 check 响应里每个非 null slot,直接把字节 PUT 给 R2:
curl -X PUT "<slot.uploadUrl>" \
-H "Content-Type: <slot.requiredHeaders['Content-Type']>" \
--data-binary @/path/to/variant.bin- 用 PUT,raw body(不是 multipart)
requiredHeaders里每个 header 都要原样回填,缺一个或 Content-Type 不对 = R2 返回403 SignatureDoesNotMatch- 成功 = HTTP
200,无 JSON body - 这一步直接打 R2,不带
Authorization: Bearer,不带 Glymonir API key —— 签名 URL 本身就是凭证 - 多个 slot 可以并发上传
check响应里null的 slot 直接跳过
POST /picture/upload/r2/finalize — 阶段二
把原图的 staging/* 对象提升到永久 photos/* key,创建 picture 行, bump blob 的 ref_count,返回 picture VO。事务性 —— 失败时回滚 picture 行,R2 staging 对象由 GC sweeper 后续清理。
请求体 —— 完整字段,按必填 → 可选排列:
{
"sha256": "<原图 sha256,跟 /check 里一致>",
"stagingKey": "<check 响应里 original.stagingKey>",
"size": 524288,
"format": "JPEG",
"ext": "jpg",
"width": 4032,
"height": 3024,
"thumbKey": "<check 响应里 thumb.stagingKey>",
"previewKey": "<check 响应里 preview.stagingKey>",
"embeddingKey": "<check 响应里 embedding.stagingKey>",
"thumbhash": "<base64,约 28 字节>",
"aveColorPalette":"<JSON 字符串>",
"aveMosaicLab": "<JSON 字符串>",
"exif": "<JSON 字符串>",
"name": "Mt. Fuji at sunrise",
"introduction": "Wikimedia Commons, CC0",
"category": "landscape",
"tags": ["nature", "mountain"],
"libraryId": null,
"pictureId": null
}| 字段 | 必填? | 说明 |
|---|---|---|
sha256 | 是 | 原图 sha256,用于查询/创建 picture_blob 行 |
stagingKey | 条件必填 | check 响应里的 original.stagingKey。去重命中时省略(blob 已有永久 key) |
size | 是 | 原图字节数 |
format | 新文件必填 | JPEG / PNG / WEBP 之一(大写)。去重命中时省略 |
ext | 新文件必填 | 小写不带点。去重命中时省略 |
width / height | 推荐 | 原图像素宽高。即使语义上可选,也强烈推荐 —— 列表布局靠宽高比 |
thumbKey / previewKey | 新变体上传时必填 | 透传 check 响应里的值。去重命中且服务端已有该变体 → 省略 |
embeddingKey | 可选 | check 有签发就透传;跳过 embedding 时传 null |
thumbhash / aveColorPalette / aveMosaicLab / exif | 全部可选 | 见美学元数据。算不出来的字段传 null 或省略 |
name | 可选 | 显示名。省略 → 默认用文件名(去扩展名) |
introduction | 可选 | 说明 / 描述 |
category | 可选 | 自由字符串 |
tags | 可选 | string[]。公共 gallery 列表当前不展示 tags(走 concept browse);library 会用 |
libraryId | 可选 | null = 公共 gallery,要跟 check 时传的值一致 |
pictureId | 可选 | 只在编辑已有图片(纯元数据 patch)时填。新增上传留 null |
响应 —— BaseResponse<PictureVO>:
{
"code": 0,
"data": {
"id": 2058948115995348994,
"blobId": 12345,
"imagePath": "photos/74/74ec3715...c1cd6ebf.jpg",
"url": "https://img.glymonir.com/photos/74/74ec3715...c1cd6ebf.jpg",
"thumbnailUrl": "https://img.glymonir.com/thumb/74ec3715...c1cd6ebf.webp",
"originalUrl": "https://img.glymonir.com/photos/74/74ec3715...c1cd6ebf.jpg",
"thumbhash": "<base64 回显>",
"exif": "<JSON 字符串回显,可能为 null>",
"name": "Mt. Fuji at sunrise",
"introduction": "Wikimedia Commons, CC0",
"tags": ["nature", "mountain"],
"picSize": 524288,
"picWidth": 4032,
"picHeight": 3024,
"picScale": 1.333,
"picFormat": "JPEG",
"picPalette": "<JSON 字符串回显>",
"picMosaicLab": "<JSON 字符串回显>",
"userId": 1001,
"originUserId": null,
"spaceId": null,
"createTime": "2026-05-26T05:48:00.000+00:00",
"editTime": "2026-05-26T05:48:00.000+00:00",
"updateTime": "2026-05-26T05:48:00.000+00:00",
"likeCount": 0,
"viewCount": 0,
"downloadCount": 0,
"visibility": "PUBLIC",
"reviewStatus": 1,
"slug": "mt-fuji-at-sunrise"
},
"message": ""
}上传后客户端最常用的字段:id(以后操作这张图的句柄)、 url / thumbnailUrl(展示 URL)、reviewStatus(1 = 自动通过 / 0 = 待审核 / 2 = 拒绝)、slug(SEO 友好的 slug,用于规范 URL /picture/<slug>-<id>)。
POST /picture/upload/batch/selected — 管理员 URL 批量采集
服务端拉取器:给一组公开 URL,服务端逐个下载、去重、上传到 R2、 持久化为公开 gallery 图片。只有管理员可调用。响应是 SSE 流 (每个 URL 一个事件 + 一个最终汇总事件),不用 JSON 封装格式。
请求体:
{
"urlList": [
"https://upload.wikimedia.org/wikipedia/commons/.../foo.jpg",
"https://upload.wikimedia.org/wikipedia/commons/.../bar.jpg"
],
"namePrefix": "wikimedia-",
"tags": ["nature", "cc0"]
}单批次最多 50 个 URL。
每个 URL 处理完发一个事件:
data: {"index":0,"url":"...","status":"success","message":"...","done":false}最终汇总事件:
data: {"done":true,"total":50,"successCount":48}Worker / cron 的 curl 示例:
curl -N -X POST https://<host>/api/picture/upload/batch/selected \
-H "Authorization: Bearer gly_live_..." \
-H "Content-Type: application/json" \
-d '{"urlList":["https://...jpg"],"namePrefix":"cc0-","tags":["cc0"]}'无人值守的批量采集场景优先用这个 endpoint —— 不需要自己处理 R2 presigned PUT。
稳定性保证
- Scope 字符串永久不变。 一旦发布,就永远不重命名。如果某个 scope 要废弃,会从公开目录里移除(无法再被授予),但
ApiScopes.hasScope会继续认它,以免持有该 scope 的旧密钥失效。 - Token 格式
gly_live_*稳定。 未来可能引入额外前缀(如iph_test_*用于 staging 环境),但gly_live_*永远代表生产 级密钥。 /api/...路径下的 endpoint 遵循 semver。 已发布 endpoint 的破坏性变更会提前公告,并在路径中升一个主版本号。新增字段 (可选)、新增 endpoint、新增 scope 这种纯加法变更随时可能发生。- 错误码值稳定。
40100永远是"未登录",40101永远是"权 限不足 / scope 不匹配"。
路线图
数据库 schema 和 filter chain 在设计时就为下面的功能预留了接缝, 未来落地时全部是纯加法,无需迁移:
- 每密钥限速 ——
rate_limit_rpm列 + Bucket4j filter。 - IP 白名单 ——
ip_allowlist CIDR[]列 + filter 检查。 - 审计日志 —— 新增
api_key_audit表 + 异步监听器。 - 密钥轮换 ——
rotated_from_key_id列 + 轮换 endpoint。 - publishable / restricted 密钥类型 —— 已在现有 schema 的
key_type列预留。 - OAuth 风格的第三方应用授权 —— 独立流程、独立实体。
如果你正在对接本 API 并希望上面某项尽早实现,欢迎在 GitHub 上提 issue,带上具体使用场景。
