首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >基于Workbuddy的微信小程序开发:如何让同一用户每次抽题不重复

基于Workbuddy的微信小程序开发:如何让同一用户每次抽题不重复

原创
作者头像
用户12800590
发布于 2026-10-04 22:30:14
发布于 2026-10-04 22:30:14
550
举报

作者:漫步瓯鹿 | 标签:#WorkBuddy #微信小程序 #教育应用

一、起因

我做了个儿童哲学课堂的小程序,微信原生,没后端、没联网。

上线后老师提了个要求:同一个孩子每次进来,抽到的话题别重复。

我心想这还不简单,随机抽题不就完了。改完第一版让老师试,她点开,聊了一个,退出,再进来——

这道题我刚才不是聊过了吗?

回去一看,去重逻辑是写了,但只管了"话题"这一层,题目没管,聊满一轮之后怎么办也没管。

还有个更扎心的地方:这个 bug 我自己手动测不出来。因为我自己用的是新身份,历史上根本没记录,程序每次都判定"这话题没聊过",自然测不出问题。得用真实孩子的账号,用够了次数,才会暴露。

所以"不重复"这件事,至少得拆成三层来管。少一层,用户立刻就能发现。

二、三层去重:一次"抽题"函数要干三件事

我最后把逻辑收进了两个函数。topicsOf负责列出去重后的话题清单,drawTopic 负责挑。

整体的决策流是这样的:

进入 drawTopic(grade, excludeTopics, excludeIds) │ ├─ 第 1 层 话题级:这个话题聊过没有? │     └─ 全部聊过 → 兜底:pool = list.slice(0),清空重开一轮 │                    (没有这一句会永远抽不到题) │ ├─ 第 2 层 题目级:优先挑"话题内所有题都没做过"的 │     └─ 全都做过了 → 兜底:退回整池随机 │ └─ 第 3 层 轮次边界:记录进 talkedTopics,供下一轮排除

三层各有各的兜底分支,少任何一层都会在某个边界情况下翻车。

第1 层:话题级——这个话题聊过没有

function topicsOf(grade) {   var list = BANKS[grade] || [];   var out = [];   for (var i = 0; i < list.length; i++) {     if (out.indexOf(list[i].t) < 0) out.push(list[i].t);   }   return out; }

第 2 层:题目级——这个话题里的题做过了吗

我的题库结构是这样的:每个年级 12 个话题,每个话题 1道主问题 + 2 道延伸题,一共 36 道。

所以即使话题没聊过,同一个话题内部还有 3 道题可以挑。这就是第 2 层存在的意义:

// fresh = 这个话题下所有问题都还没做过的 var fresh = []; for (var j = 0; j < pool.length; j++) { var qs = questionsOf(pool[j]); var allNew = true; for (var k = 0; k < qs.length; k++) { if (exQ.indexOf(pool[j].id + '|' + qs[k]) >= 0 || exQ.indexOf(qs[k]) >= 0) { allNew = false; break; } } if (allNew) fresh.push(pool[j]); } var cand = fresh.length ? fresh : pool;   // 全都做过了就退回整池

第3 层:轮次边界——聊满了怎么办

这是最容易漏的一层。如果不写这一句,等孩子把某个年级的 12 个话题全聊完,程序就会去一个空池子里抽题,表现是永远抽不到题。

我自己一开始没意识到这个坑,因为低年级孩子根本聊不满 12 个——只有高年级、或者长期使用的孩子才会撞上。这种 bug 在测试阶段几乎抓不到,只能等真实用户用够了才暴露。

if (!pool.length) pool = list.slice(0);   // 聊完一轮 → 自动重新开始

三、记忆键怎么设计:别用随机数,用"身份三元组"

要记住"谁聊过什么",就得有个查找依据。我用的是:

function topicKey(p) {   return [p.school, p.nick, p.grade].join('|'); }

也就是 学校 | 昵称 | 年级。这个设计有个好处:同一个孩子换设备、重装、清缓存都不影响,只要他填同样的三个信息,历史就还在。而且不同孩子、不同年级之间天然隔离,不会互相污染。

存储结构是一个对象,每个键下挂两个数组:

var KEY_TOPICS = 'wr_topics';   // { '学校|昵称|年级': { talked:[], qids:[] } }

这里我踩过一个很典型的坑

我一开始在登录页的初始化里写了一句:

S.talkedTopics = [];   // ❌ 错

看起来只是"初始化",但它每次登录都会把历史清空。结果就是:孩子第一次抽到新题很兴奋,退出重进又回到原题。

修法就一行——初始化时必须读回:

S.talkedTopics = STORE.talkedTopics(profile);

教训:凡是"重置型"的初始化代码,先问一句"这个变量是不是跨会话需要保留的"。需要保留的,就该从存储读,而不是赋空值。

四、最难的一个 bug:状态机和生成逻辑错位

这个坑值得单独讲,因为它花了我最久,而且表面上看不出任何错误。

现象

对话主线是设计成这样的 4 段:

探索(6 问)→ 结论时刻 → 余问时刻 → 收尾

但实际跑起来,结论时刻被整段跳过了,界面上的进度点和 AI 说的话对不上。

我最初的错误判断

我以为状态机写错了,去检查 phase 的跳转逻辑,越查越觉得没问题——因为它单独看是对的。

真正的根因

问题出在顺序上。原来的写法大概是:

1. advance()  先算出下一个阶段,立刻 setData 更新 phase 2. 生成 AI 回应 ← 这里读到的却还是旧 phase

也就是说,界面已经显示进入"结论时刻"了,但 AI 这一轮说的话还是按"探索"阶段生成的。气泡内容和进度点对不上,下一轮再算阶段时就跳过去了。

修法:三段式

把它拆成"先算 → 再说 → 最后落库"三个明确的动作,每一步职责单一:

nextPhase: function () {         // ① 先算出本轮该处于什么阶段 var fi = this.data.followIdx + 1; var cur = this.data.phase; if (cur === 'explore')  return fi >= GUIDE_QUESTIONS ? 'conclude' : 'explore'; if (cur === 'conclude') return fi > GUIDE_QUESTIONS + 1 ? 'wonder' : 'conclude'; if (cur === 'wonder') { var last = this.turns[this.turns.length - 1]; return (last && AI.hasWonder(last.text)) ? 'wonder' : 'done'; } return 'done'; }, answer: function (text) { var phase = this.nextPhase();   // ① 先算 var r = AI.reply({ answer: text, phase: phase });   // ② 按这个阶段说话 this.pushAi(r.text, r.strategy); this.commit(phase, fi);         // ③ 说完再落库 }, commit: function (phase, fi) { ... }   // 只负责写数据,不负责决策

一句话总结:状态不能边算边改,要先算清楚、说完话、再落库。凡是"计算下一步"和"执行当前步"写在一起,就容易出现这种"显示和内容对不上"的错位。

五、一个隐蔽的坑:改名会静默覆盖旧方法

重构 Page 对象里的方法时,我犯过一个错:把 answer 改成了三个方法,但旧的 answer 没删。

Page({ answer: function (text) { /* 旧版本 */ }, // ...中间加了别的代码... nextPhase: function () {...}, answer: function (text) { /* 新版本 */ },   // 覆盖了旧的,JS 不报错 commit: function (phase, fi) {...} });

JavaScript 允许对象字面量里有同名属性,后者默默覆盖前者,不报任何错。运行时表现是"新代码没生效",但你翻代码时两段都在,会怀疑人生。

修法很土但有效:改完数一遍方法名出现次数。

grep -c "answer:" pages/chat/chat.js    // 应该是 1

六、给 AI 写的"门禁"脚本,让我少改了 3/4 的错

既然 bug 都是"看起来对其实错"的类型,我干脆写了 5 套自动检查脚本(无头运行,不打开界面),像给小学生判卷一样判自己的代码。目前全绿 326 项。

一个关键判据:失败时,先怀疑测试,还是先怀疑代码?

跑门禁时我遇到 14 项失败,直觉是"代码又坏了",准备改代码。逐条看下来,11 项是我自己的断言写错了,只有 3 项是真缺陷。

比如我断言"出现『不对』二字就是批判孩子",于是 FAIL 了。可实际句子是:

先别管它对不对,我想说的是……

这明显是接住孩子的念头、不急着评判,是好设计,不是 bug。我改的是断言。

而另外 3 项是真的:5 类价值观没识别出来、重复字压缩把孩子的叠词压坏了("为什么为什么"被压成"为什")。这些改了代码。

11 项误判的完整清单

我把当时的判据整理成了一张表。左边是门禁报错的现象,右边是我最后的判定。

#

门禁报错现象

真实原因

判定

处理

1

回应含 " 不对 " ,判定为批判孩子

原句是 " 先别管它对不对 " ,是在接住念头

断言错

改断言

2

句库缺 " 引导追问 " 关键词

引导问题已改成动态生成,不再是固定句式

断言过时

改断言

3

追问策略数应为  11

实际就是  11 ,是断言里写死了  12

断言错

改断言

4

追问模板未以问号收尾

逐条查了,真有一条模板用了句号

代码错

改模板

5

" 这个题真难 " 触发了价值观

" 难 " 不是价值观触发词,只是抱怨

断言错

改断言

6

" 公园 " 被当成错别字纠正

词表里 " 公 "→" 工 " 的规则误伤了正常词

代码错

加保护位

7

价值观漏判: " 抄别人的答案 "

正则只覆盖书面语

代码错

补正则

8

价值观漏判: " 把他赶走 "

同上,儿童口语未覆盖

代码错

补正则

9

重复字压缩把 " 为什么为什么 " 压成 " 为什 "

重复单元取错

代码错

改算法

10

重复字压缩把 " 天天 " 压成 " 天 "

合法叠词未进白名单

代码错

加白名单

11

总分  58  未达  70  分上榜线

断言用旧样本间接验证,样本已涨分

断言错

改断言

从这张表能看出两件事:

第一,"断言错"占了 11/14。门禁脚本本身也是代码,也会错。如果你把它当成"绝对权威",就会陷入无限改代码的死循环。

第二,真正的代码缺陷有共性——都是"覆盖不全"或"边界处理错"。"抄别人的答案"和"把他赶走"漏判是同一类问题:写规则时只想着书面表达,没想着真实用户会怎么说。

我给自己定的规矩

看到 FAIL 先逐条判断"断言过时 or 代码错",别看到 FAIL 就改代码。

具体操作上,我用了一个很土但有效的判据:把报错的那句话在脑子里过一遍,问"这句话在一个正常用户嘴里会被说出来吗?"

会→ 大概率是断言错;不会 → 才是代码错。

比如"这个题真难"——正常孩子绝对会说这句话,所以它不该触发价值观追问,是断言错了。而"抄别人的答案"也是正常孩子会说的,所以是正则漏了,是代码错。

这条经验可能比任何一条技术细节都值钱。

顺便说个工具层面的坑

有一轮批量改文案时,我用脚本一次性替换 108 处字符串,每处都报"命中成功"。但跑完门禁发现少了 7 处——工具报告成功,文件实际没变。

从那以后我养成了习惯:批量改完必须抽查几个关键锚点,比如

grep -r "连起来想" .    # 应该是 0 命中

不放心就再跑一遍全量替换,脚本按"精确整串 old→new"匹配,重复运行是幂等的。

所以我现在不信任何"已完成 N 处修改"的报告,只信我自己 grep 出来的结果。

门禁脚本到底怎么写

可能有朋友好奇:326 项检查是怎么跑起来的?其实没有测试框架,就是纯 Node 脚本,手写 ok() 数对错。

核心就三行:

let pass = 0, fail = 0, fails = []; function ok(name, cond, extra) {   if (cond) pass++; else { fail++; fails.push(name + (extra ? ' → ' + extra : '')); } }

难的是怎么让小程序代码能在 Node 里跑起来。小程序没有浏览器环境,wx、Page、getApp 全都不存在。解法是造一个假环境,把这些 API 用 vm 沙箱注入进去:

const vm = require('vm'); function makeEnv(seedStore) { const wxObj = { __store: seedStore || {}, getStorageSync(k) { return this.__store[k] === undefined ? '' : JSON.parse(JSON.stringify(this.__store[k])); }, setStorageSync(k, v) { this.__store[k] = JSON.parse(JSON.stringify(v)); }, showModal(o) { if (o.success) o.success({ confirm: false, cancel: true }); } // ...按需补toast / navigateTo 等 }; const sandbox = { console, wx: wxObj, getApp() { return app; }, Page(o) { sandbox.__page = o; }, setTimeout, clearTimeout, Date, Math, JSON, Object, Array, String, Number, RegExp, require(p) { return sandbox.__require(p); } }; return sandbox; }

这里有个必须注意的坑:getStorageSync 首次必须返回空字符串,不能返回 undefined。因为小程序里读一个从没写过的 key,返回的就是空字符串;你要是返回 undefined,代码里 if (!cached) 和 cached.length 的行为就全变了,测出来的东西不作数。

同理,setStorageSync 存进去要深拷贝,否则测试里改一个对象会污染到"上一次读取"的值:

setStorageSync(k, v) { this.__store[k] = JSON.parse(JSON.stringify(v)); },

让页面对象能跑起来

Page({...}) 只是注册,不会自动实例化。测试里要自己造一个带 setData 的实例:

function mount(cfg) {   const inst = Object.assign({}, cfg);   inst.data = JSON.parse(JSON.stringify(cfg.data || {}));   inst.setData = function (o) { Object.assign(this.data, o); };   return inst; }

setData 写成同步的 Object.assign 就够了——真实小程序是异步的,但测试里我们只关心"状态有没有变对",不需要模拟渲染。

断言不要绑死内部结构

我一开始写断言时直接去读假存储的内部结构:

// ❌ 错:绑死了实现细节 wxObj.__store[KEY].records.length === 3

这样写有个坏处:只要你改了存储的内部组织方式(比如把数组换成对象),测试就全红了,但代码其实没问题。

正确做法是调它自己暴露的接口:

// ✅ 对:只依赖公开方法 STORE.all().length === 3

all() 返回什么、怎么存的,是它自己的事。你的断言只关心"能读出来几条"。测试应该验证行为,而不是验证实现。

异步的定时器要等够

小程序里有 1600ms 的延迟动画。我在测试里等 2100ms:

const wait = ms => new Promise(r => setTimeout(r, ms)); await wait(2100);   // finishRound 的定时器必须跑完

等不够就读不到刚写入的成绩,会误报 FAIL。这个坑我踩了两次才学会:不是代码有问题,是你的等待时间不够。

跑测试时的一个小技巧

把结果写进文件,别指望 stdout:

fs.writeFileSync('_mp_check.txt', `PASS ${pass} / FAIL ${fail}\n` + fails.join('\n')); process.exit(fail ? 1 : 0);

因为脚本一调 process.exit,管道里的 stdout 就被截断了,终端上常常只能看到第一行"已就绪"。写文件最稳。

为什么要写这 5 套

我把检查拆成 5 个文件,各管一段:

脚本

干什么

项数

_input_check.cjs

输入处理:错别字纠正、价值观追问

138

_ai_check.cjs

对话质量:追问不重复、语气、价值观识别

60

_mp_check.cjs

小程序全流程(沙箱真跑)

61

_topic_flow.cjs

完整对话流程

34

_topic_check.cjs

话题结构静态检查

33

合计 326 项。拆开的好处是跑得快、失败好定位。全塞一个文件里,跑一次要一分多钟,失败还得翻日志找是哪块。

拆开的好处是跑得快、失败好定位。全塞一个文件里,跑一次要一分多钟,失败还得翻日志找是哪块。

说实话,这 326 项里有一部分是"改了需求就得改断言"的重复劳动。但比起"改完代码不知道对不对,每次都要手动点一遍小程序",这个成本划算太多了。

七、几件想提醒的事

一、做不出来的事,界面上别装。

需求是"家长能看到孩子聊的内容"。但微信本地存储是不跨设备的,纯本地做不出实时同屏——家长手机上打开,孩子平板上聊,两个设备各存各的。

我一开始想用房间号糊过去,后来想明白了:房间号在这套纯本地方案里,真实作用只能是"约定同一场对话",不是"同屏"。所以我把文案改了,直接告诉用户这是干什么用的。真要同屏得接云开发,那是另一个项目。

装得出来的东西骗得了一时,骗不过第二周。

二、需求会翻案,别急着删东西。

登录页那个"其他学校(自己填写)"的入口,老师一开始说不要,说只留指定的几所。我照做了,把相关代码全清了。

后来拿到完整的需求清单,才发现这项要保留。

教训是:删东西之前先问一句"这个会不会被加回来"。收到新清单,别推倒重来,拿现有的逐条对一遍,只改有差异的地方。

三、AI 写代码,便宜的是速度,贵的是验证。

写代码确实快了很多,但我后来算过一笔账:真正花时间的地方全是"怎么确认它是对的"。

所以我才去写那 5 套门禁。一开始觉得是自我折腾,写完发现每次改完代码跑一遍,心里踏实。改代码的心理负担小了很多,这个投入是值的。

就是有点烦——需求一变,断言就得跟着改,改完还得重跑。

八、改完之后

待补充:效果段

以下事实需要作者确认后补入正文,在确认之前不要发布。 请填真实情况(不代为编造): · 上线后有 ____ 个孩子实际在用 · ____ 天内没有出现"题目重复"的反馈 · 之前手动测测不出的问题,是怎么被发现的(谁反馈的?用了多久?) · 有没有某个孩子/某次对话的具体例子,能说明改动前后的差别

九、如果你也在做类似的事

下面这些是可以直接抄走的。

三个最关键的经验

1. "不重复"这类需求,至少拆成话题级 / 内容级 / 轮次边界三层,每层都要有兜底分支。少一层就会在边界情况下翻车。 2. 记忆键用"身份三元组"(学校|昵称|年级),天然隔离不同用户,重装不丢。 3. 状态机:先算 → 再说 → 再落库,三者分开写。凡是"算下一步"和"做当前步"混在一起写,就会出现"界面显示和实际内容对不上"的错位。

一份更完整的检查清单

如果你也在做类似的项目,我整理了这份对照:

环节

容易踩的坑

怎么防

需求理解

把 " 不要重复 " 当成一层,实际要三层

追问边界:上限是多少?超了怎么办?

初始化

登录时  xxx = []  把跨会话数据清空

凡 " 重置型 " 初始化,先问 " 这变量要跨会话保留吗 "

状态机

边算下一阶段边改状态, AI  说的话和进度点错位

拆成  nextPhase / answer / commit  三个方法

重构

对象字面量里方法重名,旧方法被静默覆盖

改完  grep -c " 方法名 :"  确认只剩  1  个

批改

工具报告 " 已替换  108  处 " 但实际有几处没落盘

批量改完  grep  关键锚点抽查

存储

测试里直接读假存储的内部结构

断言只调公开方法(如  STORE.all() )

异步测试

定时器等不够,读不到刚写的成绩

await wait(ms)  比定时器长一点

门禁

看到  FAIL  就改代码

先判断 " 断言错  or  代码错 "

交付

收尾内容放在某些标签里,转换时静默丢失

转换后解包校验关键文本是否在场

诚实性

做不到的功能在  UI  上假装做得到

做不到就写明,别骗用户

工具链上的一条真实建议

最实用的不是某个 AI 工具,而是给自己写自动检查脚本。当项目有 300 多个断言在盯着你的时候,改代码的心理负担会小很多。

而且这个投入是一次性的:门禁写完之后,每次改代码都免费复用。

关于这个项目

本文提到的项目是我在工作坊里做的一个儿童哲学课堂小程序,无后端、无联网,所有数据都存在本地。如果你想看某一部分的具体实现,欢迎在评论区留言,我补充上来。

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

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

目录
  • 作者:漫步瓯鹿 | 标签:#WorkBuddy #微信小程序 #教育应用
    • 一、起因
    • 二、三层去重:一次"抽题"函数要干三件事
      • 第1 层:话题级——这个话题聊过没有
      • 第 2 层:题目级——这个话题里的题做过了吗
      • 第3 层:轮次边界——聊满了怎么办
    • 三、记忆键怎么设计:别用随机数,用"身份三元组"
      • 这里我踩过一个很典型的坑
    • 四、最难的一个 bug:状态机和生成逻辑错位
      • 现象
      • 我最初的错误判断
      • 真正的根因
      • 修法:三段式
    • 五、一个隐蔽的坑:改名会静默覆盖旧方法
    • 六、给 AI 写的"门禁"脚本,让我少改了 3/4 的错
      • 一个关键判据:失败时,先怀疑测试,还是先怀疑代码?
      • 11 项误判的完整清单
      • 我给自己定的规矩
      • 顺便说个工具层面的坑
      • 门禁脚本到底怎么写
      • 让页面对象能跑起来
      • 断言不要绑死内部结构
      • 异步的定时器要等够
      • 跑测试时的一个小技巧
      • 为什么要写这 5 套
    • 七、几件想提醒的事
    • 八、改完之后
    • 九、如果你也在做类似的事
      • 三个最关键的经验
      • 一份更完整的检查清单
      • 工具链上的一条真实建议
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档