止观
AI前沿观察

[F3]给娃做了个背古诗App,第14天他主动要账号

一个小学生产品把坑踩全了:75 首教材诗、服务端判卷、三道防刷闸门、拼音逐字对齐。这篇只讲技术选型和判分设计,完整代码与数据库不放。

先说结果:我上二年级的儿子,连续 14 天每天主动打开这个 App 背诗,昨天跟我说“爸爸给我个自己的账号”。做这个之前,我在应用商店试了三个背诵类 App,第一个开屏广告 5 秒,第二个挖空答案是前端明文,他用开发者工具看了两分钟就跟我炫耀“这诗我全都会”。

那一刻我就决定了:自己写一个。

这篇是 AI 项目实战系列第一篇。全程一个人加一个 AI 结对,从第一行后端代码到今天 16 天,130 次提交,136 个测试用例。下面按踩坑顺序讲:选型、注音、判分、防刷。

※ ※ ※

📱 选型:为什么是 uni-app + FastAPI,不是小程序

第一反应当然是小程序,孩子iPad上即用即走。但小程序有两个月我绕不过去的坎:审核和类目。教育类目要资质,个人主体根本下不来;每次改动热修复要走审核,孩子第二天要默写的诗今天发现标错拼音,你等不了那几个小时。

所以选了一套源码三端可转的方案,先出 H5 部署在自己的服务器上,域名备案下来后直接切,将来想上小程序/App 同一套代码编译转端:

层选型一句话理由
前端uni-app(Vue3 + Vite + TS)一套代码,H5 先行,小程序/App 留后路
后端FastAPI + SQLAlchemy判分逻辑全是 Python,AI 结对写起来最顺
存储SQLite(开发)→ MySQL 同模型BigInteger 主键做双库兼容,起步阶段零运维
后台sqladmin 挂在 FastAPI 上内容运营(诗词、审核、参数)要有人话界面

一句话:不为架构美感买单,为“改一个字第二天能生效”买单。

设备方向上定了一条铁律:手机竖屏、平板横屏,两套页面模板各端专用,不是响应式拉伸。平板竖屏持有时会弹一个旋转引导页。这条后来被验证是对的,孩子家 mostly 用平板,横屏双栏的答题体验跟竖屏根本不是一回事,硬用一套 CSS 适配,两头都别扭。

※ ※ ※

🔤 第一个大坑:拼音必须站在汉字正上方

教材古诗必须逐字注音,这事的复杂度远超我预估。三个坑连着踩。

坑一:分词错位。 用 pypinyin 整句取音,默认走词组模式(“两岸”读 liǎng àn 没问题),但诗句里的标点会把“第 i 个汉字对应第 i 个读音”的索引全部打乱。解法是先把整句切成「汉字段/非汉字段」,汉字段整段取音 zip 回字,标点原位留空。

坑二:多音字教材口径。 “远上寒山石径斜”的“斜”,pypinyin 按现代语境给 xié,但教材注释要求读 xiá 押韵;“白云生处有人家”的“生”不是“深”,这种是内容问题不是读音问题。最后落成一个覆写表:结构是 {诗题: {字: (读音, 第几次出现)}}。按“字+行内序次”而不是全局位置来定位,因为增删标点会让字符偏移漂移,而行内“这是第几个'斜'”永远稳定。通假字那类(“风吹草低见牛羊”的见读 xiàn)逐条查统编教材注释收进规则表,有争议的旧读只写注释不做覆写。

坑三:渲染层的物理定律。 拼音要在汉字正上方,最初用 flex 纵向单元(上 text 拼音、下 text 汉字)做等宽字格。但标点怎么办?把逗号做成独立字格,它的空拼音占位会把逗号视觉上顶到上排;把“逗号”并进前一个汉字的内联流(<text>鹅,</text>),字格容器垂直居中又把“鹅”字本体挤离格中心,拼音倒是居中了,两者错开。调 margin 救不了,这是布局物理:只要标点参与居中宽度计算就必歪。

最终修法:汉字独立 <text> 居中占格,标点绝对定位悬挂在字右缘(position:absolute; left:100%),不计入居中宽度。验收不靠肉眼,用 CDP 探针逐格断言 拼音中心x - 汉字中心x == 0,带标点的格子一起断。小格子的像素错位肉眼根本看不出,探针是唯一可信的裁判。

