码上问学问代码之理,学编程之道

开发十步 · 从空文件夹到上线

零基础照着走:每一步都有目标、操作、过关标准、常见坑、交付物。左侧导航可跳步, 完成一步点亮一个 ✓(存在本机,下次接着来)。所有命令与代码都来自本项目的真实源码,不是玩具示例。

开始之前:确认电脑装了 Python 3.10+ Node 18+ MySQL 8(第 3 步会用到)。 全程预计 6 次课 + 课后练习;卡住先看每步的「常见坑」,再去 项目文档 · 排错手册 对症。
01

需求与原型:想清楚,画出来

建议 1 次课(45 分钟)|产出:需求文档.md + 原型草图
本步目标|把「我想做个文学网站」这句话,变成一份能照着开发的需求文档和五张页面草图。这一步不写一行代码——但决定了后面九步是「照图施工」还是「边盖边改」。

1.1 把兴趣变成需求(AI 当参谋)

和「主题网站 HTML 骨架」作业一样,先让 AI 帮你把主题拓成需求,但你要做裁剪决策。本项目最终的裁剪结果:

古典文学网 · 功能清单(MVP)
板块功能点优先级接口预估
首页轮播图、分类入口、编辑推荐P0 必做3 个(banner / category / recommend)
赏文学作品列表:分类筛选、关键字搜索、排序、分页P0 必做1 个(article/list)
作品详情正文、浏览量自增、书评列表、点赞 / 收藏P0 必做5 个(detail / like / comment×3)
登录注册注册、登录(JWT)、退出P0 必做3 个
个人中心资料查看与修改、我的收藏、头像上传P1 应做5 个
在线听书 / AI 赏析 / 社区——都砍掉P2 不做理由:纯前端做不了或超出课程范围,写进需求文档的「不做清单」

1.2 画原型图

