作为一名长期从事教育信息化系统开发的全栈工程师,我最近完成了一个基于Vue3和ThinkPHP的课程互助学习平台项目。这个系统从立项到上线历时4个月,期间经历了3次大的架构调整,最终形成了一个稳定可用的版本。现在我将这个项目的完整开发过程和关键技术细节分享出来,希望能给正在开发类似系统的同行提供参考。
这个系统主要解决高校师生在课外学习中的三个痛点:一是课程资源分散难以集中管理,二是学生遇到问题缺乏即时求助渠道,三是教师无法跟踪学生的课外学习情况。我们采用前后端分离架构,前端使用Vue3+Pinia+Element Plus技术栈,后端采用ThinkPHP8.0+MySQL组合,通过JWT实现认证授权,使用WebSocket实现实时消息通知。
选择Vue3作为前端框架主要基于以下考虑:
实际开发中我们采用了这些关键配置:
javascript复制// vite.config.js
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag.startsWith('ion-') // 处理自定义元素
}
}
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'comps': path.resolve(__dirname, './src/components')
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})
ThinkPHP8.0相比Laravel更适合本项目是因为:
我们特别优化了ThinkPHP的数据库配置:
php复制// database.php
return [
'connections' => [
'mysql' => [
'type' => 'mysql',
'hostname' => env('database.hostname', '127.0.0.1'),
'database' => env('database.database', ''),
'username' => env('database.username', 'root'),
'password' => env('database.password', ''),
'hostport' => env('database.hostport', '3306'),
'charset' => 'utf8mb4',
'deploy' => 0, // 部署方式:0 集中式(单一服务器),1 分布式(主从服务器)
'break_reconnect' => true, // 断线重连
'fields_strict' => false, // 是否严格检查字段类型
'pool' => [
'min' => 5, // 最小连接数
'max' => 100 // 最大连接数
]
]
]
];
我们实现了RBAC(基于角色的访问控制)模型,包含以下关键表:
权限验证中间件关键代码:
php复制class AuthMiddleware
{
public function handle($request, Closure $next, $permission)
{
if (!$request->user()->can($permission)) {
return response()->json([
'code' => 403,
'message' => '无权访问'
], 403);
}
return $next($request);
}
}
文件上传采用了分片上传方案,前端使用vue-simple-uploader组件:
vue复制<template>
<uploader
:options="options"
:file-status-text="fileStatusText"
class="uploader-example"
@file-complete="fileComplete"
>
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<p>拖拽文件到此处或</p>
<uploader-btn>选择文件</uploader-btn>
</uploader-drop>
<uploader-list></uploader-list>
</uploader>
</template>
<script>
export default {
data() {
return {
options: {
target: '/api/upload/chunk',
chunkSize: 2 * 1024 * 1024, // 2MB
testChunks: true,
query: {course_id: this.courseId}
},
fileStatusText: {
success: '上传成功',
error: '上传失败',
uploading: '上传中',
paused: '暂停中',
waiting: '等待中'
}
}
},
methods: {
fileComplete(file) {
this.$emit('upload-success', file)
}
}
}
</script>
后端分片合并处理代码:
php复制public function mergeChunks(Request $request)
{
$fileName = $request->input('filename');
$chunkCount = $request->input('chunk_count');
$courseId = $request->input('course_id');
$fileExt = pathinfo($fileName, PATHINFO_EXTENSION);
// 验证分片数量
$chunkDir = storage_path('app/chunks/'.$fileName);
if (count(glob("$chunkDir/*")) != $chunkCount) {
return response()->json(['status' => 'error', 'message' => '分片数量不匹配']);
}
// 合并文件
$finalPath = storage_path('app/public/courses/'.$courseId.'/'.uniqid().'.'.$fileExt);
if (!is_dir(dirname($finalPath))) {
mkdir(dirname($finalPath), 0755, true);
}
$fp = fopen($finalPath, 'wb');
for ($i = 0; $i < $chunkCount; $i++) {
$chunkPath = "$chunkDir/$i";
$chunkContent = file_get_contents($chunkPath);
fwrite($fp, $chunkContent);
unlink($chunkPath);
}
fclose($fp);
rmdir($chunkDir);
// 保存到数据库
$file = CourseFile::create([
'course_id' => $courseId,
'name' => $fileName,
'path' => str_replace(storage_path('app/public'), '', $finalPath),
'size' => filesize($finalPath),
'type' => $fileExt
]);
return response()->json(['status' => 'success', 'data' => $file]);
}
我们使用Swoole作为WebSocket服务器,与ThinkPHP集成:
php复制class WebSocketController
{
protected $server;
public function __construct()
{
$this->server = new Swoole\WebSocket\Server("0.0.0.0", 9502);
$this->server->on('open', function ($server, $request) {
$userId = $this->getUserIdFromToken($request);
$server->users[$userId] = $request->fd;
});
$this->server->on('message', function ($server, $frame) {
$data = json_decode($frame->data, true);
$this->handleMessage($server, $data);
});
$this->server->on('close', function ($server, $fd) {
$userId = array_search($fd, $server->users);
unset($server->users[$userId]);
});
$this->server->start();
}
private function handleMessage($server, $data)
{
switch ($data['type']) {
case 'question':
$question = Question::create([
'user_id' => $data['user_id'],
'content' => $data['content'],
'course_id' => $data['course_id']
]);
$message = [
'type' => 'new_question',
'data' => $question
];
foreach ($server->users as $fd) {
$server->push($fd, json_encode($message));
}
break;
case 'answer':
$answer = Answer::create([
'question_id' => $data['question_id'],
'user_id' => $data['user_id'],
'content' => $data['content']
]);
$question = Question::find($data['question_id']);
$targetFd = $server->users[$question->user_id] ?? null;
if ($targetFd) {
$message = [
'type' => 'new_answer',
'data' => $answer
];
$server->push($targetFd, json_encode($message));
}
break;
}
}
}
我们创建了一个WebSocket服务类来管理连接状态:
javascript复制class SocketService {
constructor() {
this.socket = null
this.callbacks = {}
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectDelay = 5000
}
connect(token) {
if (this.socket) return
this.socket = new WebSocket(`ws://localhost:9502?token=${token}`)
this.socket.onopen = () => {
console.log('WebSocket connected')
this.reconnectAttempts = 0
}
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (this.callbacks[data.type]) {
this.callbacks[data.type].forEach(cb => cb(data.data))
}
}
this.socket.onclose = () => {
console.log('WebSocket disconnected')
this.socket = null
this.attemptReconnect()
}
}
on(event, callback) {
if (!this.callbacks[event]) {
this.callbacks[event] = []
}
this.callbacks[event].push(callback)
}
send(type, data) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type, ...data }))
}
}
attemptReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++
setTimeout(() => {
console.log(`Reconnecting attempt ${this.reconnectAttempts}`)
this.connect(localStorage.getItem('token'))
}, this.reconnectDelay)
}
}
disconnect() {
if (this.socket) {
this.socket.close()
this.socket = null
}
}
}
export default new SocketService()
javascript复制const CourseList = () => import('./views/CourseList.vue')
const QuestionDetail = () => import('./views/QuestionDetail.vue')
javascript复制function throttle(fn, delay) {
let timer = null
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
}
// 使用示例
window.addEventListener('scroll', throttle(() => {
if (isNearBottom()) {
loadMoreData()
}
}, 300))
vue复制<template>
<RecycleScroller
class="question-list"
:items="questions"
:item-size="72"
key-field="id"
v-slot="{ item }"
>
<QuestionItem :question="item" />
</RecycleScroller>
</template>
php复制// 避免N+1查询
$questions = Question::with(['user', 'answers.user'])
->where('course_id', $courseId)
->orderBy('created_at', 'desc')
->paginate(15);
// 使用索引提示
$users = User::where('status', 1)
->whereHas('courses', function($query) {
$query->where('id', 5);
})
->forceIndex('idx_status_created')
->get();
php复制// 使用Redis缓存热门课程
public function getPopularCourses()
{
$cacheKey = 'popular_courses';
if (Redis::exists($cacheKey)) {
return json_decode(Redis::get($cacheKey), true);
}
$courses = Course::withCount('students')
->orderBy('students_count', 'desc')
->limit(10)
->get()
->toArray();
Redis::setex($cacheKey, 3600, json_encode($courses));
return $courses;
}
php复制// 配置队列连接
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
],
// 分发文件处理任务
ProcessUploadedFile::dispatch($file)
->onQueue('file_processing')
->delay(now()->addMinutes(1));
我们采用Docker容器化部署,docker-compose.yml配置如下:
yaml复制version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
volumes:
- .:/var/www/html
environment:
- DB_HOST=db
- DB_DATABASE=${DB_DATABASE}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
depends_on:
- db
- redis
db:
image: mysql:8.0
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
- MYSQL_DATABASE=${DB_DATABASE}
- MYSQL_USER=${DB_USERNAME}
- MYSQL_PASSWORD=${DB_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:6.2
ports:
- "6379:6379"
volumes:
- redis_data:/data
websocket:
build:
context: .
dockerfile: Dockerfile.websocket
ports:
- "9502:9502"
depends_on:
- redis
nginx:
image: nginx:1.21
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- app
volumes:
mysql_data:
redis_data:
我们使用Prometheus+Grafana监控系统关键指标:
php复制// 在ThinkPHP中暴露指标端点
Route::get('/metrics', function() {
$registry = new \Prometheus\CollectorRegistry(new \Prometheus\Storage\Redis([
'host' => env('REDIS_HOST'),
'port' => env('REDIS_PORT'),
]));
$counter = $registry->getOrRegisterCounter(
'app',
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
);
$renderer = new \Prometheus\RenderTextFormat();
header('Content-Type: ' . \Prometheus\RenderTextFormat::MIME_TYPE);
echo $renderer->render($registry->getMetricFamilySamples());
});
javascript复制// 使用web-vitals库监控前端性能
import {getCLS, getFID, getLCP} from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify(metric);
navigator.sendBeacon('/analytics', body);
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
在开发这个课程互助学习系统的过程中,我积累了一些值得分享的经验:
状态管理陷阱:初期我们过度使用了Pinia全局状态,导致组件间耦合度过高。后来调整为"组件优先使用本地状态,仅在跨组件共享时使用全局状态"的原则,代码可维护性显著提升。
WebSocket连接管理:移动端网络不稳定导致频繁断开重连,我们实现了指数退避重连算法,并将重要消息缓存至重连成功后发送,用户体验大幅改善。
文件上传优化:大文件上传最初采用整体上传,失败率很高。改为分片上传后,配合断点续传功能,上传成功率提升至99.5%。
权限系统演进:从简单的角色控制逐步演进为RBAC模型,并最终加入了数据权限控制(如教师只能管理自己课程的资源),系统灵活性大大增强。
性能调优经验:N+1查询问题是初期性能瓶颈,通过大量使用Eloquent的with预加载,API响应时间从平均800ms降至200ms以内。
这个项目目前已在3所高校试点运行,支持了2000+师生的日常学习活动。后续我们计划加入AI智能答疑、学习行为分析等高级功能,进一步提升系统的智能化水平。