首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用 WorkBuddy 云服务做零后端投票页:PostgREST 三个坑的实战复盘

用 WorkBuddy 云服务做零后端投票页:PostgREST 三个坑的实战复盘

原创
作者头像
用户12812351
修改于 2026-10-11 17:44:44
修改于 2026-10-11 17:44:44
310
举报

需求:一个不该存在的后端

读书会要定场次时间,四个选项(上午场 / 下午场 / 两场都行 / 那天来不了)。之前靠群里接龙,消息一刷就看不出谁投了什么,还经常有人重复投。

需求本身很朴素,但约束很硬:

  • 零成本,不买服务器、不备案
  • 不记名,页面上不能出现姓名、手机号
  • 一人一票,但可以改票(点错了得能救回来)
  • 结果不公开,只有主持人能看到汇总

最后一条其实最麻烦:一旦页面上能读到别人的选择,就变成了公开投票,心理压力完全不同。

选型是静态 HTML + WorkBuddy 云服务(底层是 PostgREST,跟 Supabase 那套同源)。前端一个 HTML 文件,CDN 引 SDK,写完直接发布成在线链接。全程没有一行后端代码。

一个下午做完,上线前测了四轮。这三个坑,我一个没躲过去。


表结构设计

代码语言:javascript
复制
create table votes (
  id         bigint primary key generated always as identity,
  device_id  text,
  choice     text,
  created_at timestamptz default now()
);

device_id 是前端存在 localStorage 里的随机串,不含任何个人信息:

代码语言:javascript
复制
var DID = (function(){
  var k = 'gd_vote_device_id';
  try {
    var v = localStorage.getItem(k);
    if (!v) { v = 'd' + Math.random().toString(36).slice(2) + Date.now().toString(36); localStorage.setItem(k, v); }
    return v;
  } catch(e){ return 'd' + Math.random().toString(36).slice(2); }
})();

注意 try/catch 不能省——iOS 无痕模式下 localStorage 会抛异常,不兜底整个页面就白屏了。


坑一:唯一索引把改票功能堵死了

一开始「一人一票」我用的是唯一索引,看起来天经地义:

代码语言:javascript
复制
create unique index votes_device_uniq on votes(device_id) where device_id is not null;

重复投票会撞约束,前端拦下来提示「你已经投过了」。逻辑没问题,直到用户提了个新要求:点错了要能改。

这时候唯一索引就成了死结——第二次写入直接被数据库拒绝,UPDATE 走不通,INSERT 也进不去,没有任何「覆盖」的余地。唯一索引的语义是 拒绝,不是 替换,而我要的是替换。

改成普通索引:

代码语言:javascript
复制
drop index votes_device_uniq;
create index votes_device_idx on votes(device_id);

改票交给应用层做:进页面先读回这台设备的旧票,有就 update,没有才 insert。

顺带还有一个错误码的坑。撞唯一约束时,PostgREST 返回的不是裸的 23505,而是带前缀的 DATABASE_23505。我原来写的判断:

代码语言:javascript
复制
if (err.code === '23505') { /* 重复投票 */ }

完全漏判,重复投票全被当成「网络错误」弹给用户。正确的写法是子串匹配:

代码语言:javascript
复制
if (String(err.code).indexOf('23505') !== -1) { /* 重复投票 */ }

教训:唯一约束适合「投票不可撤销」的场景;只要需求里出现「改」这个字,就得换成普通索引 + 应用层 upsert。建索引前先想清楚语义,别等改需求时才发现选错了。


坑二:GRANT 了 UPDATE,改票却静默失败

改成普通索引后,我给 anon 加了 UPDATE 权限:

代码语言:javascript
复制
grant select, insert, update on votes to anon, authenticated;

然后改票还是不管用——点了没反应,看控制台没有任何报错,返回的是空数组。一度以为是网络问题。

真实原因是 RLS(行级安全)静默过滤。

GRANT 是 SQL 层的表权限,RLS policy 是行级的可见性规则,这是两套东西。缺 UPDATE policy 时,UPDATE 语句不是被拒绝,而是匹配到 0 行——于是 PostgREST 老老实实返回 [],连个 error 都不给。看起来跟「网络没记上」一模一样。

补上 policy 才好:

代码语言:javascript
复制
create policy votes_update_any on votes
  for update using (true) with check (true);

教训:PostgREST 下判断写操作是否成功,不能只看 error,还要看影响行数。返回空数组且无错误,八成是 RLS 把你过滤掉了,不是网络问题。这个坑的隐蔽性在于它不报错——不报错的失败最难查。


坑三:INSERT 返回 201,响应体是空数组——成功被判成失败

这是最狠的一个,因为它骗过了我、还骗过了用户。

上线后用户反馈:投票总显示「网络有点挤,没记上」。

抓包一看:POST /rest/votes 返回 201 Created,票确实落库了,但响应体是空数组。PostgREST 在这条 INSERT 语句下不回传行数据。

而我的成功判定写的是:

代码语言:javascript
复制
.then(function(r){
  if (!r.data || !r.data.length) {
    fail('网络有点挤,没记上');   // ← 把成功当成了失败
    return;
  }
  ...
})

于是每一次成功投票,都被我告诉用户"没记上"。

危害比看起来严重得多:用户以为没投上,就会反复点,第一票其实早就写进去了——一人多票直接污染统计。这是最坏的一种 bug,它让人不敢相信页面。

修了四处:

1. 成功判定只看 error,不看回传行

