☰
基于Laravel与uniapp的毕业论文选题系统设计与实现
2026/9/26 8:02:42 网站建设 项目流程

毕业设计选题,真的是每年教务老师最头疼的环节。去年那版用 Excel 收课题、微信群盯选题的操作,一到高峰期就乱:课题重复、名额超了、没选上的学生反复问。后来我跟团队给学校做了一套毕业论文选题系统,后端以 Laravel 为主线(最早原型用 ThinkPHP 快速验证的),前端改成 uniapp 编译成微信小程序。整套系统把教师申报、管理员审核、学生选题、双向确认这串流程全部线上化,连最终的选题名单都可以一键导出。

如果你也在做类似的选题管理系统,或者学校学院正好需要一套工具,这篇实战记录可以直接参考。里面会讲清楚状态机怎么设计、Laravel 接口怎么写、uniapp 端怎么跟小程序打交道,以及我在联调过程中踩过的那些文档上查不到的坑。

1. 整体设计:从业务痛点梳理到状态机

1.1 三种角色与六条核心链路

开始动手写代码之前,我先把业务角色捋了一遍。用户只有三类:管理员、教师、学生。这三类人的诉求完全不同,系统要守住的核心链路也就六条:

  1. 教师申报课题。
  2. 管理员审核课题,通过后进入选题池。
  3. 学生浏览课题池,选择导师与题目。
  4. 教师确认或拒绝学生。
  5. 管理员统计选题结果,处理未选上的学生。
  6. 系统按学期隔离数据,每一届毕业生的选题互不干扰。

很多新手拿到这种题目上来就建表写接口,结果做到一半发现"老师出题审核"和"学生选题确认"之间的状态完全对不上。我的建议是:先画一张状态流转图,把每个业务节点可能出现的分支都标出来,再动数据库。

课题状态我用四个关键词:待审核、已通过、未通过、已截止。选题状态稍微复杂,涉及学生提交、教师确认、退选三个分支,我最终简化为:已提交待确认、已确认、教师已拒绝、已退选。整套系统的复杂逻辑都围绕这几条状态展开,状态定义清楚了,接口和界面的设计就是顺着往下走。

1.2 为什么我从 ThinkPHP 快速原型迁到 Laravel

有朋友看到项目名里的"ThinkPHP-Laravel"觉得奇怪,是不是两个框架混着用?其实不是。我在早期验证流程时用 ThinkPHP 写过一版原型,因为它建 CRUD 最快,一个命令生成控制器和模型,半小时能把后台搭起来。但后来发现,这个系统需要处理小程序端大量的 JSON 接口、中间件权限校验、并发选课控制,ThinkPHP 写起来不慢,但代码组织上不如 Laravel 的 Eloquent 和中间件机制清晰。

所以我最终架构是:整个后台和 API 用 Laravel 重写,ThinkPHP 原型里的表结构设计、字段命名习惯、状态枚举全部保留,这样业务上不用重新推倒。如果你是从零开始,我会直接建议你上 Laravel,没必要先去 ThinkPHP 绕一圈。它的route:list、ORM 关联、Sanctum 登录认证,写这类小程序后台天然匹配。

Laravel 的另一个优势是代码的可读性,对做毕业设计的同学来说尤其重要。答辩的时候老师会翻源码,Laravel 的 Controller、Service、Model 分层一眼就能看懂,比把所有逻辑堆在 index.php 里强得多。

2. 数据库设计:五张核心表如何撑起整个选题流程

2.1 每张表的关键字段与设计意图

选题系统不是一个复杂的电商系统,主要数据也就是人员、课题、选题关系,但字段设计必须提前考虑清楚。我最终保留了这五张核心表。

表名核心字段设计意图
usersusername, password, role, student_no, major_id, class_name三种角色共存一张表,角色用枚举区分,避免冗余的人员表
categoriesname, semester课题分类,比如软件工程、网络安全、大数据方向
topicsuser_id, title, description, requirements, category_id, students_limit, selected_count, audit_status, semester选题池,所有课题状态集中在这里管理
selectionstopic_id, student_id, status, remark, confirmed_at学生与课题的中间表,承载双向确认信息
operation_logsuser_id, action, detail, ip关键操作留痕,方便老师和管理员回溯

users 表我在设计时加了一个role字段,值是 admin、teacher、student 三个字符串。有人喜欢建 role 关联表,但对这种规模的项目没必要,一个字段加查询索引就够了。

