本文记录了使用 WorkBuddy 从零构建一个健康打卡 + 记账工具的完整过程。起点是一个单文件 HTML,终点是打包提交 Chrome Web Store。中间被 7 个坑逼着重构了 7 次------每个坑的根因都不一样,但有一个共同点:踩之前看不出来,踩完之后都觉得"这我怎么不知道"。
起因:半小时能做完的东西
早上 10 点,想做个健康打卡加记账的工作台。需求很简单:记录每天的体重、运动、饮食打卡,加上收支流水,能看到图表趋势。
技术选型也简单到不像话:单文件 HTML,localStorage 存数据,内联 SVG 画图表,不依赖任何框架。半小时写完,双击打开就能用。
到下午 4 点,这个文件已经变成了一个 Chrome 扩展,打包成 zip 准备上架。中间 7 次重构,每次都是被一个具体的坑逼的。
下面按时间线还原每次重构的触发条件和根因。
坑 1:在线 page ≠ 云端同步
触发条件:想把工具发布成在线链接,手机能打开。
用 WorkBuddy 的「在线 page」能力,import HTML + publish,30 秒拿到公开链接。手机浏览器打开,页面正常显示,图表能画,表单能填。
但数据存在哪?还是 localStorage。电脑上记的账,手机打开看不到。在线 page 只是把 HTML 部署到了 CDN,localStorage 是浏览器本地存储,跨设备不共享,甚至同一个电脑换浏览器都看不到。
根因:在线 page 是静态托管,不是应用后端。你部署了一个前端,但没有后端。前端能跑,数据没地方放。
这次重构的方向:找一个真正的后端。
坑 2:资料库数据表 + SDK 的 lint 陷阱
触发条件:用 WorkBuddy 资料库的数据表做后端,页面通过 SDK 做增删改查。
WorkBuddy 的资料库提供了数据表能力,页面通过 window.__SMART_PAGE__.database SDK 对数据表做增删改查。思路没问题------建表、写页面、联调,一气呵成。
但 SDK 有两条 lint 规则,踩了才知道。
规则一 :db.query 的 databaseId 必须内联写,不能塞进变量。
javascript
// ❌ 报错 DSDK002:databaseId 不能通过变量引用
const DB_ID = 'xlc1OtqfN2bb2UlnlhctoO';
db.query({ databaseId: DB_ID, ... });
// ✅ 正确:databaseId 必须内联
db.query({ databaseId: 'xlc1OtqfN2bb2UlnlhctoO', ... });
这条规则的设计意图是让 SDK 的静态分析能追踪到数据表依赖关系,做权限校验和缓存控制。但对开发者来说,这意味着不能把表 ID 抽成常量复用。
规则二 :含 addRecord 的页面必须接入表单输入的 localStorage 缓存,成功时 removeItem,否则报 DSDK008。
javascript
// ❌ 报错 DSDK008:表单输入未做本地缓存
db.addRecord({ databaseId: '...', record: formData });
// ✅ 正确:提交前缓存,成功后清除
localStorage.setItem('draft_form', JSON.stringify(formData));
db.addRecord({ databaseId: '...', record: formData }).then(() => {
localStorage.removeItem('draft_form');
});
这条规则防止用户填了半天表单,网络一断全白填。强制你做本地草稿,提交成功才清掉。
这两条都是 SDK 的静态分析在拦,不是运行时错误------代码逻辑没问题,但 lint 不过就发布不了。修完之后数据终于写进云端表,查表确认 15 条记录,读写双向皆通。
但新的问题来了。
坑 3:多用户共用一张表
触发条件:上线之后发现,公开链接谁都能打开,所有人共用同一张数据表。
我的记账金额、健康数据,任何人拿到链接都能看到。这不是一个 bug,这是一个设计层面的问题:资料库的 page 是公网托管的,数据表没有用户隔离机制。SDK 查询是全表读取,不做按用户过滤。
根因:个人工具和多人工具是两种东西。
| 维度 | 个人工具 | 多人工具 |
|---|---|---|
| 数据存储 | localStorage 够用 |
必须有后端 |
| 用户隔离 | 不需要 | 必须有账号体系 |
| 部署形态 | 本地文件 / 在线 page | 需要认证服务 |
我一开始把"个人工具"当成"多人工具"来做,跳过了认证和数据隔离这一层。现在得补回来。
这次重构的方向:换一个带用户认证和数据隔离的后端。