还有个连锁坑:七言诗一行七个字格,定宽格 ×7 超过手机正文宽度就折行。按该行汉字数分档缩格(格宽和字号同步缩),四档:≤5 字、6-7、8-9、10-12。验收时先在数据层断言,再真机看观感,无头浏览器里量宽度全是假象(视口固定小宽会把 flex 挤成竖排,截图跟真机完全两样)。

※ ※ ※

📚 75 首诗的入库血泪:标题会骗人,正文不会

选型和渲染都是前菜,真正让我返工最多的是内容:统编教材必背 75 篇,一篇都不能错。

最初我以为拿篇目表按标题去爬就行。跑完第一遍对账就傻了:《凉州词》有两首(王翰、王之涣),《绝句》有两首,《悯农》《四时田园杂兴》各有两组,教材里还带编号变体(《回乡偶书》在库里可能写成《回乡偶书·其一》)。按标题匹配,同名诗互相吞,改题变体全漏。

后来定的口径是正文指纹:每首取正文首句的前 8 到 10 个字(剥掉标点)当唯一键,全库扫描比对。标题只用来做人眼核对,绝不用来对账。唯一命中即入库,多命中拿作者消歧,零命中的才是真缺口,人工逐首核。一句话:内容系统的对账键要选“不会重名也不会被改写”的那一列,诗里就是正文本身。

抓取源的选择也是一课。第一个站正文卡片结构精美但脆,页面改版一次碎一片;换成从 <title> 提作者朝代、按固定小节标题切正文段落之后,管道三个月没碎过。每个 id 的抓取结果落一份 JSON 缓存,重跑跳过已有,网络断了接着跑。抓回来的译文赏析尾部混着“拼音有误?我来纠错”这类页面残骸,清洗锚点用特征正则一次性截掉,终扫断言全库 0 残留才许入库。

教育内容我给自己立了条铁律:任何一篇正文、任何一条注释,AI 默写的一律不算数。 AI 背诗会“自信地”改字,“白云深处有人家”还是“生处”这种一字之差,它敢给你编个出处。所有正文必须教材/多源交叉核准,AI 只干清洗、断句、格式化的活。断行口径后来也分了两套:诗词按逗号逐逗断行(教材版式),文言文按句末标点断、段内连读(“人之初,性本善”是一行不是三行)——两套规则喂反一次,整个库的行就全碎了,所以入库脚本里断行函数前面挂着文体判断。

※ ※ ※

⚖️ 判分为什么必须放服务端:前端给的答案,孩子五分钟就能破解

这个 App 的核心玩法是“挖空背诵”:整诗展示,随机一部分汉字挖掉,四个选项选一。判分如果在前端,答案数组就在响应里,等于没有判分。

所以定了一条架构红线:客户端永远拿不到“正确答案”这个字段,判分在服务端重算。

具体做法是自出卷可复现。挖空的位置、选项的顺序,全部由一个种子随机数生成器决定,种子是用户 ID、诗 ID、日期三者的哈希:

import hashlib, random

def _seed(user_id, poem_id, day) -> int:
    h = hashlib.sha256(
        f"poemapp:{user_id}:{poem_id}:{day}".encode())
    return int(h.hexdigest()[:12], 16)

def gen_quiz(lines, user_id, poem_id, day, homophones, reroll=0):
    rng = random.Random(_seed(user_id, poem_id, day)
                        + reroll * 7919)
    blanks = []
    for li, line in enumerate(lines):
        idxs = [i for i, (ch, _) in enumerate(line)]
        n = max(1, min(4, round(len(idxs) * 0.30)))
        for pos in sorted(rng.sample(idxs, n)):
            ch, py = line[pos]
            blanks.append({"line": li, "pos": pos,
                           "correct": ch,
                           "options": make_options(...)})
    public = redact(blanks)          # 下发前去掉了 correct
    key = {(b["line"], b["pos"]): b["correct"] for b in blanks}
    return public, key

孩子提交答案时,服务端拿同一种子重新算一遍题面,逐空比对。不存“当前会话答案”,判卷无状态,重放和篡改题面都没有落脚点。当天重背(reroll)换盐不换机制。

服务端判卷全链路:题面由种子复现,答案只在服务端

