把 Google Sheet 当成理财 App 的后端

Joe NG
2267 字
6 分钟
–

把 Google Sheet 当成理财 App 的后端

发布于

我一直用一张 Google Sheet 记帐,痛点很单纯:输入麻烦、图表丑。我想要一个能一眼看懂趋势的界面,但完全不想为了一个自己用的小工具去养一台服务器——要付费、要更新、要监控,还要备份一份跟 Sheet 一模一样的数据。

于是后端就是那张 Sheet 本身。前端是一个纯静态网站丢在 GitHub Pages,中间夹一个 Google Apps Script Web App 当 API。零服务器、零数据库、零月费,数据还是躺在我自己的 Google 帐号底下。这篇记录实作过程中三个真正花掉时间的地方。

整个数据流长这样

flowchart LR
  A[React 靜態網站<br/>GitHub Pages] -->|POST text/plain| B[Apps Script<br/>Web App]
  B -->|讀寫分頁| C[(Google Sheet)]
  A -.離線時.-> D[localStorage 快取]

前端没有任何秘密,任何人打开网址都只会看到一个「连接你的 Google Sheet」画面。网址和密钥存在浏览器,数据进出都经过那个 Apps Script 端点。

绕过 CORS 预检:用 text/plain 送 JSON

第一个坑。正常你会想用 Content-Type: application/json 送 POST,但那会触发浏览器的 CORS 预检(preflight),而 Apps Script 的 Web App 对 OPTIONS 请求的处理很难搞,常常直接失败。

解法是把 body 当成纯文字送:

await fetch(cfg.url, {
  method: "POST",
  headers: { "Content-Type": "text/plain;charset=utf-8" },
  body: JSON.stringify({ token: cfg.token, action: "save", data }),
});

text/plain 是 CORS 的「简单请求」之一,不会触发预检。服务器那边照样 JSON.parse(e.postData.contents) 就拿到对象。内容明明是 JSON,只是骗浏览器说它是纯文字——这招在 Apps Script 几乎是标配。

两部设备同时改:乐观锁

同步一开始是最笨的做法:每次数据变动,就把整份 JSON 覆盖写回 Sheet。单机没问题,但只要我在手机和电脑同时开着,后存的一方会把先存的改动整份盖掉,而且完全无声无息。

修法是加一个版本戳。Apps Script 用 ScriptProperties 存一个 updatedAt,前端每次加载都记住它;存盘时把「我这份是基于哪个版本」一起送上去:

const stored = getUpdatedAt();
if (!body.force && body.baseUpdatedAt && stored && body.baseUpdatedAt !== stored) {
  return json({ ok: false, error: "conflict" });   // 雲端比較新,先不要蓋
}
writeAll(body.data);
setUpdatedAt(new Date().toISOString());

外层再包一个 LockService 的脚本锁,避免两个请求同时进到写入区。前端收到 conflict 就不硬存,改弹一条横幅让我自己决定:加载云端版本,还是以这边覆盖。

这是乐观锁(optimistic locking)最精简的样子——不预先锁,等冲突发生才处理。对一个用户顶多开两三个分页的场景,这样刚刚好,不用扯到什么版本矢量。

净资产走势:每天一行快照

资产在数据模型里只是「当前值」,没有时间维度,所以画不出走势。理财 App 最想看的那张「净资产有没有在往上」的图,反而是缺的。

解法是在存盘时,顺手把当天的总资产、净资产写进一个独立分页,一天只留一行:

const today = toDateStr(new Date());
const existing = rows(sheet).findIndex((r) => toDateStr(r[0]) === today);
if (existing === -1) sheet.appendRow([today, total, net]);   // 今天還沒有 → 新增
else sheet.getRange(existing + 2, 1, 1, 3).setValues([[today, total, net]]); // 有 → 覆蓋

计算交给前端、只把算好的数字送上来,这样「净资产 = 总资产 − 借贷」这条公式只活在一个地方。累积几天之后,仪表板那条迷你走势图就从「拿收支累计来凑」换成真实的资产曲线。

让旧后端也不会坏

上面两个功能都要改 Apps Script,而重新部署是手动的——我很可能哪天先更新了前端、却忘了贴新的后端。所以前端对新字段一律采取「没有就当空的」:

const snapshots = out.snapshots || [];              // 舊後端沒回這欄 → 空陣列
return { data: out.data, snapshots, updatedAt: out.updatedAt ?? null };

版本戳是 null 时,冲突判断的 stored && body.baseUpdatedAt 直接短路,等于自动退回「无保护」的旧行为。新前端配旧后端,功能少一点但不会爆。这种向后兼容不用写什么兼容层,把「缺值」当成合法输入处理就好。

整个东西加起来大概两百行,没有数据库、没有服务器帐单。对一个自己每天打开两分钟的工具来说,这个成本结构才是重点。

后来我把同一套「Sheet 当后端」的做法搬去做订阅追踪器 SubLens,也顺便踩到另一组完全不同的问题——为 SubLens 加上 Demo 模式。