纸笔画、白板画、或用墨刀 / Figma 都行,标准只有一条:每个区块都标出「数据从哪个接口来」。 本项目的五页原型(含区块→接口的对照)已画好,照着看:

  • 五页线框:prototype.html · 原型图(首页 / 列表 / 详情 / 登录 / 个人中心)
  • 每页下方都有「区块 ↔ 接口」对照表——原型画完,接口清单其实就出来了
  • 1.3 需求文档模板

    需求文档.md · 骨架
    # 古典文学网 · 需求文档
    ## 1. 一句话定位   面向文学爱好者的赏析阅读站(纯展示 + 轻互动)
    ## 2. 用户与场景   谁来用?在什么情况下打开?
    ## 3. 功能清单     板块 / 功能点 / 优先级(照上表抄)
    ## 4. 不做清单     明确写「为什么不做」,防止后期无限膨胀
    ## 5. 页面结构     五页原型缩略图 + 每页调用的接口
    ## 6. 变更记录      后期每次改需求都记一行
    ✔ 过关标准(做完自查,能打勾才进下一步)
    需求文档里有功能清单 + 不做清单;五页原型每页标了数据来源;找个同学看图能讲出「这页是干什么的」。
    ⚠ 常见坑(历年学生踩过的)
    • 需求写成「做一个好看的文学网站」——不可验证、不可拆解。改成「首页有轮播图 / 分类入口 / 编辑推荐三个区块」这种能打勾的句子。
    • 一口吃成胖子:在线听书、AI 赏析、社区全想做。先做 P0 跑通全链路,P1/P2 放二期——真实公司也是这么干的。
    交付物:需求文档.md原型草图 × 5接口预估清单
    02

    数据库设计:七张表撑起一个站

    建议 2 次课|产出:hiwen.sql(可重复执行的建库脚本)
    本步目标|照着需求清单,把数据拆成 7 张表,画出 ER 图,写出能在 MySQL 8 里一键执行的 hiwen.sql。

    2.1 从需求到表:三类东西

    类型表怎么想出来的
    「谁在用」user需求里有登录、个人中心 → 必有用户表
    「有什么内容」category、article、banner作品要分类 → 分类表;轮播图是内容 → 单独一张
    「用户做了什么」comment、favorite、user_like书评 / 收藏 / 点赞各一张;点赞针对「作品或评论」→ 加 target_type 字段一张表搞定

    完整 ER 图(含字段、外键、一对多关系)见 流程图 · ER 图。

    2.2 建表 SQL(节选,完整版 pyweb/sql/hiwen.sql)

    hiwen.sql · user 表与 article 表
    CREATE TABLE `user` (
      `id`       INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID,主键自增',
      `username` VARCHAR(50)  NOT NULL               COMMENT '登录名(唯一)',
      `password` VARCHAR(255) NOT NULL               COMMENT '密码哈希——绝不存明文',
      `nickname` VARCHAR(50)                          COMMENT '昵称',
      `avatar`   VARCHAR(255) DEFAULT ''             COMMENT '头像路径',
      `status`   TINYINT NOT NULL DEFAULT 1           COMMENT '1正常 0禁用(软删除)',
      `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户';
    
    CREATE TABLE `article` (
      `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT,
      `title`       VARCHAR(150) NOT NULL            COMMENT '标题',
      `category_id` INT UNSIGNED                     COMMENT '所属分类,外键',
      `views`       INT NOT NULL DEFAULT 0            COMMENT '浏览量(冗余计数)',
      `likes`       INT NOT NULL DEFAULT 0            COMMENT '点赞数(冗余计数)',
      `content`     TEXT                             COMMENT '正文',
      PRIMARY KEY (`id`),
      CONSTRAINT `fk_article_category` FOREIGN KEY (`category_id`)
        REFERENCES `category`(`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    2.3 四个设计决策(面试会问)

  • 冗余计数列:article.likes / views 不 COUNT 从表,直接在主表存数字——列表页 20 篇作品如果每篇都去数点赞表,查询次数爆炸。
  • status 软删除:删作品 = 置 0,不真删。数据是资产,误删能找回。
  • utf8mb4:MySQL 的 utf8 是残缺版(3 字节),存不了 emoji;从建库第一天就 utf8mb4。
  • created_at / updated_at 必备:所有表都有。以后排查「这条数据什么时候坏的」全靠它。
  • ✔ 过关标准(做完自查,能打勾才进下一步)
    mysql -uroot -p < hiwen.sql 执行无报错;SHOW TABLES; 恰好 7 张;SELECT COUNT(*) FROM article; 能看到种子数据。
    ⚠ 常见坑(历年学生踩过的)
    • 密码字段存明文——课程作业里最常见也最致命的问题。数据库只存哈希(第 5 步由 werkzeug 生成)。
    • 建库时忘了 utf8mb4,后存「😊」直接报错或变问号。
    • 外键建了但两边字符集 / 类型不一致,INSERT 时报 1452。
    交付物:hiwen.sqlER 图
    03

    后端工程初始化:空项目先跑通一条线

    建议 1 次课|产出:hiflask 骨架(app / config / extensions / 第一个蓝图)
    本步目标|把 Flask 工程的「四梁八柱」搭起来,并跑通第一个接口 GET /api/health。

    3.1 建工程

    终端
    mkdir hiflask && cd hiflask
    python -m venv venv                  # 每个项目一个虚拟环境,依赖互不打架
    venv/bin/pip install flask flask-sqlalchemy flask-migrate \
        flask-jwt-extended flask-cors python-dotenv pymysql
    venv/bin/pip freeze > requirements.txt   # 锁版本,别人拿到就能装

    3.2 目录结构(这就是最终形态,先照着建空文件)

    hiflask/
    hiflask/
    ├── app.py            # 工厂 create_app:装配扩展、蓝图、错误处理
    ├── config.py         # 三套配置:dev / prod / testing,读 .env
    ├── extensions.py     # db / jwt / cors / migrate 只在这里 new 出来
    ├── models/           # 7 张表 → 7 个模型类(第 4 步)
    ├── views/            # 蓝图:home / user / article / comment / favorite
    ├── utils/            # 统一响应 response.py、鉴权 auth.py(第 5 步)
    ├── static/upload/    # 头像等上传文件
    ├── requirements.txt
    └── .env              # 密码密钥,绝不进版本库(.env.example 是模板)

    3.3 三个关键文件

    extensions.py · 为什么扩展单独放
    # 模型文件要 import db,app.py 又要 import 模型——
    # 如果 db 在 app.py 里创建,就形成「app → models → app」循环导入。
    # 单独一个 extensions.py 打破循环:谁都来拿,但它谁也不依赖。
    from flask_sqlalchemy import SQLAlchemy
    from flask_jwt_extended import JWTManager
    from flask_cors import CORS
    from flask_migrate import Migrate
    
    db, jwt, cors, migrate = SQLAlchemy(), JWTManager(), CORS(), Migrate()
    app.py · 应用工厂三步装配
    def create_app(config_name: str = "dev") -> Flask:
        app = Flask(__name__)
        app.config.from_object(config_map[config_name])
    
        # ① 初始化扩展(db/jwt/cors 绑到 app 上)
        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 异常统一成 {code:401} + 全局错误兜底
        register_jwt_handlers(); register_error_handlers(app)
        return app

    第一个蓝图:先让健康检查亮起来

    views/home.py · 最小可用版
    home_bp = Blueprint("home", __name__, url_prefix="/api")
    
    @home_bp.get("/health")
    def health():
        db.session.execute(db.text("SELECT 1"))   # 顺带探数据库连通性
        return success({"service": "hiflask", "status": "up"})
    ✔ 过关标准(做完自查,能打勾才进下一步)
    python app.py 启动无报错;浏览器开 http://127.0.0.1:5000/api/health 看到 {"code":200,...}。
    ⚠ 常见坑(历年学生踩过的)
    • 把 db = SQLAlchemy() 写在 app.py 里,一建模型就循环导入。扩展永远单独一个模块。
    • venv 建在项目外 / 忘了激活,pip 装到了全局——报 ModuleNotFoundError 时先 which python 检查。
    • 代码里写死数据库密码还提交到 Git。用 .env + python-dotenv,.env 进 .gitignore。
    交付物:可运行的 hiflask 骨架/api/health 通
    04

    数据模型:7 张表 → 7 个类

    建议 1 次课|产出:models/ 七个模型 + 建表完成
    本步目标|把第 2 步的 7 张表映射成 SQLAlchemy 模型类,让 Python 代码「看得见」数据库。

    4.1 一个模型的完整责任

    看 models/user.py——它要干四件事:映射表结构、管密码、管序列化(对象 → 字典)、管关系:

    models/user.py · 节选
    class User(db.Model):
        __tablename__ = "user"
    
        id       = db.Column(db.Integer, primary_key=True)
        username = db.Column(db.String(50), nullable=False, unique=True)
        password = db.Column(db.String(255), nullable=False)   # 只存哈希
        status   = db.Column(db.SmallInteger, nullable=False, default=1)
        created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    
        # lazy="dynamic":拿到的是可继续筛选的 Query,适合一对多
        comments  = db.relationship("Comment", back_populates="user", lazy="dynamic")
    
        # —— 密码 ——
        def set_password(self, raw): # 自动加盐的哈希,绝不存明文
            self.password = generate_password_hash(raw)
        def check_password(self, raw):
            return check_password_hash(self.password, raw)
    
        # —— 序列化:白名单写法,password 永远不会出现在响应里 ——
        def to_dict(self):
            return {"id": self.id, "username": self.username,
                    "nickname": self.nickname, "avatar": self.avatar or "", ...}

    4.2 两个序列化版本的用意

    Article 有 to_list_dict()(不带正文 content)和 to_detail_dict()(带正文)。 列表页一次 20 条,如果把每篇几千字的正文都发出去,流量和渲染都白费——列表轻、详情重,是接口设计的常识。

    4.3 建表:两种方式

    方式命令什么时候用
    flask-migrate(推荐)flask db init → migrate → upgrade表结构以后还会改,能生成升级脚本、不丢数据
    db.create_all()with app.app_context(): db.create_all()一次性建齐、图省事;改字段要删库重建
    ✔ 过关标准(做完自查,能打勾才进下一步)
    flask shell 里执行 User.query.count()、Article.query.count() 不报错(种子数据来自 python seed_demo.py)。
    ⚠ 常见坑(历年学生踩过的)
    • relationship 里写的字符串类名拼错,启动时报 Can't determine relationship——对着模型类名逐字核对。
    • DateTime 字段忘了 default=datetime.now,插入报 NULL 约束错误。
    • JWT 的 identity 传了 int——flask-jwt-extended 4.x 要求字符串,create_access_token(identity=str(user.id))。
    交付物:models/ 七个模型建表完成 + 种子数据
    05

    统一响应与鉴权:先立规矩,再写业务

    建议 1 次课|产出:utils/response.py + utils/auth.py
    本步目标|18 个接口共用两种工具:统一响应结构(前端一套拦截器吃遍全部接口)和登录校验装饰器(一行代码保护一个接口)。

    5.1 统一响应:HTTP 恒 200,业务看 code

    utils/response.py · 全文
    def success(data=None, msg="success"):
        return jsonify(code=200, msg=msg, data=data)
    
    def fail(msg="参数错误", code=400):
        return jsonify(code=code, msg=msg, data=None)   # msg 可直接展示给用户
    
    def page_data(items, total, page, page_size):
        # 所有分页接口共用这一种 data 结构
        return {"list": items, "total": total, "page": page, "page_size": page_size}

    为什么不让 HTTP 状态码直接表达失败?——前端 axios 拦截器只要认 code 字段就能写一套统一逻辑; 若状态码五花八门,每个接口都要单独 try。约定大于配置,全站只认一种结构。

    5.2 登录校验:一个装饰器管所有接口

    utils/auth.py · 核心逻辑
    def login_required(view_func):
        @wraps(view_func)
        def wrapper(*args, **kwargs):
            verify_jwt_in_request()               # ① 校验请求头里的 Bearer token
            uid = int(get_jwt_identity())         # ② identity → 用户 id
            user = db.session.get(User, uid)
            if user is None or user.status != 1:  # ③ 账号还在吗?被禁用了吗?
                return fail("请先登录", code=401)
            g.current_user = user                 # ④ 挂到 g 上,视图直接用
            return view_func(*args, **kwargs)
        return wrapper
    
    # 用法:写在哪个视图上,哪个接口就要登录
    @comment_bp.post("/add")
    @login_required
    def comment_add(): ...

    optional_login():另一种常见姿势

    作品详情页没登录也能看,但登录用户要回填「是否已收藏 / 已点赞」。于是详情接口用 optional_login():带了合法 token 就认,没有就当游客——一个接口同时服务两种人。

    5.3 token 过期了怎么办(app.py 里统一应答)

    app.py · JWT 异常统一成 {code:401}
    @jwt.expired_token_loader
    def expired(header, payload):
        return fail("登录已过期,请重新登录", code=401)
    # unauthorized / invalid / revoked 三种异常同理。
    # 前端拦截器只要看到 code===401:清登录态 → 跳登录页 → 记住回来的地址。
    ✔ 过关标准(做完自查,能打勾才进下一步)
    curl 三连:① curl -X POST .../api/user/login 拿到 token;② 不带 token 访问 /api/user/profile 返回 code 401;③ 带 -H "Authorization: Bearer <token>" 返回 code 200。
    ⚠ 常见坑(历年学生踩过的)
    • 某个接口忘了加 @login_required——上线后任何人都能替别人发书评。用接口清单逐条核对「需不需要登录」。
    • fail 的 msg 写成堆栈或英文术语。msg 是要直接弹给用户看的。
    交付物:utils/response.pyutils/auth.py统一 401 处理
    06

    写完 18 个接口:五模块流水线

    建议 3 次课|产出:views/ 五个蓝图全部完成 + 自测通过
    本步目标|按「首页 → 用户 → 作品 → 书评 → 收藏」的顺序写完 18 个接口。每个模块都是同一套动作:定路由 → 校验参数 → 查库 → 统一响应。

    6.1 接口总表(对着写,写完一个勾一个)

    18 个接口 · ★ = 需登录(Authorization: Bearer token)
    方法路径作用关键参数
    GET/api/health健康检查(顺带探库)—
    GET/api/banner/list首页轮播图—
    GET/api/category/list分类下拉框数据—
    GET/api/article/recommend编辑推荐limit(1~20)
    GET/api/article/list作品列表(搜索 / 筛选 / 排序 / 分页)page、page_size、category_id、dynasty、keyword、order_by
    GET/api/article/detail/<id>作品详情(浏览量 +1,游客可看)路径参数 id
    POST/api/article/like ★点赞 / 取消点赞(幂等切换)body: article_id
    POST/api/user/register注册(用户名 3-20 位字母数字下划线)body: username、password、nickname?、email?
    POST/api/user/login登录 → 返回 tokenbody: username、password
    GET/api/user/profile ★个人信息 + 书评 / 收藏统计—
    PUT/api/user/profile ★改资料(白名单字段)body: nickname?、intro?、gender?、email?
    POST/api/user/avatar ★上传头像(jpg/png/gif ≤2MB)form-data: file
    POST/api/user/logout ★退出(前端清 token 为主)—
    GET/api/comment/list书评列表(顶层分页,回复内嵌 children)article_id、page、page_size
    POST/api/comment/add ★发书评 / 回复书评body: article_id、content(≤1000 字)、parent_id?
    POST/api/comment/like ★给书评点赞body: comment_id
    DELETE/api/comment/<id> ★删自己的书评路径参数 id
    POST/api/favorite/toggle ★收藏 / 取消收藏body: article_id

    还有一个 GET /api/favorite/list ★(我的收藏,分页)——共 19 条路由、18 个业务接口。

    6.2 最难的一个:作品列表(条件叠加 + 分页)

    views/article.py · article_list 核心写法
    page, page_size = parse_page()          # 夹取:page≥1,page_size≤50,防手滑打崩库
    category_id = request.args.get("category_id", type=int)
    keyword     = request.args.get("keyword")
    order_by    = request.args.get("order_by", "latest")
    
    query = select(Article).where(Article.status == 1)     # 从「已发布」这条主线开始
    if category_id:                        # 条件逐段叠加到同一个 query 上
        query = query.where(Article.category_id == category_id)
    if keyword:
        like = f"%{keyword}%"
        query = query.where(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())
    
    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))

    6.3 写接口的三条纪律

  • 参数校验前置:先把非法请求挡在数据库之前(正则、长度、类型),省一次查询也省一次报错。
  • 不泄露信息:登录失败统一「用户名或密码错误」——分开提示等于告诉攻击者「这个号存在」。
  • 写完立刻自测:别攒到最后。每写完一个接口,curl 或 Postman 打一遍成功 + 失败两种情况。
  • ✔ 过关标准(做完自查,能打勾才进下一步)
    接口总表 18 行逐条 curl 全绿:公开接口 code=200;★ 接口不带 token 得 401、带 token 得 200;参数错误得 400 且 msg 可读。
    ⚠ 常见坑(历年学生踩过的)
    • LIKE '%关键词' 少写一边 % —— 查不到还不报错,最隐蔽。
    • 分页接口没夹取参数,page_size=99999 一次拖全表。
    • POST 接口用 request.form 接 JSON(或反之),取出来全是 None。JSON 接口用 request.get_json(silent=True)。
    交付物:18 个接口全部实现自测记录
    07

    前端工程初始化:三分钟造好工地

    建议 1 次课|产出:hivue 骨架 + axios 封装
    本步目标|用 Vite 建 Vue 3 工程,装好 Element Plus / Pinia / Router / axios,并把「请求层」封装好——后面写页面就只剩业务了。

    7.1 建工程

    终端
    npm create vite@latest hivue -- --template vue
    cd hivue && npm install
    npm install element-plus @element-plus/icons-vue axios pinia vue-router

    7.2 main.js:四件套装配

    src/main.js
    import { createPinia } from 'pinia'
    import ElementPlus from 'element-plus'
    import 'element-plus/dist/index.css'
    import router from '@/router'
    import App from './App.vue'
    
    createApp(App)
      .use(createPinia())      // 全局账本(登录态住这里)
      .use(router)             // 游园动线
      .use(ElementPlus)        // 预制构件厂
      .mount('#app')           // 开门

    7.3 request.js:整个项目唯一发请求的地方

    src/utils/request.js · 精讲
    const request = axios.create({
      baseURL: import.meta.env.VITE_API_BASE,  // '/api'——写配置不写死 IP
      timeout: 10000,
    })
    
    // 请求拦截器:有 token 就自动带上——页面代码永远不用手写 Authorization
    request.interceptors.request.use((config) => {
      const token = useUserStore().token
      if (token) config.headers.Authorization = `Bearer ${token}`
      return config
    })
    
    // 响应拦截器:只认 code。200 → 直接返回 data;401 → 清登录态跳登录页;其它 → 弹 msg
    request.interceptors.response.use((response) => {
      const { code, msg, data } = response.data
      if (code === 200) return data
      if (code === 401) { userStore.logout(false); router.push('/login') }
      else ElMessage.error(msg || '操作失败')
      return Promise.reject(new Error(msg))
    })

    api 层再按模块分文件(api/home.js、api/article.js…), 页面只 import 函数、不写 URL——后端改路径只动 api 层一个文件,这是「工程」和「脚本堆」的分水岭。

    ✔ 过关标准(做完自查,能打勾才进下一步)
    npm run dev 打开 http://localhost:5173 能看到页面;控制台无报错;随便一个页面里 request 发出的请求 URL 以 /api 开头。
    ⚠ 常见坑(历年学生踩过的)
    • baseURL 写死 http://127.0.0.1:5000——换环境就得全局搜索替换。用 .env 的 VITE_API_BASE。
    • @ 别名忘了在 vite.config.js 配,满屏 ../../..。
    • Element Plus 只 import 了组件忘了引 CSS,页面「能用但丑得莫名其妙」。
    交付物:hivue 骨架request.js 封装api/ 分层目录
    08

    五个页面逐个做:从首页到个人中心

    建议 4 次课|产出:五个页面 + 全站导航可点通
    本步目标|按「首页 → 列表 → 详情 → 登录 → 个人中心」的顺序把页面做完。每页套路一致:onMounted 发请求 → ref 存数据 → 模板渲染 → 事件回调。

    8.1 首页:三个接口一次拿齐

    views/Home.vue · onMounted + Promise.all
    onMounted(async () => {
      loading.value = true
      // 三个接口互不依赖 → 并发请求,比串行 await 快两倍
      const [bannerData, categoryData, recommendData] = await Promise.all([
        getBannerList(), getCategoryList(), getRecommend(),
      ])
      banners.value = bannerData || []
      categories.value = categoryData || []
      recommends.value = recommendData?.list || []
      loading.value = false
    })

    8.2 列表页:筛选一变,回第一页

    views/ArticleList.vue · watch 的标准用法
    // 用户改了筛选条件 → 当前页码很可能已越界 → 强制回第 1 页再查
    watch([selectedCategory, keyword], () => {
      page.value = 1
      fetchList()
    })

    8.3 卡片组件:computed 把「算」和「存」分开

    components/ArticleCard.vue · 节选
    const props = defineProps({ article: { type: Object, required: true } })
    
    // 后端两种接口给的字段名不同(category_name vs categoryName)——
    // 兼容逻辑写在 computed 里,模板永远只写一个名字
    const categoryName = computed(() =>
      props.article.category_name || props.article.categoryName || '')

    8.4 登录页:表单校验交给 el-form

  • :rules + prop 声明校验规则,loginRef.validate() 一行触发——校验代码不用自己写 if。
  • 登录成功:userStore.setLogin(token, user) 存 Pinia + localStorage;router.push(redirect) 回到被拦截前的页面。
  • 8.5 个人中心:改资料 + 我的收藏

  • 资料表单初始值来自 GET /profile,保存用 PUT /profile(白名单字段)。
  • 收藏列表是 el-pagination + v-model:current-page——组件 v-model 双向绑定页码,翻页即重新请求。
  • ✔ 过关标准(做完自查,能打勾才进下一步)
    五个页面互链全部可点通;未登录访问个人中心被路由守卫拦到登录页;列表筛选 / 分页 / 详情浏览量自增都正常。
    ⚠ 常见坑(历年学生踩过的)
    • v-for 的 key 用数组下标:删除中间一项时后面卡片全部错位。key 用数据的唯一 id。
    • 子组件里直接改 props(props.article.likes = 1)——控制台警告且数据不可控。改父级数据用 emit。
    • 页面上到处写 fetch:绕过 request.js 拦截器,token 丢失、错误也不弹提示。
    交付物:五个页面ArticleCard 复用组件路由守卫生效
    09

    前后端联调:三道关卡

    建议 2 次课|产出:全链路跑通(注册 → 登录 → 浏览 → 书评 → 收藏)
    本步目标|前端 5173、后端 5000 是两个「国家」——把跨域、token、错误处理三道关卡全过掉。

    9.1 关卡一:跨域(为什么会有 CORS 报错)

    阶段做法原理
    开发环境Vite 代理:vite.config.js 里 server.proxy['/api'] → 127.0.0.1:5000请求发给自己(同源),Vite 在服务端转发——浏览器根本不知道后端存在
    生产环境nginx 同域反代:location /api/ → gunicorn(本站即此方案)前后端在同一个域名下,天然同源
    兜底Flask-CORS:cors.init_app(app, resources={r"/api/*": ...})后端在响应头里声明「允许谁跨域访问」

    9.2 关卡二:token 的完整旅程

    一次受保护请求的完整链路
    登录成功 → Pinia 存 token → localStorage 持久化(刷新不丢)
      ↓ 发请求
    请求拦截器自动加 Authorization: Bearer <token>
      ↓ 后端
    @login_required 校验 → 过期/无效统一返回 code 401
      ↓ 前端
    响应拦截器看到 401 → 清登录态 → 跳 /login?redirect=原地址 → 登录后跳回

    9.3 关卡三:错误处理验收表

    现象排查
    控制台 CORS 报错Network 看请求发到哪了:开发环境应是 5173 自己(代理);若直连 5000 就是绕过了代理
    「网络异常,请检查后端服务」后端没起 / 端口不对:curl 127.0.0.1:5000/api/health 先自证清白
    一直弹「登录已过期」token 没带上:Network → 请求头里有没有 Authorization;拦截器是否 import 到正确的 store
    数据拿到但页面空白console 报 undefined —— 后端返回结构和预期不符,先 console.log 拦截器里的 data
    ✔ 过关标准(做完自查,能打勾才进下一步)
    全链路自测:注册新号 → 登录 → 首页三区块有数据 → 列表筛选/翻页 → 详情浏览量+1 → 发书评 → 点赞 → 收藏 → 个人中心统计数变化 → 退出登录。八步一口气走完不报错。
    ⚠ 常见坑(历年学生踩过的)
    • 改了 vite.config.js 不重启 dev server——代理不生效还以为代码错了。
    • 开发环境好使、build 后 404:生产没有代理,nginx 必须配 /api 反代(见第 10 步)。
    • 登录成功却跳回登录页:redirect 参数丢了,检查 router.currentRoute.value.query.redirect。
    交付物:全链路自测通过截图联调问题记录
    10

    测试与部署上线:让所有人都能访问

    建议 2 次课|产出:线上可访问的完整网站
    本步目标|补几个冒烟测试,npm run build 出产物,gunicorn 跑后端、nginx 托前端并反代接口——走完这步,你的项目就是一个真正上线的网站。

    10.1 冒烟测试(pytest + 内存 SQLite)

    tests/test_api.py · 节选
    @pytest.fixture()
    def client():
        app = create_app("testing")     # config 里 testing 用内存 SQLite,不碰开发库
        with app.app_context(): db.create_all()
        yield app.test_client()
    
    def test_health(client):
        assert client.get("/api/health").get_json()["code"] == 200
    
    def test_register_login_profile(client):
        r = client.post("/api/user/register", json={...})
        tok = client.post("/api/user/login", json={...}).get_json()["data"]["token"]
        r = client.get("/api/user/profile", headers={"Authorization": f"Bearer {tok}"})
        assert r.get_json()["code"] == 200

    10.2 构建:npm run build

    终端
    npx vite build --base=/mysite/   # 部署在子目录时必须指定 base!根路径部署则省略
    # 产物 dist/:带指纹的 assets + 单页 index.html,扔给 nginx 就行

    10.3 生产部署:本站真实配置(三段式)

    gunicorn 跑后端(systemd 守护,只听 127.0.0.1)
    ExecStart=/opt/hiwen-api/venv/bin/gunicorn --workers 2 --timeout 30 \
        --bind 127.0.0.1:5099 --access-logfile - --error-logfile - 'app:create_app()'
    nginx · 前端 SPA + 接口反代(以子路径 /mysite/ 为例)
    # Vue 演示站:history 路由刷新不 404 的关键就是 try_files 回落 index.html
    location ^~ /mysite/ {
        try_files $uri $uri/ /mysite/index.html;
    }
    # 接口反代:同域名 → 天然同源,浏览器无跨域问题
    location ^~ /api/ {
        proxy_pass http://127.0.0.1:5099;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
    location ^~ /static/ {
        proxy_pass http://127.0.0.1:5099;   # 轮播图 / 上传头像
    }

    10.4 上线自检清单

  • app.py 里 debug 必须关(生产用 gunicorn,本就不走 debug)
  • .env 的 SECRET / JWT 密钥换成长随机串,不进 Git
  • CORS 收紧到自己的域名(同域反代后其实可以关)
  • HTTPS(本站 Let's Encrypt)、gzip、静态资源缓存头
  • health 接口 + journalctl 日志留好——出事先看日志再猜
  • ✔ 过关标准(做完自查,能打勾才进下一步)
    外网访问 https://域名/你的前端地址/ 全链路可走;直接刷新作品详情页不 404;systemctl status hiwen-api 显示 active (running)。
    ⚠ 常见坑(历年学生踩过的)
    • 部署在子目录却忘了 --base=子路径:页面白屏,资源 404(全去根目录找 /assets 了)。
    • history 路由没配 try_files:首页能开,刷新子页面就 404。
    • 数据库文件 / 上传目录没给运行用户写权限:注册、传头像全部 500。看 journalctl 一查一个准。
    交付物:pytest 冒烟通过dist 产物线上地址