接口文档已经定死了,现在把它变成能跑的代码。这一阶段的产出是一个「可运行、可自测」的后端服务:统一的响应结构、统一的异常处理、JWT 鉴权、18 个接口一个不少。做完的标志很清楚——Postman 里点一下 Run,从「注册」到「查个人信息」一路绿灯,这就是里程碑 M3。
# 在 pyweb/code/hiflask/ 目录下执行
python -m venv .venv
# macOS / Linux 激活
source .venv/bin/activate
# Windows(PowerShell):.venv\Scripts\Activate.ps1
pip install -r requirements.txt
pip list | grep -i flask
tree -L 2 --dirsfirst -I '.venv|__pycache__'
from flask import Flask
from config import config_map
from extensions import cors, db, jwt, migrate
from models import * # noqa: F401,F403 —— 让 Flask-Migrate 能发现全部模型
from views import register_blueprints
def create_app(config_name: str = "dev") -> Flask:
"""创建并装配一个 Flask 应用。"""
app = Flask(__name__)
app.config.from_object(config_map[config_name])
# 让 jsonify 直接输出中文,而不是 \uXXXX 转义(Flask 2.3+ 的写法)
app.json.ensure_ascii = False
# ① 初始化扩展(实例在 extensions.py 里创建,这里只做绑定)
db.init_app(app)
jwt.init_app(app)
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
migrate.init_app(app, db)
# ② 挂上所有蓝图(18 个接口都在这一步注册)
register_blueprints(app)
# ③ 统一 JWT 异常与全局错误处理
register_jwt_handlers()
register_error_handlers(app)
return app
if __name__ == "__main__":
create_app("dev").run(host="127.0.0.1", port=5000, debug=True)
import os
from urllib.parse import quote_plus
from dotenv import load_dotenv
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(BASE_DIR, ".env"))
def build_mysql_uri() -> str:
"""按环境变量拼出 MySQL 连接串。
密码交给 quote_plus 处理:像 @ / # / % 这类字符不会把连接串拼坏。
"""
user = os.getenv("DB_USER", "root")
password = os.getenv("DB_PASSWORD", "123456")
host = os.getenv("DB_HOST", "127.0.0.1")
port = os.getenv("DB_PORT", "3306")
name = os.getenv("DB_NAME", "hiwen")
return (
f"mysql+pymysql://{user}:{quote_plus(password)}@{host}:{port}/{name}"
"?charset=utf8mb4"
)
class Config:
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-me")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-jwt-secret-change-me")
SQLALCHEMY_DATABASE_URI = build_mysql_uri()
SQLALCHEMY_TRACK_MODIFICATIONS = False
# MySQL 连接空闲 8 小时会被服务端断开,pool_recycle 让连接提前回收
SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 3600, "pool_pre_ping": True}
from datetime import datetime
from werkzeug.security import check_password_hash, generate_password_hash
from extensions import db
class User(db.Model):
"""用户。表名单数,字段与 hiwen.sql 逐字对应。"""
__tablename__ = "user"
id = db.Column(db.Integer, primary_key=True, comment="用户ID")
username = db.Column(db.String(50), nullable=False, unique=True, comment="登录名")
password = db.Column(db.String(255), nullable=False, comment="密码哈希(切勿明文)")
nickname = db.Column(db.String(50), comment="昵称")
email = db.Column(db.String(100), unique=True, comment="邮箱(唯一,可空)")
avatar = db.Column(db.String(255), default="", comment="头像路径")
intro = db.Column(db.String(255), comment="个人简介")
gender = db.Column(db.SmallInteger, default=0, comment="0未知 1男 2女")
status = db.Column(db.SmallInteger, nullable=False, default=1, comment="1正常 0禁用")
created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
updated_at = db.Column(
db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now
)
# ---------- 密码:只存哈希,绝不存明文 ----------
def set_password(self, raw: str) -> None:
self.password = generate_password_hash(raw) # 自动加盐
def check_password(self, raw: str) -> bool:
return check_password_hash(self.password, raw)
# ---------- 序列化:白名单写法,password 永远不会出现在响应里 ----------
def to_dict(self) -> dict:
return {
"id": self.id,
"username": self.username,
"nickname": self.nickname,
"email": self.email,
"avatar": self.avatar or "",
"intro": self.intro,
"gender": self.gender,
"created_at": self.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
def to_brief(self) -> dict:
"""登录响应里的 user 字段,只要四个字段。"""
return {
"id": self.id,
"username": self.username,
"nickname": self.nickname,
"avatar": self.avatar or "",
}
from flask import jsonify
def success(data=None, msg: str = "success"):
"""成功响应。"""
return jsonify(code=200, msg=msg, data=data)
def fail(msg: str = "参数错误", code: int = 400):
"""失败响应。msg 可直接展示给用户。"""
return jsonify(code=code, msg=msg, data=None)
def page_data(items: list, total: int, page: int, page_size: int) -> dict:
"""分页响应的 data 结构(接口文档 1.5 节)。"""
return {"list": items, "total": total, "page": page, "page_size": page_size}
from werkzeug.exceptions import HTTPException
def register_error_handlers(app: Flask) -> None:
"""兜底异常处理:绝不把堆栈抛给前端。"""
@app.errorhandler(404)
def not_found(error):
return fail("接口不存在", code=404)
@app.errorhandler(405)
def method_not_allowed(error):
return fail("请求方法不被允许", code=405)
@app.errorhandler(Exception)
def internal_error(error):
# 其余 HTTP 异常(400/415 等)保持原本的语义,不要一律当 500
if isinstance(error, HTTPException):
return error
db.session.rollback() # 出异常后必须回滚,否则会话一直处于失败状态
app.logger.exception("未捕获的服务器异常")
return fail("服务器内部错误", code=500)
import re
from flask import Blueprint, g, request
from flask_jwt_extended import create_access_token
from extensions import db
from models.user import User
from utils.auth import login_required
from utils.response import fail, success
user_bp = Blueprint("user", __name__, url_prefix="/api/user")
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{3,20}$")
@user_bp.post("/register")
def user_register():
"""注册:POST /api/user/register"""
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
# ① 参数校验前置:把非法请求挡在数据库之前
if not USERNAME_RE.match(username):
return fail("用户名需为 3-20 位字母、数字或下划线")
if not (6 <= len(password) <= 20):
return fail("密码长度需为 6-20 位")
if User.query.filter_by(username=username).first():
return fail("用户名已存在")
nickname = (data.get("nickname") or "").strip() or username
user = User(username=username, nickname=nickname)
user.set_password(password) # 只存哈希
db.session.add(user)
db.session.commit()
return success({"user_id": user.id}, "注册成功")
@user_bp.post("/login")
def user_login():
"""登录:POST /api/user/login → 返回 token 与用户简要信息"""
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
if not username or not password:
return fail("缺少参数:username / password")
user = User.query.filter_by(username=username).first()
# 用户不存在 / 密码错误,统一提示,避免暴露账号是否存在(用户名枚举漏洞)
if user is None or not user.check_password(password):
return fail("用户名或密码错误")
if user.status != 1:
return fail("账号已被禁用", code=403)
# flask-jwt-extended 4.x 要求 identity 必须是字符串
token = create_access_token(identity=str(user.id))
return success({"token": token, "user": user.to_brief()}, "登录成功")
from functools import wraps
from flask import g
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
from extensions import db
from models.user import User
from utils.response import fail
def login_required(view_func):
"""需要登录的视图加上它。
流程:校验 token → 按 identity 取出用户 → 检查状态 → 挂到 g.current_user。
token 缺失 / 无效 / 过期时,由 app.py 里注册的 JWT 回调统一返回 401。
"""
@wraps(view_func)
def wrapper(*args, **kwargs):
verify_jwt_in_request()
identity = get_jwt_identity()
try:
user_id = int(identity)
except (TypeError, ValueError):
return fail("登录状态无效,请重新登录", code=401)
user = db.session.get(User, user_id)
if user is None:
return fail("请先登录", code=401)
if user.status != 1:
return fail("账号已被禁用", code=403)
g.current_user = user
return view_func(*args, **kwargs)
return wrapper
def optional_login():
"""可选登录:带了合法 token 就返回 user_id,没带或失效返回 None。
给「公开接口但要回填当前用户状态」的场景用,比如作品详情里的
is_favorite / is_liked —— 没登录也能看,只是状态都是 false。
"""
try:
verify_jwt_in_request(optional=True)
except Exception:
return None
identity = get_jwt_identity()
if not identity:
return None
try:
return int(identity)
except (TypeError, ValueError):
return None
| 蓝图文件 | 变量与前缀 | 接口数 | 负责的接口 |
|---|---|---|---|
| views/home.py | home_bp · /api | 4 | health、banner/list、category/list、article/recommend |
| views/user.py | user_bp · /api/user | 6 | register、login、profile(GET/PUT)、avatar、logout |
| views/article.py | article_bp · /api/article | 3 | list、detail/<id>、like |
| views/comment.py | comment_bp · /api/comment | 4 | list、add、like、delete/<id> |
| views/favorite.py | favorite_bp · /api/favorite | 2 | toggle、list |
| 合计 | 5 个蓝图 | 19 | 18 个业务接口 + 1 个自测用健康检查 |
"""蓝图集中注册。app.py 只调用 register_blueprints(app),不关心有几个模块。"""
from views.article import article_bp
from views.comment import comment_bp
from views.favorite import favorite_bp
from views.home import home_bp
from views.user import user_bp
ALL_BLUEPRINTS = (home_bp, user_bp, article_bp, comment_bp, favorite_bp)
def register_blueprints(app) -> None:
for bp in ALL_BLUEPRINTS:
app.register_blueprint(bp)
其中 article/list 是参数最多的一个接口——六个查询参数逐段叠加到同一个 query 上,最后统一排序和分页。把它的写法吃透,剩下的接口都是同一个套路。
from flask import Blueprint, request
from extensions import db
from models.article import Article
from utils.response import page_data, success
article_bp = Blueprint("article", __name__, url_prefix="/api/article")
def parse_page():
"""分页参数统一夹取,避免 page=0 / page_size=99999 打崩数据库。"""
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
return max(page, 1), min(max(page_size, 1), 50)
@article_bp.get("/list")
def article_list():
"""作品列表:支持 page / page_size / category_id / dynasty / keyword / order_by"""
page, page_size = parse_page()
category_id = request.args.get("category_id", type=int)
dynasty = request.args.get("dynasty")
keyword = request.args.get("keyword")
order_by = request.args.get("order_by", "latest")
query = Article.query.filter(Article.status == 1)
# 条件逐段叠加到同一个 query 上,有几段加几段
if category_id:
query = query.filter(Article.category_id == category_id)
if dynasty:
query = query.filter(Article.dynasty == dynasty)
if keyword:
like = f"%{keyword}%"
query = query.filter(
db.or_(Article.title.like(like), Article.summary.like(like))
)
# 排序必须带次关键字 id,否则同值记录顺序不保证,翻页会重复或漏条
if order_by == "hot":
query = query.order_by(Article.views.desc(), Article.id.desc())
elif order_by == "likes":
query = query.order_by(Article.likes.desc(), Article.id.desc())
else:
query = query.order_by(Article.created_at.desc(), Article.id.desc())
page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)
return success(
page_data(
[a.to_list_dict() for a in page_obj.items],
page_obj.total,
page,
page_size,
)
)
// ① 环境变量(三个)
// base_url = http://127.0.0.1:5000/api
// token = (留空,登录后自动写入)
// article_id = 1
// ② 在【登录】请求的 Tests 标签里写断言:既验证结果,又把 token 存下来
pm.test("登录成功并拿到 token", function () {
const body = pm.response.json();
pm.expect(body.code).to.eql(200);
pm.expect(body.data.token).to.be.a("string");
});
// ③ 写入环境变量,后面的请求用 {{token}} 自动带上
pm.environment.set("token", pm.response.json().data.token);
// ④ 需要登录的请求,在 Headers 里加一行:
// Authorization : Bearer {{token}} ← Bearer 后面必须有一个空格
BASE=http://127.0.0.1:5000/api
# ① 注册(用户名重复会返回 400,换一个即可)
curl -s -X POST $BASE/user/register \
-H "Content-Type: application/json" \
-d '{"username": "zhangsan", "password": "123456", "nickname": "张三"}' \
| python -m json.tool
# ② 登录,把 token 存进 shell 变量
TOKEN=$(curl -s -X POST $BASE/user/login \
-H "Content-Type: application/json" \
-d '{"username": "zhangsan", "password": "123456"}' \
| python -c "import sys, json; print(json.load(sys.stdin)['data']['token'])")
# ③ 带着 token 查个人信息
curl -s $BASE/user/profile -H "Authorization: Bearer $TOKEN" | python -m json.tool
# ④ 反证:不带 token 应该被挡回来
curl -s $BASE/user/profile | python -m json.tool
| # | 验收项 | 合格标准 | 怎么验 |
|---|---|---|---|
| 1 | 服务能启动 | 无报错,监听 127.0.0.1:5000 | python app.py 看启动日志 |
| 2 | 数据库连通 | 健康检查返回 code 200、status: up | GET /api/health |
| 3 | 接口齐全 | 路由表列出 18 个业务接口 + 1 个健康检查 | flask --app app routes 逐行对接口文档 |
| 4 | 响应统一 | 所有接口都是 {code, msg, data} 三个字段 | Postman 逐个看,或全局搜 jsonify 确认没有漏网的 |
| 5 | 注册可用 | 返回 user_id,库里多一条记录,密码是哈希 | 注册后查 SELECT username, password FROM user |
| 6 | 登录可用 | 返回 token 与 user 四字段;密码错时统一提示 | POST /api/user/login,再用错密码试一次 |
| 7 | 鉴权生效 | 不带 token 访问需登录接口返回 401 | curl /api/user/profile(不带 Authorization) |
| 8 | 越权被拦 | 删别人的书评返回 403,不是删成功 | 用 A 的 token 删 B 的书评 |
| 9 | 不泄露敏感字段 | 任何响应里都搜不到 password | 在 Postman 里全局搜索响应内容 |
| 10 | 分页正确 | total 是筛选后的总数;page_size=99999 被夹到 50 | /api/article/list?page=2&page_size=2 |
| 11 | 浏览量自增 | 连续访问详情两次,views 每次 +1 | 连打两次 /api/article/detail/1 |
| 12 | 异常不脏会话 | 连续打错接口后,正常接口依然能用 | 故意传错参数刷几次,再请求列表 |
| 13 | M3 里程碑 | Postman 集合一键 Run 全绿 | Run Collection,截图留档 |
# ---------- 第 1 题:只允许改白名单里的字段 ----------
@user_bp.put("/profile")
@login_required
def user_profile_update():
data = request.get_json(silent=True) or {}
# 逐个挑,绝不 setattr(user, k, v) 地整包写入
if "nickname" in data:
nickname = (data.get("nickname") or "").strip()
if not (1 <= len(nickname) <= 50):
return fail("昵称长度需为 1-50 位")
g.current_user.nickname = nickname
if "intro" in data:
g.current_user.intro = (data.get("intro") or "").strip()
if "gender" in data:
if data.get("gender") not in (0, 1, 2):
return fail("性别取值不合法")
g.current_user.gender = data.get("gender")
db.session.commit()
return success(None, "修改成功")
# ---------- 第 2 题:收藏 / 取消收藏(toggle 三步骨架) ----------
@favorite_bp.post("/toggle")
@login_required
def favorite_toggle():
data = request.get_json(silent=True) or {}
article_id = data.get("article_id")
if not article_id:
return fail("缺少参数:article_id")
article = db.session.get(Article, article_id)
if article is None or article.status != 1:
return fail("作品不存在", code=404)
uid = g.current_user.id
row = Favorite.query.filter_by(user_id=uid, article_id=article_id).first()
try:
if row: # 已收藏 → 取消
db.session.delete(row)
favorited = False
else: # 未收藏 → 收藏
db.session.add(Favorite(user_id=uid, article_id=article_id))
favorited = True
db.session.commit()
except IntegrityError:
# 并发连点:另一个请求刚好插进去了,唯一键 uk_fav_user_article 报错
db.session.rollback()
return success({"favorited": True}, "操作成功")
return success({"favorited": favorited}, "操作成功")
# ---------- 第 3 题(红影赏析:换字段名,不换骨架) ----------
movie_bp = Blueprint("movie", __name__, url_prefix="/api/movie")
@movie_bp.get("/list")
def movie_list():
page = max(request.args.get("page", 1, type=int), 1)
page_size = min(max(request.args.get("page_size", 10, type=int), 1), 50)
genre = request.args.get("genre")
order_by = request.args.get("order_by", "latest")
query = Movie.query
if genre: # 古典文学网这里是 dynasty
query = query.filter(Movie.genre == genre)
if order_by == "rating": # 古典文学网这里是 views / likes
query = query.order_by(Movie.rating.desc(), Movie.id.desc())
else:
query = query.order_by(Movie.created_at.desc(), Movie.id.desc())
page_obj = db.paginate(query, page=page, per_page=page_size, error_out=False)
return success(
page_data(
[m.to_list_dict() for m in page_obj.items],
page_obj.total, page, page_size,
)
)
第 1 题的关键词是白名单:能改的字段一个一个列出来,绝不照着请求体整包写入。否则前端只要加一个 "status": 0,就能把自己禁用掉。第 3 题的关键:换的是字段和业务词(朝代 → 类型、浏览量 → 评分),不变的是「夹取分页 → 逐段叠加查询条件 → 统一排序 → 分页返回」这套骨架。
做完点「检查本节」看解析与得分;做错可重做。