FR-039 分散式檔案 Agent(證據檔案落地客戶端)— 設計文件

項目 內容
FR 編號 FR-039
提出日期 2026-06-17
arc 關聯 relates to FR-016(Google Drive 整合,同屬檔案儲存抽象)/ FR-030(證據分類,證據檔案來源)
狀態 設計定案 + Phase 0/1 實作驗證完成evidence-agent 已建、local+minio+docker+預覽 smoke 全通);Phase 2 雲端 adapter 待 FR-039 branch
版本 v2(2026-06-18,含實作驗證回填 + per-tenant STORAGE_CONFIG 更正)
本期範圍(demo) 證據檔案的上傳 / 下載 / 預覽改走「客戶端檔案 agent」;雲端不落 binary;依客戶(tenant)分流到不同 agent
出 scope(demo 後續) 落地/傳輸加密、雲端↔︎agent 認證金鑰、路由設定正式存放(表 vs config)、upload_files RLS 收緊、agent 健康檢查 / 對帳

1. 問題與動機

1.1 白話需求

Guidant AI 是雲端 SaaS。但客戶的證據資料與檔案屬於機敏資料,部分客戶不同意把這些檔案上傳到我們的系統託管。

因此要做一個機制:把「檔案的儲存」從雲端拉出來,落地在客戶自己的設備上(先支援 Linux)。雲端在上傳時,依不同客戶自動呼叫不同的客戶端 API server,把檔案傳過去存在客戶端;下載與預覽同理。

Demo 階段會有兩個客戶(A、B),各自提供一台伺服器放置證據檔案。

1.2 Demo 階段的明確邊界

本期是 demo,目標是把「binary 不落雲端、依客戶分流到客戶設備」這件事證明出來。下列項目刻意延後,但骨架先留好接縫:

  • 加密(傳輸 / 落地)— demo 走 TLS 傳輸即可,落地不額外加密
  • 認證金鑰 — 雲端↔︎agent 之間每客戶一組 key,demo 先略過
  • 網路問題 — 連線可達性、防火牆、固定 IP 等,demo 先不考慮
  • 路由設定的正式存放 — demo 可先寫 config,正式化再決定(見 §9)

1.3 信任模型(demo)

純位置隔離,先不加密。客戶得到的保障是:「證據 binary 靜態不存在我們雲端磁碟上,而是落在客戶自有設備」。雲端在下載 / 預覽時會短暫經手 bytes(relay 給瀏覽器),但不落雲端磁碟。完整的「雲端全程拿不到明文」屬於後續加密階段。


2. 現況分析(拉出去的接縫已存在)

開工前盤點現有檔案功能,結論是接縫天然存在、業務碼幾乎不用動

2.1 已是 adapter 架構

jedi-file-upload 是獨立套件,對外只透過一個介面 IUploadFileProvider,底下已有三個實作:

Adapter 落地後端
LocalFileUploadAdapter 本機磁碟
MinioUploadAdapter MinIO 物件儲存
GoogleDriveUploadAdapter Google Drive

四個進入點全部收斂在這層之上:

  • POST /api/1.0/file/upload(s) — 通用上傳
  • POST /api/1.0/job-evidence — 任務證據上傳(save_dir = JOB_EVIDENCES/{job_execution_uid}
  • GET /api/1.0/file/download/{uid} — 下載(send_file(as_attachment=True)
  • GET /api/1.0/file/pdf-preview/{uid} — 預覽(Office 檔以 LibreOffice headless 轉 PDF 後 inline 回傳)

2.2 檔案 metadata 與儲存設定

  • metadata 表public.upload_files(uid / file_name / file_ext / save_file_name / size / mimetype / path / storage_type / checksum / created_user…)。目前無 tenant_id、無 RLS
  • 業務關聯compliance.job_evidences.file_id FK → upload_files.id;雲端列表 / 顯示 / 權限都讀 upload_filesjob_evidences 本身有 RLS,靠 workflow→project→tenant 間接隔離)。
  • 儲存設定:已經是 per-tenant。讀 system_configSTORAGE_CONFIG.CONFIG,而 system_configs 的 ORM model 繼承 TenantScopedMixinModel(自帶 tenant_id + RLS)→ 在 session_scope 裡 RLS 會依當前 tenant 自動回該租戶自己那筆設定。
  • DI 是 providers.Factoryfile_upload_serviceManagedFileUploadService)每次注入都 new 一個,_load_config 每 request 重讀 → 沒有 singleton 把第一個 tenant 設定快取給所有人的問題。adapter 快取只在單一 instance 生命週期內。

