DragonAI烛龙智元

烛龙智元 · 文件中转站 — API 接口使用说明

适用版本:filehub 2.1 · 线上地址 https://files.dragonai.tech 本文面向需要用脚本/服务/AI agent 对接上传下载的开发者。浏览器端 UI 已内置全部能力,无需对接本文档。 想让 coding agent 直接代管上传下载? 用现成的 Agent SKILL(零依赖 CLI,已封装下面所有接口与鉴权握手),无需自己实现本文档。


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. 认证 —— 口令即空间(password-as-namespace)

没有固定口令。 口令会确定性地映射到它专属的隔离空间:用同一口令登录就能看到同一批文件,换一个口令就是另一个(全新空)空间。要共享文件,上传方与下载方必须用同一口令

口令安全要求(对所有登录统一强制):口令须至少 8 位,同时包含数字和至少 2 个字母,且不得出现 4 个及以上的连续数字或字母(如 1234abcd,倒序 4321dcba 同样不允许,字母不分大小写),也不得出现 4 个及以上连续相同的字符(如 1111aaaa,任意字符,区分大小写)。任何不合规口令一律拒绝(400 weak_password),无老空间放行。

认证系统升级说明:早期弱口令的老空间已由管理员逐一重新分配了强口令(通过服务端别名 space_aliases.json 将新口令映射到原有文件,不搬动数据)。老用户若原口令无法登录,请联系管理员获取该空间的新口令。

安全性 = 口令强度;空间不可枚举(口令经服务器密钥做 HMAC),但弱口令对应的空间也弱。口令打错不一定报错,可能静默进入另一个空空间——所以登录后请看响应里的 space 指纹和 isNew 确认进对了地方。

