FR-039 · 設計規格 v2 · 2026-06-18

分散式檔案 Agent — 證據檔案落地客戶設備

客戶證據檔案屬機敏資料,部分客戶不接受上傳到雲端託管。本案把「檔案儲存」拉出成客戶端 evidence-agent,雲端依 per-tenant 設定自動分流到各客戶 agent,binary 不存我們雲端磁碟。雲端業務碼幾乎不動。

一句話:agent = jedi-file-upload 包成獨立 Docker 服務(自帶本地 Postgres、存 Local/MinIO);雲端新增 RemoteAgentAdapter(實作既有 IUploadFileProvider)依 tenant 呼叫 agent。
本期 demo:上傳/下載/預覽 改走客戶端 agent 客戶 A=local 磁碟、B=自家 MinIO 信任模型 純位置隔離(先不加密) agent 專案 evidence-agent(獨立 repo)
Phase 0 R-1 spike 驗證 ✅ Phase 1 agent 建置 + local/minio/docker/預覽/持久化 smoke 全通 ✅ Phase 2 雲端 RemoteAgentAdapter(待 FR-039 branch) Phase 3 SHA-256 完整性

需求與 demo 邊界

本期要做(demo)

  • binary 不落雲端,落在客戶自有設備(Linux)
  • 雲端依不同客戶自動呼叫不同 agent
  • 證據 上傳 / 下載 / 預覽 三操作改走 agent
  • 客戶端可選 Local 磁碟或自家 MinIO

刻意延後(骨架先留)

  • 加密(傳輸 / 落地)— demo 走 TLS
  • 雲端↔agent 認證 key
  • 網路可達性 / 防火牆
  • SHA-256 完整性(Phase 3)/ agent DB 備份策略
信任模型(demo):純位置隔離、先不加密。客戶得到的保障是「證據 binary 靜態不在我們雲端磁碟」。下載/預覽時雲端短暫經手 bytes(relay 給瀏覽器)但不落磁碟。

現況:接縫已在,路由已是 per-tenant

業務碼幾乎不用動——現在就是 adapter 架構,且 per-tenant 路由已現成。

jedi-file-upload 已是 adapter 模式

  • 對外只有 IUploadFileProvider,底下已有 Local / MinIO / GoogleDrive adapter
  • 四進入點(upload / job-evidence / download / pdf-preview)全收斂於此
  • 拉出去 = 新增第四個 adapter(RemoteAgentAdapter),不是搬整套

per-tenant 路由「已存在」

  • STORAGE_CONFIG 存在 system_configs,該表繼承 TenantScopedMixinModel(RLS)→ 每客戶讀到自己那筆
  • DI 是 Factory,每 request 重讀 → 無 singleton 快取陷阱
  • 「客戶 A→agent A、B→agent B」不需新對照表,沿用 STORAGE_CONFIG

