烛龙智元 · 文件中转站 — API 接口使用说明
适用版本:filehub 1.x · 线上地址
https://files.dragonai.tech本文面向需要用脚本/服务对接上传下载的开发者。浏览器端 UI 已内置全部能力,无需对接本文档。
1. 总体架构(对接前必读)
文件字节永远不经过后端。 后端只做三件事:鉴权、签发指向阿里云 OSS 的预签名 URL、维护对象列表/索引。真正的上传(PUT)和下载(GET)由客户端直接对 OSS 完成。
┌─────────┐ 1. 登录/签 URL (小流量 JSON) ┌──────────────┐
client │ 你的脚本 │ ───────────────────────────────▶ │ filehub 后端 │
└─────────┘ └──────────────┘
│ 2. 用拿到的预签名 URL 直接 PUT/GET 文件字节(大流量)
▼
┌──────────────────────────────────────┐
│ 阿里云 OSS (oss-cn-heyuan.aliyuncs.com) │
└──────────────────────────────────────┘
带来的几个对接要点: - 上传:先向后端要一个预签名 PUT URL,再把文件字节 PUT 到该 URL(目标主机是 OSS,不是 filehub)。 - 下载:请求后端的 /api/download,它返回 302 重定向到一个预签名 GET URL,跟随重定向即可从 OSS 直接下载(支持 HTTP Range 断点续下)。 - 不限带宽、不限大小,瓶颈是 OSS 而非这台服务器。
2. 认证
单一共享口令 + 签名 Cookie 会话。所有写操作(POST)都必须带 CSRF 头 X-Requested-With。
| 约定 | 说明 |
|---|---|
| Base URL | https://files.dragonai.tech |
| 会话 | 登录成功后返回 Set-Cookie: fh_token=...(HttpOnly、Secure、SameSite=Lax、有效期 7 天)。后续请求带上该 Cookie。 |
| CSRF | 所有 POST /api/* 必须带请求头 X-Requested-With: XMLHttpRequest(值任意非空)。缺失返回 403。 |
| 登录限频 | 同一 IP 5 分钟内 8 次失败后返回 429,需稍后重试。 |
| 内容类型 | 请求体为 JSON 时带 Content-Type: application/json。 |
POST /api/login
请求体:{"password": "你的口令"} - 200 {"ok": true} + 下发 Cookie - 401 {"detail": "invalid password"} 口令错误 - 429 {"detail": "..."} 触发限频
POST /api/logout
清除会话 Cookie。200 {"ok": true}
GET /api/me
探测登录态。200 {"authed": true} 或 401。
GET /api/health
健康检查(免鉴权)。200 {"ok": true}
3. 通用约定
- 成功:HTTP 2xx + JSON。
- 失败:非 2xx +
{"detail": "错误描述"}。常见:400参数错误 /401未登录 /403缺 CSRF 头 /409上传会话过期 /422请求体格式错 /429限频。 - key:对象在桶内的完整路径,如
报表/2026/Q2.xlsx。由folder + filename组合而成;禁止..路径穿越;.filehub/为系统保留前缀。 - 每一个 PUT 到 OSS 的请求,Content-Type 必须是
application/octet-stream,且要与签名时一致——这是 OSS v1 签名的硬性要求,不一致会返回403 SignatureDoesNotMatch。
4. 上传文件
客户端自行决定走哪条路:文件 ≤ 100MB 用单次 PUT;> 100MB 用分片(也可以一律走分片)。
4.1 小文件 — 单次预签名 PUT
第 1 步 向后端要 PUT URL:
POST /api/upload/put-url
{ "filename": "report.pdf", "size": 1048576, "folder": "报表/2026/" }
返回:
{ "key": "报表/2026/report.pdf", "url": "https://dragonai-filehub.oss-cn-heyuan.aliyuncs.com/...&Signature=..." }
folder可选(不传则上传到根目录),需以/结尾。
第 2 步 把整个文件 PUT 到该 url:
curl -X PUT "<url>" \
-H "Content-Type: application/octet-stream" \
--data-binary @report.pdf
# 期望 HTTP 200
第 3 步(可选,秒传索引) 见 §6,上传完成后登记内容哈希。
4.2 大文件 — 分片上传(multipart)
第 1 步 初始化:
POST /api/upload/init
{ "filename": "big.zip", "size": 1234567890, "folder": "" }
返回:
{ "key": "big.zip", "uploadId": "AF3...", "partSize": 8388608, "partCount": 148 }
partSize = max(8MB, ⌈size/9000⌉ 向上取整到 1MB);partCount = ⌈size/partSize⌉(≤ 10000)。客户端必须按返回的 partSize 切片。
第 2 步 批量取分片签名 URL(可分批,一批最多几百个):
POST /api/upload/sign-parts
{ "key": "big.zip", "uploadId": "AF3...", "partNumbers": [1, 2, 3, 4, 5] }
返回:
{ "urls": [ { "partNumber": 1, "url": "https://...partNumber=1&uploadId=AF3...&Signature=..." }, ... ] }
第 3 步 逐片(可并发)PUT 到 OSS,读取每片响应头里的 ETag:
# 第 n 片 = 文件第 [(n-1)*partSize, n*partSize) 字节
curl -X PUT "<part_url>" \
-H "Content-Type: application/octet-stream" \
--data-binary @part_n.bin -D - -o /dev/null | grep -i '^etag:'
# 响应头含: ETag: "E37530F3...." ← 记下来
第 4 步 合并:
POST /api/upload/complete
{
"key": "big.zip",
"uploadId": "AF3...",
"parts": [
{ "partNumber": 1, "etag": "E37530F387794404C7C2C867BA4C7638" },
{ "partNumber": 2, "etag": "1159C07ED1CF610FC244E5C1CCF92258" }
]
}
返回 {"ok": true, "key": "big.zip"}。 > etag 带不带外层引号都行,后端会去掉;parts 必须覆盖全部 1..partCount 且按 partNumber 升序。
放弃上传:POST /api/upload/abort { "key", "uploadId" } → {"ok": true}(释放未完成的分片)。
5. 断点续传
分片上传中断后,只要还持有 key 和 uploadId,即可向 OSS 查询已经成功收到的分片,只补传缺失的:
POST /api/upload/list-parts
{ "key": "big.zip", "uploadId": "AF3..." }
返回:
{ "parts": [ { "partNumber": 1, "etag": "E375...", "size": 8388608 }, { "partNumber": 2, "etag": "1159...", "size": 8388608 } ] }
- 续传逻辑:
待传分片 = 全部分片 − list-parts 返回的分片;补传完后用 list-parts 的 etag + 新传分片的 etag 一起complete。 409 {"detail": "upload session expired"}:uploadId已过期/被中止,需重新init。
浏览器端 UI 会把
{key, uploadId, partSize, 已完成分片}持久化到 localStorage,刷新/断网后自动用本接口续传。脚本对接时自行持久化这几个字段即可。
6. 秒传(相同内容免上传)
若桶里已存在相同内容的对象,可让 OSS 服务端复制到目标 key,0 字节上传。
内容哈希算法(务必一致,否则命不中):把文件按 8MB 分块,对每块取 SHA-256,将各块的 32 字节摘要按顺序拼接,再对拼接结果取一次 SHA-256,输出 hex(64 位小写)。
POST /api/upload/exists{ "hash": "<hex>", "size": 123 }→{ "exists": true, "key": "已有对象key", "size": 123 }或{ "exists": false }- 命中则
POST /api/upload/instant{ "hash", "filename", "folder?" }→{ "ok": true, "key": "目标key" }(服务端copy_object,瞬时完成) - 未命中则正常上传(§4),完成后
POST /api/upload/record-hash{ "hash", "key" }→{ "ok": true },把内容登记进索引,供后续秒传。
7. 列举 / 下载 / 预览 / 删除 / 新建文件夹
GET /api/files?prefix=<目录>&marker=<分页游标>
按目录层级列举(prefix 以 / 结尾,空为根)。
{
"prefix": "报表/",
"folders": ["报表/2026/"],
"files": [ { "key": "报表/汇总.xlsx", "name": "汇总.xlsx", "size": 20480, "lastModified": "2026-06-14T03:00:00+00:00", "type": "xlsx" } ],
"nextMarker": null
}
- 单页最多 200 项;
nextMarker非空时带着它再请求下一页。
GET /api/download?key=<key>
返回 302 重定向到带 attachment 的预签名 GET URL(文件名按 RFC5987 编码,支持中文)。跟随重定向即从 OSS 直接下载:
curl -L -b cookies.txt "https://files.dragonai.tech/api/download?key=报表/汇总.xlsx" -o 汇总.xlsx
GET /api/preview?key=<key>
同上,但 inline(浏览器内预览图片/PDF),不强制下载。
POST /api/delete
{ "keys": ["a.txt", "报表/旧.xlsx"] }
→ { "deleted": ["a.txt", "报表/旧.xlsx"] }(批量删除)。
POST /api/mkdir
{ "path": "新目录/子目录" }
→ { "ok": true, "key": "新目录/子目录/" }(创建空目录标记对象)。
8. 端点速查表
| 方法 | 路径 | 鉴权 | CSRF | 用途 |
|---|---|---|---|---|
| POST | /api/login |
— | ✓ | 登录,下发 Cookie |
| POST | /api/logout |
✓ | ✓ | 登出 |
| GET | /api/me |
✓ | — | 探测登录态 |
| GET | /api/health |
— | — | 健康检查 |
| POST | /api/upload/put-url |
✓ | ✓ | 小文件单 PUT 签名 |
| POST | /api/upload/init |
✓ | ✓ | 初始化分片上传 |
| POST | /api/upload/sign-parts |
✓ | ✓ | 批量签分片 URL |
| POST | /api/upload/list-parts |
✓ | ✓ | 查已传分片(断点续传) |
| POST | /api/upload/complete |
✓ | ✓ | 合并分片 |
| POST | /api/upload/abort |
✓ | ✓ | 放弃分片上传 |
| POST | /api/upload/exists |
✓ | ✓ | 秒传:查内容是否已存在 |
| POST | /api/upload/instant |
✓ | ✓ | 秒传:服务端复制到目标 key |
| POST | /api/upload/record-hash |
✓ | ✓ | 秒传:登记内容哈希 |
| GET | /api/files |
✓ | — | 列举对象 |
| GET | /api/download |
✓ | — | 下载(302→OSS) |
| GET | /api/preview |
✓ | — | 预览(302→OSS) |
| POST | /api/delete |
✓ | ✓ | 批量删除 |
| POST | /api/mkdir |
✓ | ✓ | 新建文件夹 |
9. 限制与注意事项
- Content-Type:每个对 OSS 的 PUT 都必须发
application/octet-stream,与签名一致;否则 403。 - ETag:分片 PUT 的响应头
ETag必须取到并回传给complete(浏览器需 OSS CORS 暴露 ETag,本桶已配置)。 - 预签名 URL 有效期:上传/分片/预览 URL 约 1 小时;下载 URL 约 1 小时。大文件慢传中途某片 URL 过期,重新
sign-parts再传该片即可。 - 分片:单片最小 8MB,最多 10000 片(单文件理论上限约 48.8TB);务必按
init返回的partSize切片。 - 下载断点续传:由 OSS 的 HTTP Range 支持,用支持续传的下载器/浏览器即可。
- 文件名:支持中文/空格/括号;后端用 RFC5987 编码下载文件名。
10. 完整示例(Python · requests)
import requests, hashlib, math, os
BASE = "https://files.dragonai.tech"
PASSWORD = "你的口令"
HDR = {"X-Requested-With": "XMLHttpRequest"} # 所有 POST 必带
OCTET = {"Content-Type": "application/octet-stream"}
SINGLE_MAX = 100 * 1024 * 1024 # ≤100MB 走单 PUT
s = requests.Session()
s.post(f"{BASE}/api/login", json={"password": PASSWORD}, headers=HDR).raise_for_status()
def content_hash(path, chunk=8 * 1024 * 1024):
"""与系统一致的分块 SHA-256(用于秒传);文件大时可跳过秒传直接上传。"""
digs = []
with open(path, "rb") as f:
while (b := f.read(chunk)):
digs.append(hashlib.sha256(b).digest())
return hashlib.sha256(b"".join(digs)).hexdigest()
def upload(path, folder=""):
name, size = os.path.basename(path), os.path.getsize(path)
# —— 秒传尝试(可选)——
h = content_hash(path)
ex = s.post(f"{BASE}/api/upload/exists", json={"hash": h, "size": size}, headers=HDR).json()
if ex.get("exists"):
r = s.post(f"{BASE}/api/upload/instant",
json={"hash": h, "filename": name, "folder": folder}, headers=HDR).json()
print("秒传完成:", r["key"]); return r["key"]
if size <= SINGLE_MAX:
# —— 小文件单 PUT ——
r = s.post(f"{BASE}/api/upload/put-url",
json={"filename": name, "size": size, "folder": folder}, headers=HDR).json()
with open(path, "rb") as f:
requests.put(r["url"], data=f, headers=OCTET).raise_for_status()
key = r["key"]
else:
# —— 大文件分片 ——
r = s.post(f"{BASE}/api/upload/init",
json={"filename": name, "size": size, "folder": folder}, headers=HDR).json()
key, upload_id, ps, pc = r["key"], r["uploadId"], r["partSize"], r["partCount"]
urls = {u["partNumber"]: u["url"] for u in s.post(
f"{BASE}/api/upload/sign-parts",
json={"key": key, "uploadId": upload_id, "partNumbers": list(range(1, pc + 1))},
headers=HDR).json()["urls"]}
parts = []
with open(path, "rb") as f:
for n in range(1, pc + 1):
chunk = f.read(ps)
resp = requests.put(urls[n], data=chunk, headers=OCTET); resp.raise_for_status()
parts.append({"partNumber": n, "etag": resp.headers["ETag"]})
s.post(f"{BASE}/api/upload/complete",
json={"key": key, "uploadId": upload_id, "parts": parts}, headers=HDR).raise_for_status()
# 登记秒传索引(可选)
s.post(f"{BASE}/api/upload/record-hash", json={"hash": h, "key": key}, headers=HDR)
print("上传完成:", key); return key
def download(key, save_as):
# 跟随 302 直接从 OSS 下;requests 默认跟随重定向
with s.get(f"{BASE}/api/download", params={"key": key}, stream=True) as r:
r.raise_for_status()
with open(save_as, "wb") as f:
for c in r.iter_content(1024 * 1024):
f.write(c)
print("已下载:", save_as)
# 用法
key = upload("./report.pdf", folder="报表/2026/")
download(key, "./report_back.pdf")
实测:小文件与 116MB 分片文件上传 + 下载,md5 往返一致。