坑 4:Supabase RLS 实测
触发条件:选了 Supabase 做后端,需要验证数据隔离是否真的生效。
Supabase 基于 PostgreSQL,提供 Row Level Security(RLS)做行级权限控制。建了 3 张表(打卡 health_checkins、收支 transactions、预算 budgets),每张加 user_id 列,开 RLS:
sql
-- 启用行级安全
alter table health_checkins enable row level security;
-- 创建策略:用户只能读写自己的数据
create policy "用户只能读写自己的数据"
on health_checkins for all
using (auth.uid() = user_id) -- 读取时过滤:只能看到自己的行
with check (auth.uid() = user_id); -- 写入时校验:不能伪造别人的 user_id
两个子句的区别很关键:
using控制读取 :查询时自动加上WHERE auth.uid() = user_id,看不到别人的数据with check控制写入 :插入/更新时校验auth.uid() = user_id,不能伪造 user_id 写别人的数据
用 Python 跑了端到端测试,验证四个场景:
| 测试场景 | 预期结果 | 实际结果 |
|---|---|---|
| 匿名读取 | 0 行 | ✅ 0 行,RLS 生效 |
| 账号 A 写打卡 + 读 | 成功 | ✅ 成功 |
| 账号 B 读 | 看不到 A 的数据 | ✅ 0 行 |
账号 A 写 user_id = B |
拒绝 | ✅ 403 Forbidden |
第 4 条是关键。没有 with check 子句,攻击者可以用合法登录态伪造 user_id,把数据写到别人的名下。前端过滤只是查询参数,不是权限边界;RLS 是数据库层面的硬隔离,绕不过去。
后端搞定了,接下来把网页版改成 Chrome 扩展。
坑 5:MV3 的 CSP 杀了内联 script
触发条件:把网页版改成 Chrome 扩展,manifest V3,popup 弹窗 360px。
第一版 popup.html 里直接内联 <script>,逻辑全写在 HTML 里。加载扩展,点登录按钮,没反应。打开 DevTools 看:控制台不报错,网络面板没请求,按钮像死的。
根因 :MV3 的默认 CSP 是 script-src 'self',禁止内联 script。
html
<!-- ❌ MV3 下这段代码不会执行,静默失败,不报错 -->
<script>
console.log('hello'); // 你看不到这行输出
document.getElementById('login').addEventListener('click', login);
</script>
html
<!-- ✅ 正确:提取到外部文件 -->
<script src="popup.js"></script>
这个问题隐蔽到什么程度------代码逻辑完全正确,fetch 调用没问题,Supabase 配置没问题,但脚本压根没跑。你能调出的所有错误都是"没反应"三个字。没有报错信息,没有异常堆栈,没有网络请求,因为脚本连第一行都没执行到。
为什么 MV3 要禁止内联 script? 这是 Chrome 的安全策略:防止 XSS 攻击者通过注入内联脚本劫持扩展的权限。扩展有比普通网页更高的权限(跨域访问、读写标签页),一旦被 XSS,后果比普通网页严重得多。所以 MV3 从 CSP 层面直接掐断了内联脚本的可能。
修复方法不复杂:把所有内联 JS 提取到外部 popup.js,HTML 里只留 <script src="popup.js"></script>。但排查过程花了不少时间,因为"静默失败"是调试中最恶心的模式。
扩展能正常跑了,接下来要做自动化测试。
坑 6:Chrome 扩展 ID 算法,Windows 上是 UTF-16LE
触发条件:为了做 CDP(Chrome DevTools Protocol)自动化测试,需要提前算出扩展 ID。
Chrome 扩展的 ID 是由扩展安装路径经过 SHA256 哈希后映射生成的,算法公开。网上能搜到的 Python 实现:
python
import hashlib
p = r'C:\path\to\extension'
h = hashlib.sha256(p.encode('utf-8')).hexdigest()[:32]
eid = ''.join(chr(int(c, 16) + ord('a')) for c in h)
算出来跟 Chrome 实际加载的对不上。
查 Chromium 源码 extensions/common/id_util.cc,找到 GenerateIdForPath 函数。关键在编码方式:Windows 上 base::FilePath::StringType 是 std::wstring(宽字符),SHA256 的输入是 UTF-16LE 编码的字节,不是 UTF-8。
python
# ❌ Windows 上算出来的 ID 和 Chrome 实际不一致
h = hashlib.sha256(p.encode('utf-8')).hexdigest()[:32]
# ✅ Windows 正确写法:用 UTF-16LE 编码
h = hashlib.sha256(p.encode('utf-16-le')).hexdigest()[:32]
另外还有一个容易踩的小坑:映射规则是对 hex 字符串的每个字符 做 +ord('a')(即 0-9a-f → a-p),不是对字节拆 nibble。这两个错误叠加,算出来的 ID 差了十万八千里。
正确且跨平台的实现:
python
import hashlib
import sys
def get_extension_id(path: str) -> str:
# Windows 用 UTF-16LE,其他平台用 UTF-8
encoding = 'utf-16-le' if sys.platform == 'win32' else 'utf-8'
h = hashlib.sha256(path.encode(encoding)).hexdigest()[:32]
return ''.join(chr(int(c, 16) + ord('a')) for c in h)
# 用法
eid = get_extension_id(r'C:\path\to\extension')
print(eid) # 输出 32 位字母 ID,和 chrome://extensions 显示的一致
ID 算对了,CDP 测试环境搭起来了......但下一个坑在等着。