topics 表里最重要的是一对兄弟字段:students_limit和selected_count。前者是教师限制的最大选课人数,后者是实际已选/已确认人数。每次学生选题成功,selected_count加一;退选或教师拒绝后减一。这样统计名额只需要读一行数据,不用count()去现算。

selections 表要注意status的语义:默认提交后是待确认,教师通过后改成已确认,学生可退选,教师可拒绝。主键使用自增 id,同时给topic_id和student_id联立唯一索引,避免同一学生重复提交同一课题。

2.2 并发抢题:事务加锁防止超额选课

选题系统最容易出事故的就是最后几分钟大家都去抢同一位老师的课题。如果不做并发控制,两个学生同时把selected_count从 0 改到 1,最后一个字段就错了。

我在 Laravel 里用事务加锁的方案处理这段代码。核心是使用lockForUpdate()对 topic 行加排他锁,让同一时刻只有一个请求能读到最新名额,其他请求必须等锁释放。

use Illuminate\Support\Facades\DB; use App\Models\Topic; use App\Models\Selection; public function submit(Request $request) { $request->validate([ 'topic_id' => 'required|exists:topics,id', ]); $studentId = $request->user()->id; try { $selection = DB::transaction(function () use ($topicId, $studentId) { $topic = Topic::where('id', $topicId) ->where('audit_status', Topic::STATUS_APPROVED) ->lockForUpdate() ->firstOrFail(); if ($topic->selected_count >= $topic->students_limit) { throw new \Exception('该课题名额已满'); } $exists = Selection::where('student_id', $studentId) ->whereIn('status', [Selection::STATUS_PENDING, Selection::STATUS_CONFIRMED]) ->exists(); if ($exists) { throw new \Exception('你已经有一个进行中的选题'); } $topic->increment('selected_count'); return Selection::create([ 'topic_id' => $topic->id, 'student_id' => $studentId, 'status' => Selection::STATUS_PENDING, ]); }); return response()->json($selection); } catch (\Exception $e) { return response()->json(['message' => $e->getMessage()], 422); } }

有人会问为什么不用 Redis 原子操作?因为选题的高峰并发通常也就是每秒几十次,数据库锁完全扛得住,而且不需要额外维护 Redis 服务,对小型项目更实在。真正要注意的是事务里的顺序:先锁行,再做校验,最后更新,这是一个不变的顺序。

3. 后端接口实现:路由、登录、选题 API 的完整方案

3.1 小程序登录与权限中间件

微信小程序登录不是传统的账号密码登录,而是用小程序的 wx.login 拿到临时 code,在后端换 openid。用户在绑定过一次学生号之后,后续登录直接通过 openid 换取 token。

我在 Laravel 里用 Sanctum 做登录态管理,auth 表用的是 API token,不走 session。这一步非常关键,小程序端没有 Cookie 概念,如果沿用 Laravel 默认的 session 方式,会遇到"明明登录了,接口还是 401"的问题。

登录接口的关键逻辑:

public function login(Request $request) { $request->validate([ 'code' => 'required|string', 'username' => 'required_without:password|string', 'password' => 'required_without:username|string', ]); $user = User::where('student_no', $request->username)->first(); if (!$user || !Hash::check($request->password, $user->password)) { $user = $this->wechatLogin($request->code); } $token = $user->createToken('wechat-mini-program')->plainTextToken; return response()->json([ 'token' => $token, 'user' => $user->only(['id', 'username', 'role', 'student_no']), ]); }

路由分组里加上角色校验中间件,比如教师端出题接口只允许 teacher 访问,管理员接口只允许 admin 访问:

Route::middleware('auth:sanctum')->group(function () { Route::get('/topics', [TopicController::class, 'index']); Route::post('/topics/{id}/submit', [SelectionController::class, 'submit']); Route::middleware('role:teacher')->group(function () { Route::post('/topics', [TopicController::class, 'store']); Route::post('/selections/{id}/confirm', [SelectionController::class, 'confirm']); }); Route::middleware('role:admin')->group(function () { Route::post('/topics/{id}/audit', [TopicController::class, 'audit']); Route::get('/statistics', [StatisticsController::class, 'index']); }); });

中间件代码很简单,判断当前用户 role 是否在允许列表里面:

public function handle($request, Closure $next, string $role) { $user = $request->user(); if ($user->role !== $role) { return response()->json(['message' => '没有权限操作'], 403); } return $next($request); }

