一个"给孩子用"的小工具,如何长成一个带后端、多家庭隔离、管理员权限和云发音的完整系统。
本文为 2026-10 升级版:新增账号版前端同步重构、TTS 稳健化加固、部署安全收紧等内容。
- 日期:2026-10-12
- 技术栈:原生 HTML/CSS/JS · Node.js · Express · SQLite · 腾讯云 TTS · Docker

一、缘起:为什么自己造一个听写工具
孩子上三年级,每天要听写英语单词。市面上的听写 App 要么收费、要么满屏广告、要么必须注册账号;网页版不是发音太机械,就是交互复杂到孩子自己不会用。我的需求其实只有三条:
- 双击就能用,孩子自己会操作;
- 发音清晰,能听清每个音节;
- 免费、无广告、数据不丢。
于是有了第一个版本——一个单文件 HTML,把单词库、听写流程、积分、错题本全部塞进一个文件里,双击打开就能用。
后来亲戚朋友的孩子也要用,需求从"我自己娃用"升级成"多家庭共用":
- 每个家庭的数据要互相隔离;
- 孩子的积分、错题本要存到云端,换设备不丢;
- 家长要能管理自己家的孩子、看学习情况;
- 还需要一个系统管理员来统筹所有家庭。
于是就有了账号版:Node.js + Express + SQLite 后端 + 腾讯云 TTS 发音。这篇文章记录整个项目的设计思路和关键技术点。
二、项目全景
2.1 两个版本对比
| 维度 | 单机离线版 | 账号版(多家庭) |
|---|---|---|
| 形态 | 单个 HTML 文件 | Express 后端 + 前端页面 |
| 前端技术 | 原生 HTML/CSS/JS(零依赖) | 原生 HTML/CSS/JS |
| 数据存储 | localStorage | 服务器 SQLite(localStorage 只存登录态) |
| 单词发音 | 浏览器 speechSynthesis | 腾讯云 TTS + 本地缓存,失败自动降级浏览器 TTS |
| 多家庭 | 不支持 | 家庭隔离 + 家长码 |
| 管理员 | 无 | 超管 / 普通管理员 RBAC |
| 部署 | 双击打开 | Docker / PM2 / nginx 反代 |
| 代码规模 | 单文件 1600+ 行 | 后端核心 4 个文件约 1300 行 + 前端 2800+ 行 |
2.2 功能一览
- 四种听写模式:普通(听音写词)、简单(带中文提示)、困难(无提示、加分、可买提示)、限时(90 秒冲刺,用整本词库)
- 年级选择条:普通/简单/困难/限时四模式跟随所选年级,记忆最近选择,换设备不丢
- 年级闯关:3-6 年级分级词库,正确率 ≥ 80% 解锁下一年级,胶囊关卡路径展示
- 积分与等级:答对加分,累计积分定等级(英语萌新 → 单词小能手 → 听写达人 → 英语小超人)
- 错题本:答错 3 次的词自动收录,翻转卡片复习,复习答对自动移除
- 进度云同步:防抖自动保存 + 页面离开兜底,最后一步也不丢
- 音效与动效:Web Audio 合成音效、Canvas 烟花、升级背景动画,全部零外部资源
2.3 版本演进
| 版本 | 里程碑 |
|---|---|
| v1 | 单文件单机版:四种模式 + 积分 + 错题本 |
| v2 | 单家庭账号版(后端雏形) |
| v3 | 多家庭:家庭隔离 + 家长管理 |
| v5 | 家长码修改与手机号重置 |
| v7 | 管理员体系重构:账号存数据库 + RBAC 权限 |
| v8 | 腾讯云 TTS 发音 + 本地缓存 |
| v9 | 家庭改名 / 删除家庭 |
| 2026-10 | 账号版前端同步重构、年级选择条 + lastGrade 云记忆、TTS 稳健化加固、Docker 端口安全收紧 |
2.4 本次升级一览
| 升级点 | 解决的问题 | 效果 |
|---|---|---|
| 前端数据上云 | 进度只存本地,换设备/清缓存就丢 | localStorage 只存登录态,数据全部存服务器 |
| 防抖 + 兜底同步 | 高频答题触发大量保存请求、关页丢最后一步 | 400ms 防抖合并 + pagehide keepalive 冲账 |
| 年级选择条 | 普通/简单/困难/限时永远练 3 年级,与解锁进度脱节 | 四模式跟随所选年级,只可选已解锁年级 |
| lastGrade 云记忆 | 换设备后年级选择被重置 | 最近使用年级随进度存服务器,自动恢复 |
| 默认头像 | 新孩子没有头像,空位难看 | 按名字哈希生成固定默认头像,可再自定义 |
| TTS 并发锁 | 孩子连点发音 → 并发调腾讯云 → 被限流、听感像"读了一半" | 同一单词并发请求共享一次合成,写缓存后一起返回 |
| TTS 完整性校验 | 接口偶发返回截断音频,坏数据写进缓存后永远"读一半" | <1KB 判定异常,丢弃并重试,缓存命中时也校验 |
| TTS 自动重试 | 腾讯云偶发限流/超时 | 合成失败自动重试 1 次 |
| Docker 只绑本机 | 8543 端口暴露公网,有被直连扫描的风险 | 只绑定 127.0.0.1,外网只能走 nginx 反代 |
三、单机版:一个 HTML 文件的设计
3.1 单词库与数据模型
单词按年级分组成 WORD_BANK,每个词是一个 { w: 英文, c: 中文 } 对象:
const WORD_BANK = {
3: [{ w: 'hello', c: '你好' }, { w: 'apple', c: '苹果' }, /* 每年级 20 个 */],
4: [/* ... */], 5: [/* ... */], 6: [/* ... */]
};
四个年级各 20 个词条(三年级和六年级都含 cat,去重后共 79 个单词)。单机版进度用 localStorage 保存,key 带版本号,读取时做容错:
const STORE_KEY = 'engDictStore_v1';
let store = { score: 0, unlocked: [3], wrong: [] };
function loadStore() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
const d = JSON.parse(raw);
store = {
score: d.score || 0,
unlocked: (d.unlocked && d.unlocked.length) ? d.unlocked : [3],
wrong: d.wrong || []
};
}
} catch (e) { console.warn('读取存档失败:', e); }
if (!store.unlocked.includes(3)) store.unlocked.push(3); // 3 年级永远默认解锁
}
3.2 听写流程:小状态机 + 计分规则
每道题是一个 3 次机会的状态机:
| 情况 | 得分 / 处理 |
|---|---|
| 第 1 次答对 | +10 分 |
| 第 2 次答对 | +5 分 |
| 第 3 次答对 | +2 分 |
| 困难模式答对 | 额外 +5 分 |
| 困难模式看提示 | -3 分(每题限一次) |
| 答错 3 次 | 揭示答案,单词进错题本 |
答对后 2 秒自动跳下一题,答错 3 次后 2.6 秒跳转;lockUI 状态在答案揭晓后锁住交互,防止连按提交重复计分。
限时模式 90 秒倒计时,最后 10 秒每秒滴答 + 中文语音警告:“注意!还剩十秒!”。
键盘交互是专门给孩子设计的:空格键重播发音,回车键提交答案——手不离键盘就能完成整轮听写。
3.3 零依赖音效:Web Audio API 现场合成
不想引入任何音频文件,就用 Web Audio API 的振荡器现场合成。核心是 tone():
function tone(freq, dur, type, vol, delay) {
const ctx = ensureAudio();
if (!ctx) return;
const t0 = ctx.currentTime + (delay || 0);
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type || 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(vol || 0.25, t0);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur); // 平滑渐弱,避免爆音
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.02);
}
在此基础上封装了 9 种场景音效:答对是上行琶音(C5-E5-G5),答错是低频方波蜂鸣,升级是号角式琶音……加起来不到 100 行,没有任何音频文件。
一个容易踩的细节:浏览器要求"用户手势之后才能发声",所以要在 pointerdown / keydown 时懒初始化 AudioContext 并 resume(),否则首次点击没有声音。
3.4 Canvas 烟花:一个 70 行的粒子系统
答对时在屏幕随机位置发射 3 个"火箭",飞到顶点爆炸出 70 个粒子,带重力、按剩余寿命淡出:
function explode(x, y, color) {
for (let i = 0; i < 70; i++) {
const ang = Math.random() * Math.PI * 2;
const spd = 1 + Math.random() * 4.5;
fireworks.push({
type: 'particle', x, y,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
life: 40 + Math.random() * 25, maxLife: 65, color
});
}
}
requestAnimationFrame 驱动,粒子清空后自动停止;画布 fixed 定位 + pointer-events: none,不挡任何点击。
3.5 发音 1.0:浏览器自带 TTS
单机版用 speechSynthesis,自动挑一个美式英语音色(优先 Google / Microsoft / Samantha / Zira / Aria),语速 0.8、音调 1.1:
cachedVoice =
voices.find(v => v.lang === 'en-US' && /google|microsoft|samantha|zira|aria/i.test(v.name)) ||
voices.find(v => v.lang === 'en-US') ||
voices.find(v => v.lang && v.lang.startsWith('en')) ||
null;
用下来最大的痛点是:浏览器 TTS 音色机械,孩子听辨吃力。这是后面接入腾讯云 TTS 的直接动机。
四、账号版:为什么上服务端
4.1 需求升级
- 数据上云:换设备进度不丢;
- 多家庭:每个家庭独立,互不可见;
- 家长管理:家长能新建/删除孩子、改手机号、改头像、管理家长码;
- 系统管理员:统筹所有家庭和孩子数据。
4.2 数据模型:三张表
| 表 | 关键字段 | 说明 |
|---|---|---|
| parents | id, username(唯一), code_hash | 一个家长 = 一个家庭 |
| users | id, parent_id, name, score, unlocked, wrong, phone_dad, phone_mom, avatar, last_grade | 归属家庭,家庭内名字唯一 |
| admins | id, username, password_hash, role, permissions | super / admin + 权限 JSON |
几个设计决策:
- better-sqlite3 同步 API + WAL 模式:单机部署规模用同步 API 最省心,
journal_mode = WAL提升并发读写; - 家长码 / 密码只存 sha256 哈希,不存明文,丢了只能重置;
- 孩子的
unlocked(已解锁年级)和wrong(错题本)直接存 JSON 文本列——字段少、结构稳定,没必要拆表; last_grade(最近使用的单词年级)作为单独列持久化,让"年级记忆"跨设备生效(详见第六节)。
4.3 旧数据自动迁移:升级不丢数据
账号版是从单机版一路演进来的,数据库结构变过好几次,db.js 启动时做了一套自动迁移:
// 1) 缺列就补:PRAGMA table_info 检查
ensureColumn('phone_dad', "phone_dad TEXT NOT NULL DEFAULT ''");
ensureColumn('last_grade',"last_grade INTEGER NOT NULL DEFAULT 3");
// 2) 没有 parent_id 列 → 事务里重建表,旧孩子挂到默认家庭
ensureParentColumn();
// 3) 存在"无主孩子"才创建「我的家」并随机生成家长码(打印到启动日志)
ensureDefaultParent();
// 4) 首次启动自动建超级管理员 admin/admin(提示立即改密)
ensureDefaultAdmin();
迁移只在"检测到旧结构"时执行,正常新库直接跳过,全程事务保护,不会丢已有积分和错题本。
4.4 认证设计:为什么孩子用"手机号验证"而不是密码
低龄孩子记不住密码,但能记住"爸爸/妈妈的手机号"。所以孩子登录链路设计成:
选家庭 → 点自己的名字 → 输入爸爸或妈妈任一手机号 → 进入
家长侧的"家长码"设计:
- 4-20 位,全站唯一——登录只输家长码就能唯一定位家庭,回车即登录;
- 只存哈希;改码后踢掉该家庭所有登录会话,防止旧码继续生效;
- 忘记家长码 → 用该家庭任一孩子绑定的手机号验证后重置。
会话用内存 Map 存 token(crypto.randomBytes(24)),孩子 / 家长 / 管理员三套独立。服务器重启 token 全部失效——换来的是"零持久化会话、无泄漏风险",孩子重新点一次名字 + 手机号即可,数据不丢。
4.5 管理员 RBAC
/admin 是独立入口,主页不显示。管理员账号存数据库,不受配置文件控制:
- 超级管理员(role=super):全部权限,还能增删管理员、给普通管理员授权;
- 普通管理员(role=admin):按
permissions数组授权,family(家庭管理)/user(孩子管理),不选权限就只能只读; - 所有管理员都能改自己的密码,改完踢掉自己的全部会话。
权限用两个中间件落地,十分直白:
function requirePerm(perm) {
return (req, res, next) => {
const a = req.admin;
if (a.role === 'super') return next(); // 超管放行
if ((a.permissions || []).includes(perm)) return next(); // 有权限放行
return res.status(403).json({ error: '该账号没有此操作权限' });
};
}
五、发音:从浏览器 TTS 到腾讯云 TTS 的稳健化
5.1 缓存优先方案
单机版浏览器 TTS 音色机械,换成腾讯云「WeJack 美式英语精品男声」(神经网络音色)。直接每次调 API 会有延迟和费用问题,所以设计成缓存优先:
flowchart TD
A[前端点发音 / 按空格] --> B[GET /api/tts/word?word=hello]
B --> C{缓存 mp3 存在且 ≥1KB?}
C -- 是 --> D[直接返回 mp3 文件]
C -- 否(损坏则先删除) --> E{同一单词正在合成?}
E -- 是 --> F[等待已有合成结果]
E -- 否 --> G[调用腾讯云 TextToVoice 合成<br/>失败自动重试 1 次]
G --> H{音频完整性校验 ≥1KB?}
H -- 是 --> I[写入 mp3 缓存]
I --> D
H -- 否(异常/截断) --> J[丢弃,返回 503]
F --> D
J --> K[前端自动降级浏览器语音合成]
合成参数按"小学生听辨"优化:Speed: 0.85 稍慢语速、Volume: 5、Codec: mp3、PrimaryLanguage: 1(英文)。
5.2 防路径穿越:safeName
缓存文件名来自用户输入的单词,直接用会有路径穿越风险,所以加了一层白名单校验和长度限制:
function safeName(word) {
const w = String(word || '').trim().toLowerCase();
if (!/^[a-z][a-z0-9 \-']*$/.test(w) || w.length > 30) return null;
return w.replace(/[^a-z0-9]/g, '_');
}
只允许小写字母开头 + 字母/数字/空格/连字符/撇号,其余一律替换成下划线,长度上限 30——既保证了文件系统安全,也兜住了乱传参的请求。
5.3 并发合成锁:孩子连点发音不再"读一半"
问题:孩子快速连点发音按钮时,多个请求同时发现缓存未命中,会并发多次调用腾讯云合成同一个单词,触发限流/超时,前端降级后听感像"读了一半"。
解法:用一个 inflight Map 把"同一单词正在合成的 Promise"登记起来,并发请求共享同一个结果:
const inflight = new Map(); // word -> Promise<文件路径|null>
async function getWordAudio(word) {
const p = cachePath(word);
if (!p) return null;
// ① 缓存命中:先校验文件大小,异常(损坏/截断)则删除重合成
if (fs.existsSync(p)) {
const st = fs.statSync(p);
if (st.size >= 1024) return p;
try { fs.unlinkSync(p); } catch (e) {}
}
// ② 已有同一单词正在合成 → 直接等待它的结果(不重复调腾讯云)
const key = safeName(word);
if (inflight.has(key)) return inflight.get(key);
// ③ 未命中 → 创建合成任务并登记为 in-flight
const task = (async () => {
const buf = await synthesize(word); // 调腾讯云合成(内部已带重试)
if (!buf) return null;
fs.writeFileSync(p, buf); // 写入缓存,下次直接读
return p;
})();
inflight.set(key, task);
try {
return await task;
} finally {
inflight.delete(key); // 合成结束释放锁
}
}
5.4 合成重试 + 完整性校验:坏数据不进缓存
腾讯云接口偶发限流/超时,且偶尔会返回截断的音频。如果直接把坏音频写进缓存,以后每次播放都是"读一半"的 mp3。所以合成做了两件事:
for (let attempt = 0; attempt < 2; attempt++) { // 自动重试 1 次
try {
const data = await client.TextToVoice(params);
const audio = data && data.Audio;
if (!audio) continue;
const buf = Buffer.from(audio, 'base64');
// 完整性校验:正常单词语音 mp3 远大于 1KB,<1KB 视为异常/截断数据
if (buf.length < 1024) continue;
return buf;
} catch (e) {
if (attempt === 0) continue; // 第一次失败 → 重试
console.error('TTS 合成失败(已重试):', word, e.message);
}
}
return null;
配合缓存命中的大小校验(≥1KB,否则删掉重合成),从"写入"到"读取"两端都堵住了坏缓存。
5.5 成本几乎为零
- 全部单词约 80 个(去重后 79),合计不到 1 千字符;
- 腾讯云免费额度 800 万字符,每个词只合成一次,之后永远走本地缓存;
- 部署后跑一次预生成脚本,听写全程零 API 调用、零延迟:
npm install
npm run pregen-tts # 从 index.html 正则提取单词,跨年级去重,已有缓存自动跳过
最关键的兜底:合成失败 / 未配密钥 / 断网时,前端自动降级浏览器语音合成——发音功能永远可用,TTS 只是体验增强而不是硬依赖。
六、账号版前端:从 localStorage 到服务器同步
这是本次升级最大的一个改动。之前账号版的前端其实还是"数据写本地"的思维,本轮彻底重构为数据存服务器、本地只存登录态:
服务器 SQLite(权威数据)
↕ API:/api/auth/me + /api/progress(防抖 + 兜底)
浏览器 localStorage(只存登录态)
edt_token / edt_userName / edt_familyId / edt_parentToken
6.1 统一请求封装
所有接口走一个 api() 封装,自动带 token、自动解析错误,前端代码从此不用关心请求细节:
async function api(path, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
const t = opts.parent ? parentToken : token;
if (t) headers['Authorization'] = 'Bearer ' + t;
const res = await fetch(path, {
method: opts.method || 'GET',
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '请求失败');
return data;
}
6.2 防抖保存 + pagehide 兜底:最后一步也不丢
答题过程中积分/错题本高频变化,不能每改一次就发一次请求。saveStore() 用 400ms 防抖合并请求;同时在 pagehide 事件里用 keepalive: true 的 fetch 把最后一步冲上服务器——即使孩子直接关掉标签页,最后一次改动也不会丢:
let saveTimer = null;
let savePending = false; // 是否有尚未保存的本地改动
function saveStore() {
if (!token) return;
savePending = true;
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
try {
await api('/api/progress', {
method: 'POST',
body: { score: store.score, unlocked: store.unlocked, wrong: store.wrong, lastGrade: store.lastGrade }
});
savePending = false;
} catch (e) { showToast('⚠️ 进度保存失败:' + e.message, 'warn'); }
}, 400);
}
window.addEventListener('pagehide', () => {
if (savePending && token) {
clearTimeout(saveTimer);
fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ score: store.score, unlocked: store.unlocked, wrong: store.wrong, lastGrade: store.lastGrade }),
keepalive: true
}).catch(() => {});
}
});
6.3 单词年级选择条:四模式跟随解锁进度
升级前,普通/简单/困难/限时四个模式永远用 3 年级的单词——孩子解锁了高年级却练不到。现在首页新增一条单词年级选择条:
- 只有已解锁的年级可点选,选中态深紫填充;
- 四个模式取词全部跟随这里选中的年级;
- 点年级闯关也会同步更新选择(两者联动)。
// 当前选中的单词年级:优先最近选择;无效(未解锁/越界)时回退到已解锁最高年级
function getSelectedGrade() {
const lg = Number(store.lastGrade) || 0;
if (lg >= 3 && lg <= 6 && store.unlocked.includes(lg)) return lg;
return Math.max(3, ...store.unlocked);
}
6.4 lastGrade 云记忆:换设备不重置
选择年级时写入 store.lastGrade 并随进度一起保存到服务器;孩子登录时从服务器恢复,旧数据没有该字段则回退到已解锁最高年级。服务端只做"合法 + 已解锁"校验,非法值直接忽略保持原值:
// server.js —— /api/progress 中 lastGrade 必须合法才更新
let lg;
if (typeof lastGrade === 'number' && lastGrade >= 3 && lastGrade <= 6 && unlocked.includes(lastGrade)) {
lg = lastGrade;
}
store.updateProgress(req.user.userId, score, JSON.stringify(unlocked), JSON.stringify(wrong), lg);
这里有个小细节:updateProgress 只在传入数字时才更新 last_grade,避免旧版客户端把年级覆盖回默认值:
function updateProgress(id, score, unlocked, wrong, lastGrade) {
if (typeof lastGrade === 'number') {
db.prepare('UPDATE users SET score = ?, unlocked = ?, wrong = ?, last_grade = ? WHERE id = ?')
.run(score, unlocked, wrong, lastGrade, id);
} else {
db.prepare('UPDATE users SET score = ?, unlocked = ?, wrong = ? WHERE id = ?')
.run(score, unlocked, wrong, id);
}
}
6.5 默认头像:同名同头像,确定性生成
新孩子没有头像时,登录页和首页都显示一个空位。现在按名字做确定性哈希,从 40 个 emoji 头像库里选一个固定的默认头像——同名永远同头像,且孩子/家长可以随时自定义覆盖:
const AVATARS = ['🤴','👸','👑','🎩','🦸','🦸♀️','🧚','🧜♀️','🧜♂️','🧙','🧙♀️','💃','🕺','👮','👩🎤','👨🎤','👩🚀','👨🚀','🕵️','🧛',
'🦄','🐱','🐶','🐰','🦊','🐼','🦁','🐸','🐨','🐯','🐷','🐹','🐮','🐻','🐧','🐥','🦋','🐢','🐳','🦉'];
function avatarFor(name) {
let h = 0;
for (const ch of name) h = (h * 31 + (ch.codePointAt(0) || 0)) >>> 0;
return AVATARS[h % AVATARS.length];
}
七、部署与运维
7.1 三种启动方式
| 方式 | 命令 | 适用场景 |
|---|---|---|
| Docker(推荐) | docker compose up -d --build |
一键部署,数据持久化到 ./data |
| PM2 | pm2 start server.js --name english-dictation |
宝塔面板 / 开机自启 |
| nohup | nohup node server.js > app.log 2>&1 & |
临时跑一下 |
7.2 Docker 两阶段构建的小优化
better-sqlite3 有官方预编译二进制,多数服务器直接下载就能用,不需要编译工具链。所以 Dockerfile 分两阶段:
- 构建阶段:
npm ci --omit=dev,预编译下载失败才现场装python3 make g++编译; - 运行阶段:只拷
node_modules+ 源码,不带编译工具,镜像更小。
7.3 端口只绑本机:外网不能直连
升级前 8543 端口直接暴露在公网,任何人扫到端口都能直连。现在 docker-compose 把端口只绑定到 127.0.0.1:
ports:
- "127.0.0.1:8543:8543" # 只绑定本机:外网无法直连,只能通过 nginx 反代访问
外网访问链路变成:HTTPS → nginx(80/443)→ 127.0.0.1:8543,攻击面收敛到只留一个 nginx 入口。
另外两个关键决策:
config.json不拷进镜像,端口 / 密钥全走环境变量(TTS_SECRET_ID/TTS_SECRET_KEY),密钥不进镜像层;- 数据卷
./data:/app/data,数据库和发音缓存都持久化到宿主机,容器重建不丢。
7.4 配置分层
const config = {
port: Number(process.env.PORT) || configJson.port || 8543,
maxUsers: Number(process.env.MAX_USERS) || configJson.maxUsers || 50
};
环境变量 > config.json > 默认值——本地开发写 config.json,生产 Docker 全走环境变量,同一套代码两种环境都不用改。
7.5 域名与备份
- nginx 反向代理到
127.0.0.1:8543,HTTPS 由域名证书层处理; - 备份 = 直接复制
data.db文件(建议每周一次),恢复就是放回去重启; - 发音缓存
./data/tts/也在数据卷里,备份./data/即同时备份发音。
八、安全与工程细节清单
| 关注点 | 做法 |
|---|---|
| 家长码 / 管理员密码 | sha256 哈希存储,不存明文,无法找回只能重置 |
| 缓存文件名 | 白名单正则 + 长度限制,防路径穿越 |
| 请求体 | express.json({ limit: '200kb' }),防滥用 |
| 数据隔离 | 所有查询按 parent_id 过滤;普通接口不返回手机号字段 |
| lastGrade 校验 | 必须为 3-6 且已解锁才写入,非法值忽略保持原值 |
| 默认账号 | 无默认家庭、无默认家长码,全部家长自助注册;超管 admin/admin 首次启动即提示立即改密 |
| 会话失效 | 改家长码 / 删孩子 / 改管理员密码后,立即踢掉对应登录态 |
| 端口暴露 | Docker 只绑定 127.0.0.1,外网只能走 nginx 反代 |
| 旧数据 | 启动时自动迁移,事务保护,不丢积分和错题本 |
九、踩坑与取舍
- 浏览器 TTS 音色机械 → 服务端 TTS + 本地缓存 + 前端降级三层方案,音质和可用性兼得;
- 孩子连点发音被限流 → 同一单词的 in-flight 并发锁,并发请求共享一次合成,同时配合自动重试和 <1KB 完整性校验,坏数据不落盘;
- 孩子登录不能用密码 → 用"家长手机号验证",家长好管、孩子好记;
- 家长码必须全站唯一 → 登录只输码即可唯一定位家庭;旧数据里可能出现的重复码,交给管理员处理;
- 内存会话重启失效 → 接受"重启后重新登录",换来零会话持久化、无泄漏风险;
- 进度保存的请求风暴 → 400ms 防抖合并 + pagehide keepalive 兜底,既不打扰服务器也不丢数据;
- 单文件 HTML 的边界 → 数据只在本地,无法云同步,于是演进出账号版;单机版仍然保留,作为离线备份和断网兜底。
十、结语
一个"给娃用"的小工具,最后长成了一个带后端、多家庭隔离、管理员权限体系和云发音的完整系统。最近这一轮升级最有意思的地方在于:功能没怎么加,体验和安全却实打实上了一个台阶——数据真正上云、连点发音不再"读一半"、8543 端口不再裸奔公网。
回头看,最有价值的设计原则只有两条:
- 简单到孩子会用:两个快捷键、三个步骤完成听写;
- 强大到家长好管:一个页面管完孩子、积分、错题和家长码。
技术栈都不新,但"把合适的技术用到合适的位置"——localStorage 撑起单机版,SQLite 撑起账号版,TTS 缓存把一分钱成本用到极致,这就是这个项目最大的收获。
附:项目文件结构
English-Dictation-Training/
├── 小学英语单词听写训练.html # 单机离线版(单文件,localStorage + 浏览器TTS)
└── server/ # 账号版后端
├── server.js # Express 主程序(30+ 接口)
├── db.js # SQLite 数据层(建表 + 自动迁移)
├── tts.js # 腾讯云 TTS:缓存 + 并发锁 + 重试 + 完整性校验
├── pregen-tts.js # 预生成全部单词发音缓存
├── config.json # 本地配置(生产用环境变量覆盖)
├── Dockerfile # 两阶段构建
├── docker-compose.yaml # 端口 127.0.0.1:8543 + 数据卷 ./data
├── 部署说明.md # 完整部署文档
└── public/
├── index.html # 前端主页(登录 + 听写 + 服务器同步)
└── admin.html # 管理员页面(/admin)
评论区