项目长啥样:看图猜成语小程序
用户在微信小程序里看到一张图(比如"🐍 🌿"),从 18 个备选字里挑出 4 个组成成语("杯弓蛇影"),答对就过关。本章只写后台 API,前端小程序不写——上完这节课,你的接口能直接对接前端同学。
前后端分离的接口契约
装包 + 建目录
$ pip3 install fastapi "uvicorn[standard]" "python-jose[cryptography]" -i https://pypi.tuna.tsinghua.edu.cn/simple
$ mkdir /home/idiom && cd /home/idiom
$ touch main.py db.py auth.py init_db.py
- main.py — FastAPI 路由 / 业务接口
- db.py — SQLite 数据库操作
- auth.py — JWT 生成 / 验证
用户表 + 题目表
init_db.py
import sqlite3, json
conn = sqlite3.connect("idiom.db")
c = conn.cursor()
# ============ 用户表 ============
c.execute("DROP TABLE IF EXISTS users")
c.execute("""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
nickname TEXT,
level INTEGER DEFAULT 1 -- 当前关卡,从 1 开始
)
""")
# ============ 题目表 ============
# answer = "杯弓蛇影" options = "杯弓蛇影山月日云风火..."(含正确答案的备选字)
c.execute("DROP TABLE IF EXISTS games")
c.execute("""
CREATE TABLE games (
level INTEGER PRIMARY KEY, -- 关卡号
image TEXT NOT NULL, -- 图片 URL(emoji 代替)
answer TEXT NOT NULL, -- 正确答案
options TEXT NOT NULL -- 18 个备选字(一串)
)
""")
# 灌一组测试题
games = [
(1, "🐍🌿", "杯弓蛇影", "杯弓蛇影山月日云风火水土木金"),
(2, "🐎🚀", "一马当先", "一马当先二三四五六七八九十百千"),
(3, "🌕🌳", "花好月圆", "花好月圆春夏秋冬山水风云东西南北"),
(4, "🐯🐲", "龙腾虎跃", "龙腾虎跃日月星辰天地玄黄宇宙洪荒"),
]
for g in games:
c.execute("INSERT INTO games VALUES (?, ?, ?, ?)", g)
# 默认建一个测试用户
c.execute("INSERT INTO users (username, nickname) VALUES (?, ?)",
("alice", "小爱"))
conn.commit(); conn.close()
print("✅ 数据库就绪")
db.py
import sqlite3
DB = "idiom.db"
def _conn():
c = sqlite3.connect(DB)
c.row_factory = sqlite3.Row
return c
def get_or_create_user(username: str):
# 没注册就建一个,返回用户行
with _conn() as c:
u = c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if u: return u
c.execute("INSERT INTO users (username, nickname) VALUES (?, ?)",
(username, username))
return c.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
def get_game(level: int):
with _conn() as c:
return c.execute("SELECT * FROM games WHERE level=?", (level,)).fetchone()
def level_up(username: str):
# 答对了 → 关卡 +1
with _conn() as c:
c.execute("UPDATE users SET level=level+1 WHERE username=?", (username,))
def rank_top10():
with _conn() as c:
return c.execute(
"SELECT nickname, level FROM users ORDER BY level DESC LIMIT 10"
).fetchall()
JWT:手机端鉴权的标准方案
- 登录成功 → 服务器返回一个签名后的字符串(token)
- 客户端把 token 存起来,每次请求都带上(放在 HTTP Header 里)
- 服务器验签(不用查数据库)就能识别用户——纯无状态,扩展性极好
auth.py:发 / 验 JWT
# ============ JWT 鉴权工具 ============
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from fastapi import Header, HTTPException
# 上线项目要从环境变量读,且要足够复杂的随机串
SECRET_KEY = "idiom-2026-classroom-demo-key"
ALGORITHM = "HS256" # HMAC + SHA256,最常用的 JWT 算法
EXPIRE_DAYS = 7
def create_token(username: str) -> str:
# payload 是要塞进 token 里的数据
# sub 是 JWT 标准字段——subject(主体),用来放用户标识
# exp 是过期时间
payload = {
"sub": username,
"exp": datetime.now(timezone.utc) + timedelta(days=EXPIRE_DAYS)
}
# jwt.encode:用 SECRET_KEY 给 payload 签名 → 字符串 token
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(authorization: str = Header(None)) -> str:
# 这是 FastAPI 的"依赖项"——在每个需要登录的接口参数里 Depends 它
# Header(None) 表示从 HTTP 头取 Authorization
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="缺少认证 token")
# 标准格式:"Bearer eyJhbGciOiJIUzI1..."
token = authorization[7:] # 去掉 "Bearer " 前缀
try:
# jwt.decode:用 SECRET_KEY 验签 + 自动检查过期
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if not username:
raise HTTPException(status_code=401, detail="token 内容不正确")
return username
except JWTError:
raise HTTPException(status_code=401, detail="token 无效或已过期")
main.py:第 1 个接口(登录发 token)
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
import db
from auth import create_token, get_current_user
app = FastAPI(title="看图猜成语 API")
# =========== 请求 / 响应模型 ===========
class LoginRequest(BaseModel):
username: str
class LoginResponse(BaseModel):
token: str
nickname: str
level: int
# =========== ① 登录接口 ===========
@app.post("/login", response_model=LoginResponse)
def login(req: LoginRequest):
# 在小程序对接时,这里用 wx.login 拿到的 openid 替代 username
user = db.get_or_create_user(req.username)
return LoginResponse(
token=create_token(req.username),
nickname=user["nickname"],
level=user["level"],
)
uvicorn main:app --reload --host 0.0.0.0,访问 /docs:找到 POST /login → Try it out → body 填 {"username":"alice"} → 拿到一长串 token。把这串 token 复制下来,下面要用。
答题接口:取题 + 判分
追加到 main.py:
# =========== 业务模型 ===========
class GameInfo(BaseModel):
level: int
image: str
options: str # 18 个字组成的字符串
class AnswerRequest(BaseModel):
answer: str # 用户提交的 4 字答案
class AnswerResponse(BaseModel):
correct: bool
next_level: int # 答对则进入的下一关;答错保持原关
finished: bool # 是否通关
# =========== ② 当前关卡题目 ===========
# 注意:参数 user = Depends(get_current_user)
# Depends:FastAPI 自动调用 get_current_user 把返回值塞进来
# 没有合法 token 的请求会被它拦下来 → 返回 401,根本进不到函数体
@app.get("/game/current", response_model=GameInfo)
def current_game(user: str = Depends(get_current_user)):
u = db.get_or_create_user(user)
game = db.get_game(u["level"])
if not game:
raise HTTPException(404, "你已经通关啦!")
# 注意:不能把 answer 字段返回给前端——前端拿到答案就作弊了
return GameInfo(
level=game["level"],
image=game["image"],
options=game["options"],
)
# =========== ③ 提交答案 ===========
@app.post("/game/answer", response_model=AnswerResponse)
def submit_answer(req: AnswerRequest,
user: str = Depends(get_current_user)):
u = db.get_or_create_user(user)
game = db.get_game(u["level"])
if not game:
return AnswerResponse(correct=False,
next_level=u["level"],
finished=True)
# 比较答案。strip 防止前端误传空格
if req.answer.strip() == game["answer"]:
db.level_up(user) # 关卡 +1
new_level = u["level"] + 1
finished = db.get_game(new_level) is None # 没下一题 = 通关
return AnswerResponse(correct=True,
next_level=new_level,
finished=finished)
else:
# 错了不升级,让前端提示用户重答
return AnswerResponse(correct=False,
next_level=u["level"],
finished=False)
用 /docs 测试带 token 的接口
带认证的接口在 Swagger UI 里测的方法:
- 右上角点 Authorize 🔓 按钮
- 把刚才登录拿到的 token 填进去(仅写 token 本身,前面
Bearer不用写——但下面我们 main.py 中用的是 raw Header 方式,所以这里需要一个变体)
get_current_user(authorization: str = Header(None)) 写法简单清晰,但 Swagger UI 默认不会有"Authorize"按钮。课堂直接用 curl:
# 1. 登录拿 token
$ TOKEN=$(curl -s -X POST http://IP:8000/login \
-H "Content-Type: application/json" \
-d '{"username":"alice"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
$ echo $TOKEN
# 2. 拿当前题目
$ curl http://IP:8000/game/current \
-H "Authorization: Bearer $TOKEN"
# 3. 答题
$ curl -X POST http://IP:8000/game/answer \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"answer":"杯弓蛇影"}'
{"correct":true,"next_level":2,"finished":false}
answer 字段返回给前端——前端能拿到就能直接显示出来,作弊一查一个准。判分必须放在服务端。
排行榜:Top 10 通关用户
排行榜不需要登录就能看,逻辑也最简单。追加到 main.py:
from typing import List
class RankItem(BaseModel):
rank: int
nickname: str
level: int
# =========== ④ 排行榜 ===========
# 不加 Depends 就是公开接口,任何人都能访问
@app.get("/rank", response_model=List[RankItem])
def rank():
rows = db.rank_top10()
# enumerate(rows, start=1) → (1, rowA), (2, rowB), ...
return [
RankItem(rank=i, nickname=r["nickname"], level=r["level"])
for i, r in enumerate(rows, start=1)
]
🎉 整套接口跑通
启动 → /docs → 试 4 个接口 → 都返回正常 JSON
现在你的后台已经准备好对接前端小程序了。前端同学只要按这 4 个接口约定调用,逻辑就能跑起来。
- 用 Pydantic 模型分离请求体(XxxRequest)和响应体(XxxResponse)——清晰、自带文档
- 用 Depends + JWT 把"鉴权"做成可复用的依赖项——一行代码挂在哪个接口哪个就要登录
- 判分等关键逻辑必须在服务端——客户端永远不可信
- "答案"等敏感字段不返回给前端——只返回前端必需的
📋 全章小结
4 个接口对接微信小程序的 4 个页面
| 接口 | 对应小程序页面 |
|---|---|
POST /login | 授权登录页(首次打开) |
GET /game/current | 答题页加载时取题 |
POST /game/answer | 用户点"提交"时调用 |
GET /rank | 排行榜页 |
课程到这里都学了哪些 Web 框架
- Flask(第6/7/12章)— 小而美,适合学概念、做小项目
- Django(第8/9/13章)— 大而全,自带后台 / ORM / 用户系统,适合中大型网站
- Tornado(第10/14章)— 异步专长,适合长连接 / 高并发
- FastAPI(第11/15章)— 现代化 API 框架,适合前后端分离 / 微信小程序后台
下一步可以做什么
- 把 SQLite 换成 MySQL,部署到服务器,挂域名
- 注册微信小程序账号,把上面 4 个接口对接到一个真小程序
- 用 Docker 把后台打包,方便部署
- 挑一个感兴趣的方向(爬虫、机器学习、自动化运维)继续深入
课堂练习
两组共 12 题:第一组 JWT 与认证,第二组 API 设计与安全。
JWT 与认证(6 题)
移动端 / 小程序常用 JWT 而不是 Session 的主要原因?
JWT 字符串的本质是?
客户端把 JWT 放在 HTTP 请求的哪个 Header 里?
为什么不能往 JWT payload 里塞密码?
FastAPI 中 Depends(get_current_user) 在做什么?
SECRET_KEY 泄漏后,攻击者可以伪造任意用户的 JWT。
API 设计与安全(6 题)
这个接口有什么严重问题:GET /game/current 返回 {"image": "...", "answer": "杯弓蛇影"}
用 Pydantic 把请求/响应分别用 XxxRequest / XxxResponse 模型描述,主要好处?
登录接口为什么用 POST 而不是 GET?
本项目"通关数排名"的 SQL 应该用?
ORDER BY ... DESC LIMIT 10 是经典套路。FastAPI 视图里函数返回 Pydantic 模型,最终客户端收到什么?
response_model=,连字段过滤都做了——你不想给前端的字段不会出去。本章我们用"用户名"代替"微信 openid"——架构是一样的,对接小程序时只需把 username 字段改成接收前端调用 wx.login 拿到的 code 并交换成 openid。