代码语言:javascript
复制
.then(function(r){
  // 只有服务端明确报错才算失败;返回空数组不代表失败(INSERT 不回传行)
  if (r.error) throw new Error(r.error.message || '写入被拒');
  return confirmSaved(c.dataset.key).then(function(saved){
    if (!saved) throw new Error('写入后未能确认');
    ...
  });
})

2. 写入后单独回查一次,以库里的实际结果为准

代码语言:javascript
复制
function confirmSaved(key){
  return cloud.database.from('votes').select('id, choice, created_at')
    .eq('device_id', DID).order('created_at', { ascending:false }).limit(2)
    .then(function(r){
      var rows = (r && r.data) || [];
      if (!rows.length) return null;
      var newest = rows[0];
      if (rows.length > 1) {
        // 并发重复写入:删掉多余的,只留最新一条
        var extra = rows.slice(1).map(function(x){ return x.id; });
        return cloud.database.from('votes').delete().in('id', extra).select()
          .then(function(){ return newest; })
          .catch(function(){ return newest; });
      }
      return newest;
    });
}

注意回查是独立的确认步骤,不是塞进成功判定里的条件。这两件事必须分开:判定只回答"写没写上",回查回答"写成了什么"。混在一起就会重蹈覆辙。

3. 并发兜底:快速连点会产生多行,收敛成最新一条,避免一人多票。

4. 错误文案改成人话:「没记上,可能是网络断了 —— 点下面重试一次,你的选择还在这。」后半句很重要,告诉用户选择没丢,不用慌。


可复用的写入函数

代码语言:javascript
复制
function saveChoice(key){
  // 已有票 → 覆盖;没有 → 新投。都不依赖回传行数据,成功与否由 confirmSaved 回查确认
  if (myRow) {
    return cloud.database.from('votes').update({ choice: key }).eq('id', myRow.id);
  }
  return cloud.database.from('votes').insert({ device_id: DID, choice: key });
}

进页面先读回旧票:

代码语言:javascript
复制
function loadMine(){
  return cloud.database.from('votes').select('id, choice').eq('device_id', DID).limit(1)
    .then(function(r){
      if (r.error || !r.data || !r.data.length) { myRow = null; return; }
      myRow = r.data[0];
      votedChoiceEl.textContent = LABEL[myRow.choice] || myRow.choice;
      votedEl.classList.add('show');
    })
    .catch(function(){ /* 读不到就当作没投过,不打扰用户 */ });
}

初始化(记得脱敏)

代码语言:javascript
复制
var CFG = {
  endpoint: "https://<你的应用域名>",
  oauthRelayBaseUrl: "https://www.workbuddy.cn/v2/as/genie-baas/oauth",
  publishableKey: "wbpk_<你的应用ID>_<随机串>"
};
var cloud = WorkBuddyCloud.createWorkBuddyCloud({
  endpoint: CFG.endpoint,
  oauthRelayBaseUrl: CFG.oauthRelayBaseUrl,
  publishableKey: CFG.publishableKey
});

CDN 一行引入:

代码语言:javascript
复制
<script src="https://cdn.jsdelivr.net/npm/@tencent-ai/workbuddy-cloud-sdk@dev/lib/index.global.js"></script>

上线时还有一个小坑

发布前一定要在项目配置里显式指定入口 HTML。我这个目录里同时有 poster.html(海报)和 survey.html(投票页),不指定入口就被发布成了海报页——打开链接是一张图,投票功能根本不存在。

代码语言:javascript
复制
appId: <你的 applicationId>
appType: web
entryHtml: survey.html   # 不写这行就会发布错页面

另外做宣传图截图时踩过一个:无头 Chrome 的 --window-size 和 CSS 视口不是一回事,第一次截图右侧「9:00—13:00」被切掉了一截。改用 playwright-core + channel:'chrome' 显式设定 viewport 才正常。


三个坑的对照表

#

现象

根因

正确做法

1

改票写不进去

唯一索引语义是「拒绝」不是「替换」

改用普通索引 + 应用层 upsert

1b

重复投票被报成网络错误

PostgREST 错误码是 DATABASE_23505,带前缀

String(code).indexOf('23505') !== -1

2

改票静默失败、无任何报错

GRANT 了 UPDATE 但缺 RLS policy,被行级过滤

补 for update using(true) with check(true)

3

明明投上了却提示「没记上」

INSERT 返回 201 但响应体是空数组

只用 error 判失败;另做回查确认内容

4

打开链接是海报页

目录里多个 HTML,未指定入口

配置里显式写 entryHtml


最后

真正有价值的不是这三个 API 细节,而是那条更一般的经验:

写操作的成功判定,永远以服务端 error 为准,不要以「有没有回传数据」为准。 需要确认内容,就单独回查一次,别把回查塞进成功判定里。

PostgREST 这类 REST 化的数据接口,出于安全和体积考虑常常不回传行数据。如果你的代码里写了 if (!data.length) 报错,那它迟早会把成功报成失败——而且是在最不该出错的线上环境。

整套东西零成本、无后端、无需登录,一个 HTML 文件加一张表就跑起来了,选完自动生成可以贴回群里的文本。对于「临时起意、用完即弃」的小工具,这个组合值得放进工具箱。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 需求:一个不该存在的后端
  • 表结构设计
  • 坑一:唯一索引把改票功能堵死了
  • 坑二:GRANT 了 UPDATE,改票却静默失败
  • 坑三:INSERT 返回 201,响应体是空数组——成功被判成失败
  • 可复用的写入函数
  • 初始化(记得脱敏)
  • 上线时还有一个小坑
  • 三个坑的对照表
  • 最后
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档