跳轉到主要內容

概述

本指南將幫助您在 superun 中對接飛書多維表格,實現數據的自動同步和展示.通過 Edge Function 調用飛書開放平台 API,將多維表格數據同步到 Supabase 數據庫,並在前端實時展示.

一、整體架構

數據流:
  1. 用戶在前端填入飛書應用憑證 + 多維表格鏈接
  2. Edge Function 調用飛書 API 驗證憑證、解析鏈接、拉取數據
  3. 數據寫入 Supabase 數據庫(data_sources / synced_rows / sync_logs
  4. 前端通過 React Query 實時讀取數據庫展示看板

二、飛書端配置(4 步)

步驟 1:創建企業自建應用

  1. 打開 飛書開放平台,登錄後進入「開發者後台
  2. 點擊「創建企業自建應用 創建企業自建應用
  3. 填寫應用名稱(如「數據工作台」)和描述,點擊創建

步驟 2:獲取應用憑證

  1. 進入應用詳情頁,左側菜單選擇「憑證與基礎信息
  2. 記錄 App IDApp Secret 獲取應用憑證
示例:
  • App ID: cli_a9xxxxxxxxxx ← 以 cli_ 開頭
  • App Secret: ************************ ← 妥善保管,不要洩露
⚠️ 注意:App Secret 只顯示一次,請妥善保管.如果丟失,需要重新生成.

步驟 3:配置應用權限

  1. 進入「權限管理」,搜索並開通以下權限:
權限標識說明必須
bitable:app:readonly讀取多維表格數據(表、字段、記錄)
wiki:wiki:readonly讀取知識庫節點信息(用於解析 wiki 鏈接)僅當表格嵌在知識庫中時需要
配置應用權限
  1. 創建版本並發布:
    • 左側菜單 → 「版本管理與發布
    • 創建版本 → 填寫版本號和更新說明
    • 提交發布(如果是企業內部應用,通常自動通過)
💡 提示:權限開通後必須創建版本並發布,權限才會生效.

步驟 4:設置多維表格共享權限

確保飛書應用能訪問到目標多維表格:
  1. 打開多維表格 → 右上角「分享」按鈕
  2. 將鏈接權限設置為 「組織內獲得鏈接的人可閱讀」
⚠️ 注意:如果權限設置不正確,應用將無法訪問多維表格數據.

三、數據庫表結構

系統使用 3 張表存儲數據:

data_sources — 數據源配置

CREATE TABLE data_sources (
  id                UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  name              TEXT NOT NULL,              -- 數據源名稱
  spreadsheet_token TEXT NOT NULL,              -- 多維表格 app_token
  sheet_id          TEXT NOT NULL,              -- 數據表 table_id
  sheet_name        TEXT NOT NULL,              -- 數據表名稱
  data_range        TEXT NOT NULL DEFAULT '',   -- 視圖 view_id(可選)
  feishu_app_id     TEXT NOT NULL,              -- 飛書應用 App ID
  feishu_app_secret TEXT NOT NULL,              -- 飛書應用 App Secret
  status            TEXT NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending','active','syncing','error')),
  last_synced_at    TIMESTAMPTZ NOT NULL,
  row_count         INTEGER NOT NULL DEFAULT 0,
  column_headers     JSONB NOT NULL DEFAULT '[]', -- [{index, name, field_id}]
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

synced_rows — 同步的數據行

CREATE TABLE synced_rows (
  id             UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  data_source_id UUID NOT NULL REFERENCES data_sources(id),
  row_index      INTEGER NOT NULL,
  row_data       JSONB NOT NULL,   -- {"字段名1": "值1", "字段名2": "值2", ...}
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_synced_rows_source ON synced_rows(data_source_id);
CREATE INDEX idx_synced_rows_index  ON synced_rows(data_source_id, row_index);

sync_logs — 同步日誌

CREATE TABLE sync_logs (
  id             UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  data_source_id UUID NOT NULL REFERENCES data_sources(id),
  status         TEXT NOT NULL,    -- 'success' | 'failed'
  rows_synced    INTEGER NOT NULL DEFAULT 0,
  rows_added     INTEGER NOT NULL DEFAULT 0,
  rows_updated    INTEGER NOT NULL DEFAULT 0,
  rows_deleted   INTEGER NOT NULL DEFAULT 0,
  error_message  TEXT NOT NULL DEFAULT '',
  started_at     TIMESTAMPTZ NOT NULL,
  completed_at   TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_sync_logs_source  ON sync_logs(data_source_id);
CREATE INDEX idx_sync_logs_started ON sync_logs(started_at DESC);

四、後端:Edge Function 核心代碼

Edge Function feishu-sync 是整個對接的核心,提供 3 個 action:

4.1 獲取 tenant_access_token

所有飛書 API 調用的前置步驟,用應用憑證換取訪問令牌:
const FEISHU_BASE_URL = "https://open.feishu.cn/open-apis";

async function getFeishuTenantToken(
  appId: string,
  appSecret: string
): Promise<string> {
  const response = await fetch(
    `${FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        app_id: appId,        // cli_a9xxxxxxxxxx
        app_secret: appSecret  // ************************
      }),
    }
  );
  const result = await response.json();
  if (result.code !== 0) {
    throw new Error(`獲取令牌失敗: ${result.msg}`);
  }
  return result.tenant_access_token; // 有效期約 2 小時
}

4.2 Action: validate — 驗證憑證 + 解析鏈接

支持兩種飛書鏈接格式:
格式示例
/base/ 直链https://xxx.feishu.cn/base/DQu7bPBrEaUxU2s9kkNcWz9zn1B
/wiki/ 知识库https://xxx.feishu.cn/wiki/QFA2wYY62iTFuEkGusSchjomnOh?table=tblxxx
解析 URL 格式:
// 1. 解析 URL 格式
function parseFeishuUrl(urlOrToken: string) {
  if (urlOrToken.startsWith("http")) {
    const urlObj = new URL(urlOrToken);
    const pathParts = urlObj.pathname.split("/").filter(Boolean);
    // pathParts = ["wiki", "QFA2wYY..."] 或 ["base", "DQu7bP..."]
    return {
      token: pathParts[1],
      isWiki: pathParts[0] === "wiki"
    };
  }
  return { token: urlOrToken, isWiki: false };
}

// 2. 如果是 wiki 鏈接,需要解析出真正的 bitable app_token
async function resolveWikiToken(accessToken: string, wikiToken: string) {
  const response = await fetch(
    `${FEISHU_BASE_URL}/wiki/v2/spaces/get_node?token=${wikiToken}`,
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );
  const result = await response.json();
  // result.data.node.obj_token 就是 bitable 的 app_token
  // result.data.node.obj_type 應該是 "bitable"
  return result.data.node.obj_token;
}

// 3. 列出多維表格中的所有數據表
async function listBitableTables(accessToken: string, appToken: string) {
  const response = await fetch(
    `${FEISHU_BASE_URL}/bitable/v1/apps/${appToken}/tables?page_size=100`,
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );
  const result = await response.json();
  return result.data.items; // [{table_id, name, revision}, ...]
}
validate 完整流程:
憑證 (App ID + Secret) + 鏈接


  獲取 tenant_access_token


  解析 URL → 提取 token + 判斷 wiki/base

        ├── wiki? → 調用 wiki API 解析為 app_token


  調用 bitable API 列出數據表


  返回 { valid: true, app_token, tables: [...] }

4.3 Action: metadata — 獲取字段定義

async function listBitableFields(
  accessToken: string,
  appToken: string,
  tableId: string
) {
  const response = await fetch(
    `${FEISHU_BASE_URL}/bitable/v1/apps/${appToken}/tables/${tableId}/fields?page_size=200`,
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );
  const result = await response.json();
  return result.data.items;
  // [{field_id: "fldxxx", field_name: "姓名", type: 1}, ...]
}

4.4 Action: sync — 全量同步數據

這是核心同步邏輯,步驟如下:
async function handleSync(dataSourceId: string) {
  // 1. 從數據庫讀取數據源配置
  const dataSource = await supabase
    .from("data_sources").select("*")
    .eq("id", dataSourceId).single();

  // 2. 獲取飛書訪問令牌
  const accessToken = await getFeishuTenantToken(
    dataSource.feishu_app_id,
    dataSource.feishu_app_secret
  );

  // 3. 獲取字段定義(用於 column_headers)
  const fields = await listBitableFields(
    accessToken,
    dataSource.spreadsheet_token,  // 存儲的是 app_token
    dataSource.sheet_id             // 存儲的是 table_id
  );

  // 4. 分頁拉取全部記錄
  const { records } = await listBitableRecords(
    accessToken,
    dataSource.spreadsheet_token,
    dataSource.sheet_id,
    dataSource.data_range || undefined  // 可選的 view_id
  );

  // 5. 轉換記錄格式,提取顯示值
  const rows = records.map((record, index) => ({
    data_source_id: dataSourceId,
    row_index: index,
    row_data: Object.fromEntries(
      fields.map(f => [f.field_name, extractDisplayValue(record.fields[f.field_name])])
    ),
  }));

  // 6. 寫入數據庫(先刪後插)
  await supabase.from("synced_rows").delete().eq("data_source_id", dataSourceId);
  // 分批插入,每批 500 條
  for (let i = 0; i < rows.length; i += 500) {
    await supabase.from("synced_rows").insert(rows.slice(i, i + 500));
  }

  // 7. 更新數據源狀態 + 寫入同步日誌
  await supabase.from("data_sources").update({
    status: "active",
    row_count: records.length,
    column_headers: fields.map((f, i) => ({ index: i, name: f.field_name })),
    last_synced_at: new Date().toISOString(),
  }).eq("id", dataSourceId);

  await supabase.from("sync_logs").insert({
    data_source_id: dataSourceId,
    status: "success",
    rows_synced: records.length,
    rows_added: addedCount,
    rows_updated: updatedCount,
    rows_deleted: deletedCount,
    // ...
  });
}

4.5 多維表格字段值提取

Bitable 的字段值格式因類型而異,需要統一提取為可顯示的字符串:
function extractFieldDisplayValue(value: unknown): string {
  if (value === null || value === undefined) return "";
  if (typeof value === "string") return value;
  if (typeof value === "number") return String(value);
  if (typeof value === "boolean") return value ? "是" : "否";

  // 數組類型:多選、人員、富文本等
  if (Array.isArray(value)) {
    return value.map(item => {
      if (typeof item === "string") return item;
      if (item?.text) return item.text;   // 富文本
      if (item?.name) return item.name;   // 人員
      return JSON.stringify(item);
    }).join(", ");
  }

  // 對象類型:超鏈接等
  if (typeof value === "object") {
    if (value.text) return value.text;
    if (value.link) return value.link;
    return JSON.stringify(value);
  }

  return String(value);
}
常見字段類型與值格式:
字段類型type值示例
文本1[{type:"text", text:"你好"}]
數字2123.45
單選3"選項A"
多選4["選項A", "選項B"]
日期51709251200000(毫秒時間戳)
複選框7true / false
人員11[{id:"xxx", name:"張三"}]
超鏈接15{text:"點擊", link:"https://..."}

五、前端:React Hooks 調用層

5.1 驗證連接

// hooks/useFeishuSync.ts
export function useValidateFeishu() {
  return useMutation({
    mutationFn: async (input: {
      feishu_app_id: string;
      feishu_app_secret: string;
      bitable_url: string;
    }) => {
      const { data, error } = await supabase.functions.invoke("feishu-sync", {
        body: { action: "validate", ...input },
      });
      if (error) throw error;
      return data; // { valid, app_token, tables: [...] }
    },
  });
}

5.2 觸發同步

export function useSyncDataSource() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (dataSourceId: string) => {
      const { data, error } = await supabase.functions.invoke("feishu-sync", {
        body: { action: "sync", data_source_id: dataSourceId },
      });
      if (error) throw error;
      return data; // { status, rows_synced, rows_added, ... }
    },
    onSuccess: () => {
      // 同步完成後自動刷新相關查詢
      queryClient.invalidateQueries({ queryKey: ["data-sources"] });
      queryClient.invalidateQueries({ queryKey: ["synced-rows"] });
      queryClient.invalidateQueries({ queryKey: ["sync-logs"] });
    },
  });
}

5.3 數據源 CRUD

// hooks/useDataSources.ts
export function useCreateDataSource() {
  return useMutation({
    mutationFn: async (input) => {
      const { data, error } = await supabase
        .from("data_sources")
        .insert({
          name: input.name,
          spreadsheet_token: input.app_token,    // 存 app_token
          sheet_id: input.table_id,              // 存 table_id
          sheet_name: input.table_name,
          data_range: input.view_id || "",        // 存 view_id
          feishu_app_id: input.feishu_app_id,
          feishu_app_secret: input.feishu_app_secret,
          status: "pending",
          row_count: 0,
          column_headers: [],
        })
        .select().single();
      if (error) throw error;
      return data;
    },
  });
}

六、完整對接流程圖


七、常見問題排查

錯誤信息原因解決方案
獲取訪問令牌失敗 (code: 10003)App ID 或 App Secret 錯誤檢查憑證是否複製正確
Access denied…wiki:wiki:readonly應用缺少知識庫讀取權限開通 wiki:wiki:readonly 權限並發布新版本
獲取數據表列表失敗 (code: 99991668)應用缺少多維表格權限開通 bitable:app:readonly 權限
獲取記錄失敗 (code: 1254607)應用無權訪問該多維表格將多維表格的鏈接共享設為「組織內可閱讀」
該知識庫節點不是多維表格鏈接指向的是文檔而非多維表格確認粘貼的鏈接確實指向多維表格頁面
無法從 URL 中解析出 TokenURL 格式不正確使用 /wiki/xxx/base/xxx 格式的完整鏈接
violates check constraint數據庫狀態約束不匹配檢查 status 字段的 check 約束是否包含所需值

superun 網站

訪問該網站以了解更多功能和示例.