| 項目 | 內容 |
|---|---|
| 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 健康檢查 / 對帳 |
Guidant AI 是雲端 SaaS。但客戶的證據資料與檔案屬於機敏資料,部分客戶不同意把這些檔案上傳到我們的系統託管。
因此要做一個機制:把「檔案的儲存」從雲端拉出來,落地在客戶自己的設備上(先支援 Linux)。雲端在上傳時,依不同客戶自動呼叫不同的客戶端 API server,把檔案傳過去存在客戶端;下載與預覽同理。
Demo 階段會有兩個客戶(A、B),各自提供一台伺服器放置證據檔案。
本期是 demo,目標是把「binary 不落雲端、依客戶分流到客戶設備」這件事證明出來。下列項目刻意延後,但骨架先留好接縫:
純位置隔離,先不加密。客戶得到的保障是:「證據 binary 靜態不存在我們雲端磁碟上,而是落在客戶自有設備」。雲端在下載 / 預覽時會短暫經手 bytes(relay 給瀏覽器),但不落雲端磁碟。完整的「雲端全程拿不到明文」屬於後續加密階段。
開工前盤點現有檔案功能,結論是接縫天然存在、業務碼幾乎不用動。
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 回傳)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_files(job_evidences 本身有 RLS,靠 workflow→project→tenant 間接隔離)。system_config 的 STORAGE_CONFIG.CONFIG,而 system_configs 的 ORM model 繼承 TenantScopedMixinModel(自帶 tenant_id + RLS)→ 在 session_scope 裡 RLS 會依當前 tenant 自動回該租戶自己那筆設定。providers.Factory:file_upload_service(ManagedFileUploadService)每次注入都 new 一個,_load_config 每 request 重讀 → 沒有 singleton 把第一個 tenant 設定快取給所有人的問題。adapter 快取只在單一 instance 生命週期內。adapter 接縫已在、per-tenant 路由也已存在(每 tenant 自己的 STORAGE_CONFIG)。所以要補的只有:
storage_type = remote_agent,value JSONB 放 agent base_url(+ 未來的 key)RemoteAgentAdapter 對應這種 storage_typeupload_files 補 tenant_id(+ 後續 RLS)— 這張參照表本身才是真的沒 tenant 隔離(路由本身不靠它,靠 request 的 tenant context → per-tenant STORAGE_CONFIG)換句話說:「客戶 A→agent A、B→agent B」不需要新對照表,沿用既有的 per-tenant STORAGE_CONFIG 就分流了。
重用 jedi-file-upload |
metadata DB 在哪 | 代價 | |
|---|---|---|---|
| C | ✅ 完整 | agent 連回雲端 DB | 無重複,但破壞隔離:客戶端要帶雲端 DB 帳密、吃網路延遲/斷線 |
| B(採用) | ✅ 完整 | agent 本地小 Postgres | 重複幾欄不可變的 metadata |
| A | ❌ 自己寫薄 blob 服務 | 不需 DB | 無資料重複,但得 fork 檔案/轉檔邏輯(程式碼分岔) |
決策依據是最小化長期維護成本:
poetry add jedi-file-upload → 接 routes + DI → 收工;套件進版 poetry update 就跟上。一套碼、一個心智模型。C 被排除,因為「agent 連回雲端 DB」違反隔離初衷(帳密外帶、網路耦合),與「先不考慮網路問題」前提衝突。
B 的「兩份 metadata」不是「兩個真相來源在打架」,兩邊擁有不同事實、互不覆寫:
| 擁有者 | 擁有的事實 | 對方會不會寫 |
|---|---|---|
| agent | binary 實際落點(path / bucket / save_file_name) | 雲端永不寫 |
| 雲端 | evidence 參照哪個 uid、tenant、業務 FK | agent 永不寫 |
| 重疊 | file_name / size / mimetype / checksum | 上傳當下寫一次、之後永不變 |
重疊欄位是 immutable 快照,沒有同步 / 一致性負擔。
若轉檔在雲端做,source(Word)必須整個拉回雲端才能轉,等於 binary 回流,牴觸初衷。改為:
convert_to_pdf(jedi-file-upload Local/MinIO adapter 既有行為:在儲存端轉檔、把 PDF 存回原儲存當快取)代價:agent container 要裝 LibreOffice。可接受(反正都是 container 處理)。
┌─ 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。RemoteAgentAdapter:實作 IUploadFileProvider,用 HTTP client 呼叫 agent REST API。agent 的 base_url 直接從 _load_config 拿到的 per-tenant STORAGE_CONFIG 取得,不需自己查任何對照表。業務碼、介面、其他 adapter 全不動。_load_config / factory 加 remote_agent 分支:storage_type == 'remote_agent' 時 build RemoteAgentAdapter(帶 base_url)。adapter 選擇本來就是 per-tenant(每 request 重讀 STORAGE_CONFIG),這裡只是多一個 case。upload_files 多認一種 storage_type = REMOTE_AGENT:save_file_name 記 uid,binary 不在雲端;path 不再是雲端本機路徑。upload_files 加 tenant_id(+ 後續補 RLS):這張參照表目前全租戶可見,要補 tenant 隔離。注意路由本身不靠它(靠 request tenant context → per-tenant STORAGE_CONFIG),此欄是為了參照表本身的安全隔離。upload_files row(storage_type=REMOTE_AGENT、uid、檔名、size、tenant)as_attachment=True,不落磁碟)inline)source 與轉好的 PDF 都待在客戶端,雲端只經手 PDF bytes。
binary 落在客戶機房、不在我們控制下,需能偵測「上傳後被竄改」。
演算法:用 SHA-256(既有 upload_files.checksum 是 MD5,對抗竄改已不安全 → 在 upload_files 加 sha256 欄;此欄加在 jedi-file-upload 模型,兩邊同時擁有)。
信任模型:
sha256 是權威錨點,由「雲端在上傳串流時就地計算」產生——不信 agent 回報的值(雲端是我們的基礎設施,才是信任根)。流程:
| 時機 | 動作 |
|---|---|
| 上傳 | 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 自存那份是輔助。
不是主專案架構縮小版,也不是手寫薄服務,而是把 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,不必我們起)
要點:
FileUploadService 配 env 設定,不必搬「從 system_config 查設定」的 ManagedFileUploadService → agent 的 DB 只需要 upload_files 一張表。| 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。
| 項目 | 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 對帳 |
原本以為要新建一份「tenant → agent」對照表,但 §2.2 確認 STORAGE_CONFIG 已經是 per-tenant(system_configs 走 RLS)。所以:
base_url(+ 未來 key)放進該 tenant 的 STORAGE_CONFIG value JSONB,storage_type = 'remote_agent'。// 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 即可。
IUploadFileProvider 介面、既有三個 adapter 不得修改。upload_files 重疊欄位是 write-once 快照,雲端不回寫 agent、agent 不回寫雲端。jedi-file-upload,不得 fork 其檔案 / 轉檔邏輯。sha256,由雲端在串流時自算;驗證一律對實際 bytes 重算,不得只比對已存欄位值。