说起课程互助学习系统,这算是我做过的教育类项目里最有代表性的一种。用 Vue 3 做前端、ThinkPHP 做后端,是目前这类业务系统里很顺手的一套组合。最近我刚把一套跑在校园内部的课程互助系统完整重写了一遍,课程列表、互助问答、学习资料分享一个不少,前后端完全分离,从数据库建模到接口对接全链路都是自己一个人撸完的。这篇就专门讲讲整个项目是怎么从零做出来的,包括数据库怎么设计、后端接口怎么写、前端页面怎么接,以及我在开发过程中踩过的坑和一些比较实用的避坑技巧。
这套系统面向的对象主要是学生和任课老师,学生可以浏览课程、发起提问、回答同学的问题、上传学习资料,老师可以管理课程内容、审核资料并参与答疑。开发完实际测试下来,课程学习、互助答疑、资料共享这几条核心链路都跑得非常稳。如果你正在做类似的校园学习平台或个人项目,这篇可以给你一个完整的参考,尤其是前后端设计思路和接口对接的部分,值得多看几遍。
1. 项目整体设计与技术选型思路
1.1 核心需求拆解:课程互助学习系统的功能边界
开工之前,我先把系统要干的活列了一个清晰的清单,避免做到一半不停加需求。课程互助学习系统本质上就是一个“内容型业务系统”,核心模块可以分为四大块:
用户模块:学生的基本操作是登录、注册、修改个人信息、查看我的提问和回答;老师除登录外还要能创建课程、维护课程内容、点评回答;管理员管用户和全站内容审核。
课程模块:课程分类、课程列表、课程详情,本质上就是一个“内容发布 + 检索”的功能。学生可以按分类浏览或者直接搜索课程名,老师发布课程后自动展示到列表里。
互助问答模块:这个是最核心的部分。学生可以针对某门课程发起提问,其他学生和老师都能回答,提问者可以采纳满意的回答,同时支持点赞和收藏。
学习资料模块:支持上传文档和图片,可以挂到具体课程下面。需要处理文件存储、类型校验和访问权限,简单来说就是一个带鉴权的文件管理功能。
我当时把需求列成表格贴在项目文档最前面,每次开发都对照这个表查缺补漏。这个习惯现在看来非常值得,至少避免了中后期加需求导致的返工。
1.2 为什么用 Vue 3 和 ThinkPHP 组合
选 Vue 3 的主要原因,是组合式 API 带来的代码组织方式的提升。课程互助系统这种项目,页面上有列表刷新、搜索、分页、弹窗提示、表单校验等一堆交互逻辑,用传统的选项式 API 写起来逻辑会散落在 data、methods、watch 里,改一处要翻好几个地方。Vue 3 的 setup 语法把同一块业务逻辑集中在一起,比如问答模块的“获取问题列表、处理分页、点赞”全都放在一个函数里,维护起来舒服很多。
选 ThinkPHP 则是出于开发效率的考虑。我是做独立开发的,没有团队分工,后端必须选一个开发效率高的框架。ThinkPHP 8 自带的路由、ORM、验证器、中间件、缓存机制都很完整,写课程相关的增删改查接口基本不用怎么造轮子,文档和社区案例也丰富。PHP 在业务 CRUD 这块本身就是强项,配合 ThinkPHP 的数据库链式操作,开发速度比用其他语言写快了不止一倍。
这套组合还有一个好处是部署成本低。前端打包成纯静态文件扔到 Nginx,后端 PHP 项目放到 Nginx + PHP-FPM 环境,配好伪静态规则就能跑。学校内部的服务器配置一般都不高,这套方案完全能扛得住。
1.3 前后端分离架构与目录规划
前后端分离是必然的选择。前端 Vue 3 项目独立维护,开发时用 Vite 自带的热更新服务器,接口通过代理转发到后端;上线后前端打包成 dist 目录,由 Nginx 直接托管。后端 ThinkPHP 项目只提供 JSON 接口,不关心页面长什么样。
后端我按多应用模式组织目录,方便后续如果要做管理端也拆成单独的应用:
/app /api 前端接口应用 /controller 控制器 /model 模型 /validate 验证器 /middleware 中间件 /index 默认应用前端目录规划为:
/src /api 接口封装 /components 公共组件 /views 页面 /stores Pinia 状态 /router 路由配置 /utils 工具函数这种结构的好处是,前端页面、组件、状态、路由各管一摊,后端接口、模型、验证逻辑也各管一摊,出问题能快速定位到具体文件,而且代码量大了之后依然能保持清晰。
2. 数据库设计与表结构规划
2.1 用户模块:不要把所有字段塞一张表
用户表设计我一开始犯过懒,想把用户的昵称、头像、角色、个性签名全塞进一张表。后来实际写代码的时候发现不行,课程评价需要关联用户信息、问答需要关联用户信息、资料上传也需要关联用户信息,每个地方都要查好几张表,如果字段全堆在一起查询性能会变差,代码也乱。
最后拆成了用户主表和用户扩展表。主表存账号、密码、角色这类核心字段,扩展表存头像、签名这类展示用信息:
CREATE TABLE `p_user` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `username` varchar(32) NOT NULL COMMENT '登录账号', `password` varchar(255) NOT NULL COMMENT '密码哈希', `role` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1学生 2老师 3管理员', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1正常 0禁用', `created_at` int(11) NOT NULL, `updated_at` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主表'; CREATE TABLE `p_user_profile` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '用户ID', `nickname` varchar(50) DEFAULT '' COMMENT '昵称', `avatar` varchar(255) DEFAULT '' COMMENT '头像地址', `signature` varchar(255) DEFAULT '' COMMENT '个性签名', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户扩展表';密码存储用的是密码哈希函数,不存明文,注册时哈希一次,登录时比对哈希值。这里需要特别说明一下,用 MD5 做密码哈希已经不适合作为安全方案,原因是彩虹表攻击很容易反查短密码,一定选择带盐的哈希算法,PHP 自带的 password_hash 函数就是现成的解决方案,不管是性能还是安全性都比手工拼接盐的方式靠谱。
2.2 课程与问答模块:高频核心表的设计
课程表和问答相关的表是整个系统的核心,设计上重点关注关联关系和状态字段。
课程表保存课程的基本信息,归属老师 ID 建立用户关系,分类用独立的字段存储,不单独拆分类表,因为本项目分类数量比较稳定,拆表反而增加一次联表查询:
CREATE TABLE `p_course` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '创建者ID', `title` varchar(100) NOT NULL COMMENT '课程名称', `category` varchar(30) NOT NULL DEFAULT '' COMMENT '课程分类', `cover` varchar(255) NOT NULL DEFAULT '' COMMENT '封面图', `intro` text COMMENT '课程简介', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1上架 0下架', `view_count` int(11) NOT NULL DEFAULT '0' COMMENT '浏览量', `created_at` int(11) NOT NULL, `updated_at` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_category` (`category`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';问答表分为问题表和回答表。理所当然采用的是一对多设计,也就是一个问题对应多条回答。问题表存 title、content、course_id、user_id、status 这些字段,同时加了 answer_count 和 view_count 这类冗余计数字段。
这里有一个设计取舍想多说两句。问答列表页肯定要展示每个问题的回答数,如果不冗余 answer_count 字段,就得用子查询去统计答案数量。课程互助系统这种项目,等数据量到几十万的时候,子查询会成为数据库主要压力来源。冗余计数字段以后,写入的时候多维护一下就行,换取的是读接口的高效和代码简洁,对中小型系统来说非常划算。如果担心并发更新问题,还可以用事务处理:回答问题后同时更新 answer_count,另一个字段 resolve_count 用于记录已采纳的次数,方便后续做统计。
回答表需要重点考虑数据库事务边界。实际问题中一个学生可以回答多条回答,一个老师也会点评多个学生。问答配合“采纳最佳答案”的功能做逻辑判断,回答表加 status 和 采纳标记字段,采纳时通过事务同时更新问题表和回答表的状态。这个事务操作简单的说就是:把问题表标记为已解决,并把对应回答的采纳标记设为 1,之前被采纳的答案要置为 0。如果不用事务,可能出现问题已经标记已解决,但回答的采纳状态还是旧值的情况。
2.3 学习资料与小组模块:文件信息表和关联关系
学习资料表相对简单,但文件类型和文件大小字段一定不能省。之前我看到有的人把文件信息全部存在课程表的 content 字段里,这是一条不能走的路,因为后续要做资料列表、统计下载次数、控制访问权限都需要结构化字段支持。
我设计了一张独立的资料表:
CREATE TABLE `p_resource` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `course_id` int(11) NOT NULL COMMENT '所属课程ID', `user_id` int(11) NOT NULL COMMENT '上传者ID', `title` varchar(100) NOT NULL COMMENT '资料名称', `file_path` varchar(255) NOT NULL COMMENT '文件存储路径', `file_type` varchar(20) NOT NULL COMMENT '文件类型:pdf/doc/image', `file_size` int(11) NOT NULL COMMENT '文件大小,单位字节', `download_count` int(11) NOT NULL DEFAULT '0' COMMENT '下载次数', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待审核 1通过 2拒绝', `created_at` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_course_id` (`course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学习资料表';学习小组模块,如果项目初版要控制工期,可以把群聊简化为帖子式的讨论区。创建小组表 p_group,字段包括 id、名称、简介、创建者ID、成员数;关联表 p_group_member 存 组ID 和 用户ID。提问和回答都需要关联到具体的小组,方便按组筛选和管理。说白了,互助学习系统终究是内容驱动的,只要把“人、内容、关系”这三类数据梳理清楚,表结构基本就稳定了,后面加字段的频率会低很多。
3. ThinkPHP 后端核心接口实现
3.1 接口目录结构与公共返回格式
后端我严格按 ThinkPHP 多应用模式来做。接口统一放在 api 应用下,每个模块一个控制器。路由规则提前定义好,不直接用默认路由,这样接口文档更清晰,效率也更高:
// route/api.php use think\facade\Route; Route::group('api', function () { // 用户模块 Route::post('login', 'Login/login'); Route::post('register', 'Login/register'); Route::get('profile/:id', 'User/profile'); Route::post('profile/update', 'User/updateProfile'); // 课程模块 Route::get('course/list', 'Course/list'); Route::get('course/:id', 'Course/detail'); Route::post('course/create', 'Course/create'); Route::post('course/:id/update', 'Course/update'); Route::delete('course/:id', 'Course/delete'); // 问答模块 Route::get('question/list', 'Question/list'); Route::post('question/create', 'Question/create'); Route::get('question/:id', 'Question/detail'); Route::post('answer/create', 'Answer/create'); Route::post('answer/adopt', 'Answer/adopt'); Route::post('question/:id/favorite', 'Question/favorite'); // 资料模块 Route::get('resource/list/:course_id', 'Resource/list'); Route::post('resource/upload', 'Resource/upload'); Route::get('resource/down/:id', 'Resource/download'); // 小组模块 Route::get('group/list', 'Group/list'); Route::post('group/create', 'Group/create'); Route::post('group/join', 'Group/join'); })->middleware([app\api\middleware\Auth::class]);路由这块要注意 ThinkPHP 的中间件作用域。把 Auth 中间件挂在整组路由上,然后在中间件里做白名单判断,比如登录和注册接口不需要鉴权直接放行,其他接口全部校验 Token。这种做法比在每个控制器里单独写鉴权更省事,也不容易漏保护某个接口。
项目上为了避免每个接口返回格式不统一,我封装了一个 JSON 结果类,所有接口都走这个统一出口:
<?php namespace app\api\lib; class Result { public static function success($data = [], string $msg = 'ok', int $code = 0) { return json([ 'code' => $code, 'msg' => $msg, 'data' => $data ]); } public static function error(string $msg = 'error', int $code = 1, $data = []) { return json([ 'code' => $code, 'msg' => $msg, 'data' => $data ]); } }前端所有的 axios 拦截器只需要针对这一个结构做处理,判断 code 的值,省得每个页面各写一套逻辑。既然要做到统一,异常处理器也需要配合,否则 ThinkPHP 框架自带的异常页面会让前端接收到一堆 HTML 字符串。我专门写了一个异常处理类,把业务异常和系统异常全部转成标准 JSON 输出,前端不管遇到任何错误都能稳定拿到结构一致的返回,联调和部署后排查问题效率明显高不少。
3.2 商品课程接口的控制器与模型层实现
课程列表接口看起来简单,但里面最容易出问题的就是数据返回的字段控制。刚开始我直接把数据库查询结果原样返回,结果前端拿到的一些字段要么不需要、要么格式对不上、要么多了不该暴露的数据,联调的时候来回折腾了好几轮。
后来给模型加了字段隐藏和类型转换。课程模型使用 ThinkPHP 的模型功能做统一输出控制:
<?php namespace app\api\model; use think\Model; class Course extends Model { protected $table = 'p_course'; protected $hidden = ['user_id', 'updated_at']; protected $append = ['user_info', 'resource_count']; public function getUserInfoAttr() { $user = User::find($this->user_id); return $user ? $user->nickname : ''; } public function getResourceCountAttr() { return Resource::where('course_id', $this->id) ->where('status', 1) ->count(); } public function getIntroAttr($value) { return mb_substr($value, 0, 120) . '...'; } }课程列表控制器的实现就清爽很多:
<?php namespace app\api\controller; use app\api\lib\Result; use app\api\model\Course; use think\Request; class Course { public function list(Request $request) { $page = (int) $request->param('page', 1); $size = (int) $request->param('size', 10); $keyword = trim((string) $request->param('keyword', '')); $category = trim((string) $request->param('category', '')); $query = Course::where('status', 1); if ($keyword !== '') { $query->whereLike('title', '%' . $keyword . '%'); } if ($category !== '') { $query->where('category', $category); } $total = $query->count(); $list = $query->order('id', 'desc') ->page($page, $size) ->select() ->toArray(); return Result::success([ 'list' => $list, 'total' => $total, 'page' => $page, 'size' => $size ]); } public function detail($id) { $course = Course::where('id', $id) ->where('status', 1) ->find(); if (!$course) { return Result::error('课程不存在或已下架'); } $course->view_count++; $course->save(); return Result::success($course->toArray()); } }这里有一个小细节需要说明:搜索的时候 whereLike 避免传空字符串去匹配,否则会把全表都捞出来。我采用了先判断空值再拼接查询条件的做法,符合常规实践。如果后续课程量上来,要上高性能搜索,可以考虑为标题字段加全文索引,而不是继续用 LIKE 模糊匹配。
3.3 问答模块的提交与采纳最佳答案实现
问答模块是这个系统里最复杂的业务逻辑。写提问接口时除了写入问题本身,还要处理敏感词过滤和内容清洗。我没有接第三方的内容安全接口,用了简单的敏感词数组过滤,配合 HTML 标签剥离函数,阻止用户往问题内容里嵌入脚本:
public function create(Request $request) { $data = $request->only(['course_id', 'title', 'content']); $validate = new QuestionValidate(); if (!$validate->check($data)) { return Result::error($validate->getError()); } $data['user_id'] = $request->userId; $data['content'] = strip_tags($data['content']); $question = Question::create($data); if ($question) { return Result::success(['id' => $question->id], '发布成功'); } return Result::error('发布失败'); }回答问题接口除了写入正文,还要维护问题的回答总数。这部分我用了一个事务操作,回答创建成功后,问题表的 answer_count 字段加一,因为只是简单递增,即使高并发下也很安全。如果做更严谨的并发保护,可以用数据库自带的原子更新表达式 thinkphp 中的 inc 方法,不需要额外加锁。
采纳答案是真正需要谨慎的地方。一个事务里同时做三件事:先把该问题下所有回答的采纳状态清零,然后把当前回答的采纳状态改成 1,最后把问题表状态改成已解决。为什么不能直接更新当前回答的采纳状态?因为如果之前已经有其他回答被采纳过,不改回普通状态,列表页就会出现两个“已采纳”的回答,数据库数据就乱了。
public function adopt(Request $request) { $answer_id = (int) $request->post('answer_id'); $question_id = (int) $request->post('question_id'); $user_id = $request->userId; $question = Question::find($question_id); if (!$question) { return Result::error('问题不存在'); } if ($question->user_id !== $user_id) { return Result::error('只有提问者可以采纳回答'); } Db::startTrans(); try { Answer::where('question_id', $question_id)->update(['is_adopt' => 0]); Answer::where('id', $answer_id)->update(['is_adopt' => 1]); Question::where('id', $question_id)->update(['resolve_status' => 1]); Db::commit(); return Result::success([], '已采纳该回答'); } catch (\Exception $e) { Db::rollback(); return Result::error('操作失败'); } }这个接口必须做权限校验,非提问者不允许采纳。做项目过程中经常看到有人为省事后端不校验直接前端写死操作,结果越权问题到处都是,这一点初学者最容易忽略。
页面展示上,已解决的问题会高亮显示。前端在问题列表里还会展示每节课下被采纳回答的占比,作为课程质量参考。这些后续功能都依赖上述状态字段的准确维护,刚开始就把基础定扎实,后面加功能就顺理成章。
4. Vue 3 前端核心功能实现
4.1 前端工程初始化与目录规划
前端工程我直接用 Vite 创建,相比之前的构建工具最大的感受就是快,项目大了之后启动基本秒开:
npm create vite@latest course-frontend -- --template vue-ts cd course-frontend npm install npm install axios pinia vue-router@4关于是否上 TypeScript,我建议如果你对类型熟悉就上,不熟也别硬上。课程互助系统的业务复杂度用 JS 也能扛住,但上了 TS 之后,接口返回的对象、表单数据、组件 props 这些状态的类型清楚很多,尤其在多人协作或一个月后回来看代码的场景,优势太明显了。最终我选了 vue-ts 模板,项目里所有请求响应数据都定义了 interface。
目录规划上按照功能拆分:
/src /api/user.js 用户相关接口 /api/course.js 课程相关接口 /api/question.js 问答相关接口 /api/resource.js 资料相关接口 /api/group.js 小组相关接口 /components/CourseCard.vue 课程卡片组件 /components/QuestionItem.vue 问答列表项组件 /stores/user.js 用户状态管理 /utils/request.js axios 封装 /views/course/CourseList.vue 课程列表页 /views/course/CourseDetail.vue 课程详情页 /views/question/QuestionList.vue 问答列表页 /views/question/AskPage.vue 提问页面一个项目如果从开始就把目录分清楚,后面每个文件放哪里基本不用思考,闭着眼睛就能往对的地方放,这对开发效率的提升非常明显。
4.2 axios 封装与 Pinia 用户状态管理
axios 请求封装是最值得认真写的一块公共代码。我实际的封装逻辑是:请求拦截器统一加 Token,响应拦截器统一处理 code 判断、错误提示、401 跳转登录:
import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 0) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') ElMessage.error('登录状态已过期,请重新登录') } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default request如果业务上需要文件上传,需要注意 axios 默认的请求头处理。FormData 格式的请求不要手动设置 Content-Type,让 axios 根据数据格式自动使用 multipart/form-data 并带好 boundary,否则服务器解析时会报错。这个坑我踩过不止一次,花时间排查的时候心态很崩。
用户状态用 Pinia 管理。登录之后把用户信息和 Token 存进 store,刷新页面再从持久化存储里恢复状态。用 Pinia 替代 Vuex 的感受就是少写了太多样板代码,defineStore 一个函数搞定:
export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || '{}') }), actions: { setLogin(data) { this.token = data.token this.userInfo = data.userInfo localStorage.setItem('token', data.token) localStorage.setItem('userInfo', JSON.stringify(data.userInfo)) }, logout() { this.token = '' this.userInfo = {} localStorage.removeItem('token') localStorage.removeItem('userInfo') } } })在 Vue 组件里调用时只需要 useUserStore() 拿实例,然后直接调用 store 里的 state 和 action。这套状态管理方案比原生 Vuex 简洁,特别适合课程互助系统这种中等规模项目。
4.3 课程列表与详情页的实现细节
课程列表页我的做法是:使用卡片式布局,每张卡片包含封面、课程名、分类标签、简介截断和老师昵称。前端不能直接用富文本渲染课程简介,需要做文本截断处理。这里有一个常见的坑:如果直接用 CSS 的截断却不限制单行,会遇到换行问题;我写了一个纯 JS 的文本截断方法,超过 60 个字符的简介自动加省略号,既兼顾了展示效果又避免了换行带来的布局错位。
课程详情页除了基本信息,还包含问答入口、资料列表和讨论小组成员展示。完整的信息架构应该是“课程概览 + 相关问答 + 资料下载 + 成员互助”四块组合,用户进来能顺着一条浏览路径把内容都看全。这个部分我用组件拆分的方式实现:CourseIntro 组件负责介绍区,QuestionSection 组件负责问答区,ResourceSection 组件负责资料区。联调时只要每个组件的数据来源正确、交互状态切换顺畅,页面整体基本没大问题。
浏览量的更新我放在详情接口里做。后端详情接口做了自增,前端不需要额外发请求,这一点保证了统计和查看是同步的。如果前端单独发一个浏览统计接口,用户开多个页面或多点几次操作,数字会有偏差。
4.4 问答交互与小组讨论页面
问答列表页的重点是过滤和分页。我做了一个顶部 Tab 切换:全部问题、待回答、已解决三个状态。前端切换 Tab 后重新请求接口,问题列表项展示标题、所属课程、回答数和采纳状态。已采纳的回答在列表里会用高亮背景标注,用户一眼就能分辨出哪些问题有了靠谱的答案。
提问页面的表单用了 Vue 3 的响应式数据,提交前要做校验再调接口:
const form = reactive({ course_id: null, title: '', content: '' }) const submit = async () => { if (!form.course_id) { ElMessage.warning('请选择所属课程') return } if (form.title.length < 5) { ElMessage.warning('标题至少5个字符') return } if (form.content.length < 10) { ElMessage.warning('问题描述至少10个字符') return } const loading = ElLoading.service({ text: '发布中...' }) try { await createQuestion(form) ElMessage.success('发布成功') router.push('/question') } finally { loading.close() } }我实际使用下来,发布成功后用 ElMessage 提示一下,再把用户引导回列表页,整个过程交互比较顺畅。很多新人在提交过程中连点击按钮都要写防重复提交,其实 loading 状态足够挡住大部分误操作。如果更严谨一点,可以在按钮上绑定 disabled 状态为 loading 值。
小组讨论区我做得比较轻量:创建小组表单、小组列表、小组详情里的帖子列表。帖子和回答都关联 group_id,前端根据当前小组过滤数据。如果项目后续要扩展到实时聊天,可以在这一套基础上加 WebSocket 服务,把核心的“课程互助”逻辑保留即可。
5. 前后端对接与常见问题排查技巧
5.1 跨域配置与 Nginx 部署踩坑记录
本地开发环境的前后端对接主要问题就是跨域。前端跑在 Vite 的 localhost:5173,后端跑在 localhost:8000。我在 Vite 配置文件里设置代理,让所有的 /api 请求转发到后端:
// vite.config.ts import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })后端 ThinkPHP 也要允许跨域。如果只配前端代理,部署环境不走 Vite,生产环境的 Nginx 反向代理同样可以解决。不过考虑到有时候需要直接调试后端接口,我在 ThinkPHP 的全局中间件里加了一个跨域处理逻辑,这样两边的兼容性都兼顾了。注意 Options 预检请求必须在路由匹配之前返回 200,并且带上跨域头,否则前端请求会先被预检拦截,正式请求根本发不出去。
生产环境的 Nginx 配置有几处细节必须写对。一是前端 history 路由模式需要配置 try_files,否则刷新非首页路由会直接白屏或 404;二是 ThinkPHP 需要配置伪静态规则,把请求都转到入口文件;三是前后端如果部署在同一台服务器,要确保两个不同的 root 路径不会覆盖。
我在实际项目中使用的 Nginx 配置片段参考如下:
server { listen 80; server_name example.com; # 前端静态文件 root /var/www/course-frontend/dist; index index.html; # 前端路由重写 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }部署完之后我一般会逐个测试几条关键链路:课程列表是否能加载、登录后刷新页面状态是否保持、上传文件后 Nginx 能否正确代理到后端接收。有一次在测试环境运行得好好的,部署到生产环境就 404,排查半天发现就是 Nginx 忘了加 try_files 的规则。这个问题我印象深刻,所以这次特意强调。
5.2 文件上传与下载的联合安全问题
文件上传是教育系统的高危点之一。除了校验后缀名,我还做了文件大小限制和目录隔离:
public function upload(Request $request) { $file = $request->file('file'); if (!$file) { return Result::error('请选择文件'); } $validate = [ 'size' => 20 * 1024 * 1024, 'ext' => 'jpg,jpeg,png,pdf,doc,docx,ppt,pptx,xls,xlsx' ]; $check = ValidateRule::check($file, $validate); if (!$check) { return Result::error($validate instanceof \think\exception\ValidateException ? $validate->getMessage() : '文件类型或大小不符合要求'); } $savePath = '/uploads/' . date('Ymd') . '/'; $fileName = uniqid() . '.' . $file->extension(); $file->move(public_path() . $savePath, $fileName); $path = $savePath . $fileName; Resource::create([ 'course_id' => $request->post('course_id'), 'user_id' => $request->userId, 'title' => $request->post('title'), 'file_path' => $path, 'file_type' => $file->extension(), 'file_size' => $file->getSize(), 'status' => 0 ]); return Result::success(['path' => $path], '上传成功,等待审核'); }文件名必须重命名,不能保留用户原始文件名。原因一是防止中文文件名和特殊字符在服务器上引发乱码问题,二是避免不安全的文件名被恶意构造。用 uniqid 生成的文件名不存在路径穿越风险的常规认识,配合扩展名白名单校验,基本能防住绝大多数风险。
文件下载接口要做权限校验和下载次数统计。我写的下载逻辑是:判断资源状态为审核通过才允许下载,同时友好地提示下载者下载成功。下载次数的自增用模型里的 inc 方法,不要用先查询再更新的方式,否则并发下载时统计会丢。
图片上传需要注意压缩处理。原始手机照片动辄几 MB,上传到服务器展示时加载很慢。我在前端接了一个图片压缩逻辑:用户选择图片后,先用 canvas 把分辨率缩到最大宽度 1200px,再把质量降到 0.8,转成 blob 后上传。实际测试下来,一张 3MB 的照片能压到 300KB 左右,列表页加载速度提升非常明显。虽然这个操作增加了代码量,但从用户体验角度看绝对值得。
5.3 常见问题速查表与独家避坑技巧
开发过程中遇到的问题真不少,我整理成一个速查表方便以后排查,也方便看到这篇博文的朋友直接对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端请求返回 404 | Nginx 未配置 try_files 或伪静态规则 | 检查 Nginx location 规则是否把请求转发到入口文件 |
| 打开页面白屏 | 前端路由 history 模式刷新时后端未匹配 | Nginx 添加 try_files $uri $uri/ /index.html |
| 上传文件返回 413 | 服务器上传大小限制太小 | 修改 Nginx client_max_body_size 和 PHP upload_max_filesize |
| 登录接口正常但列表页报 401 | Token 未存入 store 或拦截器未携带 | 检查 axios 请求拦截器 Authorization 是否正确组装 |
| 问题回答数量不一致 | 写入回答时未同步更新计数字段 | 用 Db 事务统一维护 answer_count |
| 上传中文文件名为乱码 | 恶意文件名或编码不一致 | 统一用 uniqid 重命名文件,避免原始文件名 |
| 跨域请求失败 | 预检请求未正确处理 | 确保 Options 请求返回 200 并带上跨域响应头 |
| 密码偷懒用 MD5 | 安全习惯问题 | 使用带盐哈希算法,或框架自带的 hash 方法 |
再分享几个实际操作中积累的独家经验:
第一,前后端接口联调时,本地先约定好返回结构,代码里写死“code 为 0 表示成功”,不要每个接口单独判断,一大堆逻辑冗余让全局处理变得混乱。
第二,数据库操作用数据库原子递增而不是先查后写。ThinkPHP 里的 inc 方法就是底层用数据库引擎的自增操作,课程浏览量、回答数、下载次数这些高频更新的统计字段统统用这个方案,既能保证性能又不容易出错。
第三,验证器一定要放在后端做,不能依赖前端表单校验。前端校验只是提升用户体验的手段,后端验证才是防线。我用 ThinkPHP 的验证器定义规则,课程标题必填和长度限制、问答内容必填和最小长度都写在校验类里,接口第一行先跑校验。
第四,测试环境用默认配置没问题不代表生产环境也这样。PHP 的缓存、Nginx 的代理设置、目录权限这三点是最容易出问题的。部署完一定要重启 PHP-FPM 清空框架缓存,否则改完的代码不生效还以为是配置写错了。
第五,安全方面,用户提交的富文本内容一定做过滤。我自己的做法是后端保存前用 strip_tags 处理掉 HTML 标签,只允许纯文本保存。如果确实需要运行 HTML 的富文本能力,必须对允许的标签做严格白名单,并禁止内联事件属性,这个优先级一定要排到功能迭代前面。
做完整套系统,我的体会是 Vue 3 和 ThinkPHP 这对组合在课程互助学习系统这类业务上非常实用。前端的组合式 API 让复杂交互组件变得清晰,后端 ThinkPHP 的 ORM、验证器、中间件让接口开发和高频逻辑维护省下大量时间。尤其是问答模块的“采纳最佳答案”和“问题状态流转”,必须在后端用事务严格控数据一致性,前端再配合高亮样式做交互反馈,这两端缺一个体验都会大打折扣。
最后再分享一个小技巧:把课程互助系统的权限设计搞定了,很多类似的内容型系统都能直接套用同一个框架。登录鉴权、角色权限、内容审核、资源管理、状态流转,这些在选课、交流、资源平台里都通用。你做了一套,就等于把这一类业务都打通了。希望这篇实战复盘能帮你少踩几个坑,尤其是数据库冗余字段和文件上传安全这两处,务必重视起来。