2.3 真正的 gap(比想像小)

adapter 接縫已在、per-tenant 路由也已存在(每 tenant 自己的 STORAGE_CONFIG)。所以要補的只有:

  1. STORAGE_CONFIG 多認一種 storage_type = remote_agent,value JSONB 放 agent base_url(+ 未來的 key)
  2. 新增 RemoteAgentAdapter 對應這種 storage_type
  3. upload_filestenant_id(+ 後續 RLS)— 這張參照表本身才是真的沒 tenant 隔離(路由本身不靠它,靠 request 的 tenant context → per-tenant STORAGE_CONFIG)

換句話說:「客戶 A→agent A、B→agent B」不需要新對照表,沿用既有的 per-tenant STORAGE_CONFIG 就分流了。


3. 設計原則與方案決策

3.1 三個方案

重用 jedi-file-upload metadata DB 在哪 代價
C ✅ 完整 agent 連回雲端 DB 無重複,但破壞隔離:客戶端要帶雲端 DB 帳密、吃網路延遲/斷線
B(採用) ✅ 完整 agent 本地小 Postgres 重複幾欄不可變的 metadata
A ❌ 自己寫薄 blob 服務 不需 DB 無資料重複,但得 fork 檔案/轉檔邏輯(程式碼分岔)

3.2 採用 B:完整搬 jedi-file-upload,agent 自帶本地 DB

決策依據是最小化長期維護成本

  • 重用 > fork。A 的「抽出檔案/轉檔邏輯重用」實質是 fork:MinIO 存取、LibreOffice 轉檔複製一份到 agent 後,套件日後修 bug / 改進,agent 那份不會自動拿到,得手動同步——這是長期負債。
  • B 是真重用。agent 就是「雲端在跑的同一套檔案子系統,獨立部署」。poetry add jedi-file-upload → 接 routes + DI → 收工;套件進版 poetry update 就跟上。一套碼、一個心智模型。
  • 架構準則:寧可重複一點不可變的資料,也不要重複程式碼。agent 應是「同一個檔案子系統的不同部署」,不是「它的第二種實作」。

C 被排除,因為「agent 連回雲端 DB」違反隔離初衷(帳密外帶、網路耦合),與「先不考慮網路問題」前提衝突。

3.3 metadata 重複是良性的(disjoint owners + write-once)

B 的「兩份 metadata」不是「兩個真相來源在打架」,兩邊擁有不同事實、互不覆寫:

擁有者 擁有的事實 對方會不會寫
agent binary 實際落點(path / bucket / save_file_name) 雲端永不寫
雲端 evidence 參照哪個 uid、tenant、業務 FK agent 永不寫
重疊 file_name / size / mimetype / checksum 上傳當下寫一次、之後永不變

重疊欄位是 immutable 快照,沒有同步 / 一致性負擔。

3.4 預覽轉檔在客戶端 agent 做(不回流雲端)

