PAYATHON 2026

Google Apps Script Web App 为何提示无法打开文件

排障赵师傅

结论

当 Execute as: Me 与 SpreadsheetApp.openById() 配合使用时,Google Sheet 的读写操作以部署者身份执行。访问者通常无须获得该表格的共享权限,也不应通过共享表格来解决 Web App 的访问问题。

当前错误更可能出在 Web App 入口的账号识别或部署授权阶段。以下现象都指向入口问题:

  • Incognito 或干净会话可以正常访问。
  • 登录多个账号的浏览器更容易报错。
  • 页面显示 Google Drive 的“无法打开文件”,没有出现 Apps Script 执行异常。

还要注意,当 Web App 允许匿名访问并以部署者身份运行时,Session.getActiveUser().getEmail() 经常返回空字符串,因此不能用它为任意 Google 账号提供通用身份认证。

Execute as: Me 实际控制什么

Web App 的访问过程有两层。

第一层是入口访问。Google 会判断当前浏览器是否有权打开这个 Apps Script 部署。判断结果受以下因素影响:

  • Who has access 的设置
  • Google 当前选中的账号
  • Google Workspace 域策略
  • 部署是否仍然有效
  • 用户打开的是否为当前部署的 /exec URL

第二层是脚本执行。请求进入 doGet()、doPost() 或 google.script.run 后,Execute as: Me 决定服务器端以谁的权限访问 Google 服务。

例如:

SpreadsheetApp.openById("MY_SHEET_ID")

在“Execute as: Me”部署中,这段代码使用部署者的授权,访问者无须拥有该表格的查看或编辑权限。

如果失败请求没有出现在 Apps Script 的 Executions 页面中,请求就尚未进入脚本,此时应从部署入口排查,而不是检查 openById()。

为什么多账号登录时容易出现这个错误

Google 登录会话可以同时保存多个账号。访问 /exec 地址时,Google 有时会选择另一个账号完成入口授权。这个账号可能不属于公司域,也可能因 Workspace 策略而无法访问该部署。

从 URL 中删除 /u/0 或添加 /a/example.com/,只能影响账号或域的路由选择,无法改变部署权限,也无法绕过 Workspace 管理策略。这些 URL 调整在不同浏览器会话中的表现并不稳定,不适合作为正式解决方案。

如果 Incognito 可以正常运行,通常说明代码和表格权限没有根本问题。普通浏览器中的账号选择、Cookie 或登录状态更可能导致入口路由异常。

先确认错误发生在哪一层

可以暂时将 doGet() 简化为:

function doGet() {
  return HtmlService.createHtmlOutput("Web App is running.");
}

部署一个新版本,然后让遇到问题的用户访问新的 /exec URL。

打开 Apps Script 左侧的 Executions,根据结果继续排查:

  • 有执行记录且执行失败:查看具体异常、函数名和堆栈。
  • 完全没有执行记录:请求被挡在部署入口,应检查账号选择、部署访问范围和 Workspace 管理策略。
  • 简化后的页面正常,恢复表格代码后失败:检查 Sheet ID、部署者是否拥有文件及相关 OAuth 授权。
  • 只有某个浏览器配置失败:使用单独的浏览器 Profile 登录目标账号,比修改 /u/0 更可靠。

每次修改代码后,还要在 Manage deployments 中编辑部署并选择新版本。只保存代码不会自动更新现有的版本化部署。

同一 Workspace 域内的推荐方案

如果所有访问者都属于同一个 Google Workspace 域,可以使用以下配置:

  • Execute as: Me
  • Who has access: 组织内用户,具体名称以部署界面显示为准
  • Google Sheet:仅共享给部署者或后台维护账号
  • 在脚本中使用 Email 白名单限制具体人员

示例:

const ALLOWED_EMAILS = new Set([
  "myemail1@domain.com",
  "myemail2@domain.com",
]);

function getCurrentUserEmail_() {
  return Session.getActiveUser()
    .getEmail()
    .trim()
    .toLowerCase();
}

function assertAllowedUser_() {
  const email = getCurrentUserEmail_();

  if (!email) {
    throw new Error(
      "无法识别当前 Google 账号。请使用组织账号登录后重新打开此应用。"
    );
  }

  if (!ALLOWED_EMAILS.has(email)) {
    throw new Error("Access denied.");
  }

  return email;
}

function doGet() {
  try {
    assertAllowedUser_();

    return HtmlService
      .createHtmlOutputFromFile("index")
      .setTitle("LARK System BSS SKP");
  } catch (error) {
    return HtmlService.createHtmlOutput(
      `<p>${escapeHtml_(error.message)}</p>`
    );
  }
}