所有写操作(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 分钟内 40 次登录后返回 429,需稍后重试(放慢空间猜测)。
防撞库 同一 IP 连续 5 个不同的失败口令(弱口令被拒,或进入全新空空间)后锁定 60 秒(429 login_locked + Retry-After);此后每个新的失败口令再锁 60 秒;成功进入有文件的空间即重置。锁定期间的提交不被处理。
内容类型 请求体为 JSON 时带 Content-Type: application/json

POST /api/login

请求体:{"password": "<口令>"}(口令须至少 8 位,同时包含数字和至少 2 个字母,且不含 4 位及以上连续数字/字母或连续相同字符;对所有登录统一强制) - 200 {"ok": true, "space": "1a2b3c4d", "isNew": false, "hint": "已进入既有空间。"} + 下发 Cookie - space:该空间的短指纹(非敏感,仅供「是不是上次那个空间」确认)。 - isNew:true 表示这是个全新空白空间(尚无文件)——若你预期这里有文件,多半是口令打错了。 - 400 {"code": "weak_password", "detail": "口令不符合安全要求:..."} 口令不合规,一律拒绝(无老空间放行);老用户原口令无法登录时 hint 提示联系管理员获取新口令。 - 400 {"code": "empty_password", "detail": "口令不能为空。..."} 口令为空 - 429 {"code": "login_locked", ...} + Retry-After:防撞库锁定(连续 5 个不同失败口令触发,每次继续失败再锁 60 秒),等待后重试 - 429 {"code": "rate_limited", ...} 普通登录限频(40 次/5 分钟)

POST /api/logout

清除会话 Cookie。200 {"ok": true}

GET /api/me

探测登录态。200 {"authed": true, "space": "1a2b3c4d", "isNew": false}401(见 §3 的鉴权错误结构)。

GET /api/health

健康检查(免鉴权)。200 {"ok": true}

GET /api/info

能力发现(免鉴权)——让 agent 不读文档即可自配置:返回版本、鉴权模型、CSRF 头、分片/URL 有效期/限频等限制、错误契约。

{
  "ok": true, "service": "filehub", "version": "3.2.0",
  "authModel": "password-as-namespace", "csrfHeader": "X-Requested-With",
  "passwordPolicy": { "minLength": 8, "minDigits": 1, "minLetters": 2, "forbidSequentialRunOf": 4, "forbidRepeatRunOf": 4, "appliesTo": "all-logins" },
  "login": { "method": "POST", "endpoint": "/api/login", "body": {"password": "<口令>"}, "agentAction": "ask_user_for_password" },
  "limits": { "multipartPartMinBytes": 8388608, "multipartMaxParts": 10000, "uploadUrlExpiresSec": 3600, "downloadUrlExpiresSec": 3600, "loginRateLimit": {"max": 40, "windowSec": 300}, "stuffingGuard": {"freeDistinctFailures": 5, "lockSeconds": 60} },
  "errorContract": { "shape": {"detail":"string","code":"string?","hint":"string?"}, "authCodes": ["auth_required","session_expired","invalid_session"] },
  "docs": "/docs/", "skill": "/skill/"
}

2.5 给 AI agent 的接入约定(鉴权握手)

本平台对所有「未带口令 / 会话失效」的请求返回结构化、可自纠的 401,让 agent 无需读文档就知道该做什么:

HTTP 401
{
  "detail": "未提供访问口令(未登录)。",
  "code": "auth_required",
  "hint": "本平台「口令即空间」:请向用户索取访问口令,用它登录后即可读写对应的独立空间。",
  "auth": {
    "method": "POST", "endpoint": "/api/login",
    "body": {"password": "<访问口令 / access password>"},
    "note": "口令须至少 8 位,同时包含数字和至少 2 个字母,且不得出现 4 个及以上的连续数字或字母如 1234/abcd、或 4 个及以上连续相同字符如 1111/aaaa(对所有登录统一强制,无例外);同口令可重复访问,共享文件需双方用同一口令。老用户原口令不合规无法登录时请联系管理员获取新口令。",
    "agentAction": "ask_user_for_password"
  }
}

agent 推荐流程: 1. 先调一次接口(如 GET /api/me)。若收到 code: "auth_required"(或 session_expired / invalid_session), 2. 停下来向用户索取访问口令(不要自己编), 3. 用用户给的口令 POST /api/login,再用返回的 space / isNew 向用户确认进对了空间, 4. 继续正常的上传/下载/列举。

code 取值:auth_required(从未登录)、session_expired(会话过期)、invalid_session(凭证无效)。三者都应触发「重新用口令登录」,只有 auth_required 通常意味着要先向用户要口令。

更省事的方式:直接用 Agent SKILL 里的 filehub.py,它已把这套握手封装好——口令缺失时退出码为 3 并打印 AUTH_REQUIRED,提示 agent 去要口令。


3. 通用约定

错误码速查(code 字段;每个都带可读 detail,多数带 hint):

code HTTP 含义 / agent 应对
auth_required 401 未登录 → 向用户要口令再 login(错误体带 auth 对象)
session_expired 401 会话过期 → 用同一口令重新 login
invalid_session 401 凭证无效 → 重新 login
empty_password 400 口令为空 → 提供非空口令
csrf_required 403 X-Requested-With 头 → 补上
invalid_request 422 请求体字段/类型不符 → 看 errors 数组修正
rate_limited 429 登录限频 → 读 Retry-After(秒)等待后重试
invalid_path / invalid_key / reserved_key / invalid_filename 400 路径/key 非法(穿越、保留前缀、空) → 修正
invalid_part_number / invalid_hash 400 分片号/哈希格式非法
missing_etag / no_parts 400 合并分片参数缺失
upload_session_expired 409 uploadId 失效 → 重新 init(CLI 自动处理)
hash_not_indexed / source_missing 409 秒传不可用 → 走普通上传
object_not_found 400 目标对象不存在
oss_<Code> 4xx/5xx 透传的 OSS 错误(如 oss_NoSuchKey),404 附 hint

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..." }

返回(含进度汇总,便于 agent 直接读 pct):

{
  "key": "big.zip", "uploadId": "AF3...",
  "parts": [ { "partNumber": 1, "etag": "E375...", "size": 8388608 }, { "partNumber": 2, "etag": "1159...", "size": 8388608 } ],
  "partsUploaded": 2, "bytesUploaded": 16777216
}

GET /api/upload/uploads — 列出未完成的分片上传

枚举当前空间里所有在途/未完成的分片上传,供发现并续传或放弃中断的任务:

{ "uploads": [ { "key": "big.zip", "uploadId": "AF3...", "initiated": "2026-06-14T11:29:41+00:00" } ], "count": 1 }

拿到 key+uploadId 后:用 list-parts 查进度续传,或用 abort 放弃。

浏览器端 UI 会把 {key, uploadId, partSize, 已完成分片} 持久化到 localStorage,刷新/断网后自动用本接口续传;filehub.py CLI 把这些落到 ~/.filehub/uploads/,中断后重跑 upload 自动续传(见 Agent SKILL)。脚本自行对接时持久化这几个字段即可。


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": "新目录/子目录/" }(创建空目录标记对象)。


7.5 文件分享链接(对外分发,免登录)

单个文件生成一个免口令的下载链接发给客户。访问者只能看到/下载这一个文件,无法进入任何内部页面、无法看到其它文件或目录。前端「文件浏览器」每个文件旁的「复制链接」即调用本接口。

安全模型:分享令牌是一枚 SECRET_KEY 签名的 bearer 凭证,只编码 {命名空间, 该文件 key, 该文件内容 etag}。它只授权这一个对象——没有任何「令牌 + 任意 key」的接口,持有者既不能列目录也不能碰其它文件。令牌与登录 cookie 用不同 salt,互不可用。改令牌即失效(签名);删除/移动该文件即失效;用不同内容覆盖同名 key 也会失效(etag 绑定,防止旧链接指向新文件)。可选过期:配置 SHARE_EXPIRES(秒,0=永久)。公开读接口按 IP 限频(120 次/60 秒)。

POST /api/share(需登录 + CSRF)

{ "key": "报表/2026/Q2.xlsx" }

{ "token": "<签名令牌>", "path": "/s/<令牌>", "name": "Q2.xlsx", "expiresSec": null } 完整链接 = https://files.dragonai.tech + path。只能分享具体文件(目录返回 400 invalid_key;文件不存在返回 404 object_not_found)。

GET /s/<token>(公开,浏览器)

返回一个独立的极简下载页 share.html:只展示该文件名/大小 + 下载按钮(图片内联预览、PDF 可新标签预览)。该页不含任何通往登录/文件浏览/文档/技能页的链接,文件名以 textContent 渲染(防 XSS)。

GET /api/share/info?token=<token>(公开)

{ "name": "Q2.xlsx", "size": 20480, "type": "xlsx" }(Cache-Control: no-store)。

GET /api/share/download?token=<token>(公开)

302 重定向到带 attachment 的预签名 OSS URL(直接从 OSS 下载,免登录)。

GET /api/share/preview?token=<token>(公开)

→ 同上但 inline(图片/PDF 内联)。

失效情形统一返回 404:invalid_share(令牌被改/截断)、share_gone(文件已删/已被改成别的内容)、share_expired(配了过期且已过期)。


8. 端点速查表

方法 路径 鉴权 CSRF 用途
POST /api/login 登录,下发 Cookie
POST /api/logout 登出
GET /api/me 探测登录态
GET /api/health 健康检查
GET /api/info 能力发现(版本/鉴权模型/限制/错误契约)
POST /api/upload/put-url 小文件单 PUT 签名
POST /api/upload/init 初始化分片上传
POST /api/upload/sign-parts 批量签分片 URL
POST /api/upload/list-parts 查已传分片 + 进度汇总(断点续传)
GET /api/upload/uploads 列出未完成的分片上传
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 新建文件夹
POST /api/share 为单个文件生成免登录分享链接
GET /s/<token> 独立下载页(隔离,无内部入口)
GET /api/share/info 分享文件的名称/大小(公开)
GET /api/share/download 分享下载(302→OSS,公开)
GET /api/share/preview 分享预览(302→OSS,公开)

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