3.2 路由设计:接口路径与小程序请求地址必须一一对应

许多新手在联调的时候发现,Laravel 接口地址在 Postman 里能访问,小程序却一直报 404。绝大多数原因是路由配置里的地址跳转没对齐,比如小程序请求的是/api/v1/topics,后端路由写的是/topics,中间少了一层v1。

我的做法是在RouteServiceProvider里为所有接口统一加/api/v1前缀,然后在小程序端封装请求时,baseURL 直接指向该前缀。这样将来要升级 v2 版本,只需要把前缀改掉,小程序端不用大改。

Route::prefix('api/v1')->middleware('api')->group(base_path('routes/student.php'));

还有一个重点是路由必须放在 auth 中间件之外,比如登录接口和微信小程序换取 session 的接口。拿 code 换 openid 的接口如果误加了登录校验,就会互相锁死:前端调登录,后端要求先登录,等于进死胡同。

3.3 汇总统计与名单导出

管理员最常看的是选题进度统计,我直接用一个聚合查询搞定:

$stats = Topic::selectRaw('audit_status, count(*) as total') ->where('semester', $semester) ->groupBy('audit_status') ->get();

导出名单我用 Laravel 的 streamedResponse 输出 CSV,不需要额外安装 Excel 库。文件头加上 UTF-8 BOM,否则用 Excel 打开中文会乱码:

return response()->streamDownload(function () use ($selections) { $handle = fopen('php://output', 'w'); fwrite($handle, "\xEF\xBB\xBF"); fputcsv($handle, ['学号', '姓名', '课题', '导师', '状态']); foreach ($selections as $item) { fputcsv($handle, [$item->student_no, $item->name, $item->topic_title, $item->teacher_name, config('status.text')[$item->status]]); } fclose($handle); }, 'selection-list.csv');

权限中间件、接口路由、数据统计这三个部分做完,后端的骨架已经立起来了。下一步主要精力会放在微信小程序端,毕竟学生和老师日常用的都是小程序,体验不顺会前功尽弃。

4. 微信小程序端:uniapp 从构建到发布的全过程

4.1 manifest 配置与环境切换

uniapp 项目创建后,第一件事是改manifest.json。微信小程序模块里填入真实 appid,基础库版本选一个比较稳妥的,比如 2.30.0 以上,否则一堆 API 用不了。项目开发时要注意区分环境,我配置了三个环境变量:开发环境、测试环境、生产环境。

小程序端请求地址切换,我之前遇到过最头痛的问题。小程序里request的域名必须是 HTTPS 并且在微信公众平台配置过合法域名,但开发时本地后端往往是http://192.168.x.x。我采取的办法是封装一层config.js,根据process.env.NODE_ENV自动切换 baseURL:

const env = { development: 'http://localhost:8000/api/v1', testing: 'https://test-api.example.com/api/v1', production: 'https://api.example.com/api/v1' } export const BASE_URL = env[process.env.NODE_ENV] || env.production

开发时在微信开发者工具中勾选"不校验合法域名"就能本地联调。但真机预览必须用测试环境的 HTTPS 域名,这是很多朋友卡住的地方。

4.2 请求封装与 token 过期处理

小程序里我封装了一个request.js,所有接口统一走这个入口。它负责三件事:拼接 baseURL、自动携带 token、统一处理 401 和错误提示。

