DragonAI烛龙智元

烛龙智元 · 文件中转站 — 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. 通用约定


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. 断点续传

分片上传中断后,只要还持有 keyuploadId,即可向 OSS 查询已经成功收到的分片,只补传缺失的:

POST /api/upload/list-parts

{ "key": "big.zip", "uploadId": "AF3..." }

返回:

{ "parts": [ { "partNumber": 1, "etag": "E375...", "size": 8388608 }, { "partNumber": 2, "etag": "1159...", "size": 8388608 } ] }

浏览器端 UI 会把 {key, uploadId, partSize, 已完成分片} 持久化到 localStorage,刷新/断网后自动用本接口续传。脚本对接时自行持久化这几个字段即可。


6. 秒传(相同内容免上传)

若桶里已存在相同内容的对象,可让 OSS 服务端复制到目标 key,0 字节上传。

内容哈希算法(务必一致,否则命不中):把文件按 8MB 分块,对每块取 SHA-256,将各块的 32 字节摘要按顺序拼接,再对拼接结果取一次 SHA-256,输出 hex(64 位小写)。

  1. POST /api/upload/exists { "hash": "<hex>", "size": 123 }{ "exists": true, "key": "已有对象key", "size": 123 }{ "exists": false }
  2. 命中则 POST /api/upload/instant { "hash", "filename", "folder?" }{ "ok": true, "key": "目标key" }(服务端 copy_object,瞬时完成)
  3. 未命中则正常上传(§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
}

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. 限制与注意事项


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 往返一致。