function readSheetData() {
  assertAllowedUser_();

  const spreadsheet = SpreadsheetApp.openById("MY_SHEET_ID");
  const sheet = spreadsheet.getSheetByName("Sheet1");

  if (!sheet) {
    throw new Error('找不到工作表 "Sheet1"。');
  }

  return sheet.getDataRange().getDisplayValues();
}

function writeSheetValue(row, column, value) {
  assertAllowedUser_();

  const spreadsheet = SpreadsheetApp.openById("MY_SHEET_ID");
  const sheet = spreadsheet.getSheetByName("Sheet1");

  if (!sheet) {
    throw new Error('找不到工作表 "Sheet1"。');
  }

  sheet.getRange(row, column).setValue(value);
}

function escapeHtml_(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;");
}

权限检查不能只放在 doGet() 中。读取、写入和删除数据的每个服务器端入口都要再次执行 assertAllowedUser_(),包括通过 google.script.run 调用的函数。

原代码中的这一段也需要调整:

HtmlService.createHtmlOutput(
  "Access Denied: You are not authorized to use this app.",
  userEmail
);

HtmlService.createHtmlOutput() 不支持通过第二个参数附加 Email。应先生成完整字符串,或只传入固定的错误信息。

为什么 Who has access: Anyone 与 Email 白名单可能冲突

如果 Anyone 允许未登录用户访问,Google 就无须向脚本提供访问者身份。此时下面的调用可能返回空字符串:

Session.getActiveUser().getEmail()

这里缺失的是可靠的当前用户 Email,与 openById() 的权限无关。

因此,以下组合无法稳定实现“任意 Google 账号登录后,再按 Email 白名单放行”:

  • Web App 允许匿名访问
  • 脚本以部署者身份运行
  • 使用 Session.getActiveUser().getEmail() 识别外部用户

也不能让前端通过查询参数、表单字段或 JavaScript 变量提交 Email,例如:

/exec?email=user@example.com

用户可以随意修改这类值,所以它不能用于证明身份。

外部用户也需要访问时怎么办

如果白名单包含公司域外的 Google 账号,先确认当前部署选项是否要求登录,并测试 Session.getActiveUser().getEmail() 在该部署和域策略下能否返回外部用户的 Email。部署界面显示 Anyone,不代表脚本一定能够取得用户身份。

通常有两种处理方式。

使用 Google 登录并在服务端验证身份令牌

Web App 可以继续将底层 Sheet 设为私有。前端要求用户通过 Google Identity Services 登录,再将 Google ID token 交给受信任的服务端验证。服务端至少要检查:

  • 令牌签名
  • aud 是否等于本应用的 OAuth Client ID
  • iss 是否为受信任的 Google 签发方
  • 令牌是否过期
  • email_verified 是否为真
  • email 是否在白名单内
  • 如果限制 Workspace 域,还要检查相应的域声明

验证通过后,再由拥有 Sheet 权限的后端执行读写。不要只在浏览器端解析 JWT,也不要信任客户端直接提交的 Email。

这套方案比依赖 Session.getActiveUser() 更复杂。对于处理敏感数据的应用,通常更适合将认证和数据接口放在 Cloud Run 等正式后端中,并使用服务账号或受控账号访问数据。

将应用限制在同一 Workspace 域

如果业务不需要支持外部账号,可以把部署访问范围限制在公司域内,再配合 Session.getActiveUser().getEmail() 和白名单使用。这样配置更简单,身份来源也更明确。

如果部署界面中没有预期的组织访问选项,或外部用户始终在入口处被拒绝,应让 Workspace 管理员检查组织策略。脚本代码无法绕过管理员设置的应用、外部访问或授权限制。

建议的排查顺序

  1. 从 Manage deployments 重新复制当前部署的 /exec URL。
  2. 用只返回静态文本的 doGet() 创建新版本并测试。
  3. 失败后立即查看 Executions,确认请求是否进入脚本。
  4. 使用只登录目标账号的浏览器 Profile 测试,避免多账号会话干扰。
  5. 确认部署者本人仍拥有目标 Sheet,并核对 MY_SHEET_ID。
  6. 确认用户属于部署允许的账号或 Workspace 范围。
  7. 检查 Session.getActiveUser().getEmail() 是否为空。
  8. 如果请求没有进入脚本,让 Workspace 管理员检查组织策略。
  9. 如果需要支持外部账号,改用经过服务端验证的登录机制。

只要请求已经进入以部署者身份运行的脚本,访问者通常无须拥有任何 Google Sheet 权限。共享底层 Sheet 无法解决入口处的账号路由或域策略问题,还会破坏原有的权限隔离设计。

备注:内容仅供参考。