第15章 · 看图猜成语小程序后台

这是教材最后一章——给微信小程序写后台 API。重点:JWT 鉴权答题判分排行榜。前端不写,用 /docs 在线测。

🎯 用 FastAPI 跑出 4 个接口:登录、当前题、答题、排行榜

项目长啥样:看图猜成语小程序

用户在微信小程序里看到一张图(比如"🐍 🌿"),从 18 个备选字里挑出 4 个组成成语("杯弓蛇影"),答对就过关。本章只写后台 API,前端小程序不写——上完这节课,你的接口能直接对接前端同学。

前后端分离的接口契约

POST /login 用户名 → 返回 JWT token
GET /game/current 查当前用户该挑战哪一关
POST /game/answer 提交答案,返回对/错 + 是否升级
GET /rank 通关数排行榜 Top 10
💡 微信授权先简化掉
原版本要走 wx.login → code → 调微信 API → 拿 openid——课堂上没法演示。今天直接用"用户名"代替 openid,架构一模一样,对接小程序时只把"用户名"改成"openid"即可。
1 准备

装包 + 建目录

SHELL
$ 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 生成 / 验证
第三方包 python-jose:JWT 编解码工具,给后面 auth.py 用。
2 数据库

用户表 + 题目表

init_db.py

PYTHON /home/idiom/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

PYTHON /home/idiom/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()
3 JWT

JWT:手机端鉴权的标准方案

💬 为什么不用 Session
Session 依赖 Cookie——网页时代标配。移动端 / 小程序常常没 Cookie 机制,更通用的做法是 JWT(JSON Web Token):
  • 登录成功 → 服务器返回一个签名后的字符串(token)
  • 客户端把 token 存起来,每次请求都带上(放在 HTTP Header 里)
  • 服务器验签(不用查数据库)就能识别用户——纯无状态,扩展性极好
登录请求 (POST /login) 小程序 ────────────────────────> FastAPI │ ① 校验账号 ② 生成 JWT ← 返回 { token: "..." } ─┘ ↓ 把 token 存起来 后续请求带 Authorization: Bearer <token> 小程序 ────────────────────────> FastAPI ③ 验签 + 解出 username │ ← 返回业务数据 ───────┘

auth.py:发 / 验 JWT

PYTHON /home/idiom/auth.py
# ============ 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 无效或已过期")
⚠️ JWT 不是加密,只是签名
JWT 内容(payload)是 base64 编码的——任何人都能解码看到里面的字段(如用户名、过期时间)。不要把密码、隐私字段塞进 token。它的安全保证是"不可伪造"(改了一个字符签名就对不上),不是"不可看见"。

main.py:第 1 个接口(登录发 token)

PYTHON /home/idiom/main.py(第一部分)
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 复制下来,下面要用
4 业务

答题接口:取题 + 判分

追加到 main.py

PYTHON 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 里测的方法:

  1. 右上角点 Authorize 🔓 按钮
  2. 把刚才登录拿到的 token 填进去(仅写 token 本身,前面 Bearer 不用写——但下面我们 main.py 中用的是 raw Header 方式,所以这里需要一个变体
⚠️ 课堂演示更直接的做法
上面的 get_current_user(authorization: str = Header(None)) 写法简单清晰,但 Swagger UI 默认不会有"Authorize"按钮。课堂直接用 curl
SHELL
# 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 字段返回给前端——前端能拿到就能直接显示出来,作弊一查一个准。判分必须放在服务端
5 排行

排行榜:Top 10 通关用户

排行榜不需要登录就能看,逻辑也最简单。追加到 main.py

PYTHON 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 个接口约定调用,逻辑就能跑起来。

🎯 一节课学到的"接口设计"思路
  1. 用 Pydantic 模型分离请求体(XxxRequest)和响应体(XxxResponse)——清晰、自带文档
  2. 用 Depends + JWT 把"鉴权"做成可复用的依赖项——一行代码挂在哪个接口哪个就要登录
  3. 判分等关键逻辑必须在服务端——客户端永远不可信
  4. "答案"等敏感字段不返回给前端——只返回前端必需的

📋 全章小结

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 题)

Q1单选

移动端 / 小程序常用 JWT 而不是 Session 的主要原因?

