从熟悉的后端系统转向微信小程序,困难并不只是学习页面和接口。数据应该留在本机还是同步到云端,历史记录如何迁移,线上能力为何与开发环境不同,都可能直接影响真实用户。借助 AI 完成两个小程序后,我更想复盘这些具体决策、故障与测试经验,以及平台规则对产品架构的实际约束。
本文同步自我的个人博客:haifeiWu.github.io
我是写 Go 的后端,日常打交道的是微服务、消息队列和存储引擎。微信小程序对我来说是完全陌生的领域:没有构建体系、没有单元测试的默认姿势、没有可观测性,甚至连「把数据存在哪」都需要重新想一遍。
最近这段时间,我做了一件事:和 AI 结对,把两个小程序从想法推到了线上。
一个是我自己的需求——开电车两年,充电账一直算不明白;另一个是给我家孩子做的——疫苗接种时间表太容易漏针。
这篇文章不写「AI 提效 10 倍」这种结论,而是把技术取舍、真实的坑、以及那些代码之外的障碍记录下来。
新能源充电记录 + 价格统计。核心设计是本地存储、无后端、零隐私接口,个人主体以「工具-记账」类目上架。
功能上它解决的是「把电车的账算清楚」:
按《国家免疫规划疫苗儿童免疫程序》给每个宝宝生成个性化接种日历。
技术栈上两个项目刻意走了两条路:电车用纯本地存储,宝宝用微信云开发。这个差异后面会讲到,它直接决定了两边的数据安全策略完全不同。
第一个决定就是不做后端。
理由有三个:个人主体拿不到微信支付,做后端也没有变现路径;不想为了一个记账工具维护服务器;充电记录天然带位置属性,能不落别人的库就不落。
代价也很明确:清缓存、换手机,数据就没了。所以「导出」不是附属功能,而是数据安全的一部分——导出的 CSV 可以转发到微信聊天里长期保存,需要时再导入恢复。
这类取舍在小程序里非常常见:平台给你的能力边界,直接决定了你的架构。后端工程师习惯的「服务端是唯一真相源」,在这里从一开始就不成立。
微信 Storage 单个 key 有 1MB 上限。最初所有记录存在一个 key 里,记录一多就会顶到天花板。
改法是按年分片:charge_records@2026、charge_records@2025……新增和编辑只重写当年那一片,而不是把全部记录序列化一遍。从旧版本升级时,首次读数据会自动把 charge_records 里的历史记录搬到对应年份分片并清理旧键,用户不需要做任何事。
同时加了一个容量快照:某个分片接近 1MB 时,在「我的」页面提醒用户备份。
这个设计看起来像是过早优化,但在「所有数据都在用户手机上」的前提下,它是必须的——服务端可以扩容,用户的手机存储不行。
而恰恰是这段迁移逻辑,后来出过一次最危险的问题:迁移函数收到的「已存在记录」判定集合,是在把旧记录 push 进去之后才计算的,于是整批旧记录都被判为「已迁移」而跳过写入分片,紧接着旧键被删除——用户一升级,历史记录就没了。
抓到它的是一条专门为数据迁移写的断言:「迁移过程不丢记录」。这也是为什么值得为迁移单独写用例:这类 bug 不会出现在开发机上,只会出现在真实用户的升级路径里。
油电对比是这个工具最有传播力的功能,算法本身很简单:
等效里程 = 充电电量 ÷ 百公里电耗 × 100
油车成本 = 等效里程 × 百公里油耗 ÷ 100 × 油价
节省金额 = 油车成本 − 充电金额(负数代表比油车贵)
油耗、电耗、对比油号、油价省份都由用户自己设置。把公式直接印在设置页上,是因为这类「省钱计算」最容易被怀疑动了手脚——公开算法比任何文案都有说服力。
车机显示的电耗普遍偏乐观,这是电车圈的共识。所以我从充电记录里又榨了两层信息:
SOH(电池健康度):用「充电电量 ÷ SOC 增量」估算满充容量,再和标称容量比。样本随充电次数增长,画成曲线就能看出衰减趋势。
真实电耗:用相邻两次充电之间的里程差除以充电电量。这个数字来自充电桩的电表,而不是车机的估算,所以更接近真实。
这两个指标都不是新采集的数据,而是从「已经记下来的东西」里再算一层。产品设计中这类「数据复用」的性价比往往最高。
工具做好之后,第一个要解决的问题是:我过去两年的充电订单还在理想汽车 App 里,怎么搬进来?
最初的计划很朴素——用 Mac 上的微信小程序客户端逐条录入。实测之后放弃了:
用大约 85 次交互,连一个日期都没选对。按这个比例,67 条需要 1000–1700 次交互、3–8 小时。
于是方案换成:把一次性的体力活,换成一个可复用的功能。
buildCsv() 生成 CSV——复用产品代码而不是另写一份导出,保证格式永远一致解析时还发现抓包并不完整:接口返回 totalCount=68、pageCount=7,而 HAR 里只抓到了 3 页 30 条。于是用 HAR 里带的 X-LX-Token / X-LX-Deviceid 补齐第 4–7 页(响应是 gzip,还得去掉 Accept-Encoding),最终拿到 68 条,排除 1 条 0 电量的异常订单后导出 67 条、总金额 2403.25 元、总电量 3032.85 kWh。
导入功能支持三种入口:选文件(从微信聊天记录里选 CSV/Excel)、粘贴 CSV 文本、读剪贴板。按表头名自动识别列,跳过重复记录,车辆不存在时按名称自动创建。
判重是幂等的:第二次导入同一份数据,结果是「待导入 0 条 / 跳过重复 67 条」,记录数不会翻倍。坏行只跳过自己并给出带行号的提示,不会因为一行脏数据让整批导入失败。
为了支持 Excel,还引入了 SheetJS 的 mini 构建(250KB),主包体积从 106.5KB 涨到 336KB——小程序主包上限是 2MB,这个代价可以接受。这里也踩过一个精度坑:.xlsx 里未设日期格式的单元格会读成数字序列号,00:03 被截断成 00:02,原因是 SheetJS 用 General 格式把序列号截成了 8 位有效数字;解法是先折算成分钟再取整。
上线之后,有用户反馈油价页面报错,截图上是两行字:
获取失败:主源:域名未配置(开发调试请勾选"不校验合法域名");备源:域名未配置
第一反应是第三方接口挂了。查下来不是:
v2.xxapi.cn 直连返回 200,数据日期 2026-09-12,覆盖 31 个省api.nxvav.cn 返回 200--noproxy 之后正常真正的根因是:小程序后台没有配置服务器域名。微信小程序的网络请求必须走白名单,开发工具里勾了「不校验合法域名」所以本地一切正常,线上没有白名单,两个源全被拦截。
那句「域名未配置」其实是项目自己代码里的翻译——wx.request 的 errMsg 里含 domain,被 failMessage() 翻成了人话。这个翻译在开发期很贴心,但在线上掩盖了「这是配置问题、不是网络问题」的本质。
修复本身很简单:后台「开发设置 → 服务器域名」添加两条 request 合法域名(域名改动每月限 5 次)。难点在于怎么进后台:
Network.setCookie 直接注入,绕开 profile 加载最后保存时还需要管理员微信扫码确认。这类「代码没问题、卡在平台操作」的事,在小程序开发里出现的频率比 bug 高得多。
小程序没有现成的测试体系,但官方提供了 miniprogram-automator,可以驱动微信开发者工具的真实模拟器做点击、输入和断言。
用它跑了一轮从 UI 到功能的全面测试,覆盖启动与空状态、车辆管理、新增/编辑记录、明细列表、统计、导入导出等 8 个模块:
| 指标 | 结果 |
|---|---|
| 断言总数 | 222(后续迭代涨到 317) |
| 通过 | 全部通过 |
| 阻断级缺陷 | 0 |
其中导入功能的三条入口全部可用,67 条真实订单解析 0 错误;导出 CSV 的 13 列表头、BOM、行数、时间升序都逐项核对过。
写 UI 自动化最大的价值不是「回归」,而是它逼你把每个边界场景想清楚——比如「0 元记录」「超长文本」「67 条数据渲染」这些平时不会手动测的场景,写成断言之后就成了资产。
但断言写错了比没有断言更危险。这里踩过一个典型的坑:早期为了「跑通」,有两条断言直接把当时的实现固化成了预期——一条要求页面上存在两个主按钮(实际设计是一个主按钮 + 一个次按钮),另一条断言超长文本「可完整保存、未经代码层截断」,注释里还写着「自动化会绕过 maxlength」。等到后来要修这些问题时,测试反而成了阻碍。同类问题还有表单输入框的索引断言:中间插入一个时长输入框之后,ins[5] 从「充电站」变成了「时长」,断言默默指向了错误的字段。
如果说电车项目的关键词是「取舍」,宝宝项目的关键词就是「不敢丢」。
用户反馈的第一句话就是:小程序更新后记录丢失了。
排查之后,链路是这样的:
// pages/schedule/schedule.js
if (!babies.length) {
CloudSync.syncUp(); // 本地读不到宝宝时,仍然上传 items: []
return;
}
云函数 syncReminder 的逻辑是「以本次上传的 babyId 集合为准,清理孤儿文档」:
const rawItems = Array.isArray(event && event.items) ? event.items : []; // 空数组放行
for (const doc of orphanDocs) {
if (doc.babyId && !writtenBabyIds.has(doc.babyId)) {
await db.collection('reminder_schedule').doc(doc._id).remove(); // 全删
}
}
于是形成一个致命的放大链路:本地 storage 只要读空一次,云端的唯一备份就会被自己的云函数删干净,数据从「可恢复」变成「永久丢失」。
引信是 AppID 变更——微信本地 Storage 按 AppID 隔离,换了 AppID,老数据必然读不到。
修复方案是给这条链路加两道闸门,核心是区分「用户主动删除」和「storage 读空」:
syncUp() 在本地无宝宝时默认拒绝上传,只有显式传 { allowPrune: true } 才放行const shouldPrune = items.length > 0 || event.allowPrune === true{ allowPrune: true },保住原本的设计意图同一个空数组,两种语义,两种处置。这是整个修复里最关键的一笔。
修复过程用 TDD 验证:旧代码跑新测试 10 个红,新代码 17 个绿,全量测试从 201 涨到 227 条全绿。
订阅消息推送需要云端数据,但宝宝信息是敏感的。所以云端 reminder_schedule 集合只保存最小调度集:
openid / babyId / birthday / 提醒开关 / 提前量 / 订阅状态 / 已接种 doneDoseIds
不含宝宝昵称。订阅消息文案里的宝宝称呼固定为「您的宝宝」。
昵称、头像、接种记录详情这些全部只存在本机。云端知道「有个孩子在 2025-06-15 出生,还差第 3 针」,但不知道这个孩子叫什么——这个颗粒度是刻意设计的。
三个云函数:syncReminder(上传调度集)、dailyReminder(每日 08:00 定时推送)、sendSubscribe(下发订阅消息)。
定时推送这个函数看起来简单,但要考虑的东西一点不少:
MAX_RECORDS_PER_RUN = 5000,防止超时lastPushDate >= today 直接跳过,重复触发不会重复推送{ processed, pushed, skipped, failed }云函数没有专门的单元测试,所以这些边界都靠返回值暴露出来,方便在控制台排查。
这是整个项目里我最想写的一段。
从小红书抓了 122 篇同类笔记做需求调研后,整理出一批优化点。其中两条 AI 明确拒绝实现:
接种后反应记录功能里,不写「是否发烧」的阈值判断,不写「是否需就医」的结论,页面上常显一句话:
这里只记录你观察到的现象,不做任何判断。
入学入托查验指引的编写原则也是:没有官方来源的流程不许写进来,宁可让用户去问接种门诊。每条指引都带 sourceUrl / sourceName / verifiedAt。
与此对应的是数据本身的准确性——调研时发现百白破免疫程序自 2025-01-01 起已由「3/4/5/18 月龄」改为「2/4/6/18 月龄 + 6 周岁」,而项目里的数据已经过期 20 个月,且完全没有 6 周岁剂次。这类错误在育儿工具里是硬伤,所以除了补数据,还加了一张「数据来源」卡片,把依据的程序版本、生效日期、官方来源链接都标出来。
顺带一个实现细节:来源链接做的是「复制」而不是「跳转」——小程序的
web-view需要业务域名报备,个人主体通常没有。
宝宝项目的验证体系比电车重得多,因为它处理的是不能丢的数据。
静态一致性校验:对 7 个页面 + 7 个组件做 WXML ↔ JS ↔ JSON ↔ WXSS 交叉校验,检查绑定是否指向存在的变量、事件是否指向存在的方法、组件是否注册、样式引用是否存在。
这里有一个关键的实现选择:不用正则去猜 JS 里的方法名,而是用 mock 的 Page() / Component() 把页面 JS 真的 require 进来,捕获它注册的配置对象。这样方法简写、箭头函数、属性赋值全都成立。为了验证检测能力本身有效,还用 10 个合成 fixture 主动注入缺陷,确认都能被精确捕获。
E2E 拦截器:写探针时发现,只要打开首页就会触发一次真实的 syncReminder 调用,而这个调用会删掉该用户其他所有备份。所以在 App Service 层把 wx.cloud.callFunction 换成「只记录、永不外发」的包装器,全程拦下 4 次此类调用,没发出一条真实云请求。
这个拦截器自己还出过两个 bug(闭包捕获了被替换的数组、evaluate 序列化后引用不到 Node 侧常量),都是靠「有数据状态确实触发了 syncReminder」这条断言抓出来的——否则整个 E2E 会「全绿但什么都没验证」。
像素级验证:加了色板验证之后报「品牌色命中 0%」,但变量下发的断言是通过的。根因是开发者工具截图带 Display P3 色彩配置,sRGB 的 #D23F5B 在 P3 里存成 (194,75,93)。加上 ICC 色彩转换后,命中率才正常。
假 canvas 测不出纵向溢出:接种小结卡的绘制断言全绿,真机截图却发现页脚压在记录上、第三条被卡片圆角切掉。原因是假 ctx 只记录绘制指令、不做裁剪,而原有的越界检查只查横向不查纵向。修法是把页脚从「钉死卡片底部」改成「紧贴内容」,绘制函数返回实际高度,必要时按新高度重画一遍,并补两条回归测试。
UI 经过三轮迭代:第一轮去装饰(删掉 ::before/::after 装饰圆、收敛渐变、阴影从 8 档收到 2 档),第二轮数字驱动 + icon-first,第三轮落地「温柔治愈系」设计语言。
其中最有价值的是对比度审计:#FFFFFF 在 #F58CA0 上只有 2.30:1,远低于 WCAG AA 要求的 4.5:1,覆盖了主按钮、标签等多个位置;知识页的返回按钮是 1.98:1,最差。
修法不是逐个改颜色,而是新增 utils/palette.js,从一个基准色派生整套 token,并把两个承载文字的 token 强制压暗到达标,然后用解析器逐规则判定,精确替换了 14 处实心白字 + 11 处浅底品牌字。
后来又发现粉色主题的按钮数字饱和度太强——根因是派生算法用的是「相对」衰减,基色越亮压暗步数越少、降饱和反而越少,于是漏出了荧光绯红。改成在派生循环内部加绝对彩度上限(只降不升)后,主色从 #D23F5B 变成 #BD5368,而其他四套配色逐字节不变。
写代码只占这个项目一半的工作量,另一半全部消耗在平台规则上。
小程序名称看起来是最简单的事,实际是最难的。
第一轮:名称含「用药」触发医疗行业校验。要求上传《医疗机构执业许可证》,个人主体不可能提供。对照实验证实触发源是「用药」二字而非「宠物」——换成「服药 / 喂药 / 吃药 / 药盒 / 药单」都能通过。
第二轮:被驳回「命名过于广泛」。平台建议「采用具有独特性的名称,比如品牌或自身名字/小名」。正确结构是「独特标识词(品牌/小名)+ 功能关键词」。
还有几条硬规则:名称全平台唯一;必须是两个词以上的组合;不能含营销通用词;必须与实际功能一致。而名称一旦注册完成,个人主体的改名次数极少——等于获客关键词在注册那一刻就被锁死了。
这是注册页的原文,也直接推翻了我们原本的论证前提。
之前的判断是「用一个描述性的名称去承接微信搜一搜的流量」。但如果不做微信认证,小程序既搜不到也分享不了——认证从「可选项」变成了「上线前必做项」。个人主体可以做认证,30 元/年。
两个项目分别选了「工具-记账」和「工具_健康管理」,都是个人主体无需额外资质的类目。ICP 备案也是必需的,个人可备案。
一开始想复用已有的云开发环境,评估之后放弃:
cloudbase_auth 云函数,调用方要改用 new wx.cloud.Cloud({ resourceAppid, resourceEnv })最终走独立开通路线。这个决定是对的:看起来省事的路,往往在别处收利息。
提审前要核对一堆前置条件:版本号、服务类目、隐私指引配置、接口权限自检。提交后状态「审核中」,预计 1–7 天。
审核通过后还需要手动点「发布」,不是自动上线的。
审核环节里有两次「差一点就出事」:
第一次:点了提交,页面毫无反应。 勾选须知、点「继续提交」,页面完全没变化,网络面板里也没有任何请求。扒了后台的 version_manager.js 才发现,提交函数是用 <a target="_blank"> 打开一个新标签页去填真正的表单,当前页永远不会变。另外还有一个残留的弹窗遮罩层在拦截点击,以及一个「隐私检查返回 -1 时静默中止、不给任何提示」的分支。
第二次:差一点带着坏掉的导出功能过审。 提交版本时按核查结论勾了「未采集用户隐私」,微信当场拦下:代码调用了 Clipboard、MessageFile 接口,选「未采集」发布后这些接口权限会被回收。而 wx.setClipboardData / wx.getClipboardData / wx.shareFileMessage 正是这次刚做的导入导出功能的唯一通道——如果没被拦下,用户升级后会直接失去备份能力。
根因是核查时搜了定位、登录、网络请求,唯独漏搜了剪贴板类接口。修正时还发现隐私指引里的用途描述已经过时(写的是「用于识别并粘贴充电桩编号」,而这个功能在代码里根本不存在)。
做完之后才是最难的部分。
首发笔记的数据其实不算差(45 浏览、2 评论),但很快收到判定:
笔记存在推广其他平台的内容 / 存在展示其他平台界面信息的行为 / 存在尝试引导他人前往其他平台进行搜索、浏览或查看的行为
复盘时才发现,四条因素叠在一起必然触发:
第 4 条是第一次完全没意识到的:不只是文字,截图同样是证据。
处置方式是改而不是删(保住已有的互动数据):7 张配图全换成纯设计干货卡,零产品 UI;正文改成 640 字纯干货,自检不含「微信/小程序/App/关注/搜一下」。
电车那边的小红书笔记迭代了三次:首发 → 竞品文案调研后重发 → 确认正式名后删除重发(小红书图文笔记发布后不能换图,只能改文字)。删掉的那条有 72 曝光、5 赞、2 收藏、1 分享。
做竞品文案调研时(7 个关键词、171 条笔记、13 篇正文)发现一个规律:头部笔记全是车主晒真实(1747 / 1500 / 1026 / 707 赞),同类小程序的推广笔记只有 45–126 赞;高赞标题的共同点是「有具体对象和数字,一句自夸都没有」。
调研数据里最诱人的做法是把标题直接写成「一年充电花了 1068 元」——这个没做,因为那个数字是灌进去的演示数据,用第一人称写具体金额就是事实性误导。正文和配图都标注了「示例数据,不是真实」。
结论很现实:对个人号来说,合规路径只剩「内容涨粉 → 用户主动私信 → 私信里答复」,笔记本身不能再带任何钩子。
| 小红书 | 知乎 | V2EX | |
|---|---|---|---|
| 读者 | 车主/潜在用户 | 搜索问题的用户 | 程序员 |
| 驱动 | 封面 + 数字 | 答准问题 | 技术实现的真实性 |
| 讨厌 | 广告腔 | AI 味 | 营销腔、夸大 |
| 流量 | 推荐流,3-7 天 | 搜索长尾,能吃几年 | 社区讨论 |
在 V2EX 要做的不是「推广」,而是「分享我怎么做的」——功能列表在那里没人看,技术取舍和踩坑才是内容。这篇博客的思路其实也是从这里来的。
实际发帖时又撞了一串平台规则:帖子因为标题里带「微信小程序」被自动归到「微信」节点,得在 10 分钟编辑窗口内手动移到「分享创造」;回复区放图片时,只有 imgur 域名的链接会被渲染成图片,其他图床的链接会被服务端主动破坏成纯文本(在 [ ] 和 ( ) 之间插一个空格);而 V2EX 自带的图床上传需要先充值。最后的落地方案是:正文配图走 GitHub + jsDelivr,小程序码以回复形式附在 imgur 直链上。
名称、简介、服务类目、页面标题都是可被搜索的字段,「搜一搜」是零成本入口。但前提还是那一条:先完成微信认证。
1. AI 结对最擅长的不是写代码,而是把一件事推到闭环。
单看写代码,AI 的产出需要逐行审。但它在「查文档、试错、跑验证、改配置、写文档、盯部署」这些环节上的价值远超预期。上面那些踩坑记录,大部分是它自己发现并记下来的。
2. 真正的对手是平台规则,不是技术难度。
域名白名单、命名审核、类目资质、认证要求、环境共享限制、小红书引流判定——这些没有一条能靠写代码解决,但它们决定了产品能不能上线、能不能被搜到、能不能被分享。
3. 断言要有「打脸」的能力。
这一周被自己的断言打脸了至少三次:假 canvas 测不出纵向溢出、像素断言栽在 Display P3 色彩空间、E2E 拦截器自己有 bug 导致「全绿但什么都没验证」。先加断言,再被断言打脸,是唯一能让验证体系真正可信的路径。
4. 数据安全要靠「默认拒绝」。
那次数据丢失事故的根本教训不是「云函数写错了」,而是破坏性操作默认放行。改成「默认拒绝 + 显式授权」之后,同一类事故就不可能再发生。这个原则在服务端同样适用。
5. 不知道的事,不要编。
AI 在医学规则上的自我克制,是这个项目里我最认可的部分。一个育儿工具如果为了「功能看起来更全」而编造接种间隔或用药建议,风险是不可接受的。宁可让用户去问专业的人,也不要给一个看起来专业但来源不明的答案。
6. 未验证的面积,不能再扩大。
功能堆到一定程度,用户自己按下了刹车——先补齐真机验证,再谈新功能。这是整个项目里最正确的决定之一。
如果只写「做完的部分」,这篇复盘就失真了。下面是记录里明确留着、但截至写下这些字时还没收口的尾巴:
这些都是小事,但它们在记录里被反复提起过三次,说明**「知道该改」和「真的去改」之间还有一段距离**——这段距离不会因为用了 AI 而自动消失。

电车车主小助手:电车充电记账、峰谷电价、电池健康与油电对比。

我的宝贝-宝贝生活记录:宝宝疫苗接种时间表、漏种补种与成长记录。
两个都是纯工具,不登录、不要手机号、没有广告。电车的数据只存在你自己手机上,宝宝的数据只有推送调度集会上云。
有问题或者建议,欢迎在评论区聊。