坑 7:headless 模式不支持加载扩展
触发条件 :用 Chrome --headless=new + --load-extension 起 CDP 测试环境,自动化验证扩展弹窗。
扩展列表里只有系统自带的 Google Hangouts 的 service worker,我的扩展根本没加载。--load-extension 参数被静默忽略了。
根因:Chrome headless 模式不支持加载扩展。这是设计如此,不是 bug。Chrome 团队认为 headless 模式用于自动化测试和 CI/CD,加载扩展会引入不确定行为,所以从架构层面禁掉了。
这坑的代价是浪费了一小时做 CDP 自动化测试,最后发现被测对象根本没启动。最终方案:放弃自动化,人工到 chrome://extensions 开 Developer mode → Load unpacked → 手动点。
教训:在投入时间做自动化之前,先确认被测对象在目标环境下能正常加载。一步验证省一小时。
上架:manifest 加 key + 4 张截图
扩展能正常用了,开始准备上架 Chrome Web Store。
1. manifest 加 key 字段
生成一个 RSA 2048 公钥,DER 编码后 base64,写进 manifest 的 key 字段。作用是锁定扩展 ID------本机加载和商店审核时 ID 一致,用户更新不断链。
python
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import base64, hashlib
# 生成 RSA 2048 密钥对
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# 提取公钥的 DER 编码(SPKI 格式)
spki = priv.public_key().public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# 用公钥的 SHA256 生成扩展 ID(验证用)
eid = ''.join(chr(int(c, 16) + ord('a')) for c in hashlib.sha256(spki).hexdigest()[:32])
# base64 编码后的公钥,贴进 manifest.json 的 "key" 字段
pub_b64 = base64.b64encode(spki).decode()
print(f"Extension ID: {eid}")
print(f"Key (paste into manifest.json):\n{pub_b64}")
json
{
"manifest_version": 3,
"key": "<上面输出的 pub_b64>"
}
不加 key 也行,但每次换路径 ID 都会变,用户更新会断。加了 key,ID 永远固定。私钥自己存好,丢了就再也换不了 key 了。
2. 打包 zip
只含必要文件:
| 文件 | 说明 |
|---|---|
manifest.json |
扩展清单 |
popup.html |
弹窗界面 |
popup.js |
弹窗逻辑 |
icon-16.png |
16×16 图标 |
icon-32.png |
32×32 图标 |
icon-48.png |
48×48 图标 |
icon-128.png |
128×128 图标 |
不能含 README.md、PRIVACY.md 这些辅助文件------商店解析 zip 时会按 manifest 声明的文件列表加载,多余文件会导致解析失败。
3. 商店截图
4 张 1280×800 的操作截图 + 1 张 440×280 的 promo tile。用 Python PIL 画的,模拟扩展弹窗的实际界面。商店要求至少 1 张截图,建议 3-5 张覆盖核心功能场景。
4. 隐私声明
Chrome Web Store 要求披露数据收集情况。这个扩展只收集:
- 账号信息(邮箱 + 加密密码,通过 Supabase Auth)
- 用户主动输入的打卡/记账数据
不读浏览历史,不跨站追踪,不上传第三方。填完隐私问卷,提交审核。
总结:7 个坑的根因分布
回头看,这 7 个坑的根因分布在完全不同的层面:
| # | 坑 | 根因层面 | 关键认知 |
|---|---|---|---|
| 1 | 在线 page 不同步 | 架构 | 静态托管 ≠ 应用后端 |
| 2 | SDK lint 陷阱 | 工具链 | 静态分析规则 ≠ 运行时逻辑 |
| 3 | 多用户共用表 | 设计 | 个人工具 ≠ 多人工具 |
| 4 | Supabase RLS | 安全 | 前端过滤 ≠ 权限边界 |
| 5 | MV3 CSP | 平台限制 | 安全策略导致静默失败 |
| 6 | 扩展 ID 编码 | 平台差异 | Windows 路径编码是 UTF-16LE |
| 7 | headless 不加载扩展 | 平台限制 | 被测对象在目标环境不可用 |
没有一个坑是"代码写错了"------每个坑都是认知盲区。你以为的运行环境和你实际面对的运行环境之间有 gap,这个 gap 不踩看不出来,踩完之后才有资格说"我知道了"。
材料打包好了,zip 14KB。
Chrome Web Store 审核 1-3 个工作日,一次性注册费 $5。Edge 商店对 MV3 兼容,同一个 zip 直接传,免注册费。
同一个 HTML 文件,从 localStorage 走到 Chrome 商店,中间被 7 个坑逼着重构了 7 次。但每个坑都让架构往前走了一步:从无后端到有后端,从无隔离到有隔离,从网页到扩展,从临时 ID 到固定 ID。
等审核结果。