简介:这是一份基于Python和微信小程序实现的“看图猜成语”游戏完整项目,适合Python初学者、小程序开发者及计算机专业学生作为课程设计或练手项目。资源共40个文件,压缩包仅559KB,包含6个py后端文件(Flask接口与逻辑)、4个wxml、7个wxss、5个js、6个json等小程序前端页面与配置,另有sql数据库脚本、md说明及doc/docx使用文档,结构清晰便于按模块学习。目前已有116人学习了该资源,包体虽小但五脏俱全。通过分析项目可掌握Python基础语法、Flask轻量级后端搭建、微信小程序事件驱动与页面渲染、文件与数据库操作等技能;附带的说明文档还能帮助快速了解运行和配置流程,适合理解前后端联动的小型应用从设计到实现的完整过程。
1. 看图猜成语小程序的判断逻辑,为什么不能全交给前端
看图猜成语这类小程序,功能拆开看就是“图库 + 答案表 + 判分页”,很多人第一反应是前端放一个 JSON,把所有成语答案打包进去,页面里比对一下字符串就结束。但一旦答案表进了小程序包,用户随便抓一次请求响应就能把整个题库枚举出来,冷启动的题库设计就全废了。所以标题里的 Python 在这个项目里承担的不只是“写生成脚本”,而是把出题、判分、进度记录三件事收回到服务端:小程序端只拿题号和图片,用户猜的词由 Python 接口裁决。这样做的好处有三点:题库集中在后端,答案不会随前端包泄露;答题进度可跨设备统计;后续改提示、加权重不用重新发版。下面这套方案,按“图片映射选型 → Python 接口设计 → 小程序接入 → 上线前检查”的顺序讲,适合正在做微信小程序练手项目、同时想摸清 Python 后端怎么和小程序协作的人。
2. 先定核心:Python 侧用哪种图片与成语的映射方案
2.1 预设映射表:一图一成语,先把数据模型定住
看图猜成语的素材通常来自画手或 AI 生图,一张图对应唯一成语,这种情况最可靠的做法是预设映射:给每张图分配唯一 id,把图片路径、答案、提示、权重写进一张表。项目初始化时可以用 JSON 做种子数据,方便人工维护:
# seed_puzzles.json { "version": 1, "puzzles": [ {"id": 1, "image_file": "imgs/p001.png", "answer": "鹤立鸡群", "hint": "鸟群里站着个大个子", "weight": 2} ] }这里id是题号,接口出题时用它定位;image_file只存相对于静态目录的路径,不要写完整 URL,否则以后换域名要全库替换;answer是唯一正确成语,只在后端判题时读取;hint在用户答错几次后展示;weight控制抽题概率,冷门题给低权重,避免每轮都抽到同一批题。图片路径用相对路径的另一个原因是开发、测试、线上域名不同,Python 接口只返回相对路径,小程序端自己拼BASE_URL,这样迁移环境时不需要改题库。常见做法是把这份 JSON 导入 SQLite 后就不再直接读 JSON,运行期统一走数据库。
给这套映射补充一个细节:图片资源命名不要用成语拼音或汉字本身,比如heli-jiqun.png这种文件名一旦被接口返回,用户从 URL 上就能把题面拆穿,整个猜谜过程就没有意义了。
2.2 感知哈希做相似图片匹配:素材是程序拼图的时候
如果题目图是运行时用 Pillow 把多个元素随机拼出来的,比如随机放一只鹤和一群鸡,那么每次生成的图片不可能有预生成好的 id。这种情况下常见做法是:生成完成后马上算一个感知哈希指纹,把指纹和题目 id 一起入库;需要判断“这张图对应哪道题”时,用汉明距离找最接近的候选。dHash 对这个场景足够轻量,开销比 pHash 小,抗尺寸缩放和轻微亮度变化:
# dhash_register.py from PIL import Image import imagehash # pip install imagehash def register_puzzle(gen_path: str, puzzle_id: int) -> str: with Image.open(gen_path) as img: h = imagehash.dhash(img, hash_size=16) # 16x16,指纹共256位 record_fingerprint(puzzle_id, str(h)) return str(h) def find_most_similar(target_path: str, top_n: int = 3): target_h = imagehash.dhash(Image.open(target_path), hash_size=16) for row in iter_all_fingerprints(): distance = target_h - imagehash.hex_to_hash(row["fp"]) yield row["puzzle_id"], distancehash_size=16会把图缩放为 16×16 灰度图,对相邻行像素做差分后得到 256 位指纹;汉明距离小于 10 一般视为同一画面,距离大于 20 基本可以判定不是同一题。实际项目里给dhash增加一个缓存层,不然每出一次题都要打开原图重新计算一次,本地压力不大,但线上同时几百人答题会拖慢出题接口。这里不要试图把答案写进图片 EXIF 或文件名,图片一旦返回给前端,EXIF 是可以被读取的。
2.3 OCR 方案的边界:什么时候才值得引入
搜索热词里经常把 OCR 和“看图猜成语”绑在一起,但这种方案适用面其实很窄。OCR 适合题目图里本身就嵌了字的情况,比如从诗句里抠出几个字,让用户组合成成语;而大部分画师出的意境图根本没有文字,OCR 对着纯画面硬识别只会得到一堆不可靠的候选字。
| 映射方案 | 实现成本 | 适合题目形式 | 常见误用 |
|---|---|---|---|
| 预设映射表 | 低 | 人工整理的一图一成语 | 答案写进前端包 |
| dHash/pHash | 中 | 程序现场拼图、用户上传相似图 | hash_size 太小导致误判 |
| OCR(PaddleOCR/pytesseract) | 高 | 图上带提示字、诗词抠字 | 对无文字的意境图硬识别 |
引入 OCR 后还要处理“识别出多个字如何组成成语”的组合问题,得额外维护候选字表再排列,这个复杂度对一个小程序来说得不偿失。我一般只在题目图由运营文案生成时才加 OCR 通道,普通素材直接走预设映射。判断标准很简单:人眼看到的图里有没有字,有才考虑 OCR,没有就不要碰。
3. 把出题变成接口:Flask、SQLite 与答案校验
3.1 用 SQLite 建题库:为什么比 JSON 更省心
开发机装 Python 3.8+,Windows 上记得安装时勾选 Add to PATH,Linux 下用python3 -m venv .venv起虚拟环境再装flask和pillow。上一章的 JSON 适合做种子,但不适合做运行期存储。多人同时答题时要记录“这个人做过哪些题、错了几次”,JSON 并发写会越来越乱。SQLite 是 Python 自带的解决方案,单文件、零部署、支持事务和索引,对小程序后端完全够用:
# schema.py import sqlite3 def init_db() -> None: conn = sqlite3.connect("riddle.db") conn.executescript(""" CREATE TABLE IF NOT EXISTS puzzles ( id INTEGER PRIMARY KEY, image_file TEXT NOT NULL UNIQUE, answer TEXT NOT NULL, hint TEXT NOT NULL DEFAULT '', weight INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE IF NOT EXISTS attempts ( uid TEXT NOT NULL, puzzle_id INTEGER NOT NULL, correct INTEGER NOT NULL DEFAULT 0, wrong INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (uid, puzzle_id) ); CREATE INDEX IF NOT EXISTS idx_puzzles_answer ON puzzles(answer); """)puzzles.image_file加 UNIQUE 约束,避免同一张图被录两次;attempts以(uid, puzzle_id)作为联合主键,用户重复提交答案时只会更新计数,而不会插出重复行。idx_puzzles_answer留给以后做答案模糊查询用,题库到几千题规模时还能保持在毫秒级返回。后面所有接口都通过连接riddle.db完成,连接池不必上,Flask 单进程下每次请求短开短关足够。
3.2 出题接口 GET /api/puzzle:只给题面,不给答案
学生项目里最常见的错误,是出题接口把answer字段也返给前端。哪怕前端不显示,微信开发者工具的 Network 面板里一样看得到,猜谜游戏的核心答案就全暴露了。出题接口只返回三样东西:题号、图片相对路径、提示文字:
# app.py from flask import Flask, jsonify, request import random import sqlite3 app = Flask(__name__) def query(sql: str, args=()): conn = sqlite3.connect("riddle.db") conn.row_factory = sqlite3.Row rows = conn.execute(sql, args).fetchall() conn.close() return rows @app.get("/api/puzzle") def get_puzzle(): uid = request.args.get("uid", "guest") rows = query( "SELECT p.id, p.image_file, p.hint, p.weight FROM puzzles p " "LEFT JOIN attempts a ON a.puzzle_id = p.id AND a.uid = ? " "WHERE a.puzzle_id IS NULL OR a.correct < 1", (uid,), ) if not rows: return jsonify({"code": 200, "done": True}) picked = random.choices(rows, weights=[r["weight"] for r in rows])[0] return jsonify({ "code": 200, "puzzle": { "id": picked["id"], "image": "/static/" + picked["image_file"], "hint": picked["hint"], }, })LEFT JOIN attempts配合WHERE a.correct < 1会把该用户已答对的题过滤掉,用户进入第二轮时不会立刻遇到同一题。weights=[r["weight"] for r in rows]让权重高的题被抽中的概率更高,但权重不是概率本身,它是相对比值:权重 2 的题被抽中的概率是权重 1 的题的两倍。如果rows为空,说明这个 uid 已经清完题库,返回done: True,小程序端拿到这个标志就展示“通关”页面,不需要再请求图片。
3.3 判题接口 POST /api/guess:清洗输入再比答案
判题不能直接做字符串相等。用户可能末尾多敲一个空格,输入法会把中文引号打成英文引号,还有人用全角字母试探。先做规范化,再和正确答案对比:
# guess.py import re import unicodedata from flask import jsonify, request # app.py 中的 query 函数在这里 import 复用 def clean_answer(raw: str) -> str: if not raw: return "" text = unicodedata.normalize("NFKC", raw) # 全角转半角 text = re.sub(r"[\s,,。.;;!!??、'\"“”·]+", "", text) # 去掉空白与标点 return text.lower() @app.post("/api/guess") def make_guess(): data = request.get_json(force=True) pid = int(data.get("id", 0)) user_answer = clean_answer(data.get("answer", "")) rows = query("SELECT answer FROM puzzles WHERE id = ?", (pid,)) if not rows: return jsonify({"code": 404, "ok": False, "message": "题目不存在"}) correct_answer = clean_answer(rows[0]["answer"]) ok = user_answer == correct_answer record_attempt(data.get("uid", "guest"), pid, ok) return jsonify({"code": 200, "ok": ok, "message": "答对了" if ok else "再想想"})unicodedata.normalize("NFKC", raw)会把全角A转成半角A,也能把全角逗号转成半角逗号,后面的正则把英中文逗号、句号、感叹号、问号、引号一并删掉,只保留汉字主体。这里有个坑:NFKC 不会把繁体字转成简体字,如果题库里是简体成语,用户输入繁体“鶴立雞群”会判错。需要兼容繁体时加一步 opencc 转换:
from opencc import OpenCC cc = OpenCC("t2s") raw = cc.convert(raw) # 繁体转简体,放在 NFKC 之前record_attempt用一条 UPSERT 完成正确与错误次数累加:
def record_attempt(uid, puzzle_id, ok): col = "correct" if ok else "wrong" query( f"INSERT INTO attempts (uid, puzzle_id, correct, wrong) VALUES (?, ?, ?, ?) " f"ON CONFLICT(uid, puzzle_id) DO UPDATE SET {col} = {col} + 1", (uid, puzzle_id, 1 if ok else 0, 1 if not ok else 0), )col在函数内部按ok固定取值,不接收用户输入,所以不存在 SQL 注入面。答错时接口不要返回正确答案,最多返回“再想想”,正确成语只在用户最终查看答案或通关汇总时给出。
3.4 接口参数速查:小程序端对接时不迷路
| 接口 | 方法 | 必填参数 | 返回关键字段 |
|---|---|---|---|
/api/puzzle | GET | uid | puzzle.id、puzzle.image、puzzle.hint,不返回 answer |
/api/guess | POST | id, answer, uid | ok、message |
/api/hint | GET | id | first_char、hint |
POST 请求体用application/json,小程序端wx.request的header必须显式写成{'content-type': 'application/json'}。虽然 Flask 的get_json(force=True)能兜底解析,但force会吞掉格式错误,实际项目里我宁可让它直接抛 400,由统一异常处理器返回“参数格式错误”,这样前端更容易定位问题。
4. 小程序端接入方案:请求封装、答题状态机与加载降级
4.1 先用一个 callApi 把 wx.request 包起来
微信小程序的wx.request每次都要写success/fail/complete,接口一多就大量重复。我在这个项目里第一件事是封一层callApi,统一处理超时、HTTP 状态码和错误提示:
// utils/request.js const BASE_URL = "https://puzzle.example.com"; // 替换成自己的合法域名 function callApi(path, { method = "GET", data = {}, timeout = 5000 } = {}) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${path}`, method, data, timeout, header: { "content-type": "application/json" }, success(res) { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data); } else { reject(new Error(`HTTP_${res.statusCode}`)); } }, fail(err) { if (err.errMsg && err.errMsg.includes("timeout")) { wx.showToast({ title: "请求超时,请重试", icon: "none" }); } reject(err); }, }); }); } module.exports = { callApi };timeout默认 5000 毫秒,普通题目接口返回足够快;如果后续加“玩家上传图片求助”功能,这个参数要单独调到 15000。fail分支把timeout单独摘出来,是为了给用户明确反馈,而不是一律弹“网络错误”。注意BASE_URL必须在微信公众平台后台配置为 request 合法域名,开发阶段可以临时勾选“不校验合法域名”,提交审核前必须关掉这个调试选项,否则线上请求会被拦。
4.2 答题状态机:从加载到答对再进入下一题
小程序页面至少需要四种状态:加载中、答题中、答错停留、答对等待。用散落的if判断容易漏掉“加载中禁止提交”“答对后不能立刻重复提交”这两个边界,我会用 state 字段管住:
// pages/index/index.js const { callApi } = require("../../utils/request"); const STATE = { LOADING: "loading", READY: "ready", WRONG: "wrong", CORRECT: "correct", }; Page({ data: { state: STATE.LOADING, puzzle: null, input: "", wrongTimes: 0, }, async onLoad() { await this.nextPuzzle(); }, async nextPuzzle() { this.setData({ state: STATE.LOADING, wrongTimes: 0, input: "" }); const res = await callApi("/api/puzzle", { data: { uid: getUid() } }); if (res.done) { this.setData({ state: STATE.READY, puzzle: null, done: true }); wx.setNavigationBarTitle({ title: "全部通关" }); return; } this.setData({ state: STATE.READY, puzzle: res.puzzle }); wx.setNavigationBarTitle({ title: "看图猜成语" }); }, async onSubmit() { if (this.data.state === STATE.LOADING || this.data.state === STATE.CORRECT) { return; // loading 和 correct 状态下不重复提交 } const res = await callApi("/api/guess", { method: "POST", data: { id: this.data.puzzle.id, answer: this.data.input, uid: getUid(), }, }); this.setData({ state: res.ok ? STATE.CORRECT : STATE.WRONG, wrongTimes: res.ok ? 0 : this.data.wrongTimes + 1, }); if (res.ok) { setTimeout(() => this.nextPuzzle(), 300); } }, });关键在两步:nextPuzzle一开始就把状态置为LOADING,旧题的提交按钮立即失效;onSubmit对CORRECT状态直接 return,防止用户快速连点触发两次 POST。后端虽然有attempts表兜底,但前端先挡一层能少一堆无效请求。setTimeout(300)是给“答对”的视觉反馈留时间,直接 0ms 切换会让页面看起来像闪了一下。导航栏标题随状态切换,对应搜索热词里“小程序动态设置标题”的需求,用户通关后看到标题变化比弹窗更有沉浸感。
4.3 图片加载失败与加载页降级
后端返回的是/static/imgs/p001.png这种相对路径,小程序端要拼上BASE_URL。图片服务偶尔会慢,加载页不能一直转圈。我会在加载完成前先用占位图顶住,等binderror触发时切到本地兜底图:
<view class="puzzle-wrap"> <image wx:if="{{puzzle}}" src="{{imgLoaded ? puzzleImageFull : placeholder}}" mode="aspectFit" binderror="onImageError" lazy-load /> <view wx:if="{{state === 'loading'}}" class="loading-mask">题目加载中</view> </view>onImageError() { if (!this.data.puzzle) return; this.setData({ placeholderBroken: true }); },这里的思路是:src先用placeholder占位,图片地址通过另一段逻辑在onLoadImage成功回调里替换成真实题目图;如果真实图加载失败,binderror把placeholderBroken置为 true,页面显示“图片开小差了,点击重试”。注意不能反过来——先把src设成真实地址,失败后再换 placeholder,否则binderror触发时源已经是坏地址,后期排查会把问题误判到后端。题目图地址只需要拼一次BASE_URL,小程序端不要在代码里根据题目 id 自行猜图路径,防止被爬走成语表。
5. 上线前值得做的三件事:题库一致性、判题边界与冒烟压测
5.1 题库一致性检查:别等用户帮你发现坏图
# check_puzzles.py import sqlite3 import pathlib conn = sqlite3.connect("riddle.db") images = [r["image_file"] for r in conn.execute("SELECT image_file FROM puzzles")] missing = [p for p in images if not pathlib.Path("static", p).exists()] dup = conn.execute( "SELECT answer, COUNT(*) c FROM puzzles GROUP BY answer HAVING c > 1" ).fetchall() print("missing files:", missing) print("duplicated answers:", dup)跑一遍就能找到指向不存在文件的坏题,以及答案重复的题。后者会让玩家换一张图后猜同一个词,体验很差。脚本返回非 0 退出码时直接 fail 掉 CI,这份检查应该和单元测试并列,而不是上线前手动跑。
5.2 判题边界用例:全角、繁体、空格一起测
中小项目不一定要上 pytest,本地先跑一串断言也能暴露大部分问题:
assert clean_answer("鹤立鸡群 ") == "鹤立鸡群" assert clean_answer("鹤立鸡群!") == "鹤立鸡群" assert clean_answer("ABC") == "abc" from opencc import OpenCC cc = OpenCC("t2s") assert cc.convert("鶴立雞群") == "鹤立鸡群"第一行验证末尾空格被清掉;第二行验证中文感叹号被剔除;第三行验证全角字母 NFC 归一化;第四行验证繁体转简体。判题接口真正的坑不在相等判断,而在输入归一化不完整。
5.3 单机冒烟压测:先看 p95 再谈缓存
合并主干前用 locust 对出题接口做一次短时冒烟:
locust -f loadtest.py --headless -u 20 -r 5 -t 30s重点看 p95 延迟是否超过 300ms。如果超了,优先优化 SQLite 连接复用,把每次请求都connect改成请求级复用或单例连接;不要一开始就上 Redis,题库几百条时 Redis 是多余的复杂度。把locust的退出码接进 CI,p95 超阈值就让流水线失败,再往下走才是数据库换 PostgreSQL 或加图片 CDN 的事。
本文还有配套的精品资源,点击获取