AJWT 更快
B移动端 / 小程序 Cookie 机制不通用,JWT 用 Header 传更合适
CJWT 加密更强
DSession 用不了
解析:JWT 不依赖 Cookie,把 token 放在 HTTP Header 的 Authorization 里,前端可以是任何东西(Web、iOS、Android、小程序)。
Q2单选

JWT 字符串的本质是?

A加密后的用户信息(不可读)
Bbase64 编码的 payload + 服务端密钥的签名(可读但不可伪造)
C用户名的 MD5 哈希
D随机字符串
解析:JWT 三段:header.payload.signature。前两段是 base64,能解开看见。第三段是签名——验签确保前两段没被改过。
Q3单选

客户端把 JWT 放在 HTTP 请求的哪个 Header 里?

ACookie: jwt=...
BX-Token: ...
CAuthorization: Bearer <token>
DURL 查询参数 ?token=...
解析:这是行业标准(OAuth 2.0 / RFC 6750)——Authorization Header + Bearer 方案。
Q4单选

为什么不能往 JWT payload 里塞密码?

A太长
BJWT payload 是 base64 编码的,任何拿到 token 的人都能解码看到
C规则不允许
D会变慢
解析:JWT 不是加密——只是签名(防篡改)。payload 一定要假定是公开的——只塞身份标识、过期时间、角色等"丢了无所谓"的字段。
Q5单选

FastAPI 中 Depends(get_current_user) 在做什么?

A包了个数据库连接
B声明这个接口要依赖 get_current_user,框架自动调它(验 token 失败就 401)
C定义返回值类型
D声明异步函数
解析:Depends 是 FastAPI 的依赖注入系统——把"鉴权"这种横切逻辑抽离成函数,多个接口共享。
Q6判断

SECRET_KEY 泄漏后,攻击者可以伪造任意用户的 JWT。

解析:SECRET_KEY 是签名的"印章"——拿到它就能签出任意 token。所以必须放环境变量、不能进 git、定期轮换。
第二组

API 设计与安全(6 题)

Q1单选

这个接口有什么严重问题:
GET /game/current 返回 {"image": "...", "answer": "杯弓蛇影"}

A响应太大
B把 answer 暴露给前端,前端能拿到答案直接显示,作弊
C没用 POST
D缺少缓存头
解析:API 设计的铁律——客户端永不可信。能在前端拿到的字段都能被反编译 / 抓包看到。判分、敏感字段都得在服务端处理。
Q2单选

用 Pydantic 把请求/响应分别用 XxxRequest / XxxResponse 模型描述,主要好处?

A性能提高
B代码更短
C能跑测试
D自动校验 + 自动文档 + 类型提示,前端拿到的接口契约非常明确
解析:Pydantic 是 FastAPI 的核心——你写一次模型,校验、文档、IDE 提示全都白送。
Q3单选

登录接口为什么用 POST 而不是 GET?

AGET 速度慢
BPOST 自带加密
C用户名 / 密码这种敏感数据不应该出现在 URL(会被日志记录);同时登录"创建会话"语义上属于 POST
D规则要求
解析:URL 会出现在浏览器历史、服务器访问日志、Referer Header 里——绝不能放敏感数据。
Q4单选

本项目"通关数排名"的 SQL 应该用?

ASELECT * FROM users
BSELECT level FROM users WHERE level > 10
CSELECT nickname, level FROM users ORDER BY level DESC LIMIT 10
DSELECT COUNT(*) FROM users
解析:排行榜 = 排序 + 取前 N。ORDER BY ... DESC LIMIT 10 是经典套路。
Q5单选

FastAPI 视图里函数返回 Pydantic 模型,最终客户端收到什么?

A模型对象本身
BFastAPI 自动序列化为 JSON
CHTML
D纯文本
解析:FastAPI 看到 dict / list / Pydantic 模型 → 自动转 JSON 返回。再加上 response_model=,连字段过滤都做了——你不想给前端的字段不会出去。
Q6判断

本章我们用"用户名"代替"微信 openid"——架构是一样的,对接小程序时只需把 username 字段改成接收前端调用 wx.login 拿到的 code 并交换成 openid。

解析:正是这章的设计意图——架构稳定,认证细节可替换。生产环境 login 接口里加一步:用 code 调微信 API 换 openid,然后用 openid 当 username 即可。