若轉檔在雲端做,source(Word)必須整個拉回雲端才能轉,等於 binary 回流,牴觸初衷。改為:

  • 雲端只請 agent「轉這個 uid」
  • agent 用自帶的 convert_to_pdf(jedi-file-upload Local/MinIO adapter 既有行為:在儲存端轉檔、把 PDF 存回原儲存當快取
  • 雲端只 relay 轉好的 PDF bytes 給瀏覽器

代價:agent container 要裝 LibreOffice。可接受(反正都是 container 處理)。


4. 架構

4.1 整體(誰託管什麼)

┌─ GuidantAI 雲端 SaaS(我們託管,不落 binary)────────────────────┐
│  業務碼 ──→ IUploadFileProvider ──→ RemoteAgentAdapter(新增)    │
│  (證據上傳/下載/預覽,不動)  (統一介面,不動)  (依 tenant 轉發)│
│                                                                  │
│  雲端只保留:upload_files 參照(uid·檔名·tenant,無 binary)       │
│             STORAGE_CONFIG(per-tenant,已含 agent base_url)      │
└──────────────────────────────┬───────────────────────────────────┘
                  binary 離開雲端 │ 依 tenant
        ┌─────────────────────────┴─────────────────────────┐
┌─ 客戶 A 伺服器(Linux)──────────┐   ┌─ 客戶 B 伺服器(Linux)──────────┐
│  檔案 Agent(jedi-file-upload    │   │  檔案 Agent(jedi-file-upload    │
│  + REST + Docker)               │   │  + REST + Docker)               │
│         ↓                        │   │         ↓                        │
│  Local 磁碟(volume)            │   │  客戶自家 MinIO                  │
└──────────────────────────────────┘   └──────────────────────────────────┘
  • 灰色(雲端):業務碼與 IUploadFileProvider 介面完全不動,新增的只有 RemoteAgentAdapter
  • 紫色(客戶端):每客戶一份 agent,落地後端由 agent 自己 config 決定(A 用 Local、B 用自家 MinIO)。

4.2 雲端改動點

  1. 新增 RemoteAgentAdapter:實作 IUploadFileProvider,用 HTTP client 呼叫 agent REST API。agent 的 base_url 直接從 _load_config 拿到的 per-tenant STORAGE_CONFIG 取得,不需自己查任何對照表。業務碼、介面、其他 adapter 全不動。
  2. _load_config / factory 加 remote_agent 分支storage_type == 'remote_agent' 時 build RemoteAgentAdapter(帶 base_url)。adapter 選擇本來就是 per-tenant(每 request 重讀 STORAGE_CONFIG),這裡只是多一個 case。
  3. upload_files 多認一種 storage_type = REMOTE_AGENTsave_file_name 記 uid,binary 不在雲端;path 不再是雲端本機路徑。
  4. upload_filestenant_id(+ 後續補 RLS):這張參照表目前全租戶可見,要補 tenant 隔離。注意路由本身不靠它(靠 request tenant context → per-tenant STORAGE_CONFIG),此欄是為了參照表本身的安全隔離。

5. 三個操作流程

5.1 上傳

  1. 前端上傳檔案 → 雲端
  2. 雲端寫自己的 upload_files row(storage_type=REMOTE_AGENT、uid、檔名、size、tenant)
  3. 雲端依 tenant 把 binary + uid 轉發給對應 agent
  4. agent 落地存檔(Local / MinIO),回 200

5.2 下載

  1. 前端帶 uid 要檔 → 雲端
  2. 雲端讀自己的 row(拿 uid、原檔名、mime)→ 依 tenant 找 agent → 用 uid 取檔
  3. agent 回 binary 串流
  4. 雲端 relay 串流給瀏覽器(as_attachment=True,不落磁碟)

5.3 預覽(Word/Excel → PDF)

  1. 前端要預覽 → 雲端
  2. 雲端請 agent「轉這個 uid」
  3. agent 用 LibreOffice 轉 PDF,存回客戶儲存當快取(第二次預覽直接命中)
  4. agent 回 PDF 串流 → 雲端 relay 給瀏覽器(inline

source 與轉好的 PDF 都待在客戶端,雲端只經手 PDF bytes。

5.4 完整性驗證(防竄改 / 雙邊 hash 比對)

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

演算法:用 SHA-256(既有 upload_files.checksum 是 MD5,對抗竄改已不安全 → 在 upload_filessha256 欄;此欄加在 jedi-file-upload 模型,兩邊同時擁有)。

信任模型

  • 雲端儲存的 sha256 是權威錨點,由「雲端在上傳串流時就地計算」產生——不信 agent 回報的值(雲端是我們的基礎設施,才是信任根)。
  • agent 也獨立算一份存著,供自我稽核 / 雙邊比對。
  • 驗證必須對磁碟上實際 bytes 重新即時計算不能只比對已存的欄位值(否則同時改檔 + 改欄位即可繞過)。

流程

時機 動作
上傳 binary 經雲端轉發 → 雲端邊串流邊算 SHA-256 存錨點;agent 落地時獨立算一份存本地;(可選)agent 回傳值,雲端 assert == 錨點(驗傳輸)
下載 binary 經雲端 relay → 雲端就地對收到 bytes 重算 → 比對錨點:一致放行 / 不一致擋下 + 記稽核事件
預覽 agent 轉檔先驗 source 完整性(PDF 是衍生物,驗的是原檔)
排程稽核(後續) 定期請 agent 重算各檔 hash 回報,與錨點對帳

不一致處理:拒絕回傳 + 寫稽核事件(system_logs + event_code,沿用專案稽核埋點 pattern),標記疑似竄改。

效能權衡(可調):最嚴=每次存取都驗;省=「下載首次驗 + 排程稽核」、預覽用快取 PDF 不重複驗。demo 用「下載時驗」即可

雲端 relay 時本來就會經手 bytes,「重算比對」幾乎零額外成本,且不需信任 agent 的計算——這是最強防線;agent 自存那份是輔助。


6. 客戶端 Agent(docker 內跑什麼)

不是主專案架構縮小版,也不是手寫薄服務,而是把 jedi-file-upload 包成獨立可跑的最小 Flask app。

docker-compose(客戶端一份)
├── file-agent        ← 極小 Flask app
│     ├─ app factory(Flask + dependency-injector)   ← 與主專案同款,空殼
│     ├─ 搬一份 api/uploadfile/ routes(upload/download/pdf-preview/delete)
│     ├─ 搬一份 di_containers/upload_file/
│     ├─ 依賴 jedi-file-upload 套件(0 改動,DDD 都在套件內)
│     └─ config(DB 連線 + 直接給定的 STORAGE_CONFIG,from env)
├── agent-db          ← 小 Postgres(只放 jedi-file-upload 的 upload_files 一張表)
└── volumes           ← binary 落地(Local 模式)+ db data
   (客戶用 MinIO 時,這格換成指向客戶自家 MinIO,不必我們起)

要點:

  • 只留檔案這幾支 endpoint,不帶 GRC / OSCAL / flow_engine / jedi-auth / RLS。
  • DB 自帶、不外連雲端:agent 完全自包含,雲端↔︎agent 只透過 REST 講話。
  • agent 直接用 FileUploadService 配 env 設定,不必搬「從 system_config 查設定」的 ManagedFileUploadService → agent 的 DB 只需要 upload_files 一張表
  • 實際要新寫的程式量很小:app factory 空殼 + 搬 routes/DI 兩三個檔;檔案邏輯全在套件內。

7. Agent REST 介面(demo 草案)

Method Path 用途 回傳
POST /blob 上傳(multipart) { uid, save_file_name, size, mimetype }
GET /blob/{uid} 下載 binary 檔案串流
GET /blob/{uid}/pdf 取預覽 PDF(必要時即時轉、快取) PDF 串流
DELETE /blob/{uid} 刪除 200
GET /health 健康檢查 200

demo 不加認證;正式化時每客戶一組 key(header 帶)+ TLS。


8. Demo 範圍與後續

項目 demo 後續
binary 落客戶端、依 tenant 分流
Local / MinIO(客戶自選) ✅(agent config)
預覽轉檔在客戶端
完整性驗證(SHA-256 防竄改) ✅ 下載時驗 排程稽核對帳 + 預覽前驗 source
傳輸加密 TLS 落地加密、雲端全程不見明文
雲端↔︎agent 認證 ❌ 略過 每客戶一組 key
upload_files tenant 隔離 tenant_id 補 RLS policy
路由設定存放 沿用 per-tenant STORAGE_CONFIG(見 §9) (可選)拉專表給 admin UI
agent 失聯 / 對帳 健康檢查 + dangling reference 對帳

9. 路由設定(已解決,非開放決策)

原本以為要新建一份「tenant → agent」對照表,但 §2.2 確認 STORAGE_CONFIG 已經是 per-tenantsystem_configs 走 RLS)。所以:

  • 直接把 agent base_url(+ 未來 key)放進該 tenant 的 STORAGE_CONFIG value JSONBstorage_type = 'remote_agent'
  • 客戶 A 的 STORAGE_CONFIG 指向 agent A、客戶 B 指向 agent B——分流自然成立,不需新表、不需 config、不需 env
// tenant A 的 system_config: group=STORAGE_CONFIG, key=CONFIG
{ "storage_type": "remote_agent", "base_url": "https://<客戶A agent>", /* 未來: "api_key": "..." */ }

唯一可選的後續:若要 admin UI 方便維護多客戶 agent,再考慮拉成專表;demo 與初期沿用 STORAGE_CONFIG 即可。


10. 不變式(implementation 時要守住)

  • 業務碼、IUploadFileProvider 介面、既有三個 adapter 不得修改
  • 雲端任何路徑不得把 binary 寫進雲端磁碟(relay 用串流、不落地)。
  • agent 不得連雲端 DB;雲端↔︎agent 只走 REST。
  • upload_files 重疊欄位是 write-once 快照,雲端不回寫 agent、agent 不回寫雲端。
  • agent 完整重用 jedi-file-upload不得 fork 其檔案 / 轉檔邏輯。
  • 完整性驗證的信任根是雲端儲存的 sha256,由雲端在串流時自算;驗證一律對實際 bytes 重算,不得只比對已存欄位值。