真正要補的(gap 很小)

  • STORAGE_CONFIG 多認 storage_type = remote_agent(value 放 agent base_url
  • 新增 RemoteAgentAdapter + RemoteAgentConfigDTO
  • upload_filestenant_id(參照表隔離)+ sha256(Phase 3 防竄改)

方案決策:完整重用 jedi-file-upload

關鍵問題是「檔案 metadata 真相來源放哪」。採 B,依據是最小化長期維護成本。

方案重用 jedi-file-uploadmetadata DB代價
C✅ 完整agent 連回雲端 DB無重複,但破壞隔離:客戶端帶雲端 DB 帳密、吃網路延遲
B採用✅ 完整agent 本地小 Postgres重複幾欄不可變的 metadata(良性)
A❌ 自己寫薄服務不需 DB無資料重複,但得 fork 檔案/轉檔邏輯
重用 > fork。A 的「抽出重用」實質是 fork——套件日後修 bug、改進,agent 那份不會自動拿到。B 是「同一套檔案子系統獨立部署」,poetry update 就跟上。寧可重複一點不可變資料,也不要重複程式碼。
「兩份 metadata」是良性的。擁有者不同、互不覆寫:agent 擁有 binary 落點、雲端擁有業務參照;重疊的 file_name/size/mime 是上傳當下寫一次、之後永不變的快照,無同步負擔。

架構

灰=我們雲端託管;紫=客戶自有伺服器(binary 只存這)。雲端業務碼與介面不動,新增的只有 RemoteAgentAdapter

GuidantAI 雲端 SaaS(我們託管,不落 binary) 業務碼 上傳/下載/預覽 IUploadFileProvider 統一介面 · 不動 RemoteAgentAdapter 新增 · 依 tenant 轉發 雲端只保留 upload_files 參照 uid·檔名·tenant(無 binary) STORAGE_CONFIG(per-tenant)→ agent binary 離開雲端 → 客戶端 客戶 A 伺服器(Linux) evidence-agent jedi-file-upload · REST · Docker Local 磁碟 · volume + 本地 Postgres 客戶 B 伺服器(Linux) evidence-agent jedi-file-upload · REST · Docker 客戶自家 MinIO + 本地 Postgres

config 解析・資料流

路由完全沿用現有 RLS——RemoteAgentAdapter 裡沒有一行 if tenant

上傳時的 config 解析(tenant → 哪台 agent)

1前端上傳檔案 (帶 JWT → 解出 tenant context)
2@transactionsession_scope → 注入 RLS:app.user_id / allowed_tenant_paths
3_load_configSTORAGE_CONFIG → RLS 自動回本 tenant 那筆(無 tenant filter,靠 RLS)
4看到 storage_type=remote_agent → build RemoteAgentAdapter(base_url)
5adapter HTTP POST /blob 把 binary 轉發給對應 agent

下載

1雲端讀自己 row 拿 uid + 解析 base_url
2HTTP GET /blob/<uid> 取回串流
3relay 給瀏覽器(不落磁碟)

預覽(轉檔在客戶端)

1雲端請 agent 轉檔 GET /blob/<uid>/pdf
2agent LibreOffice 轉 PDF + 存回客戶儲存當快取
3回 PDF 串流 → 雲端 relay。source 不離開客戶端

兩邊 metadata(同一檔案、兩筆 row)

同一張 upload_files schema,部署在兩個 DB;uid 是跨邊界的鑰匙。範例:稽核報告.docx 落客戶 B 的 MinIO。

欄位雲端 upload_files(參照)客戶 B agent upload_files(真檔)
id5012 ← job_evidences.file_id88(agent 本地序號,雲端看不到)
uidf3a9…f3a9… ← 兩邊相同,跨邊界鑰匙
file_name / size / mime重疊、上傳當下寫一次、之後不變
storage_typeREMOTE_AGENTMINIO(agent 真後端)
save_file_namef3a9…(拿去跟 agent 要檔的 key)f3a9….docx(MinIO 物件名)
path空 / 不用<bucket>/<object key>
tenant_id102(新增欄)不需要(單租戶機器)
binary 本體在客戶 B 的 MinIO
agent 的 base_url 不存在每一筆 row。它在該 tenant 的 STORAGE_CONFIG(一筆)。雲端參照只要有 uid,用時 RLS 解析出本 tenant 的 base_url 再去要——endpoint 一個租戶存一次,不是每檔重複

Agent 專案(evidence-agent)

縮小版主專案:照 core/api/di_containers/config 骨架,但domain/infra/app 空殼層(邏輯都在 jedi-file-upload)。

專案結構

evidence-agent/ ├── main_app.py 進入點 ├── core/app_factory.py 極小 Flask factory ├── config/config.py env → storage DTO + DB URL ├── api/blob/ │ ├── __init__.py blueprint │ └── routes/blob_route.py 5 支 endpoint ├── di_containers/containers.py ├── Dockerfile docker-compose.yml └── pyproject.toml (poetry)

套件依賴(poetry + Nexus)

  • jedi-file-upload(檔案邏輯,連帶 jedi-common / minio / SQLAlchemy / psycopg)
  • jedi-common(session_scope / init_db / BaseModel / @transaction)
  • Flask / flask-restful / Flask-Cors / dependency-injector
  • LibreOffice(轉檔)→ 非 poetry,Dockerfile 裝

REST 介面(雲端 RemoteAgentAdapter 呼叫)

MethodPath用途
POST/blob上傳 → 回 uid / size / mimetype
GET/blob/{uid}下載 binary 串流
GET/blob/{uid}/pdf取預覽 PDF(必要時即時轉、快取)
DELETE/blob/{uid}刪除
GET/health健康檢查
設定走 env、不連雲端 DB。用原廠 FileUploadService + env 組的 config DTO(不搬 ManagedFileUploadService、不要 system_configs 表)→ agent DB 只有 upload_files 一張表。雲端多租戶才用 system_config;agent 單租戶用 env。
agent DB 是關鍵狀態,絕不可遺失。upload_files 存 uid→落點,掉了 binary 全孤兒。compose 用 host bind-mount ./pgdata(連 down -v 都刪不掉)+ ./data,整包好備份。安全網:save_file_name 內嵌 uid,DB 全毀可從檔名重建。
Nexus 憑證:image 在我們這邊 build(用 BuildKit secret 帶帳密,不進 image 層、不入版控),build 成自包含後 docker save 出貨;客戶端碰不到 Nexus,compose 改用 image: 不 build。

完整性防竄改(SHA-256,Phase 3)

binary 在客戶機房、不在我們控制下,需偵測「上傳後被竄改」。

信任根 = 雲端的 sha256由雲端在上傳串流時就地計算(不信 agent 回報值)。在 upload_filessha256 欄(兩邊都存)。
驗證對「實際 bytes 重算」,不只比已存欄位值(否則同時改檔+改欄位就繞過)。下載 relay 經手 bytes → 就地重算比對錨點,幾乎零成本。
時機動作
上傳雲端邊串流邊算 SHA-256 存錨點;agent 落地獨立算一份
下載雲端重算收到 bytes → 比對錨點:一致放行 / 不一致擋下 + 記稽核事件
預覽agent 轉檔前先驗 source 完整性

Demo 範圍 vs 後續

項目Demo後續
binary 落客戶端、依 tenant 分流
Local / MinIO(客戶自選)✅ agent env
預覽轉檔在客戶端
路由設定存放沿用 per-tenant STORAGE_CONFIG(可選)拉專表給 admin UI
完整性 SHA-256Phase 3排程稽核對帳
傳輸/落地加密TLS落地加密、雲端全程不見明文
雲端↔agent 認證❌ 略過每客戶一組 key
agent DB 備份bind-mount 持久化pg_dump 排程 / 整包備份

驗證狀態(已實作並 smoke 通過)

驗證項結果
Phase 0 R-1 spike✅ jedi-common @transaction 不需 Redis/JWT 即可裸跑(init_db 後)
agent 本機 dev✅ 上傳/下載(內容一致)/刪除/404 envelope
docker compose(Nexus secret)✅ image build + 兩容器起 + health
預覽轉檔(LibreOffice in image)✅ .rtf → PDF(%PDF-
MinIO 後端(連真實 MinIO)✅ round-trip 一致 + 刪除
持久化(down/up)✅ 重啟後資料仍在(bind-mount pgdata/data)
正式模式錯誤 envelope✅ 修 PROPAGATE_EXCEPTIONS(flask-restful 非 debug 吞 500 的雷)
Phase 2 雲端 RemoteAgentAdapter⏳ 待 FR-039 branch