读书会要定场次时间,四个选项(上午场 / 下午场 / 两场都行 / 那天来不了)。之前靠群里接龙,消息一刷就看不出谁投了什么,还经常有人重复投。
需求本身很朴素,但约束很硬:
最后一条其实最麻烦:一旦页面上能读到别人的选择,就变成了公开投票,心理压力完全不同。
选型是静态 HTML + WorkBuddy 云服务(底层是 PostgREST,跟 Supabase 那套同源)。前端一个 HTML 文件,CDN 引 SDK,写完直接发布成在线链接。全程没有一行后端代码。
一个下午做完,上线前测了四轮。这三个坑,我一个没躲过去。
create table votes (
id bigint primary key generated always as identity,
device_id text,
choice text,
created_at timestamptz default now()
);device_id 是前端存在 localStorage 里的随机串,不含任何个人信息:
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 会抛异常,不兜底整个页面就白屏了。
一开始「一人一票」我用的是唯一索引,看起来天经地义:
create unique index votes_device_uniq on votes(device_id) where device_id is not null;重复投票会撞约束,前端拦下来提示「你已经投过了」。逻辑没问题,直到用户提了个新要求:点错了要能改。
这时候唯一索引就成了死结——第二次写入直接被数据库拒绝,UPDATE 走不通,INSERT 也进不去,没有任何「覆盖」的余地。唯一索引的语义是 拒绝,不是 替换,而我要的是替换。
改成普通索引:
drop index votes_device_uniq;
create index votes_device_idx on votes(device_id);改票交给应用层做:进页面先读回这台设备的旧票,有就 update,没有才 insert。
顺带还有一个错误码的坑。撞唯一约束时,PostgREST 返回的不是裸的 23505,而是带前缀的 DATABASE_23505。我原来写的判断:
if (err.code === '23505') { /* 重复投票 */ }完全漏判,重复投票全被当成「网络错误」弹给用户。正确的写法是子串匹配:
if (String(err.code).indexOf('23505') !== -1) { /* 重复投票 */ }教训:唯一约束适合「投票不可撤销」的场景;只要需求里出现「改」这个字,就得换成普通索引 + 应用层 upsert。建索引前先想清楚语义,别等改需求时才发现选错了。
改成普通索引后,我给 anon 加了 UPDATE 权限:
grant select, insert, update on votes to anon, authenticated;然后改票还是不管用——点了没反应,看控制台没有任何报错,返回的是空数组。一度以为是网络问题。
真实原因是 RLS(行级安全)静默过滤。
GRANT 是 SQL 层的表权限,RLS policy 是行级的可见性规则,这是两套东西。缺 UPDATE policy 时,UPDATE 语句不是被拒绝,而是匹配到 0 行——于是 PostgREST 老老实实返回 [],连个 error 都不给。看起来跟「网络没记上」一模一样。
补上 policy 才好:
create policy votes_update_any on votes
for update using (true) with check (true);教训:PostgREST 下判断写操作是否成功,不能只看 error,还要看影响行数。返回空数组且无错误,八成是 RLS 把你过滤掉了,不是网络问题。这个坑的隐蔽性在于它不报错——不报错的失败最难查。
这是最狠的一个,因为它骗过了我、还骗过了用户。
上线后用户反馈:投票总显示「网络有点挤,没记上」。
抓包一看:POST /rest/votes 返回 201 Created,票确实落库了,但响应体是空数组。PostgREST 在这条 INSERT 语句下不回传行数据。
而我的成功判定写的是:
.then(function(r){
if (!r.data || !r.data.length) {
fail('网络有点挤,没记上'); // ← 把成功当成了失败
return;
}
...
})于是每一次成功投票,都被我告诉用户"没记上"。
危害比看起来严重得多:用户以为没投上,就会反复点,第一票其实早就写进去了——一人多票直接污染统计。这是最坏的一种 bug,它让人不敢相信页面。
修了四处:
1. 成功判定只看 error,不看回传行
.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. 写入后单独回查一次,以库里的实际结果为准
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. 错误文案改成人话:「没记上,可能是网络断了 —— 点下面重试一次,你的选择还在这。」后半句很重要,告诉用户选择没丢,不用慌。
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 });
}进页面先读回旧票:
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(){ /* 读不到就当作没投过,不打扰用户 */ });
}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 一行引入:
<script src="https://cdn.jsdelivr.net/npm/@tencent-ai/workbuddy-cloud-sdk@dev/lib/index.global.js"></script>发布前一定要在项目配置里显式指定入口 HTML。我这个目录里同时有 poster.html(海报)和 survey.html(投票页),不指定入口就被发布成了海报页——打开链接是一张图,投票功能根本不存在。
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 删除。