跳转到主要内容

概述

本指南将帮助您在 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 网站

访问该网站以了解更多功能和示例.