这里埋过一个非常隐蔽的雷:干扰项选项的随机数和挖空位置的随机数最初共用一个主 rng。后果是同音字字库一扩充,选项抽取把主 rng 的序列消费乱了,挖空位置跟着漂移,孩子页面上做的题和服务端重算出的题对不上,全卷误判。修法是每个空位派生独立的子种子(f“{seed}:{line}:{pos}:{reroll}”),主 rng 只管挖空。教训一句话:同一个随机流管两件事,其中一件的数据一扩容,另一件事就遭殃。

干扰项质量是产品灵魂。四个选项 = 正确字 + 带调同音字 + 去调同音字 + 形近字。同音字池不引外部字典,直接从诗库逐字注音里聚合,拼音到汉字集合,天然都是课本里学过的字;形近字维护一张教材常见错字表:

CONFUSES = {
    "已": ["己", "巳"], "己": ["已", "巳"], "候": ["侯"],
    "幕": ["暮", "慕"], "坐": ["座"], "州": ["洲"],
    "带": ["戴"], "升": ["生"], "作": ["做"],
}

孩子在这几个字上栽的跟头,全在表里。一句话:让干扰项“像那么回事”,比让题目变难更有教育价值。

※ ※ ※

🚧 三道防刷闸门:他为了刷积分能干出你想不到的事

背会通过给积分,积分能兑换小奖励(1 元等价 1000 积分的定价口径)。上线第一晚我就想清楚了:一个 motivated 的八岁孩子加上一个会 F12 的十岁表哥,就是攻击面。

防刷分三道闸,全部收口在判卷接口一个函数里:

闸门一:服务端签发的答题令牌。 题面下发时附带一个 HMAC 签名 token,内容含用户、诗、签发时间戳:

def qt_sign(payload: str) -> str:
    return hmac.new(SECRET.encode(), payload.encode(),
                    hashlib.sha256).hexdigest()[:32]
# token = f"{uid}:{pid}:{reroll}:{ts}.{qt_sign(payload)}"

闸门二:时间闸。 交卷耗时必须过门槛,门槛以服务端签发时刻起算,客户端自报的 duration_sec 只作记录、不作依据(可伪造):

def min_duration(total_blanks) -> float:
    return max(3, 2.5 * max(1, total_blanks))
    # 每空至少 2.5 秒,地板价 3 秒

一首 20 空的七绝,4 秒交卷直接 429:“答得太快啦,认真背完再交卷。”这一闸顺带封死了“脚本循环点选”路径:机器人可以秒答,但过不了物理反应时间。

闸门三:频控与配额。 滑动 1 小时判卷次数上限 30 次(含失败尝试),每首诗每天练习次数有上限,每日“新背得分”名额封顶 5 首。第三闸防的不是脚本,是肝:一个孩子如果一小时背 30 首换取积分,那是惩罚不是奖励,系统得拦住。

token 缺失或伪造的处理策略值得单说:判卷照常、积分清零、记一条守卫日志。不拒绝服务。因为旧版本客户端可能不带 token,崖式拒绝会把正常用户拦在门外;静默零分 + 日志,既能发现异常又不伤友军。

def gate_check(body, user, total_blanks):
    ok, ts = verify_token(body.quiz_token)
    if not ok:
        guard_log(user.id, "bad_quiz_token")
        return False            # 分数照算,积分不发
    if time.time() - ts < min_duration(total_blanks):
        guard_log(user.id, "too_fast")
        raise HTTPException(429, "答得太快啦")
    return True

※ ※ ※

📈 排行榜:最难的设计不是算法,是“别让孩子学会作弊”

榜单综合分五项加权:

W_RECITED = 15.0      # 每背熟一首
W_CHECKIN_DAY = 1.5   # 每个打卡日
W_REVIEW = 2.0        # 每次复习通过,每日封顶计 6 次
# 连签阶梯:≥3天×1.0/天  ≥7天×1.5/天  ≥30天×2.5/天
# 积分项按极小权重参与

权重集中一个文件,调口径只改一处。复习项挂钩艾宾浩斯间隔序列,背熟的诗按 1、3、7、15、30 天顺延到期,到期列表和徽章数字共用同一个“按诗分组取最近日志”的子查询。

艾宾浩斯复习链:背熟是间隔序列的起点

