零基础照着走:每一步都有目标、操作、过关标准、常见坑、交付物。左侧导航可跳步, 完成一步点亮一个 ✓(存在本机,下次接着来)。所有命令与代码都来自本项目的真实源码,不是玩具示例。
和「主题网站 HTML 骨架」作业一样,先让 AI 帮你把主题拓成需求,但你要做裁剪决策。本项目最终的裁剪结果:
| 板块 | 功能点 | 优先级 | 接口预估 |
|---|---|---|---|
| 首页 | 轮播图、分类入口、编辑推荐 | P0 必做 | 3 个(banner / category / recommend) |
| 赏文学 | 作品列表:分类筛选、关键字搜索、排序、分页 | P0 必做 | 1 个(article/list) |
| 作品详情 | 正文、浏览量自增、书评列表、点赞 / 收藏 | P0 必做 | 5 个(detail / like / comment×3) |
| 登录注册 | 注册、登录(JWT)、退出 | P0 必做 | 3 个 |
| 个人中心 | 资料查看与修改、我的收藏、头像上传 | P1 应做 | 5 个 |
| 在线听书 / AI 赏析 / 社区 | ——都砍掉 | P2 不做 | 理由:纯前端做不了或超出课程范围,写进需求文档的「不做清单」 |
纸笔画、白板画、或用墨刀 / Figma 都行,标准只有一条:每个区块都标出「数据从哪个接口来」。 本项目的五页原型(含区块→接口的对照)已画好,照着看:
# 古典文学网 · 需求文档
## 1. 一句话定位 面向文学爱好者的赏析阅读站(纯展示 + 轻互动)
## 2. 用户与场景 谁来用?在什么情况下打开?
## 3. 功能清单 板块 / 功能点 / 优先级(照上表抄)
## 4. 不做清单 明确写「为什么不做」,防止后期无限膨胀
## 5. 页面结构 五页原型缩略图 + 每页调用的接口
## 6. 变更记录 后期每次改需求都记一行hiwen.sql。| 类型 | 表 | 怎么想出来的 |
|---|---|---|
| 「谁在用」 | user | 需求里有登录、个人中心 → 必有用户表 |
| 「有什么内容」 | category、article、banner | 作品要分类 → 分类表;轮播图是内容 → 单独一张 |
| 「用户做了什么」 | comment、favorite、user_like | 书评 / 收藏 / 点赞各一张;点赞针对「作品或评论」→ 加 target_type 字段一张表搞定 |
完整 ER 图(含字段、外键、一对多关系)见 流程图 · ER 图。
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;article.likes / views 不 COUNT 从表,直接在主表存数字——列表页 20 篇作品如果每篇都去数点赞表,查询次数爆炸。mysql -uroot -p < hiwen.sql 执行无报错;SHOW TABLES; 恰好 7 张;SELECT COUNT(*) FROM article; 能看到种子数据。GET /api/health。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 # 锁版本,别人拿到就能装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 是模板)# 模型文件要 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()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 apphome_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 里,一建模型就循环导入。扩展永远单独一个模块。which python 检查。.env + python-dotenv,.env 进 .gitignore。看 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 "", ...}Article 有 to_list_dict()(不带正文 content)和 to_detail_dict()(带正文)。
列表页一次 20 条,如果把每篇几千字的正文都发出去,流量和渲染都白费——列表轻、详情重,是接口设计的常识。
| 方式 | 命令 | 什么时候用 |
|---|---|---|
| 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)。Can't determine relationship——对着模型类名逐字核对。create_access_token(identity=str(user.id))。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。约定大于配置,全站只认一种结构。
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():带了合法 token 就认,没有就当游客——一个接口同时服务两种人。
@jwt.expired_token_loader
def expired(header, payload):
return fail("登录已过期,请重新登录", code=401)
# unauthorized / invalid / revoked 三种异常同理。
# 前端拦截器只要看到 code===401:清登录态 → 跳登录页 → 记住回来的地址。curl -X POST .../api/user/login 拿到 token;② 不带 token 访问 /api/user/profile 返回 code 401;③ 带 -H "Authorization: Bearer <token>" 返回 code 200。@login_required——上线后任何人都能替别人发书评。用接口清单逐条核对「需不需要登录」。| 方法 | 路径 | 作用 | 关键参数 |
|---|---|---|---|
| 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 | 登录 → 返回 token | body: 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 个业务接口。
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))LIKE '%关键词' 少写一边 % —— 查不到还不报错,最隐蔽。page_size=99999 一次拖全表。request.form 接 JSON(或反之),取出来全是 None。JSON 接口用 request.get_json(silent=True)。npm create vite@latest hivue -- --template vue
cd hivue && npm install
npm install element-plus @element-plus/icons-vue axios pinia vue-routerimport { 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') // 开门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 开头。http://127.0.0.1:5000——换环境就得全局搜索替换。用 .env 的 VITE_API_BASE。@ 别名忘了在 vite.config.js 配,满屏 ../../..。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
})// 用户改了筛选条件 → 当前页码很可能已越界 → 强制回第 1 页再查
watch([selectedCategory, keyword], () => {
page.value = 1
fetchList()
})const props = defineProps({ article: { type: Object, required: true } })
// 后端两种接口给的字段名不同(category_name vs categoryName)——
// 兼容逻辑写在 computed 里,模板永远只写一个名字
const categoryName = computed(() =>
props.article.category_name || props.article.categoryName || ''):rules + prop 声明校验规则,loginRef.validate() 一行触发——校验代码不用自己写 if。userStore.setLogin(token, user) 存 Pinia + localStorage;router.push(redirect) 回到被拦截前的页面。GET /profile,保存用 PUT /profile(白名单字段)。el-pagination + v-model:current-page——组件 v-model 双向绑定页码,翻页即重新请求。v-for 的 key 用数组下标:删除中间一项时后面卡片全部错位。key 用数据的唯一 id。props.article.likes = 1)——控制台警告且数据不可控。改父级数据用 emit。fetch:绕过 request.js 拦截器,token 丢失、错误也不弹提示。| 阶段 | 做法 | 原理 |
|---|---|---|
| 开发环境 | 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/*": ...}) | 后端在响应头里声明「允许谁跨域访问」 |
登录成功 → Pinia 存 token → localStorage 持久化(刷新不丢)
↓ 发请求
请求拦截器自动加 Authorization: Bearer <token>
↓ 后端
@login_required 校验 → 过期/无效统一返回 code 401
↓ 前端
响应拦截器看到 401 → 清登录态 → 跳 /login?redirect=原地址 → 登录后跳回| 现象 | 排查 |
|---|---|
| 控制台 CORS 报错 | Network 看请求发到哪了:开发环境应是 5173 自己(代理);若直连 5000 就是绕过了代理 |
| 「网络异常,请检查后端服务」 | 后端没起 / 端口不对:curl 127.0.0.1:5000/api/health 先自证清白 |
| 一直弹「登录已过期」 | token 没带上:Network → 请求头里有没有 Authorization;拦截器是否 import 到正确的 store |
| 数据拿到但页面空白 | console 报 undefined —— 后端返回结构和预期不符,先 console.log 拦截器里的 data |
router.currentRoute.value.query.redirect。npm run build 出产物,gunicorn 跑后端、nginx 托前端并反代接口——走完这步,你的项目就是一个真正上线的网站。@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"] == 200npx vite build --base=/mysite/ # 部署在子目录时必须指定 base!根路径部署则省略
# 产物 dist/:带指纹的 assets + 单页 index.html,扔给 nginx 就行ExecStart=/opt/hiwen-api/venv/bin/gunicorn --workers 2 --timeout 30 \
--bind 127.0.0.1:5099 --access-logfile - --error-logfile - 'app:create_app()'# 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; # 轮播图 / 上传头像
}app.py 里 debug 必须关(生产用 gunicorn,本就不走 debug).env 的 SECRET / JWT 密钥换成长随机串,不进 Githttps://域名/你的前端地址/ 全链路可走;直接刷新作品详情页不 404;systemctl status hiwen-api 显示 active (running)。--base=子路径:页面白屏,资源 404(全去根目录找 /assets 了)。