# 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_files`（`job_evidences` 本身有 RLS，靠 workflow→project→tenant 間接隔離）。
- **儲存設定：已經是 per-tenant**。讀 `system_config` 的 `STORAGE_CONFIG.CONFIG`，而 `system_configs` 的 ORM model 繼承 `TenantScopedMixinModel`（自帶 `tenant_id` + RLS）→ 在 `session_scope` 裡 RLS 會依當前 tenant 自動回**該租戶自己那筆**設定。
- **DI 是 `providers.Factory`**：`file_upload_service`（`ManagedFileUploadService`）每次注入都 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_files` 補 `tenant_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_AGENT`**：`save_file_name` 記 uid，binary 不在雲端；`path` 不再是雲端本機路徑。
4. **`upload_files` 加 `tenant_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_files` 加 `sha256` 欄；此欄加在 `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-tenant**（`system_configs` 走 RLS）。所以：

- **直接把 agent `base_url`（+ 未來 key）放進該 tenant 的 STORAGE_CONFIG value JSONB**，`storage_type = 'remote_agent'`。
- 客戶 A 的 STORAGE_CONFIG 指向 agent A、客戶 B 指向 agent B——**分流自然成立，不需新表、不需 config、不需 env**。

```jsonc
// 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 重算**，不得只比對已存欄位值。