const request = (options) => { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Authorization': `Bearer ${uni.getStorageSync('token')}`, 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 401) { uni.removeStorageSync('token') uni.navigateTo({ url: '/pages/login/login' }) reject(res) return } if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data) } else { uni.showToast({ title: res.data.message || '请求失败', icon: 'none' }) reject(res) } }, fail: (err) => { uni.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) }) }

uniapp 要求所有接口调用都走 Promise,不要直接在业务页面里写uni.request,不然后期改 token 刷新、改请求日志都要逐个页面翻,非常痛苦。

4.3 导航栏高度适配与自定义分享

微信小程序顶部导航栏高度计算是个老问题。不同机型、不同微信版本的胶囊按钮位置不一样,iPhone 刘海屏跟安卓的 statusBar 高度也不同。我封装了一个获取导航栏信息的函数:

export const getNavBarHeight = () => { const { statusBarHeight, platform } = uni.getSystemInfoSync() const menu = uni.getMenuButtonBoundingClientRect() const navBarHeight = (menu.top - statusBarHeight) * 2 + menu.height return { statusBarHeight, navBarHeight, totalHeight: statusBarHeight + navBarHeight } }

页面自定义导航栏时,把占位的高度设为totalHeight,内容往下挪,就不会出现顶部与胶囊重叠的问题。

自定义分享好友很简单,写在每个页面的onShareAppMessage里,返回标题和路径即可。我做课题详情页时,把课题 id 带进了分享路径:

onShareAppMessage() { return { title: this.topic.title, path: `/pages/detail/detail?id=${this.topic.id}` } }

4.4 扫码参数解析与缓存处理

教师端我做了扫码进入页面,生成的小程序码带scene参数。小程序码升级后scene需要通过decodeURIComponent解码,再手动拆分参数:

onLoad(options) { if (options.scene) { const scene = decodeURIComponent(options.scene) const params = scene.split('&') const obj = {} params.forEach(item => { const [key, value] = item.split('=') obj[key] = value }) this.topicId = obj.topicId } else { this.topicId = options.id } }

缓存方面,选题系统里课题列表数据实时性要求不高,但也不能永远不更新。我做了带过期时间的缓存封装,比如列表缓存 5 分钟,详情缓存 1 分钟。这样快速切页面时响应快,也避免每次都打后端。

export const setCache = (key, value, expire = 300) => { const data = { value, expire: Date.now() + expire * 1000 } uni.setStorageSync(key, data) } export const getCache = (key) => { const data = uni.getStorageSync(key) if (!data) return null if (Date.now() > data.expire) { uni.removeStorageSync(key) return null } return data.value }

小程序端的开发基本可以告一段落,但真正折磨人的是上架和联调阶段,各种零零碎碎的问题。接下来我把最常遇到的几个集中整理一下。

5. 常见问题与避坑实录

症状原因解决方案
手机预览请求失败未配置 request 合法域名微信公众平台配置 HTTPS 域名,开发时可忽略校验
接口返回 401,登录态丢失token 过期或未携带请求封装统一携带 Authorization,401 时跳登录页
后端返回 500,日志显示 SQL 死锁并发选课未加锁使用事务+lockForUpdate 控制 topic 行
页面下拉刷新后列表重复分页参数未重置刷新时重置 page=1,重新请求
安卓端 canvas 导出白图绘制未完成就调用 toDataURL绘制完成后延迟 200ms 再导出
iOS 静音模式下视频/音乐没声音微信默认跟随静音开关uni.setInnerAudioOption({ obeyMuteSwitch: false })
开发工具正常,真机扫码打开白屏基础库版本过低manifest 中提高基础库最低版本
导出 CSV 用 Excel 打开中文乱码缺少 UTF-8 BOM输出前写入\xEF\xBB\xBF

后端还有两个容易被忽略的点。第一是 Laravel 的 CORS 中间件,如果未来要支持 H5 端,跨域配置必须提前写好;第二是 Laravel 的 session 在 API 模式下默认不启用,如果沿用某些教程里的session()方法取数据,会一直拿到 null。我在项目里全部用 token 机制,Session 只在后台管理页的 Web 登录里使用,两边彻底隔离。

微信小程序虚拟支付相关的问题值得单独提醒一句:选题系统如果不涉及虚拟商品,不要随意接入支付组件。苹果端对小程序虚拟支付审核很严格,我见过不少项目因为误接支付被拒审。

还有个小细节是关于 uniapp 生命周期。小程序页面切换时,onShow和onLoad的执行顺序容易被忽略。比如从详情页返回列表页,列表页的onShow会被调用,但onLoad不会。所以刷新列表数据要放在onShow,而不是onLoad,否则教师确认学生之后,学生端列表数据还是旧的。

最后说两个我自己印象特别深的细节。第一个是操作日志,最初版本我没做,结果出现纠纷时根本没法追溯是谁在什么时候批准的课题。加上operation_logs表之后,所有审核、确认、退选操作都记录了,后端排查问题方便非常多。第二个是课题的semester字段,一定要在每一处查询条件里带上,第二年开始跑的时候,如果没有这个字段,所有接口都会把上一届的数据漏出来,谁也说不清。

整套系统从表结构设计到小程序上线,前后花了两周。最耗时间的不是写代码,而是把选题流程的异常分支一个个理清楚。你把状态机和权限骨架理清之后,剩下的 CRUD 只是体力活。这是我做这个项目最大的体会,也建议所有做类似毕业设计的朋友,先画流程,再写代码,能少走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询