这个口径最初写岔过一次:徽章裸 count 日志行,复习每通过一轮写一条新 log,数字越滚越虚高,孩子会一首一首点进去跟你算账。展示层的每个数字都必须能点进去对上明细,儿童的直觉比产品经理敏锐。

飞花令对战模块跟 AI 打,三档人设:长安才女(手里 4 句)、洛阳进士(6 句)、金陵诗仙(不限)。令字从教材 75 首覆盖的字里优先抽,孩子打不过才女的时候,说明诗还没背熟,而不是系统坏了。

※ ※ ※

🚪 一次限流误伤:防机器人的墙,先拦住了真人

上线第三天,我儿子连背三首诗之后 App 报“请求过于频繁”。查出来是 nginx 层的限速被当成了主墙:一个页面加载本身就突发十几个同前缀的读请求,全家 WiFi 又是一个 NAT 出口共享一个 IP,限速 30 次/分钟直接误伤真人。

修完之后这套防线变成两层,口径写进了项目文档:防刷的主墙永远放应用层(游客按 IP 日配额、登录用户豁免、判卷走 HMAC 令牌),nginx 限速只当粗筛,拦“每秒几十路的拖库脚本”,rate 设到真人怎么点都撞不到。同一场误伤还暴露了一个前端分支缺失:带 token 被 429 和游客超配额是两回事,前者多半是 JWT 过期,提示语应该是“登录已过期,重新登录即可”,而不是把“今日免费额度用完”甩给一个已登录的孩子。一句话:任何一道闸,都要能分得清它拦的是谁,并且把这句话原样告诉被拦的人。

部署形态上还有个经典的“Chrome 打不开、微信能开”:app 只挂在 HTTPS 的 443 站下,80 口没配对应路径,浏览器自动补 http:// 就 404。修法是一行 301 整站升级。IP 直连的站证书自签,微信转发链接不抓缩略图也是这类环境限制的叠加,后来备案域名下来配了正经证书,一次迁走:新域名 vhost 沿用同一组 /app /api 前缀反代同一端口,构建产物零改动,双站并行过渡。

※ ※ ※

🧪 136 个测试,AI 结对开发的质量线

全程 AI 结对写代码,我的纪律只有一条:每个功能落地当天补 pytest 用例,136 个测试全绿才允许部署。时间口径这类全局陷阱就是测试兜住的,所有“今日/当天”的比较必须走同一个 timeutil(本地日换算 UTC 起点),SQLite 的 CURRENT_TIMESTAMP 存 UTC、Python 的 date.today() 是本地日,两个口径混用,每日限额会静默错位,防刷闸变成摆设。

一句话总结这套质量体系:凡是“闸”,必有回归用例证明它能关上;闸门没有失败用例,等于没有闸门。

※ ※ ※

写在第 14 天

回到开头那件事:他要账号的那天,我把 iPad 递给他,他自己注册、自己设了个“诗友007”的昵称,第一句是问“我能不能跟同学比赛”。产品的路线图瞬间被一个八岁用户改写了。

这 16 天里 AI 帮了我大概七成的工作量,但真正值钱的部分,是我知道哪里要立红线:答案不下发、时间不信客户端、每个数字对得上明细。AI 是个快得吓手的结对伙伴,红线画歪的时候,它不会提醒你。

下一篇预告:AI 项目实战②——《每晚 22:10,我的服务器自己给自己写投资日报》。一套 A 股量化监控系统里,市场闸门、因子评分、回测方法学三层是怎么分的,以及为什么说最难的不是策略,是让自己不骗自己。

※ ※ ※

※ ※ ※

资料来源

  • ▪ 后端判分与防刷核心:示例项目自建 FastAPI 服务(出题引擎、判卷闸门、榜单评分、内容入库四个模块)
  • ▪ uni-app 官方文档:Vue3 + Vite 工程线(H5 编译与条件编译)
  • ▪ pypinyin 官方文档:Style.TONE 与 heteronym 模式
  • ▪ 教材篇目依据:统编小学语文教材必背古诗文 75 篇(公开目录)
  • ▪ 本文所有终端输出与数字(130 commits / 136 用例 / 阈值参数)来自作者本机项目仓库实测,示例主机与项目名均为脱敏改写,文中未出现任何真实服务器 IP、域名或账号信息;部署示意一律使用 RFC 5737 文档保留地址(203.0.113.0/24)
  • ▪ 生成日期:2026-09-27

评论 (0)