AI GO Custom App — 開發者指南
本文檔說明如何透過 API 開發 AI GO Custom App,適用於 AI Agent 自動化開發和人類開發者手動整合。
1. 什麼是 Custom App
Custom App 是 AI GO 平台內的可程式化微應用,讓您以 React + TypeScript 建立自訂的業務工具。
核心概念
- VFS(Virtual File System):以 JSON 物件
{"檔案路徑": "檔案內容"}儲存所有原始碼 - esbuild 編譯器:將 React TSX 編譯為瀏覽器可執行的 JS bundle
- Runtime 沙箱:在 Shadow DOM 隔離環境中安全執行已編譯的 App
- Server-Side Actions:Python 後端腳本,在 App 專屬的隔離 runner 中執行(詳見第 8 章)
Internal vs External
| 特性 | Internal(內部應用) | External(外部應用) | Public(匿名檢視) |
|---|---|---|---|
| 使用場景 | 組織內部管理工具 | 對外客戶/供應商應用 | 產品目錄、場館展示等公開頁面 |
| 認證方式 | 主站帳號登入 | 獨立帳號系統 | 無需登入(匿名) |
| API 操作 | 完全相同(透過 Builder API) | 完全相同(透過 Builder API) | 唯讀 pub/ API(詳見第 18 章) |
| 資料寫入 | CRUD | CRUD | 僅讀取 |
2. 認證與連線
取得 JWT Token
所有 API 操作需要具備 builder.access 權限的帳號。平台管理員會提供可用的帳號與密碼。
POST https://ai-go.app/api/v1/auth/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "your_password"
}
回應:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "...",
"expires_in": 3600,
"token_type": "bearer"
}
使用 JWT
GET /api/v1/builder/apps/{slug}
Authorization: Bearer {access_token}
3. 首次連入:理解 App 架構
重要:開始修改前,必須先讀取並理解當前 App 的 VFS 結構,避免使用不相容的架構。
標準流程
1. GET /api/v1/builder/apps/{slug}
→ 取得 vfs_state, vfs_version, access_mode
2. 分析 VFS 結構:
- 讀取 src/App.tsx → 理解路由結構
- 讀取 src/routes.ts → 理解導航配置
- 讀取 src/pages/_manifest.json → 頁面清單
3. 確認 SDK:
- src/api.ts → 資料中心自建表 CRUD
- src/db.ts → DB Proxy
- src/action.ts → Server-Side Action
核心規則
- 使用 React 18 + TypeScript + HashRouter(若 App 只有單一頁面,可直接渲染元件,不需 Router)
- React / ReactDOM / lucide-react / react-router-dom 由 Runtime 提供,不可自行安裝
- CSS 使用全域
App.css,不支援 CSS Modules 或 Tailwind - 入口點必須是
src/main.tsx - Server-Side Action 用 Python 撰寫,放在
actions/目錄 - Runtime 在 Shadow DOM 中執行 — CSS 變數必須用
:host, :root雙選擇器,不可只用:root(詳見第 11 章) - Shadow DOM 容器需設定
overflow-y: auto— 否則內容過長時無法捲動(詳見第 11 章)
4. VFS 檔案結構
標準檔案樹
├── package.json # 依賴宣告
├── src/
│ ├── main.tsx # 入口點(必須存在)
│ ├── App.tsx # 路由 + Layout
│ ├── App.css # 全域樣式
│ ├── routes.ts # 導航配置
│ ├── api.ts # SDK:資料中心自建表 CRUD
│ ├── db.ts # SDK:DB Proxy
│ ├── action.ts # SDK:Server-Side Action
│ ├── approval.ts # SDK:簽核系統操作(僅 Internal App)
│ ├── user.ts # SDK:登入者角色/權限快照(僅 Internal App)
│ ├── db.json # Data Reference 定義(自動注入)
│ ├── pages/
│ │ ├── _manifest.json # 頁面清單
│ │ ├── DashboardPage.tsx # 頁面元件
│ │ └── NotFoundPage.tsx # 404 頁面
│ └── components/
│ ├── AppLayout.tsx # 主 Layout
│ ├── AppSidebar.tsx # 側邊欄
│ └── AppHeader.tsx # 頂部欄
└── actions/
├── manifest.json # Action 註冊清單
├── _shared/ # Action 之間的共用程式碼(非 Action,不需 execute)
│ └── money.py
└── example_action.py # Action 實作
actions/_shared/:Action 之間共用程式碼
Action 彼此之間不能互相 import——from other_action import ... 不會 work,因為 Action 不是模組。要共用邏輯,放進 actions/_shared/:
# actions/_shared/money.py —— 不是 Action,不需要 execute(ctx)
def round_to_cents(value):
return round(value + 1e-9, 2)
# actions/settle_overpayment.py
from _shared.money import round_to_cents
def execute(ctx):
total = round_to_cents(sum(x["amount"] for x in ctx.params["items"]))
ctx.response.json({"total": total})
_shared/ 底下的檔案不需要(也不應該)登記進 actions/manifest.json——它們不是 Action,不會被派發,也不會產生對外端點。
不可修改的 SDK 檔案
| 檔案 | 說明 |
|---|---|
src/api.ts | 資料中心自建表 CRUD SDK |
src/db.ts | DB Proxy SDK |
src/action.ts | Server Action SDK |
src/approval.ts | Approval SDK(簽核操作,僅 Internal App,詳見第 23 章) |
src/user.ts | User Context SDK(登入者角色/權限快照,僅 Internal App,詳見第 7 章) |
src/db.json | Runtime 自動注入 |
5. 程式碼注入 API
API 端點一覽
| 操作 | HTTP 方法 | 端點 |
|---|---|---|
| 取得 App(含 VFS) | GET | /api/v1/builder/apps/{slug} |
| 全量覆寫 VFS | PUT | /api/v1/builder/apps/{id}/source |
| 局部更新檔案 | PATCH | /api/v1/builder/apps/{id}/source/files |
| 刪除檔案 | DELETE | /api/v1/builder/apps/{id}/source/files |
局部更新(推薦)
PATCH /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json
{
"files": {
"src/pages/NewPage.tsx": "import React from 'react';\n\nexport default function NewPage() {\n return <div>新頁面</div>;\n}",
"src/App.tsx": "...更新後的完整內容..."
},
"expected_version": 5
}
刪除檔案
DELETE /api/v1/builder/apps/{app_id}/source/files
Authorization: Bearer {JWT}
Content-Type: application/json
{
"paths": ["src/pages/OldPage.tsx"],
"expected_version": 6
}
樂觀鎖
所有修改端點支援 expected_version 參數:
- 取得 App 時記錄
vfs_version - 修改時帶入
expected_version - 若版本不匹配 → 回傳 409 Conflict
6. 編譯與偵錯
編譯 API
POST /api/v1/compile/compile/{slug}?dev=true
Authorization: Bearer {JWT}
成功回應:
{
"success": true,
"html": "<!DOCTYPE html>...",
"bundle_js": "...",
"css": "..."
}
失敗回應:
{
"success": false,
"error": "✘ [ERROR] Could not resolve \"./pages/MissingPage\"..."
}
編譯限制
| 限制 | 值 |
|---|---|
| 最大檔案數 | 200 |
| 單檔大小 | 1 MB |
| 編譯超時 | 30 秒 |
External 模組(由 Runtime 提供)
以下模組不需安裝,直接 import 即可:
react, react-dom, lucide-react, react-router-dom, react-hot-toast
7. 內建 SDK
資料中心自建表(src/api.ts)
操作租戶級自建表(由管理員在資料中心建立,全租戶共用;結構規格見 第 13 章):
import { listTables, queryTable, insertRow, updateRow, deleteRow } from "../api";
// 列出本租戶所有自建表(含欄位定義)
const tables = await listTables();
// 查詢:回傳分頁信封 { items, total, page, page_size }
const page = await queryTable("orders", {
filters: [{ field: "status", op: "eq", value: "open" }], // op ∈ eq / contains / gte / lte
sort: "-created_at", // <實體名> 升冪、-<實體名> 降冪,單欄
page: 1,
page_size: 25,
});
await insertRow("orders", { customer_name: "王大明", amount: 1200 });
await updateRow("orders", rowId, { status: "closed" });
await deleteRow("orders", rowId);
- 表名與欄位名一律用實體名(physical name),不是顯示名——顯示名可被隨時改掉。
- SDK 依
window.__IS_EXTERNAL__自動分流/data-center或/ext/data-center,不需自己判斷。 - SDK 沒有結構操作:App 執行期無法建表或改欄,需要新表/新欄位請走第 13 章的管理員流程。
DB Proxy(src/db.ts)
操作已授權的系統資料表:
import { query, queryAdvanced, insert, update, remove } from "../db";
const customers = await query("customers", { limit: 50 });
const result = await queryAdvanced("customers", {
filters: [{ column: "status", op: "eq", value: "active" }],
order_by: [{ column: "name", direction: "asc" }],
});
db.update() PATCH 格式注意事項
後端 Proxy API 的 PATCH 端點要求 payload 必須以 {"data": {...}} 包裝,但目前 db.ts SDK 的 update() 函式直接發送欄位物件,會觸發 「無有效欄位資料」 錯誤。
臨時解法:在需要更新資料的場景中,改用直接 fetch 呼叫:
// 錯誤: 目前 db.update() 會發送 {"state": "sent"} → 後端回傳 400
await db.update("sale_orders", orderId, { state: "sent" });
// 正確做法:直接 fetch 並以 {"data": {...}} 包裝
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const appId = (window as any).__APP_ID__ || '';
const token = (window as any).__APP_TOKEN__ || '';
const resp = await fetch(`${apiBase}/proxy/${appId}/sale_orders/${orderId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
body: JSON.stringify({ data: { state: "sent" } }),
});
備註:
db.insert()同樣存在此格式不一致的問題。若insert()呼叫失敗,請套用相同的{"data": {...}}包裝模式。此問題預計在 SDK 下一版修正。
處理簽核回傳 (Approval Workflow Intercepts)
當管理者在 AI GO 後台針對特定表(如 sale_orders)設定了「簽核流程」時,您的 db.insert, db.update, 或 db.remove 呼叫可能會被簽核引擎攔截:
- Insert (Insert-then-flag):記錄會優先寫入資料庫(為了取得關聯 ID),但不會觸發正式的營運邏輯,並直接轉入 Pending 狀態等待簽核。
- Update / Delete (Pre-guard):記錄不會被實際更新或刪除。系統會將您的 Payload 暫存於簽核單中,直到審核通過後才實際執行。
被攔截時的回傳格式:
當您的操作需要簽核時,API 會回傳帶有 approval_status: "pending" 的 Payload,前端開發者應攔截此狀態並給予使用者對應的提示,而不是單純顯示「操作成功」。
// 範例:處理新增操作的簽核攔截
const result = await db.insert("sale_orders", { data: { amount_total: 5000 } });
if (result.approval_status === "pending") {
// result.approval_message: "此操作需要簽核審批(2 層),已建立申請"
toast.success(result.approval_message || "已送出簽核申請");
} else {
toast.success("訂單新增成功");
}
簽核通過/退回後的自動狀態變更 (Approval State Callbacks)
為了避免 Custom App 開發者還需要寫 Python 後端程式碼來處理簽核狀態,AI GO 提供了 全域動態狀態回調 (Generic State Callbacks)。
對於 db.insert 和 db.update 操作,您可以在建立 ApprovalWorkflow 時,配置以下三個欄位,核心引擎會在主管核准或退回時,自動更新該紀錄的狀態:
approved_state_field:要更新的狀態欄位名稱 (例如:"state","status","doc_status")。approved_state_value:核准後要寫入的值 (例如:"approved","validate","done")。rejected_state_value:退回後要寫入的值 (例如:"rejected","draft")。
運作原理:
- 當前端送出
insert操作時,紀錄會直接以draft或pending狀態存入資料庫,並產生一張待簽核單。 - 當最後一位主管點擊 核准 時,核心引擎會自動執行:
UPDATE "您的資料表" SET "{approved_state_field}" = '{approved_state_value}' WHERE id = ?。 - 若主管點擊 退回,則會自動執行:
UPDATE "您的資料表" SET "{approved_state_field}" = '{rejected_state_value}' WHERE id = ?。
有了這個機制,您的 Custom App 只要寫好前端介面與查詢條件(例如只撈取 state === 'approved' 的單據),即可完成端到端的無代碼簽核閉環!
延伸閱讀:若您要在 App 內做待簽核清單、核准/駁回按鈕、簽核進度顯示,請參閱 第 23 章 簽核工作流。
Approval(src/approval.ts)
操作平台簽核系統(僅 Internal App)。所有函式都以「目前登入使用者」的身分執行:
import { myPending, recordStatus, approve, reject, cancel } from "../approval";
const items = await myPending(); // 待我簽核清單
const status = await recordStatus("sale_orders", orderId); // 該筆單據的簽核狀態(無則 null)
await approve(items[0].request_line_id, "同意");
await reject(items[0].request_line_id, "金額有誤");
await cancel(requestId, "改單重送"); // 僅申請人本人
完整欄位、權限規則與 Server Action 端的 ctx.approval 請見 第 23 章。
User Context(src/user.ts)
讀取「目前登入使用者」的角色與權限唯讀快照,用來做角色條件顯示——與主站權限系統同一份來源,不需要(也不應該)自己 fetch('/api/v1/auth/me')。
資料由 Runtime 頁面在載入時注入(window.__USER_ROLES__ / window.__USER_PERMISSIONS__)。僅 Internal App 會被注入;External App 與匿名檢視下所有查詢皆回空陣列(不洩漏平台權限結構)。
| 函式 | 說明 |
|---|---|
getCurrentUser() | { roles: string[], permissions: string[] } 唯讀複本 |
getRoles() | 角色名稱清單(顯示用) |
getPermissions() | 權限標籤清單,如 ['sale.write', 'crm.read'] |
hasPermission(perm) | 是否具備某權限(system.admin 自動通過) |
hasAnyPermission(...perms) | 任一具備 |
hasAllPermissions(...perms) | 全部具備 |
isAdmin() | 是否擁有 system.admin |
import {
getCurrentUser, getRoles, getPermissions,
hasPermission, hasAnyPermission, hasAllPermissions, isAdmin,
} from "../user";
const me = getCurrentUser(); // { roles: [...], permissions: [...] }
if (hasPermission("sale.write")) {
// 顯示「新增訂單」按鈕
}
if (hasAnyPermission("crm.write", "crm.delete")) { /* ... */ }
if (isAdmin()) { /* 擁有 system.admin,全開 */ }
判斷授權一律用 permission 標籤(
模組.動作),不要用角色名稱。 角色可被租戶任意改名(「業務」改成「Sales」您的判斷就失效),permission 標籤才穩定;system.admin為萬能鑰匙,會自動通過所有hasPermission檢查。此行為與主站usePermissions()一致。
前端顯示/隱藏只是 UX,不是安全邊界。 前端隱藏可被繞過。若不同角色可見的資料有機敏差異,必須在後端 Server Action 讀
ctx.user_permissions分流(見第 8 章),或由 Data Reference 授權把關——後端才是強制點。
Server Action(src/action.ts)
Action 的回傳值已經由 SDK 優化解構,data 將直接取得您在 Python 中回傳的 JSON payload。
import { runAction, downloadFile } from "../action";
const { data, file } = await runAction("my_action", { key: "value" });
console.log("Action 結果:", data);
if (file) downloadFile(file);
8. Server-Side Actions
程式碼格式
def execute(ctx):
"""必須定義 execute(ctx) 函式"""
data = ctx.params.get("key", "default")
customers = ctx.db.query("customers", limit=10)
ctx.response.json({"result": customers})
ctx 物件
| 方法 | 說明 |
|---|---|
ctx.params | 前端傳入的參數 |
ctx.app_id / ctx.tenant_id | 執行中的 App 與租戶 UUID(字串) |
ctx.action_name | 當前執行的 Action 名稱 |
ctx.env | 執行環境(online / dev) |
ctx.user_id | 觸發者的使用者 UUID(字串) |
ctx.user_roles | 觸發者的角色名稱清單(list[str],唯讀快照;排程等無使用者上下文的觸發為空。顯示用) |
ctx.user_permissions | 觸發者的權限標籤清單(list[str],如 ['sale.write'])。角色條件邏輯的後端強制點——前端 src/user.ts 的隱藏只是 UX |
ctx.db.query(table, **kwargs) | 查詢資料。支援進階參數如 order_by, search, limit,範例:ctx.db.query("clients", limit=50, order_by=[{"column": "id", "direction": "desc"}]) |
ctx.db.insert(table, data) | 新增記錄。對 ERP 表寫入時受簽核管制(詳見第 23 章) |
ctx.db.list_tables() | 列出本租戶的自建表(含欄位定義) |
ctx.db.query_table(table, options) | 查詢自建表記錄,回傳分頁信封(filters / sort / page / page_size) |
ctx.db.insert_row(table, data) | 新增自建表記錄(收扁平 dict,不要包 {"data": ...}) |
ctx.db.update_row(table, row_id, data) | 更新自建表記錄 |
ctx.db.delete_row(table, row_id) | 刪除自建表記錄 |
ctx.approval.list_pending() | 取得「觸發此 action 的使用者」的待簽核清單 |
ctx.approval.get_record_status(res_model, res_id) | 查某筆記錄的簽核狀態(含各層明細) |
ctx.approval.approve(request_line_id, comment=None) | 核准一層簽核(需為該層合格審核人) |
ctx.approval.reject(request_line_id, comment=None) | 駁回簽核(需為該層合格審核人) |
ctx.approval.cancel(request_id, reason=None) | 取消簽核申請(僅申請人本人) |
ctx.erp.* | 觸發 ERP 業務引擎的正向確認類方法(confirm_sale_order、create_invoice、validate_picking 等,見下) |
ctx.knowledge.search(query, top_k=5, score_threshold=None) | 檢索企業知識中心,回傳 chunk 級結果+相關度分數(純檢索,不經 LLM,詳見第 24 章) |
ctx.knowledge.get_content(file_id) | 取單一知識檔案的完整解析文字 |
ctx.http.call(service, path, method="GET", body=None, headers=None, params=None) | 透過已授權的 egress service 呼叫外部 API。第三個位置參數是 method,不是 body;不拋例外,須檢查 status(詳見第 25 章) |
ctx.http.fetch(url, ...) | 抓取任意公網 URL(SSRF 防護仍全數保留) |
ctx.messaging.list_channels() / add_channel() / update_channel() / remove_channel() | 通訊渠道管理 |
ctx.messaging.inject_inbound() / save_ai_outbound() / get_ai_config() | 通訊中心訊息讀寫 |
ctx.mcp.execute(task, wait=True) / trigger(task) / get_status(task_id) | MCP 任務執行(同步/非同步/查狀態) |
ctx.secrets.get(key) / list_keys() | 取得金鑰值/列出可用金鑰名稱 |
ctx.crypto.hash(alg, data) | 雜湊計算 |
ctx.crypto.hmac_sign(key_name, data) | HMAC 簽章(傳金鑰名稱,非金鑰值) |
ctx.crypto.base64_encode(data) / base64_decode(data) | Base64 編解碼 |
ctx.crypto.aes_encrypt(key_name, plaintext) / aes_decrypt(key_name, ct, iv) | AES-256 加解密 |
ctx.response.json(data) | JSON 回應 |
ctx.response.file(content, filename, mime) | 檔案下載回應 |
ctx.csv.export(rows, columns=None, filename=None) | CSV 匯出 |
ctx.erp.* 方法清單
| 方法 | 觸發什麼 |
|---|---|
confirm_sale_order(id) / confirm_purchase_order(id) | 確認訂單(連動後續開票與出貨準備) |
create_invoice(order_id) | 從銷售訂單開立銷項發票。order_id 可傳清單——多張訂單彙總進同一張發票(需同客戶、同租戶) |
create_bill(order_id) | 從採購訂單建立進項帳單,只針對「已收貨未開帳」差額;同樣支援清單合併 |
validate_picking(id) | 驗證揀貨單(連動庫存移動與存貨即時計價) |
post_move(id) | 過帳會計傳票 |
confirm_payment(id) | 確認收付款(連動 FIFO 自動沖銷) |
reconcile_payment(id) | 補跑已過帳付款的沖銷引擎 |
confirm_payroll_run(id) / cancel_payroll_run(id) | 確認/撤銷薪資結算(撤銷自動產生沖銷分錄)。回傳 dict,含產生的 move 資訊 |
延伸閱讀:
ctx.approval.*的身分規則、scope 授權與例外處理,詳見 第 23 章 簽核工作流。 延伸閱讀:ctx.db不提供結構操作——Action 執行期無法建表或改欄,這是刻意的能力邊界(見 第 13 章)。 延伸閱讀:每個ctx模組方法都對應一個 scope,需經租戶管理者核可才生效;高風險 scope 另需擁有者密碼 step-up。詳見 第 26 章。
依權限分流(後端強制點)
前端 src/user.ts 的顯示/隱藏只是 UX;機敏資料的分流必須在 action 內用 ctx.user_permissions 判斷:
def execute(ctx):
perms = ctx.user_permissions or []
is_admin = "system.admin" in perms
rows = ctx.db.query("sale_orders", limit=100)
if not (is_admin or "hr.read" in perms):
# 無權限者不回傳成本/薪資等機敏欄位
rows = [{k: v for k, v in r.items() if k not in ("cost", "margin")} for r in rows]
ctx.response.json({"rows": rows})
判斷用 permission 標籤(模組.動作)而非 ctx.user_roles 的角色名稱——角色可被租戶改名。排程等無使用者上下文的觸發,兩者皆為空清單,請自行決定要放行還是拒絕。
執行隔離與資源限制
舊版文件曾描述的「語言級沙箱」已不存在:import 白名單、禁用
exec/eval/open、builtins 限制、禁class語法等規則全部已移除,一律以本節為準。
隔離改由語言層之外的三層縱深負責:
- 專屬 runner pod:每個 App 的 Action 在自己的 runner pod 內執行(單一併發),pod 就是隔離邊界,租戶之間以獨立節點排程分隔。
- 網路政策:pod 預設拒絕所有進出流量,僅放行 DNS、對平台後端的回呼,以及對外 443(叢集內私有網段除外)。
ctxRPC 白名單:存取平台資料與能力只能透過ctx模組方法,每個呼叫都經後端白名單驗證,白名單外一律 403。pod 內雖可寫任意 Python,能碰到的平台面仍受此限。
程式碼層現況
- 可 import runner 映像檔內已安裝的任何模組,可自由使用
class與完整 builtins。 - 發布前的靜態檢查只有結構防呆:語法可解析 + 必須定義
execute(ctx)。未定義名稱等錯誤要到執行期才浮現——請務必先試跑再發布。 print()會被攔截收集為執行 log 隨結果回傳,不會輸出到 stdout。
資源限制
| 限制 | 值 |
|---|---|
| 執行逾時 | 30 秒(manifest timeout_ms 可自訂,上限 30 秒);逾時後有 3 秒緩衝軟終止 |
| 記憶體 | 256 MB,屬建議值(超過僅記錄警告,不強制終止) |
9. 驗證與發布
標準開發迴圈
1. PATCH 修改檔案
2. POST 編譯(dev=true)
3. 編譯失敗 → 修改 → 回到 1
4. 編譯成功 → 預覽驗證
5. POST 發布
發布 API
POST /api/v1/builder/apps/{app_id}/publish
Authorization: Bearer {JWT}
Content-Type: application/json
{ "published_assets": {} }
10. 常見問題
| 問題 | 解法 |
|---|---|
| 白屏 | 確認 src/main.tsx 正確掛載 React |
| 路由不動 | 使用 HashRouter,不可用 BrowserRouter |
| 頁面無法捲動 | Shadow DOM 容器需設定 height: 100vh; overflow-y: auto(詳見第 11 章) |
| CSS 不生效 | 在 main.tsx 中 import "./App.css" |
| CSS 變數全部遺失(部署後) | 使用 :root 定義變數,Shadow DOM 無法穿透。改用 :host, :root(詳見第 11 章) |
db.update() 回傳「無有效欄位資料」 | SDK 的 update() 未以 {"data": {...}} 包裝 payload(詳見第 7 章 DB Proxy 注意事項) |
db.ts 呼叫回傳 500 | 此為平台後端問題而非前端 Bug。請確認:1) Data Reference 是否已建立並發布 2) 引用的表名是否正確 3) 回報平台管理員檢查後端日誌 |
db.ts / api.ts 回傳 401 | Token 可能過期或 SDK 變數未正確注入。確認 SDK 未被手動修改,且 Runtime 已正確啟動 |
| 409 Conflict | VFS 被同時修改,重新 GET 後合併重試 |
| 423 Locked | 有待審核的發布申請,等待或取消 |
| Action 超時 | 優化邏輯,控制在 30 秒內 |
| pub/ API 回傳 403 | 確認 allow_anonymous_access=true 且資料表 is_public_readable=true(詳見第 18 章) |
| pub/ API 回傳 429 | 超過匿名 Rate Limit(120/min per IP),稍後重試 |
寫入回傳 approval_status: "pending" | 操作被簽核流程攔截,不是失敗也不是成功(詳見第 23 章) |
| Action 拋「需要簽核審批」例外 | ctx.erp / ctx.db 的簽核 pre-guard,簽核通過後平台會自動執行,勿重試(詳見第 23 章) |
11. Shadow DOM 與 CSS 樣式規範
重要:Custom App 在 AI GO Runtime 中是以 Shadow DOM 封裝執行的,與獨立 HTML 頁面有本質性差異。未遵守此規範將導致「本地開發正常,部署後樣式全部消失」的問題。
根本原因
CSS :root 選擇器匹配的是文件樹的根元素 <html>。當 App 在 Shadow DOM 內執行時,:root 無法穿透 Shadow 邊界,所有透過 :root { --color: blue; } 定義的 CSS 變數在 App 內完全讀取不到。
主站 HTML(<html> = :root 生效範圍)
└── <custom-app-runtime> ← Web Component
└── #shadow-root (closed) ← Shadow DOM 邊界
└── <div id="root"> ← :root 不可觸及此區域
強制規則
所有 CSS 變數必須使用 :host, :root 雙選擇器:
/* 正確:Shadow DOM 和獨立頁面皆可運作 */
:host, :root {
--primary: #2563eb;
--background: #fafbfc;
}
/* 錯誤:部署後變數全部遺失 */
:root {
--primary: #2563eb;
}
同樣適用於 HTML 元素重設:
/* 正確 */
html, :host {
line-height: 1.5;
font-family: 'Inter', system-ui, sans-serif;
}
/* 錯誤 */
html {
line-height: 1.5;
}
選擇器對照表
| 選擇器 | 獨立 HTML 頁面 | Shadow DOM(AI GO Runtime) |
|---|---|---|
:root | 是 | 無法穿透 |
:host | 無意義 | 命中 Shadow Host |
:host, :root | fallback | 命中 |
自查 Checklist
- 全文搜尋
:root {(不含:host)— 改為:host, :root { - 全文搜尋
html {(不含:host)— 改為html, :host { - Dark Mode 的
@media區塊同樣使用:host, :root
JavaScript API 限制
部分瀏覽器原生 API 在 Shadow DOM 中會被靜默阻擋(不拋錯、不顯示),導致「preview 正常、部署後無反應」:
| API | Shadow DOM 行為 | 替代方案 |
|---|---|---|
confirm() | 靜默回傳 false | React useState 二階段確認 |
alert() | 不顯示 | react-hot-toast 或自訂 Toast |
prompt() | 回傳 null | React 自訂 input modal |
// 正確:React state 確認
const [showConfirm, setShowConfirm] = useState(false);
// 錯誤: 錯誤:confirm() 在 Runtime 中永遠回傳 false
if (!confirm("確定嗎?")) return;
可正常使用的 API:localStorage、fetch、window.location.reload()。
容器滾動限制
Shadow DOM 的根容器預設不具備捲動能力。當 App 內容超出視窗高度時,使用者無法向下滾動。
必須在最外層 Layout 元件設定明確的高度與溢出行為:
// 正確:明確設定高度與滾動
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{
height: "100vh",
overflowY: "auto",
backgroundColor: "var(--color-gray-50)",
}}>
{children}
</div>
);
}
// 錯誤: 錯誤:minHeight 不會觸發 overflow
<div style={{ minHeight: "100vh" }}>
單頁 App 路由簡化
若 Custom App 只有一個主頁面(例如訂單看板、儀表板),不需要使用 React Router。直接在 App.tsx 渲染主元件即可,避免 Router 上下文缺失導致白屏:
// 單頁 App — 直接渲染,無需 Router
import OrderBoardPage from "./pages/OrderBoardPage";
export default function App() {
return (
<AppLayout>
<Toaster position="top-center" />
<OrderBoardPage />
</AppLayout>
);
}
// 錯誤: 單頁 App 卻使用 BrowserRouter — 在 Shadow DOM 中會白屏
import { BrowserRouter, Routes, Route } from "react-router-dom";
// BrowserRouter 無法控制 Runtime 的 URL,路由永遠匹配不到
何時需要 Router:只有在 App 有多個頁面(搭配 Sidebar 導航切換)時才需要
HashRouter。
12. VFS 注入腳本開發規範
注意:使用 Python 腳本直接組合 React JSX 原始碼時,極易因字串操作引入語法錯誤,導致 esbuild 編譯失敗。
字串操作風險
# 危險:用 str.replace() 修改 JSX
text = text.replace(
"return (\n <main>",
"return (\n return (\n <main>" # 意外重複 return
)
# esbuild 報錯:Unexpected "return"
推薦做法
將每個 VFS 檔案以完整 raw string 定義,不做字串拼接或取代:
# 正確:完整定義,不做字串操作
files["src/pages/CartPage.tsx"] = r'''import React from "react";
export default function CartPage() {
return (
<main className="container">
<h1>購物車</h1>
</main>
);
}
'''
編譯防禦
部署腳本在呼叫 Compile API 後,必須檢查 success 欄位:
result = r.json()
if not result.get("success"):
print(f"編譯失敗:\n{result.get('error')}")
sys.exit(1) # 絕對不允許帶著編譯錯誤發布
VFS 版本鎖
讀取 App 詳情時必須透過 GET /builder/apps/{id}(單一物件端點)取得精確的 vfs_version,而非使用列表端點。
13. 資料中心:租戶級自建表
適用場景:App 需要平台沒有的業務實體(例如「案件」「排班」「問卷」)。這類資料放在資料中心自建表——租戶級的真實資料表,不是 App 私有的動態結構。
13.1 核心語義:綁租戶,不綁 App
自建表綁 tenant。同一租戶下所有 Custom App 與資料中心 UI 看到同一批表、同一份資料。
因此建表前必須先盤點。 兩個 App 各建一張「客戶」表 = 資料分裂成兩份,事後難以合併。
1. GET /api/v1/data-center/tables ← 盤點租戶既有自建表(不可跳過)
2. 有語意相同的表? → 重用,不要新建
3. 需要新表 → 產出建表規格請租戶管理員確認
4. POST /api/v1/data-center/tables
├─ 201 → GET 驗收,繼續開發
└─ 403 → 你不是租戶管理員:不重試、不繞路,
輸出可照抄的建表規格,請管理員到資料中心 UI 建立,
建好後 GET /tables 驗收再繼續
13.2 權限:結構與資料分開管
| 操作 | 需要權限 |
|---|---|
| 建表/改表/刪表/加欄/改欄/刪欄 | system.admin(租戶管理員) |
| 讀結構(列表/讀 schema) | builder.access |
| 記錄 CRUD(查/增/改/刪) | builder.access |
這是平台刻意收窄的治理界線:管制的是 schema 的形狀,不是它的使用。App 執行期(src/api.ts、ctx.db)沒有結構操作——不能建表也不能改欄,這是刻意的能力邊界。
13.3 雙軌命名:顯示名 vs 實體名
| 顯示名(display name) | 實體名(physical name) | |
|---|---|---|
| 誰決定 | 你提供 | 系統從顯示名生成 |
| 可否修改 | 可以,隨時 | 建立後永不可變 |
| 字元集 | 任意(中文常見) | 純 ASCII |
| 用途 | UI 呈現 | API 識別、刪除確認值 |
所有 API 在指涉既有表/欄位時一律用實體名。 改顯示名不影響任何既有引用或程式碼。
唯一例外:
relation欄位指向自建表時用的是target_table_id(目標表的 UUID,從GET /tables回應的id取),不是實體名。
13.4 欄位型別
| 型別 | 說明 | 額外契約 |
|---|---|---|
text | 文字 | |
number | 數值 | |
boolean | 布林 | |
date | 日期 | |
datetime | 日期時間 | |
select | 單選 | 必須提供選項集;值受 CHECK 約束 |
relation | 關聯 | 見下 |
json | 結構化資料 | |
image | 圖片 | 存 storage key,見 13.7 |
系統欄位 id / created_at / updated_at 自動帶,不可刪、不可改型別、不計入欄位配額。
relation 的兩種目標(恰擇其一)
- → 自建表:
target_table_id= 目標表 UUID。建真正的資料庫外鍵,刪除仍被引用的列會被擋(409,detail.dependents列出依賴者)。 - → ERP 表:
target_erp_key= ERP 表 key。軟關聯,不建外鍵(跨 schema 邊界),寫入時驗證目標存在。
兩者恰擇其一——都給或都不給皆為錯誤;建立後不可變(改欄不收這兩個參數)。
13.5 配額
| 免費檔 | 付費檔 | |
|---|---|---|
| 每租戶表數 | 20 | 200 |
| 每表非系統欄位數 | 50 | 100 |
- 超限回 409(不是靜默截斷)。
- 單次
POST /tables最多帶 50 個欄位(schema 層上限),與每表欄位配額是兩回事:付費租戶想一次建 60 欄會拿到 422 而不是 409,必須先建表再逐次POST /tables/{key}/fields。 - ERP 延伸欄位沿用同一組欄數配額。
13.6 刪除是兩段式的
刪表與刪欄不可逆,伺服器端強制兩段:
- 影響預覽:
GET /tables/{key}/impact或GET /tables/{key}/fields/{field_key}/impact→ 回傳記錄數、各欄位非空值統計、是否有其他表以關聯依賴它 - 確認執行:確認值走 query 參數
confirm,必須等於實體名(不可用顯示名——顯示名可改,拿它當確認值等於沒確認)
DELETE /api/v1/data-center/tables/{key}?confirm={表實體名}
DELETE /api/v1/data-center/tables/{key}/fields/{field_key}?confirm={欄位實體名}
13.7 圖片欄位
image 欄位存的是 storage key,不是 URL。URL 只有一小時有效期,存進欄位會過期。
| 動作 | 端點 | 契約 |
|---|---|---|
| 上傳 | POST /api/v1/data-center/tables/{key}/images | 回傳 storage key 與一張可直接顯示的 URL;把 key 存進欄位 |
| 取 URL | GET /api/v1/data-center/images/url | 帶 key,回短效期簽章 URL;每次顯示時重取 |
允許 PNG/JPEG/GIF/WebP,單檔上限 10 MB;SVG 被刻意排除(可內嵌 script)。圖片會出現在檔案總管的「資料中心圖片」資料夾,使用者可自行刪除——刪掉後欄位顯示「圖片已移除」,這是已知取捨。
13.8 API 速查(主站,登入使用者身分)
| 操作 | 方法 | 端點 | 權限 |
|---|---|---|---|
| 列表 | GET | /api/v1/data-center/tables | builder.access |
| 讀單表 | GET | /api/v1/data-center/tables/{key} | builder.access |
| 建表 | POST | /api/v1/data-center/tables | system.admin |
| 改表(顯示名等) | PATCH | /api/v1/data-center/tables/{key} | system.admin |
| 刪表影響 | GET | /api/v1/data-center/tables/{key}/impact | builder.access |
| 刪表 | DELETE | /api/v1/data-center/tables/{key} | system.admin |
| 加欄 | POST | /api/v1/data-center/tables/{key}/fields | system.admin |
| 改欄 | PATCH | /api/v1/data-center/tables/{key}/fields/{field_key} | system.admin |
| 刪欄影響 | GET | /api/v1/data-center/tables/{key}/fields/{field_key}/impact | builder.access |
| 刪欄 | DELETE | /api/v1/data-center/tables/{key}/fields/{field_key} | system.admin |
| 查記錄 | GET | /api/v1/data-center/tables/{key}/records | builder.access |
| 新增記錄 | POST | /api/v1/data-center/tables/{key}/records | builder.access |
| 更新記錄 | PATCH | /api/v1/data-center/tables/{key}/records/{record_id} | builder.access |
| 刪記錄 | DELETE | /api/v1/data-center/tables/{key}/records/{record_id} | builder.access |
建表範例
POST /api/v1/data-center/tables
Authorization: Bearer {JWT}
Content-Type: application/json
{
"display_name": "案件",
"fields": [
{ "display_name": "案件編號", "field_type": "text" },
{ "display_name": "狀態", "field_type": "select", "options": ["進行中", "已結案"] },
{ "display_name": "負責人", "field_type": "relation", "target_erp_key": "employees" }
]
}
13.9 四種存取入口
| 呼叫者 | 前綴 | 能做什麼 |
|---|---|---|
| 主站/Builder(登入使用者) | /api/v1/data-center/ | 結構(system.admin)+ 讀結構與記錄 CRUD |
| Internal App 執行期 | 同上(前端 src/api.ts 自動處理) | 讀結構 + 記錄 CRUD |
| External App 執行期 | /api/v1/ext/data-center/ | 讀結構 + 記錄 CRUD(無結構操作) |
| 第三方整合(API Key) | /api/v1/open/data-center/ | 讀結構 + 記錄 CRUD(範圍=整租戶自建表) |
| 匿名檢視 | /api/v1/pub/data-center/{slug}/ | 唯讀,且僅限被標記為公開可讀的表(見第 18 章) |
App 內的用法(src/api.ts 與 ctx.db)見 第 7 章 內建 SDK;SDK 會依 window.__IS_EXTERNAL__ 自動分流 /data-center 或 /ext/data-center,不需自己判斷。
14. 共享資料表隔離策略 (Data Domain Separation)
適用場景:當多個 Custom App 共用相同的 SaaS 標準表(如
product_templates、sale_orders、customers),如何防止資料互相污染。
在發展微服務架構的 Custom App 時,我們常會讓多個 App 共用核心表以方便未來建立統一的營收或顧客報表。但同時,不同 App 的前端應只看到屬於自己的資料。為達成此目的,必須引入 Data Domain 隔離策略。
核心策略:app_domain 標籤
利用資料表內的 JSON 欄位(通常為 custom_data),在所有相關記錄中注入 app_domain 屬性。每個 Custom App 都有專屬的 Domain 標識符(例:餐飲 = "food",空間租借 = "space")。
只適用 SaaS 表這一軌。 資料中心自建表不需要也不應該帶
app_domain——自建表沒有custom_data欄位,而且「跨 App 共用同一份資料」正是它的設計目的,用標籤把它切開是反模式(見 第 13 章)。
實作步驟
-
注入標籤(Insert): 在 SDK
db.ts或 Server-Side Action 執行insert時,強制將custom_data.app_domain寫入。await insert("sale_orders", { data: { name: `ORDER-${Date.now()}`, amount_total: 1000, custom_data: { app_domain: "space", // ← 宣告資料所有權 booking_date: "2024-05-01" } } }); -
強制過濾(Query): 在所有
query動作中,無論是列表查詢或關聯查詢,都必須顯式加入 JSON 欄位的過濾條件。const spaces = await query("product_templates", { filters: [{ column: "custom_data", op: "ilike", value: "%space%" // ← 過濾只屬於 app_domain="space" 的資料 }], limit: 100 });注意:使用
ilike或透過進階 JSONB 操作符是常見解法,未來平台將提供原生 JSON 結構過濾支援。 -
白名單隔離(AppDataReference): 在建立 App 的 DB Proxy 授權 (
app_data_references表) 時,這無法限制列級別 (Row-Level) 的存取。因此前端程式碼層級的防護是必須的。只有正確實作過濾的前端,配合正確的 AppDataReference 欄位白名單,才能達成完整的資料隔離。
分表 vs 共表 的選擇
- 使用資料中心自建表:適用於平台沒有對應實體的新業務資料(如:客戶滿意度問卷、排班表)。自建表是租戶級資源,建表前先盤點既有表避免資料分裂(見第 13 章)。
- 共用標準核心表 (Shared standard tables) +
app_domain:適用於可以共用底層基礎建設的資料,如商品(product_templates)、訂單(sale_orders)、顧客(customers)。這有助於後續在管理員後台建立跨部門統一財報。
15. 檔案上傳與儲存 (Storage API)
適用場景:Custom App 需要讓使用者上傳圖片、文件、或其他檔案時,使用平台提供的 Storage API 進行統一管理。
概述
Custom App 可透過 /api/v1/ext/storage/* 端點進行檔案的上傳、下載、列出與刪除。所有檔案會自動存放在租戶與 App 的隔離路徑下,確保資料安全。
認證方式
所有 Storage API 都需要 Custom App Token(與 ext/proxy 相同的認證機制)。Token 在 Runtime 中可透過 window.__APP_TOKEN__ 取得。
API 端點
| 操作 | HTTP 方法 | 端點 |
|---|---|---|
| 上傳檔案 | POST | /api/v1/ext/storage/upload |
| 取得檔案 URL | GET | /api/v1/ext/storage/url?path={path} |
| 刪除檔案 | DELETE | /api/v1/ext/storage/file?path={path} |
| 列出檔案 | GET | /api/v1/ext/storage/list?folder={folder} |
上傳檔案
POST /api/v1/ext/storage/upload
Authorization: Bearer {custom_app_token}
Content-Type: multipart/form-data
file: (binary)
folder: "receipts" # 可選,子資料夾名稱
回應:
{
"path": "tenant-id/app-id/receipts/invoice.pdf",
"bucket": "files",
"size": 102400,
"mime_type": "application/pdf"
}
取得 Signed URL
GET /api/v1/ext/storage/url?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
回應:
{
"url": "https://xxx.supabase.co/storage/v1/object/sign/files/...",
"expires_in": 3600
}
列出檔案
GET /api/v1/ext/storage/list?folder=receipts&limit=50&offset=0
Authorization: Bearer {custom_app_token}
回應:
{
"files": [
{ "name": "invoice.pdf", "size": 102400, "updated_at": "2026-04-07T12:00:00Z" }
],
"count": 1
}
刪除檔案
DELETE /api/v1/ext/storage/file?path=tenant-id/app-id/receipts/invoice.pdf
Authorization: Bearer {custom_app_token}
限制與安全
| 限制 | 值 |
|---|---|
| 單檔大小上限 | 100 MB |
| 儲存 Bucket | files(共用,路徑隔離) |
| 路徑格式 | {tenant_id}/{app_id}/{folder}/{filename} |
| 跨 App 存取 | 403 Forbidden |
在前端使用
// 上傳檔案
const apiBase = (window as any).__API_BASE__ || '/api/v1';
const token = (window as any).__APP_TOKEN__ || '';
async function uploadFile(file: File, folder?: string) {
const formData = new FormData();
formData.append('file', file);
if (folder) formData.append('folder', folder);
const resp = await fetch(`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
return resp.json();
}
// 取得 Signed URL
async function getFileUrl(path: string) {
const resp = await fetch(
`${apiBase.replace('/api/v1', '')}/api/v1/ext/storage/url?path=${encodeURIComponent(path)}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
const data = await resp.json();
return data.url;
}
注意:上傳的檔案會計入租戶的儲存空間用量統計,管理員可在 Dashboard 的「用量管理 > 儲存空間」查看。
16. 開發與部署最佳實踐 (Best Practices)
為了確保 Custom App 的維護性與跨環境同步的穩定度,請在開發與持續整合 (CI/CD) 過程中遵循以下防呆與最佳實踐:
15.1 VFS 同步與完整性驗證 (Environment Sync)
當您建立腳手架腳本 (Scaffolding Scripts) 將本機原始碼推送到雲端 Custom App 端點時,極易發生「本機檔案沒跟上雲端」的脫鉤狀況:
- 正確區分環境變數:若您利用 API 撰寫快速部署腳本,請確保 CLI 工具嚴格校驗或區隔
--env local與--env cloud。將程式推到錯誤的環境金鑰,將導致您在雲端 Dashboard 發生500 - 無法從 Storage 載入模板的中斷錯誤(因為雲端只讀到版號卻無實際檔案)。 - 原子性操作:強烈建議使用
PATCH /api/v1/builder/apps/{id}/source/files一次性更新所有相依的 VFS 檔案,且在指令結束前加入一次GET確認VFS 檔案數量是否一致。
15.2 防禦性編碼與禁用佔位符 (Prevent Lazy Code Replacement)
在維護龐大的 React 元件或複雜邏輯時,人類或 AI Agent 輔助編碼經常會使用 # ... 或 // ... 原有程式碼 等懶惰佔位符 (Lazy Loading)。在傳統專案中這可能無害,但在 VFS 動態編譯架構 中是致命的:
- 破壞編譯器上下文:esbuild 編譯器在雲端處理您的 TSX 時,任何省略或殘缺的程式片段將直接導致 AST 解析失敗,進而阻斷整個 App 的渲染。
- 嚴格規範:
- 無論更新多小的改動,每次覆寫單一 VFS 檔案都必須提供 100% 完整的檔案原始碼。
- 嚴禁在原始碼中殘留
// 這裡省略前面的程式碼或// ...。 - 利用 TypeScript 嚴格定義型別。一旦發生型別隱含錯誤,Runtime 沙箱的除錯成本將高於本地開發。
17. Internal App 獨立登入與成員邀請
Internal Custom App 提供獨立的登入/註冊頁面,讓組織成員無需進入主站 Dashboard 即可直接存取應用。管理員也可透過邀請連結,讓新成員一步完成註冊並進入應用。
17.1 獨立登入頁 URL
每個 Internal App 都有一個獨立的登入入口:
https://ai-go.app/app-login/{slug}
{slug}:App 的 slug(可在 Builder 或 API 查詢)- 此頁面 不需登入 即可載入,會自動顯示 App 名稱和租戶 Logo
- 若使用者已有 session,會自動檢查權限並跳轉至 Runtime
17.2 登入流程
使用者開啟 /app-login/{slug}
↓
頁面載入公開 App 資訊(名稱、Logo)
↓
使用者輸入帳號密碼 → 登入
↓
自動呼叫 check-access API 檢查權限
↓
├── 有權限 → 跳轉至 /runtime/{slug}
└── 無權限 → 顯示「無法存取此應用」
17.3 相關 API 端點
公開 App 資訊(不需認證)
GET /api/v1/builder/apps/public/{slug}
回應:
{
"name": "後廚訂單管理",
"slug": "c7c7a37d2ff0",
"subdomain": null,
"tenant_logo_url": "https://..."
}
若 App 不存在或未發布,回傳
404。
權限檢查(需認證)
GET /api/v1/builder/apps/check-access/{slug}
Authorization: Bearer {JWT}
回應:
{
"has_access": true,
"app_name": "後廚訂單管理",
"reason": null
}
權限檢查邏輯:
| 檢查項目 | 失敗時回應 |
|---|---|
| App 是否存在且已發布 | has_access: false, reason: "App 不存在或尚未發布" |
| 使用者是否屬於同一組織 | has_access: false, reason: "您的帳號不屬於此應用所屬的組織" |
| 使用者角色是否在允許名單 | has_access: false, reason: "您的角色不在此應用的允許名單內" |
17.4 邀請成員 + 直接進入 App
管理員可建立邀請連結,讓新成員在 App 登入頁完成註冊,無需進入主站:
POST /api/v1/invitations
Authorization: Bearer {JWT}
Content-Type: application/json
{
"email": "[email protected]",
"name": "新成員",
"role_ids": ["member"],
"redirect_url": "/app-login/{slug}"
}
回應:
{
"token": "U1HjLnpLHgAM8hZE...",
"chat_invite_link": "https://ai-go.app/app-login/{slug}?token=U1HjLnpLHgAM8hZE..."
}
關鍵:當
redirect_url以/app-login/開頭時,系統會自動產生 App 專用邀請連結,受邀者直接在 App 登入頁完成註冊。
17.5 受邀成員的註冊體驗
當受邀者點擊邀請連結(含 ?token=xxx),登入頁面會自動:
- 切換為註冊模式 — 標題顯示「註冊 {App名稱}」
- 鎖定 Email 欄位 — 預填邀請的 Email,不可修改
- 顯示邀請資訊 — 「由『{邀請者}』邀請您加入『{組織名}』」
- 受邀者只需填入 姓名 和 密碼 即可完成註冊
- 註冊成功後 自動登入 並跳轉至 App Runtime
17.6 錯誤處理
| 情境 | 頁面顯示 |
|---|---|
| slug 不存在 | 「應用不存在」錯誤頁 |
| 邀請 token 無效或已過期 | 「邀請連結無效」+ 「前往登入」按鈕 |
| 帳號或密碼錯誤 | 表單內顯示「帳號或密碼錯誤」(不跳轉) |
| 登入成功但無權限 | 「無法存取此應用」+ 建議聯繫管理員 |
17.7 忘記密碼
登入頁內建「忘記密碼」功能,點擊後彈出 Dialog(不離開頁面),輸入 Email 後寄送密碼重設信。重設完成後使用者可直接在原頁面登入,不會遺失 App slug 或邀請 token。
17.8 登出
Internal App 的登出為 純前端操作,不需呼叫後端端點。Custom App 開發者可在自己的應用 UI 中加入登出按鈕,呼叫 Supabase SDK:
import { supabase } from './api'; // Runtime 內建的 Supabase 實例
// 登出並導回 App 登入頁
async function handleLogout() {
await supabase.auth.signOut();
window.location.href = `/app-login/${APP_SLUG}`;
}
說明:Internal App 共用主站的 Supabase Auth,
signOut()會清除 localStorage 中的 session 並撤銷 refresh token。JWT 無狀態,後端不需額外處理。登出後使用者可在/app-login/{slug}重新登入。
注意:此登出會同時影響主站 Dashboard 的 session。若需要「僅登出 App 但保留主站」的行為,可改為手動清除 App 專屬的 localStorage key,但一般場景不需要此區分。
17.9 AI Agent 整合範例
Agent 可透過 API 自動化邀請流程,讓新成員快速加入並使用 App:
import httpx
BASE = "https://ai-go.app/api/v1"
# 1. 管理員登入
resp = httpx.post(f"{BASE}/auth/login", json={
"email": "[email protected]",
"password": "admin_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 2. 建立邀請(連結直接導向 App 登入頁)
resp = httpx.post(f"{BASE}/invitations", headers=headers, json={
"email": "[email protected]",
"name": "新同事",
"role_ids": ["member"],
"redirect_url": "/app-login/c7c7a37d2ff0"
})
invite_link = resp.json()["chat_invite_link"]
print(f"請將此連結傳給新成員:{invite_link}")
# → https://ai-go.app/app-login/c7c7a37d2ff0?token=xxx
18. Public 匿名檢視功能
適用場景:當 Custom App 需要提供不需登入即可瀏覽的公開頁面,例如產品目錄、場館介紹、價格方案展示等。此模式允許訪客匿名瀏覽已發布的 App 內容,同時仍支援登入後切換為完整功能。
18.1 概述
Public 匿名檢視是 Custom App 的第三種存取模式,補足了 Internal(內部應用)和 External(外部應用)之外的公開瀏覽需求:
- Internal:僅限組織成員登入後使用
- External:支援獨立帳號系統的對外應用
- Public(匿名檢視):任何人無需登入即可瀏覽指定的公開資料
匿名模式下,訪客只能讀取標記為公開的資料,無法進行新增、修改或刪除操作。若需要寫入功能,訪客可透過 Custom App Auth 登入後自動切換為認證模式。
18.2 啟用條件
啟用匿名公開瀏覽需同時滿足三個層級的設定:
層級一:App 設定
| 欄位 | 必要值 | 說明 |
|---|---|---|
status | "published" | App 必須已發布 |
allow_anonymous_access | true | 啟用匿名存取 |
access_mode | "external" 或 "self_built" | 僅限外部模式 |
透過 Builder API 設定:
PATCH /api/v1/builder/apps/{app_id}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"allow_anonymous_access": true
}
也可在 Builder UI → 發布面板 → 「允許匿名存取」開關中切換。
層級二:資料中心自建表
每張自建表各自控制是否對匿名 API 開放(需 system.admin):
PATCH /api/v1/data-center/tables/{key}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"is_public_readable": true
}
也可在資料中心 UI → 該表設定中切換「公開讀取」。
{key}為表實體名。
層級三:SaaS 引用資料表(AppDataReference)
若 App 引用了系統 SaaS 表(如 customers、products 等),需在 Reference 上設定:
PATCH /api/v1/refs/{ref_id}
Authorization: Bearer {JWT}
Content-Type: application/json
{
"is_public_readable": true
}
在 Builder UI → 資料引用 → 每個引用右側的「公開讀取」開關。
18.3 Public API 端點
以下端點不需要任何認證 Token,透過 App 的 slug 識別目標應用。
自建表匿名唯讀
| 端點 | 方法 | 說明 |
|---|---|---|
/api/v1/pub/data-center/{slug}/tables | GET | 列出已開放匿名唯讀的自建表(含欄位定義) |
/api/v1/pub/data-center/{slug}/tables/{key}/records | GET | 列出指定自建表的記錄(分頁信封) |
範例:列出公開資料表
GET /api/v1/pub/data-center/aeb47f756cef/tables
回應:
[
{
"id": "uuid",
"display_name": "場館",
"physical_name": "venues",
"fields": [
{ "physical_name": "name", "display_name": "名稱", "field_type": "text" },
{ "physical_name": "address", "display_name": "地址", "field_type": "text" }
]
}
]
範例:查詢記錄
GET /api/v1/pub/data-center/aeb47f756cef/tables/venues/records?page=1&page_size=25
{key}是表實體名(如venues)。匿名端點刻意只保留最小查詢面:不接受sort/filters,page_size上限 100;回傳的欄位限公開白名單(系統欄位id/created_at恆可回)。
SaaS 引用匿名唯讀
| 端點 | 方法 | 說明 |
|---|---|---|
/api/v1/pub/proxy/{slug}/{table} | GET | 簡單查詢 |
/api/v1/pub/proxy/{slug}/{table}/query | POST | 進階查詢(filters / search / sort) |
範例:進階查詢
POST /api/v1/pub/proxy/aeb47f756cef/products/query
Content-Type: application/json
{
"filters": [
{ "column": "status", "op": "eq", "value": "active" }
],
"order_by": [{ "column": "name", "direction": "asc" }],
"limit": 20,
"offset": 0
}
注意:pub/proxy 的
limit上限為 100。超過時自動 cap 到 100。
18.4 前端 SDK 整合
Custom App 的 SDK(src/api.ts)已內建匿名模式自動切換邏輯。當 Runtime 偵測到使用者未登入時,SDK 會自動使用 pub/ 端點。
自動切換原理
// api.ts 內部邏輯(由 Runtime 自動生成,不需手動修改)
export async function queryTable(key: string, options = {}): Promise<any> {
const token = (window as any).__APP_TOKEN__ || '';
if (!token) {
// 未登入 → 走公開 API(不需 Token,僅唯讀)
const res = await fetch(
`${API_BASE}/pub/data-center/${APP_SLUG}/tables/${key}/records?page=1&page_size=25`
);
return res.json();
}
// 已登入 → 走標準認證 API
const res = await fetch(`${API_BASE}/data-center/tables/${key}/records`, {
headers: { Authorization: `Bearer ${token}` }
});
return res.json();
}
Runtime 全域變數
Runtime 會在 App 啟動時注入以下全域變數:
| 變數 | 說明 | 匿名模式值 | 登入後值 |
|---|---|---|---|
window.__APP_TOKEN__ | JWT Access Token | "" (空字串) | "eyJ..." |
window.__APP_SLUG__ | App 的 slug | 有值 | 有值 |
window.__APP_ID__ | App UUID | 有值 | 有值 |
window.__API_BASE__ | API 基底 URL | 有值 | 有值 |
window.__IS_AUTHENTICATED__ | 是否已認證 | false | true |
在頁面中偵測登入狀態
import React from "react";
export default function VenueListPage() {
const isLoggedIn = !!(window as any).__APP_TOKEN__;
return (
<main>
<h1>場館列表</h1>
{/* 所有訪客都能看到場館資料 */}
<VenueList />
{/* 僅登入使用者顯示預約按鈕 */}
{!isLoggedIn && (
<p>
想要預約場地?
<a href="#/login">請先登入</a>
</p>
)}
</main>
);
}
18.5 混合模式:匿名 + 登入切換
Custom App 支援「匿名瀏覽 → 登入 → 完整功能」的流暢切換:
訪客開啟 App 頁面
↓
Runtime 偵測:無 Token
↓
注入 __APP_TOKEN__ = ""、__IS_AUTHENTICATED__ = false
↓
SDK 自動使用 pub/ API(唯讀)
↓
訪客點擊「登入」→ Custom App Auth 登入頁
↓
登入成功 → Auth SDK 更新 window.__APP_TOKEN__
↓
SDK 自動切換為認證版 API(完整 CRUD)
Auth SDK 自動注入
對於 access_mode = "external" 的 App,Runtime 會自動注入 Auth SDK,提供以下全域方法:
// 這些方法在 window.__auth__ 物件上自動可用
window.__auth__.login(email, password) // 登入
window.__auth__.register(email, password, displayName) // 註冊
window.__auth__.logout() // 登出(清除 Token)
window.__auth__.getToken() // 取得當前 Token
登入頁範例
import React, { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleLogin = async () => {
try {
await (window as any).__auth__.login(email, password);
// 登入成功 → Token 自動更新 → 跳轉首頁
window.location.hash = "#/";
window.location.reload();
} catch (err: any) {
setError(err.message || "登入失敗");
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p className="error">{error}</p>}
<button type="submit">登入</button>
</form>
);
}
18.6 Rate Limiting
為保護匿名端點免受濫用,pub/ API 設有專屬的速率限制:
| 端點範圍 | 限額 | 計量基準 |
|---|---|---|
/api/v1/pub/data/* + /api/v1/pub/proxy/* | 120 次 / 分鐘 | per IP |
/api/v1/custom-app-auth/*(POST) | 10 次 / 分鐘 | per IP |
認證版 /api/v1/data/* + /api/v1/proxy/* | 600 次 / 分鐘 | per user |
匿名 Rate Limit 與認證版 Rate Limit 互不影響。登入後的使用者享有 600/min 的配額。
回應 Header:
每個 pub/ API 回應都會包含:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
超過限額時:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
"detail": "請求過於頻繁,請稍後再試"
}
18.7 安全機制
| 防護項目 | 機制 |
|---|---|
| 欄位白名單 | filters、search_columns、select_columns 都對照 allowed_columns 白名單驗證,禁止查詢未授權欄位 |
| Limit 上限 | pub/proxy 的 limit 最大為 100,pub/data 由 FastAPI Query 驗證 |
| 唯讀 | pub/ 端點僅允許 GET 和 POST query,不允許 INSERT / UPDATE / DELETE |
| SQL Injection | 所有參數透過 SQLAlchemy 綁定,不拼接 SQL 字串 |
| App 驗證 | 每次請求驗證 slug 對應的 App 是否存在、已發布、且允許匿名存取 |
| 計費記錄 | 每次 pub/ API 呼叫記錄到 usage_events,管理員可監控用量 |
18.8 Builder API 自動化設定
AI Agent 或外部腳本可透過以下流程一次性啟用 Public 模式:
import httpx
BASE = "https://ai-go.app/api/v1"
# 1. 管理員登入
resp = httpx.post(f"{BASE}/auth/login", json={
"email": "[email protected]",
"password": "admin_password"
})
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
APP_ID = "your-app-uuid"
# 2. 啟用匿名存取
resp = httpx.patch(f"{BASE}/builder/apps/{APP_ID}", headers=headers, json={
"allow_anonymous_access": True
})
print(f"匿名存取:{resp.json().get('allow_anonymous_access')}")
# 3. 設定自建表為公開可讀(需 system.admin)
resp = httpx.get(f"{BASE}/data-center/tables", headers=headers)
for table in resp.json():
if table["key"] in ("venues", "products", "prices"):
httpx.patch(f"{BASE}/data-center/tables/{table['key']}", headers=headers, json={
"is_public_readable": True
})
print(f" ✓ {table['key']} → public")
# 4. 設定 Reference 為公開可讀
resp = httpx.get(f"{BASE}/refs/apps/{APP_ID}", headers=headers)
for ref in resp.json():
if ref["table_name"] in ("crm_tags",):
httpx.patch(f"{BASE}/refs/{ref['id']}", headers=headers, json={
"is_public_readable": True
})
print(f" ✓ ref {ref['table_name']} → public")
# 5. 發布
resp = httpx.post(f"{BASE}/builder/apps/{APP_ID}/publish", headers=headers, json={
"published_assets": {}
})
print(f"發布結果:{resp.status_code}")
# 6. 驗證匿名存取
slug = "your-app-slug"
resp = httpx.get(f"{BASE}/pub/data/{slug}/objects")
print(f"匿名存取測試:{resp.status_code} → {len(resp.json())} 個公開表")
18.9 常見問題
| 問題 | 解法 |
|---|---|
| pub/ API 回傳 404 | 確認 App 已發布(status = published),且 slug 正確 |
| pub/ API 回傳 403 | 確認 allow_anonymous_access = true 且對應資料表 is_public_readable = true |
| pub/data 看不到某些表 | 該表可能 is_public_readable = false,或 app_id 不匹配(只顯示屬於該 App 或 tenant 共用的表) |
| 頁面有資料但切換登入後消失 | 登入後 SDK 使用認證版 API,確認認證版的 Data Reference 也已正確設定 |
| 匿名模式下無法提交表單 | 正常行為 — pub/ API 僅允許讀取,提交功能需登入後使用 |
| Rate Limit 429 錯誤 | 匿名模式每 IP 限 120 次/分鐘,建議前端加入請求去重和快取 |
__IS_AUTHENTICATED__ 始終為 false | 確認 Auth SDK 是否正確注入。External App 在匿名首次載入時此值為 false 是正常的 |
19. External App 獨立認證系統
適用場景:External 模式的 Custom App 使用獨立於主站的帳號系統,讓外部使用者(客戶、供應商、訪客)透過 Email + 密碼進行註冊、登入和身份驗證。此機制與 §17 的 Internal App(Supabase Auth)完全獨立。
19.1 概述
| 比較 | Internal App(§17) | External App(本章) |
|---|---|---|
| 帳號系統 | 主站 Supabase Auth | 獨立 custom_app_users 表 |
| Token 類型 | Supabase JWT | 自訂 JWT(HS256) |
| 帳號共用 | 與主站 Dashboard 共用 | 每個 App 獨立 |
| 社群登入 | 否 | LINE / Google / LIFF |
| 匿名瀏覽 | 否 | 搭配 §18 Public 模式 |
External App 的使用者資料儲存在 custom_app_users 表中,每個 App 各自隔離。同一個 Email 可以在不同 App 中分別註冊。
19.2 統一登入 / 註冊 URL
External App 在 Runtime 中會自動掛載登入/註冊頁面路由:
https://ai-go.app/externalAppRuntime/{slug}
頁面路由由 App 的 VFS 自行定義,通常為:
| Hash 路由 | 頁面 | 說明 |
|---|---|---|
#/login | LoginPage.tsx | 登入頁 |
#/register | RegisterPage.tsx | 註冊頁 |
#/ | HomePage.tsx | 首頁(登入後) |
注意:App 開發者需自行在 VFS 中建立
LoginPage.tsx和RegisterPage.tsx。Runtime 提供 Auth SDK(window.__auth__)來呼叫後端 API。
19.3 Custom App Auth API
所有端點前綴為 /api/v1/custom-app-auth/{app_slug}/。
註冊
POST /api/v1/custom-app-auth/{slug}/register
Content-Type: application/json
{
"email": "[email protected]",
"password": "mypassword123",
"display_name": "王小明"
}
成功回應(201):
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "rt_abc123...",
"expires_in": 900,
"user": {
"id": "uuid",
"email": "[email protected]",
"display_name": "王小明",
"is_active": true,
"created_at": "2026-06-11T08:00:00Z"
}
}
| 錯誤碼 | 說明 |
|---|---|
| 409 | Email 已被註冊 |
| 422 | 參數驗證失敗 |
登入
POST /api/v1/custom-app-auth/{slug}/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "mypassword123"
}
成功回應(200):與註冊回應格式相同。
| 錯誤碼 | 說明 |
|---|---|
| 401 | 帳號或密碼錯誤 |
| 403 | 帳號已被停用 |
取得當前使用者
GET /api/v1/custom-app-auth/{slug}/me
Authorization: Bearer {access_token}
刷新 Token
POST /api/v1/custom-app-auth/{slug}/refresh
Content-Type: application/json
{
"refresh_token": "rt_abc123..."
}
舊的 Refresh Token 使用後即撤銷(Token Rotation),回應中會包含新的 Refresh Token。
登出
POST /api/v1/custom-app-auth/{slug}/logout
Authorization: Bearer {access_token}
Content-Type: application/json
{
"refresh_token": "rt_abc123..."
}
19.4 使用者管理 API(管理員)
以下端點需要 平台帳號 JWT + builder.access 權限(非 Custom App Token):
| 操作 | 方法 | 端點 |
|---|---|---|
| 列出使用者 | GET | /api/v1/custom-app-auth/manage/{app_id}/users |
| 啟用/停用 | PATCH | /api/v1/custom-app-auth/manage/{app_id}/users/{user_id} |
| 刪除使用者 | DELETE | /api/v1/custom-app-auth/manage/{app_id}/users/{user_id} |
列出使用者:
GET /api/v1/custom-app-auth/manage/{app_id}/users
Authorization: Bearer {platform_jwt}
回應:
[
{
"id": "uuid",
"email": "[email protected]",
"display_name": "王小明",
"is_active": true,
"last_login_at": "2026-06-11T08:00:00Z",
"created_at": "2026-06-01T00:00:00Z"
}
]
停用使用者:
PATCH /api/v1/custom-app-auth/manage/{app_id}/users/{user_id}
Authorization: Bearer {platform_jwt}
Content-Type: application/json
{
"is_active": false
}
19.5 OAuth 社群登入(LINE、Google 等)
提示:External App 支援第三方 OAuth 社群登入,讓使用者可透過 LINE、Google 等帳號直接登入,無需手動輸入 Email 和密碼。
查詢可用的 Auth Provider
GET /api/v1/custom-app-oauth/{slug}/auth-providers
回應:
[
{ "provider": "google", "enabled": true },
{ "provider": "line", "enabled": true }
]
發起 OAuth 授權
GET /api/v1/custom-app-oauth/{slug}/google/authorize?redirect_uri=https://your-app.com/callback
回傳 302 重導向到 Google/LINE 的授權頁面。
OAuth 回調
GET /api/v1/custom-app-oauth/{slug}/google/callback?code=xxx&state=xxx
成功後回傳 Token(與 login 端點格式相同)。
LINE LIFF Token 交換
適用於 LINE LIFF App 內嵌場景:
POST /api/v1/custom-app-oauth/{slug}/liff-swap
Content-Type: application/json
{
"liff_access_token": "LINE_LIFF_ACCESS_TOKEN"
}
19.6 Auth SDK 自動注入(Runtime)
對於 access_mode = "external" 的 App,Runtime 會自動在 window.__auth__ 上注入以下方法:
// 登入
const result = await window.__auth__.login(email, password);
// result: { access_token, refresh_token, expires_in, user }
// 註冊
const result = await window.__auth__.register(email, password, displayName);
// 登出(清除本地 Token + 撤銷 Refresh Token)
await window.__auth__.logout();
// 取得當前 Token(自動處理刷新)
const token = await window.__auth__.getToken();
// 檢查是否已認證
const isAuth = window.__auth__.isAuthenticated();
// 訂閱認證狀態變化(登入/登出時觸發回調)
const unsubscribe = window.__auth__.onAuthChange((isAuth) => {
console.log('認證狀態變化:', isAuth);
});
// 取消訂閱
unsubscribe();
// 取得 OAuth 社群登入 URL
const googleUrl = window.__auth__.getOAuthUrl('google', '#/dashboard');
// → /api/v1/custom-app-oauth/{slug}/google/authorize?return_path=%23%2Fdashboard
window.location.href = googleUrl; // 跳轉到 Google 登入
懶人版登入觸發器
若不想自建 LoginPage,可直接呼叫 window.__triggerLogin__() 觸發平台統一登入頁:
// 在任意元件中觸發登入(可選帶入登入後的返回路徑)
window.__triggerLogin__('#/booking'); // 登入成功後導向 #/booking
// 不帶路徑,登入後回到首頁
window.__triggerLogin__();
登入成功後的路由恢復
OAuth 或 __triggerLogin__ 登入成功後,Runtime 會自動將登入前的路徑存入 window.__INITIAL_ROUTE__:
// 在 App.tsx 中檢查是否有待恢復的路徑
const initialRoute = (window as any).__INITIAL_ROUTE__;
if (initialRoute) {
window.location.hash = initialRoute;
}
Auth SDK 會自動更新
window.__APP_TOKEN__。登入成功後,所有後續的 API 呼叫(api.ts、db.ts)會自動帶入新的 Token,不需手動處理。
完整 LoginPage 範例
import React, { useState } from "react";
import toast from "react-hot-toast";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const result = await (window as any).__auth__.login(email, password);
toast.success(`歡迎回來,${result.user.display_name}!`);
// Token 已自動更新,重新載入以切換到認證版 API
window.location.hash = "#/";
window.location.reload();
} catch (err: any) {
toast.error(err.message || "登入失敗");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleLogin}>
<h1>登入</h1>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="密碼"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? "登入中..." : "登入"}
</button>
<p>
還沒有帳號?<a href="#/register">立即註冊</a>
</p>
</form>
);
}
完整 RegisterPage 範例
import React, { useState } from "react";
import toast from "react-hot-toast";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [loading, setLoading] = useState(false);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await (window as any).__auth__.register(email, password, name);
toast.success("註冊成功!");
window.location.hash = "#/";
window.location.reload();
} catch (err: any) {
toast.error(err.message || "註冊失敗");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleRegister}>
<h1>註冊</h1>
<input
type="text"
placeholder="顯示名稱"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input
type="password"
placeholder="密碼(至少 6 位)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<button type="submit" disabled={loading}>
{loading ? "註冊中..." : "建立帳號"}
</button>
<p>
已有帳號?<a href="#/login">前往登入</a>
</p>
</form>
);
}
19.7 Token 機制
| 項目 | 值 |
|---|---|
| Access Token 有效期 | 15 分鐘 |
| Refresh Token 有效期 | 7 天 |
| 簽名演算法 | HS256 |
| Token Rotation | 每次 refresh 舊 token 自動撤銷 |
| 多裝置登入 | 每個裝置獨立 session |
Token 儲存位置(Auth SDK 自動管理):
localStorage:
__custom_app_access_token__ → Access Token
__custom_app_refresh_token__ → Refresh Token
Auth SDK 會在 Access Token 即將過期時自動呼叫
/refresh端點更新。開發者無需手動處理 Token 刷新邏輯。
20. 套件管理與第三方依賴
適用場景:當 Custom App 需要使用第三方 JavaScript / TypeScript 套件(如日期處理、圖表庫等),需了解 VFS 編譯環境的套件管理機制。
20.1 Runtime 內建模組
以下模組由 Runtime 頁面全域提供,不需安裝,直接 import 即可:
import React from "react";
import { createRoot } from "react-dom/client";
import { HashRouter, Routes, Route, Link } from "react-router-dom";
import { Search, Calendar, User } from "lucide-react";
import toast, { Toaster } from "react-hot-toast";
| 模組 | 版本 | 說明 |
|---|---|---|
react | ^18.x | React 核心 |
react-dom | ^18.x | DOM 渲染 |
react-router-dom | ^6.x | Hash 路由 |
lucide-react | latest | 圖示庫 |
react-hot-toast | latest | Toast 通知 |
注意:這些模組在 esbuild 編譯時會被標記為
--external,不會打包進 bundle。Runtime 頁面會提供全域版本。
20.2 package.json 依賴宣告
VFS 中的 package.json 用於宣告 App 的依賴。但與傳統 Node.js 專案不同,VFS 環境不會執行 npm install。套件的解析完全由 esbuild 在編譯時處理。
{
"name": "my-custom-app",
"private": true,
"dependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
package.json主要用於 esbuild 的模組解析提示。內建模組(react 等)已在 dependencies 中預設宣告。
20.3 新增第三方套件
對於純 JavaScript/TypeScript 套件,您可以直接在 VFS 中使用:
方法一:直接在原始碼中引用
適用於小型工具函式。直接在元件中定義:
// src/utils/date.ts — 自行實作日期格式化
export function formatDate(date: string): string {
const d = new Date(date);
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}
方法二:將套件原始碼放入 VFS
適用於小型第三方庫。將壓縮版 JS 直接放入 VFS:
src/
├── vendor/
│ └── dayjs.min.js ← 將套件原始碼放入 VFS
├── pages/
│ └── EventPage.tsx ← import dayjs from '../vendor/dayjs.min'
注意:大型套件(如 chart.js、three.js)不建議放入 VFS,因為單檔大小限制為 1MB。
20.4 限制與注意事項
| 限制 | 說明 |
|---|---|
| 不支援 CSS Modules | 所有 CSS 使用全域 App.css,不可使用 *.module.css |
| 不支援 Tailwind CSS | esbuild 不執行 PostCSS 流程 |
| 不支援 Node.js 原生模組 | fs、path、crypto 等無法在瀏覽器執行 |
| 不支援動態 import | import() 語法不支援,所有模組須為靜態引入 |
| 單檔大小上限 | 1 MB |
| VFS 最大檔案數 | 500 |
| 編譯超時 | 30 秒 |
20.5 常見套件相容性
| 套件 | 相容 | 說明 |
|---|---|---|
| date-fns(原始碼引入) | 是 | 純 JS、tree-shakable |
| lodash-es(原始碼引入) | 是 | ESM 版本 |
| uuid | 是 | 純 JS |
| chart.js | 需壓縮版 < 1MB | |
| three.js | 否 | 太大(> 1MB) |
| styled-components | 否 | 需 Babel 轉換 |
| @mui/material | 否 | 依賴太多、需 emotion |
21. 引用系統 API
前綴:
/api/v1/refs認證:主站 JWT(需builder.access權限)
引用(Reference)是 AI GO 的資料授權核心機制。每個整合必須先建立引用,指定要存取哪些表的哪些欄位,以及具備什麼操作權限。
21.1 列出可引用的表
GET /api/v1/refs/available-tables
Response 200 OK:
[
{ "name": "customers", "comment": "客戶" },
{ "name": "sale_orders", "comment": "銷售訂單" },
{ "name": "product_products", "comment": "" }
]
21.2 取得表的欄位資訊
GET /api/v1/refs/tables/{table_name}/columns
Response 200 OK:
[
{
"name": "name",
"type": "VARCHAR",
"nullable": false,
"is_system": false
},
{
"name": "email",
"type": "VARCHAR",
"nullable": true,
"is_system": false
},
{
"name": "customer_id",
"type": "UUID",
"nullable": true,
"is_system": false,
"is_fk": true,
"fk_target": "customers.id"
}
]
21.3 引用 CRUD
列出 App 的所有引用
GET /api/v1/refs/apps/{app_id}
建立引用
POST /api/v1/refs/apps/{app_id}
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
table_name | string | 必填 | 要引用的表名,如 "customers" |
columns | string[] | 選填 | 授權的欄位清單,如 ["name", "email", "phone"] |
permissions | string[] | 選填 | 權限清單,如 ["read", "create"] |
更新引用
PATCH /api/v1/refs/{ref_id}
| 欄位 | 型別 | 說明 |
|---|---|---|
columns | string[] | 新的授權欄位清單 |
permissions | string[] | 新的權限清單 |
刪除引用
DELETE /api/v1/refs/{ref_id}
21.4 權限值說明
| 權限 | 對應 Proxy 操作 | 說明 |
|---|---|---|
read | GET / POST query | 查詢資料 |
create | POST insert | 新增記錄 |
update | PATCH | 更新記錄 |
delete | DELETE | 刪除記錄 |
21.5 不可引用的表
系統核心表(如身份驗證、租戶管理、權限設定、稽核紀錄等)因安全原因永遠不可被引用。嘗試引用這些表時,API 會回傳 403 此表不可引用。
可引用的表清單請透過 GET /api/v1/refs/available-tables API 查詢。
21.6 系統欄位
以下欄位由系統自動管理,寫入時會被自動排除:
| 欄位 | 型別 | 說明 |
|---|---|---|
id | UUID | 主鍵,自動生成 |
created_at | timestamptz | 建立時間,自動設定 |
updated_at | timestamptz | 更新時間,自動更新 |
tenant_id | UUID | 租戶 ID,自動注入(row-level 隔離) |
21.7 共用業務欄位:custom_data(JSONB)
所有功能性資料表皆包含 custom_data 欄位,專為第三方應用儲存業態專屬的自訂資料設計。
| 特性 | 說明 |
|---|---|
| 型別 | JSONB(PostgreSQL 原生 JSON 二進位格式) |
| 預設值 | '{}'::jsonb(空 JSON 物件) |
| Nullable | 是(可設為 null) |
| 存取方式 | 僅透過 Proxy API(Internal / External / Open) |
| 資料隔離 | 遵循現有 tenant_id row-level 隔離機制 |
使用方式:在建立引用時將 custom_data 加入 columns 清單:
{
"table_name": "customers",
"columns": ["id", "name", "email", "custom_data"],
"permissions": ["read", "create", "update"]
}
注意:若引用的
columns中未包含custom_data,則該 App 在讀取時不會看到此欄位,寫入時也會被自動忽略。
22. 接收外部 Webhook
適用場景:當 Custom App 需要接收來自第三方服務(如 LINE、Meta、綠界、物流商等)的即時通知時,可使用平台的泛用 Webhook 閘道。
22.1 Webhook URL
每個已發布的 Custom App 自動獲得 Webhook URL:
POST https://ai-go.app/api/v1/custom-apps/webhook/{slug}
POST https://ai-go.app/api/v1/custom-apps/{slug}/webhook
其中 {slug} 為 App 的 slug 或 subdomain(非 UUID)。
無需認證:Webhook URL 是公開端點,不需要 JWT Token。App 必須處於 published 狀態。
22.2 receive_webhook.py Action
Webhook 閘道固定派發到 actions/receive_webhook.py,名稱不可變更。此檔案需列入 actions/manifest.json 中。
Action 收到的 ctx.params 結構如下:
{
"webhook_event": "incoming",
"body": "<原始 HTTP Body 字串>",
"headers": {
"content-type": "application/json",
"x-line-signature": "...",
...
}
}
22.3 實作範例
import json
def execute(ctx):
"""接收外部 Webhook 並根據事件類型分流處理"""
# 1. 解析原始 Payload
raw_body = ctx.params.get("body", "")
headers = ctx.params.get("headers", {})
try:
payload = json.loads(raw_body)
except json.JSONDecodeError:
ctx.response.json({"error": "Invalid JSON"})
return
# 2. 根據事件類型分流
event_type = payload.get("type") or payload.get("event")
if event_type == "order.created":
# 電商新訂單 → 寫入訂單表
ctx.data.insert("orders", {
"order_no": payload["order_id"],
"amount": payload["total"],
"status": "pending",
"raw_payload": raw_body,
})
elif event_type == "payment.confirmed":
# 支付確認 → 更新訂單狀態
ctx.data.update("orders",
filters=[{"column": "order_no", "op": "eq", "value": payload["order_id"]}],
data={"status": "paid"}
)
elif event_type == "message":
# 聊天訊息 → 寫入訊息記錄
ctx.data.insert("messages", {
"sender": payload.get("sender_id"),
"content": payload.get("text"),
"channel": headers.get("x-channel-id", "unknown"),
})
else:
# 未知事件 → 記錄到日誌表供後續分析
ctx.data.insert("webhook_logs", {
"event_type": event_type or "unknown",
"payload": raw_body,
"processed": False,
})
ctx.response.json({"status": "accepted"})
Action 必須命名為
receive_webhook.py:Webhook 閘道固定派發到此 Action,名稱不可變更。
22.4 限制
| 項目 | 說明 |
|---|---|
| App 狀態 | 必須為 published |
| 執行超時 | 45 秒 |
| 回應格式 | 閘道立即回傳 {"status": "accepted"},Action 在背景非同步執行 |
| 重試機制 | 平台不主動重試,建議在 Action 中記錄錯誤 |
| 簽章驗證 | 需自行在 Action 中實作(如 LINE Signature) |
22.5 Meta Webhook 訂閱驗證
在 App 的 Secrets 中設定 META_VERIFY_TOKEN,平台會自動處理 Meta 的 GET 驗證請求,不觸發 Action。
提示:完整的 Webhook 閘道規格(URL 格式、認證機制等),請參閱 AI GO 系統串接指南 — Webhook Gateway。
23. 簽核工作流 (Approval Workflow)
AI GO 內建通用簽核引擎:管理者在主站設定「哪張表、哪個動作、要誰簽」,引擎便會在該操作真正執行前攔截它,改成建立一張簽核申請單。這個引擎是非侵入式的——不改資料表結構、不需要您在 App 內自己寫簽核邏輯。
Custom App 與簽核系統有三個接觸面:
| 接觸面 | 說明 | 章節 |
|---|---|---|
| 被攔截 | 您的寫入/確認操作命中簽核流程,改為建立申請單 | 23.2 / 23.3 |
| 前端 SDK | src/approval.ts:在 App 裡做待簽清單、核准/駁回按鈕 | 23.4 |
| Server Action | ctx.approval.*:在 Python action 內查詢與簽核 | 23.5 |
簽核流程本身由租戶管理者在主站設定(簽核流程設定頁),Custom App 不負責也無法建立流程。您的責任是:正確處理「被攔截」的回傳,以及(選用)提供簽核介面。
23.1 核心概念
-
前置守衛(Pre-guard):業務操作在狀態變更前先問引擎「這個動作要簽核嗎?」。命中 → 建立
ApprovalRequest,原始操作不執行;未設定流程 → 直接放行(短路原則)。 -
多態關聯:申請單以
(res_model, res_id)(表名 + 記錄 ID)指向任意記錄。 -
回調閉環:全部層級通過後,平台自動執行當初被攔截的那個操作(例如真的去確認訂單、過帳傳票、套用暫存的 update payload)。您的程式不需要也不應該在簽核通過後再送一次。
-
狀態機:
申請單:pending → approved(全層通過)|rejected(任一層駁回)|cancelled(申請人取消) 簽核層:waiting → pending → approved|rejected|skipped -
駁回後原記錄保持可編輯,修改後重送會建立一張全新申請單(不是復活舊單)。
23.2 哪些操作會被攔截
| 來源 | 受管制的操作 | 攔截行為 |
|---|---|---|
src/db.ts(DB Proxy) | insert / update / remove 對已授權的 ERP 表 | insert = insert-then-flag;update / delete = pre-guard |
ctx.db(Server Action) | insert / update / remove 對 ERP 表 | 同上,pre-guard 會拋 ApprovalPendingError |
ctx.erp(Server Action) | confirm_sale_order、confirm_purchase_order、post_move、confirm_payment、validate_picking、confirm_payroll_run 等正向確認類方法 | pre-guard,拋 ApprovalPendingError |
| 主站 ERP 端點 | 銷售/採購確認、傳票過帳、揀貨確認、MRP 確認、HR 請假核准 | HTTP 202 pending_approval |
常見可設流程的表:sale_orders、purchase_orders、account_moves、account_payments、stock_pickings、stock_scraps、mrp_productions、hr_leaves,以及您授權給 App 的其他 ERP 表。
不在簽核範圍:資料中心自建表(src/api.ts、ctx.db.*_row)的寫入不經 ERP 表,不受簽核管制;ctx.erp.reconcile_payment、cancel_payroll_run 等修復/撤銷類方法亦不套 guard。
Custom App 不是簽核旁路。 不論您走前端 SDK、
ctx.db還是ctx.erp,同一套守衛都會生效——不要試圖用「換一條路徑寫入」來繞過租戶設定的簽核流程。
23.3 被攔截時,您的程式要怎麼處理
(1)Insert — insert-then-flag:記錄照樣寫入
為了取得關聯 ID,新增操作的記錄會寫進資料庫,但不觸發後續營運邏輯,並附帶 pending 標記回傳:
const result = await insert("sale_orders", { data: { amount_total: 5000 } });
if (result.approval_status === "pending") {
// result.approval_request_id / result.approval_message 一併回傳
toast.info(result.approval_message || "已送出簽核申請,待核准後生效");
} else {
toast.success("訂單已建立");
}
不要因為 pending 而重試 insert——會重複建立記錄與申請單。
(2)Update / Delete — pre-guard:不執行,payload 暫存
更新/刪除不會實際發生;您送出的 payload 會暫存在申請單裡,等簽核全部通過後由平台自動套用。
(3)Server Action 內:捕捉 ApprovalPendingError
ctx.db.update / ctx.db.remove 與 ctx.erp.* 的 pre-guard 以例外形式回報:
def execute(ctx):
try:
ctx.erp.confirm_sale_order(ctx.params["order_id"])
except Exception as e:
# 訊息形如「需要簽核審批:...(request_id=...),簽核全部通過後系統會自動執行此操作」
if "簽核" in str(e) or "ApprovalPending" in type(e).__name__:
ctx.response.json({"pending_approval": True, "message": str(e)})
return
raise
ctx.response.json({"ok": True})
(4)無代碼閉環:讓引擎自動改狀態欄位
若您只是想在核准後把單據的狀態欄位翻成 approved,不必寫任何 Python——請管理者在建立簽核流程時填 approved_state_field / approved_state_value / rejected_state_value,引擎會在核准/退回時自動更新該欄位(詳見 第 7 章 — 簽核通過/退回後的自動狀態變更)。您的 App 只要查 state === 'approved' 即可。
23.4 前端 Approval SDK(src/approval.ts)
以「目前登入使用者」身分操作簽核。僅 Internal App 可用——External App 呼叫任一函式會直接拋錯(External 尚無簽核端點)。
| 函式 | 說明 |
|---|---|
myPending() | 待我簽核清單(陣列,已解包 {items,total} 信封) |
recordStatus(resModel, resId) | 某筆記錄的最新簽核申請;無簽核記錄回 null |
approve(requestLineId, comment?) | 核准一層;需為該層合格審核人,否則後端回 403 |
reject(requestLineId, comment?) | 駁回;整張申請變 rejected |
cancel(requestId, reason?) | 取消申請;僅申請人本人、僅 pending 可取消 |
myPending() 每個元素的欄位:
| 欄位 | 說明 |
|---|---|
request_id | 申請單 ID(cancel 用) |
request_line_id | 簽核層 ID(approve / reject 用) |
res_model / res_id | 被簽核的表名與記錄 ID(可據此查原始單據) |
workflow_name / stage_name | 流程名稱、當前層級名稱 |
requester_name | 申請人 email;無使用者身分的來源顯示 API |
submitted_at / current_stage_sequence | 提交時間、當前層級序號 |
recordStatus() 回傳 { id, status, workflow_name, requester_name, submitted_at, completed_at, lines[] },lines[] 每層含 { id, sequence, stage_name, status, assigned_user_id, reviewer_id, comment, reviewed_at }——可直接畫成簽核進度時間軸。
完整範例:App 內的待簽核頁
import { useEffect, useState } from "react";
import { myPending, approve, reject } from "../approval";
export default function MyApprovalsPage() {
const [items, setItems] = useState<any[]>([]);
const load = async () => setItems(await myPending());
useEffect(() => { load(); }, []);
const onApprove = async (item: any) => {
try {
const res = await approve(item.request_line_id, "同意");
// res.all_approved === true 代表最後一層通過,原操作已由平台自動執行
alert(res.all_approved ? "簽核完成,操作已執行" : "已核准,進入下一層");
await load();
} catch (e: any) {
alert(e.message); // 403:您不是此層級的合格審核人
}
};
return (
<div>
{items.map((it) => (
<div key={it.request_line_id}>
<span>{it.workflow_name} — {it.stage_name}({it.requester_name})</span>
<button onClick={() => onApprove(it)}>核准</button>
<button onClick={() => reject(it.request_line_id, "金額有誤").then(load)}>駁回</button>
</div>
))}
</div>
);
}
提示:
myPending()只回傳「您有資格簽」的項目,資格由後端即時解析(角色/部門異動立即生效),前端不需要也不應該自己判斷誰能簽。
23.5 Server Action:ctx.approval.*
在 Python action 內操作簽核,身分是觸發這個 action 的使用者,不是 App 自己。
| 方法 | 說明 |
|---|---|
ctx.approval.list_pending() | 觸發者的待簽核清單(欄位同 myPending()) |
ctx.approval.get_record_status(res_model, res_id) | 某筆記錄的簽核狀態(含 lines[]);無則回 None |
ctx.approval.approve(request_line_id, comment=None) | 核准一層。回傳 {status, request_id, request_status, all_approved, callback_error} |
ctx.approval.reject(request_line_id, comment=None) | 駁回整張申請 |
ctx.approval.cancel(request_id, reason=None) | 取消申請(僅申請人本人) |
def execute(ctx):
pending = ctx.approval.list_pending()
if not pending:
ctx.response.json({"count": 0, "items": []})
return
# 只自動核准 1000 元以下的小額採購(示意:條件邏輯由 App 決定,資格仍由平台把關)
result = ctx.approval.approve(pending[0]["request_line_id"], "系統依規則核准")
ctx.response.json({
"all_approved": result["all_approved"],
"callback_error": result["callback_error"], # 非 None 代表回調失敗,可在主站重試
})
三條不可逾越的規則:
- App 不能代替任何人簽核。
approve/reject必須綁定平台使用者,且該使用者須為當前層級的合格審核人(與主站端點共用同一套判定);不合格 →PermissionError。 - 無使用者身分的 invocation 一律拒絕。 排程/背景觸發沒有帶平台 User 時,
list_pending/approve/reject/cancel會拋PermissionError。 cancel僅申請人本人,且僅pending狀態可取消。
Scope 授權:ctx.approval 需在 App 的 Scope 設定中授權,且分級不同——
| Scope | 對應方法 | 風險級別 |
|---|---|---|
approval.read | list_pending、get_record_status | 低風險(免 step-up) |
approval.decide | approve、reject | 高風險(授權擴大時需擁有者密碼二次驗證) |
approval.cancel | cancel | 高風險(同上) |
提示:
callback_error不為None代表簽核已完成、但自動執行原操作時出錯(例如缺會計科目)。簽核結果不會因此回滾,請提示使用者到主站簽核面板重試該筆回調。
23.6 流程規則(供您理解管理者的設定會如何影響 App)
- 多層序列:層級依序號由小到大逐層審,前一層完成才輪到下一層(不支援跨層平行)。
- 層內模式:
any:該層任一合格審核人核准即通過。all(會簽):建立申請時就把該層所有合格審核人各展開成一條 line(綁定本人),全員核准才推進;任一人駁回 → 整張申請 rejected。
- 非必要層(
is_required=False):若建單時該層解析不到任何合格審核人,整層自動skipped,流程不會卡死;必要層解析不到人則會停在該層等管理者修設定(寧可卡住也不放行)。 - 一表多流程:同一張表可設多個啟用中的流程,依序號優先權逐一比對觸發條件(如金額級距分流),取第一個完全匹配者。同一筆記錄同時間只會有一張 pending 申請。
- 審核人解析時機:
any層是審核當下解析(角色異動即時生效);會簽層在建單當下就綁定人員。 - API 旁路(
allow_api_bypass):若管理者在流程上勾選此項,則沒有使用者身分(API Key/背景排程)的請求會被放行不攔,並寫入稽核日誌。有使用者身分的請求永遠不旁路。
23.7 REST 端點一覽
src/approval.ts 已封裝下列端點;若您需要自行呼叫(如 AI Agent 直接打 API),端點如下(皆需 Authorization: Bearer {JWT}):
| 操作 | 方法 | 端點 |
|---|---|---|
| 待我簽核 | GET | /api/v1/approvals/my-pending |
| 記錄簽核狀態 | GET | /api/v1/approvals/record/{res_model}/{res_id} |
| 核准 | POST | /api/v1/approvals/{request_line_id}/approve |
| 駁回 | POST | /api/v1/approvals/{request_line_id}/reject |
| 取消申請 | POST | /api/v1/approvals/{request_id}/cancel |
| 重試已核准的回調 | POST | /api/v1/approvals/{request_id}/retry |
| 申請列表 | GET | /api/v1/approvals/requests |
| 流程設定 CRUD | GET/POST/PATCH/DELETE | /api/v1/approvals/workflows[/{id}] |
23.8 限制與常見問題
| 問題 | 說明 / 解法 |
|---|---|
呼叫 approve() 回 403 | 您不是該層級的合格審核人;請確認 request_line_id 來自 myPending()(該清單已過濾資格) |
| External App 呼叫 approval SDK 拋錯 | 簽核僅支援 Internal App,External App 無簽核端點 |
ctx.approval 拋 PermissionError(無使用者身分) | 該 invocation 未攜帶平台 User(如排程觸發);簽核操作必須由使用者觸發 |
| 操作「成功」了但資料沒變 | 檢查回傳是否帶 approval_status: "pending",或例外訊息是否為簽核攔截——不要把 pending 當成功 |
| 簽核通過了但原操作沒發生 | 檢查 callback_error;回調失敗不影響簽核結果,可在主站簽核面板 retry |
| 想在 App 內設定簽核流程 | 目前不開放;流程由租戶管理者在主站設定 |
| 沒有通知/催辦 | 簽核目前是純 pull 模式(「待我簽核」清單),平台不主動推播;如需提醒請自行以 ctx.messaging 實作 |
| 找不到委派/加簽/代理 | 尚未支援(含逾時升級、跨層平行簽核) |
24. 企業知識檢索 (ctx.knowledge)
ctx.knowledge 讓 Action 檢索企業知識中心(主站「知識中心 /files」裡被「設為知識」的檔案)的 per-tenant 向量庫。
核心語意(三點,先讀完再寫程式)
- 純檢索、不生成。走向量檢索回傳 chunk 原文+相關度分數,不呼叫任何 LLM、不消耗系統 AI Credit。要產生回答,請在 Action 內用自己的金鑰(App Secrets 的
OPENAI_API_KEY等)呼叫模型,把results[].content當 RAG context——生成的成本與選模完全由您掌控。 - 檢索用系統金鑰,與 BYOK 無關。租戶 vector store 建在平台的 OpenAI org 之下,您的 BYOK key 打不到它;檢索這一段由平台代打,用途僅限「檢索本租戶自己的知識」。
- 與退役中的「App 專屬知識庫/智能客服
trigger_ai_reply」是兩套系統,零依賴,勿混用。
API
def execute(ctx):
hits = ctx.knowledge.search(ctx.params["question"], top_k=5, score_threshold=0.5)
# enabled=False = 本租戶尚未把任何檔案設為知識
if not hits["enabled"]:
return ctx.response.json({"answer": "尚未設定企業知識"})
# results 是 chunk 級:同一檔案可有多筆,各自帶 score
rag = "\n\n".join(f'[{r["filename"]}] {r["content"]}' for r in hits["results"])
# 需要完整脈絡而非片段時,用 search 回傳的 file_id 取全文
# full = ctx.knowledge.get_content(hits["results"][0]["file_id"])
ctx.response.json({"context": rag})
回傳形狀
| 方法 | 回傳 |
|---|---|
search | {"enabled": bool, "results": [{"file_id", "filename", "score", "content"}]} |
get_content | {"file_id", "filename", "mime_type", "content", "truncated"} |
file_id 是平台 FileNode id(不外洩 OpenAI file id),可直接傳給 get_content。truncated=True 表示超過長度上限或來源端仍有後續分頁。
限制
| 項目 | 值 |
|---|---|
top_k | 上限 20 |
| query 長度 | 4,000 字元 |
| 單一 chunk 回傳 | 4,000 字元 |
get_content 全文 | 200,000 字元(超過標 truncated) |
檔案級 ACL 自動生效
檢索結果會依呼叫者身分過濾,受限檔案在結果中如同不存在(不回片段、不回檔名):
| 呼叫情境 | 可檢索到 |
|---|---|
| Internal App + 有 user context | 依觸發者的角色過濾(同 /files UI 所見) |
| External / Self-Built App | 僅「External 開放」層級的檔案 |
| 排程等無 user context | 視同無角色成員(全公司層級+External 層) |
已刪除、非本租戶的檔案一律排除。真相源是平台 DB——角色變更即時生效。
錯誤處理
- 租戶尚未建知識庫 →
search回{"enabled": False, "results": []}(不是錯誤) get_content傳到非知識檔/無權限檔 → 拋例外,請自行捕捉- 平台未設定檢索金鑰 → 拋
RuntimeError(部署層問題,非您的程式碼問題)
Scope
需要 knowledge.read,高風險等級——授權時需租戶擁有者當場密碼 step-up(見第 26 章)。
25. 外部服務閘道 (Egress)
Custom App 不能任意連外網。所有對外呼叫都必須經過已授權的 egress service。
授權模型:domain-only 白名單
授權以網域為單位。您授權 api.example.com,App 就只能呼叫該網域底下的路徑;未授權網域一律打不到,不論程式碼怎麼寫。
設定入口只有一個:Builder 的「外部服務」分頁。
認證方式
憑證由平台保管、不進 App 原始碼:
| 類型 | 說明 |
|---|---|
| 無認證 | 公開 API |
| API Key / Bearer Token | 固定憑證,平台注入 header |
token_exchange | 執行期以帳密換取短期 Bearer token,平台自動處理換發與快取 |
token_exchange 對應「先登入拿 token、再帶 token 呼叫」的企業內部系統模式——您的程式碼直接呼叫目標路徑即可。
呼叫與錯誤處理
def execute(ctx):
resp = ctx.http.call("logistics", "/track",
method="POST",
body={"no": ctx.params["tracking_no"]})
# 注意: call 不拋例外,務必檢查 status
if resp["status"] != 200:
d = resp["data"]
return ctx.response.json({"error": d.get("fix") or d.get("error")})
ctx.response.json(resp["data"])
兩個最常踩的坑
- 第三個位置參數是
method(字串),不是 body。 送 body 請用具名參數body=。 call不拋例外,回{status, headers, data}。失敗時data帶:
| 欄位 | 內容 |
|---|---|
error_type | 錯誤分類(未授權/逾時/憑證錯誤/網域未白名單⋯) |
error | 技術訊息 |
fix | 一句可直接轉述給使用者的修復指引 |
fix_url / required_role / retryable | 補充資訊 |
fix 是刻意設計的欄位——讓 App 把「怎麼修」直接顯示給操作者,而不是丟技術堆疊。Builder UI 與 AI 開發助手也會讀取它協助排錯。
發布前閘門
程式碼呼叫了未授權的 service、或 service 金鑰未設定時,發布會被擋下並回 409 EGRESS_NOT_READY,明確指出缺什麼。這避免「上線後才發現對外呼叫全掛」。
Scope
需要 http,高風險等級。
26. Scope 授權模型
每個 ctx 模組方法都對應一個 scope(能力群)。App 宣告需要哪些 scope,經租戶管理者核可後才生效。
兩層授權,不可互相取代
| 層 | 管什麼 | 由誰決定 |
|---|---|---|
| Scope | 能用哪些能力群(讀資料/寫資料/觸發 ERP/讀金鑰⋯) | App 宣告 → 管理者核可 |
| 細粒度邊界 | 能碰哪些具體資源 | ERP 表看 Data Reference;外部服務看網域白名單;金鑰看可用清單 |
對沒有細粒度層的模組(erp.*、messaging.*),scope 就是唯一邊界。
風險分級
| 等級 | Scope | 核可方式 |
|---|---|---|
| 低風險 | db.read、data_table.read、messaging.read、approval.read、crypto | 管理者核可 |
| 高風險 | db.write、data_table.write、erp.*、approval.decide、approval.cancel、knowledge.read、messaging.write、messaging.channel、http、mcp、secret.read | 管理者核可 + 擁有者當場密碼 step-up |
knowledge.read 雖是唯讀且已有檔案級 ACL 作第二層,仍列高風險——知識中心可能含 HR/財務文件,取保守值。
宣告與發布
- 發布時自動掃描程式碼回填
requested_scopes,您不需手動維護清單 - 發布會凍結一份 scope 快照:開發階段改 scope 設定不影響已上線版本,必須重新發布才生效
- External / Self-Built App 的 scope 需管理者顯式核可,不走 internal 的簡化流程
執行期行為
- 未核可的 scope → 呼叫回 403
- 每一次
ctx呼叫都留下稽核紀錄(含 scope 與決策結果) - Scope 授權的每一次變更都記錄執行者身分,可在主站稽核日誌查詢
不要把 scope 當成 UI 開關。 它是後端的硬閘:前端藏起來的按鈕、或您在 Action 內自己寫的權限判斷,都不能取代 scope——反之亦然,scope 通過也不代表使用者本人有權限,那由
ctx.user_permissions判斷。
延伸閱讀
- AI GO 系統串接指南 — 第三方自建應用 API Key 整合
- Custom App 支援 Internal(內部)、External(外部)和 Public(匿名檢視)三種模式