做驾校管理系统这个项目,是我目前觉得性价比最高的前后端分离练手选题之一。它不像电商系统那样业务面太宽,也不像博客系统那样结构过于简单,而是恰好踩在“业务复杂度适中、数据关系有深度、技术栈足够主流”的平衡点上。一套下来,SpringBoot、Vue、MyBatis、MySQL这四样全都能实战一遍,业务上还覆盖了学员管理、教练排班、预约练车、考试管理、财务统计这些有真实逻辑的场景,用来做毕业设计、跳槽时的面试项目,或者入职前拿来熟悉前后端协作流程都很合适。
这篇文章不是泛泛讲概念,而是把从建库到部署的过程完整梳理一遍。我会把重点放在三块:一是数据库设计和预约练车的并发冲突处理,这是面试官最喜欢追问的点;二是后端SpringBoot加MyBatis的核心实现,包括JWT鉴权、统一返回、多表联查;三是前后端打包部署的完整方案。跟着这套思路走,你复现出来的不是一个只能跑通页面的Demo,而是能直接上服务器跑的项目。
1. 项目起步:技术栈为什么这样搭
1.1 前后端分离到底解决了什么问题
驾校管理系统如果做成传统JSP那种单体应用,代码确实也能跑,但维护起来非常难受。学员端要看课时进度,教练端要维护可预约时段,管理员端要做学员、车辆、考试、财务的综合管理,三个角色的操作界面差别太大,硬塞在一套模板引擎里,前端文件会迅速膨胀。而且JSP页面和后端Java代码耦在一起,改一个按钮样式都可能要重启服务,开发效率很低。
改成前后端分离之后,前端只负责渲染和交互,后端只暴露JSON接口,数据和页面之间没有直接绑定。这样对驾校系统这种多角色、多状态的项目来说,好处很直接:权限模型可以完全放到后端控制,前端只管根据角色显示不同的菜单和按钮;接口可以单独用Postman联调,前端开发不用等后端把页面模板写完;部署的时候Vue构建出的dist目录既可以被Nginx托管,也可以直接塞进SpringBoot的static目录,弹性很大。
从项目练手的角度讲,前后端分离也逼着你把接口设计想清楚。每个接口该接收什么参数、返回什么结构、出错了怎么提示,这些在分离项目里必须提前定义好,而这个习惯恰恰是实际工作中最需要的。
1.2 版本选择是个大坑,说说我的组合
技术栈写起来很简单,真正动手才发现版本坑一堆。我这里直接给一套经过验证的组合,JDK用1.8,SpringBoot用2.7.x,这是2.x系列最后的稳定版本。SpringBoot 3.x虽然已经出来很久,但最低要求JDK 17,很多学校机房和线上服务器还停留在JDK 8,为了跑项目去折腾环境切换,纯属浪费时间。
MyBatis用3.5.x,配合PageHelper做分页。可能有人会问为什么不用MyBatis-Plus,确实Plus用起来更省事,CRUD都不用自己写SQL,但驾校系统里预约查询、考试统计这类复杂查询绕不开手写SQL,而且面试时MyBatis的XML映射、动态SQL、参数绑定这些都是高频考点。把原生MyBatis玩明白,再去用Plus基本就是降维打击。
前端我建议Vue 2.7加Element UI这个组合,组件文档全,踩坑资料多,网上能搜到的问题基本都有人趟过。如果你是从零学前端,也可以直接上Vue 3加Element Plus,原理上是一样的,但跟着本文复现时,保持2.7版本会少很多阻碍。Node.js版本用16左右,太新的版本跑老项目偶尔会有依赖兼容问题。
MySQL直接用8.0,连接驱动用com.mysql.cj.jdbc.Driver,建库时字符集统一指定utf8mb4。这里多说一句,MySQL里utf8实际上最多支持3字节,学员姓名或者考试备注里一旦出现emoji表情,直接报错,utf8mb4才是完整的4字节字符集,建表时先把这个写对能省掉很多乱码问题。
1.3 功能模块拆解:业务边界先画清楚
动代码之前,我把整个系统的功能模块和对应技术落点先列成了一张表,后面写代码、建表、设计接口都是按这个分组来推进的:
| 模块 | 核心功能 | 涉及的角色 | 关键技术点 |
|---|---|---|---|
| 系统管理 | 登录、退出、用户维护、密码修改 | 所有用户 | JWT鉴权、拦截器 |
| 学员管理 | 学员档案新增、修改、查询、状态变更 | 管理员 | CRUD、分页查询 |
| 教练管理 | 教练信息维护、授课时段设置 | 管理员、教练 | 时段配置 |
| 车辆管理 | 车辆信息、车辆状态维护 | 管理员 | 状态字段设计 |
| 预约练车 | 学员约车、教练确认、取消预约 | 学员、教练 | 时段冲突校验 |
| 考试管理 | 考试安排、成绩录入、通过率统计 | 管理员、教练 | 多表联查、聚合统计 |
| 财务管理 | 报名缴费、补考费、退款记录 | 管理员 | 金额字段、流水记录 |
这七个模块基本就是驾校管理的全部核心业务。如果后续项目想扩充,可以加一个公告管理模块,或者把课时统计细化到每个学员每次练车的消耗明细,这些在现有表结构上扩展都不难。功能边界先想清楚,写代码的时候就清楚自己要做到什么程度,不会写着写着跑偏。
2. 数据库设计:先画好表格再写代码
2.1 核心表结构:用户、教练、学员、车辆怎么串起来
数据库设计我走了两次弯路才定下最终方案。第一次把学员和教练的字段全部塞进一张用户表,结果学员有学时、教练有教龄和准驾车型,字段很乱。第二次拆开又没想清楚关联关系,查询时join得头晕。最终采用的方式是:一张sys_user表统一管登录账号和角色,再用student和coach两张扩展表维护各自的业务字段,通过user_id关联。
核心建表语句我贴一下,这几张表是整个系统的地基:
CREATE TABLE sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role_type VARCHAR(20) NOT NULL COMMENT 'ADMIN/COACH/STUDENT', status TINYINT DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE student ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender VARCHAR(10), phone VARCHAR(20), id_card VARCHAR(18), enrollment_date DATE, status VARCHAR(20) DEFAULT 'STUDYING' COMMENT 'STUDYING/GRADUATED/TERMINATED', total_hours DECIMAL(6,1) DEFAULT 0 COMMENT '累计学时' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;coach表的结构和student类似,不过业务字段换成了准驾车型、从业年限、每小时费用。vehicle表就是车牌号、车型、状态,状态我用AVAILABLE、MAINTENANCE、RETIRED三个值维护,而不是用布尔值,因为车辆的状态不止可用和不可用两种。
预约表booking是整套系统逻辑最重的表,它同时关联学员、教练、车辆三个主体:
CREATE TABLE booking ( id BIGINT AUTO_INCREMENT PRIMARY KEY, student_id BIGINT NOT NULL, coach_id BIGINT NOT NULL, vehicle_id BIGINT, booking_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status VARCHAR(20) DEFAULT 'PENDING' COMMENT 'PENDING待确认/CONFIRMED已确认/COMPLETED已完成/CANCELED已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_coach_time (coach_id, booking_date, start_time, end_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;考试记录表exam_record和支付表payment相对独立,只需要关联student_id。这样设计之后,整个系统的数据关系就是:用户表作为账号主体,学员和教练表作为业务主体,预约表作为中心枢纽把学员、教练、车辆串起来,考试和支付围绕学员展开。逻辑清晰,查询路径也短。
2.2 预约练车的时段冲突,靠什么兜底
预约练车是驾校系统里最容易出并发问题的场景。想象一下,同一时间有两个学员都在提交预约请求,都想约3号教练明天上午10点,如果只在前端做了个简单的时间校验,后端没有兜底,两个请求同时到达,就会出现一个教练同时被两个人预约的情况。这在生产环境是绝对不允许的。
解决思路我用了两层。第一层,教练先维护每天的可用时段。教练在系统里把第二天拆好了时段,比如上午9点到11点拆成9:00-10:00和10:00-11:00两档,学员只能在教练开放的时段里选,从源头限制了冲突的可能。第二层,数据库层面加唯一索引(coach_id, booking_date, start_time, end_time),当两个请求试图插入完全一样的数据时,数据库直接报唯一键冲突,后到的请求就会失败,这是最硬的兜底。
但唯一索引只能拦截完全相同的时段,如果业务上允许学员自定义任意起止时间,那9:00-10:00和9:30-10:30这种区间重叠就没法靠索引拦截了。这种情况需要业务层做重叠校验,核心SQL就是判断已有预约的时间段是否与新预约存在交集:
SELECT COUNT(*) FROM booking WHERE coach_id = #{coachId} AND booking_date = #{bookingDate} AND status IN ('PENDING', 'CONFIRMED') AND start_time < #{endTime} AND end_time > #{startTime}这个SQL的逻辑是:如果已有预约的开始时间早于新预约的结束时间,同时已有预约的结束时间晚于新预约的开始时间,那两者一定重叠。这个判断条件值得背下来,凡是涉及会议室预订、教室排课、场地租赁这类业务,都是这个套路。如果查询结果大于0,就在Service层抛出业务异常,提示该时段已被预约,同时给前端返回一个相对友好的错误消息。
2.3 考试与财务模块的状态设计
考试模块的坑主要出在科目类型和结果状态的枚举设计上。科目一、科目二、科目三、科目四,我直接用SUBJECT1到SUBJECT4四个字符串常量,而不是用数字1到4,这样查数据时一眼就能看出是哪科,不用去翻注释。考试结果用PASS和FAIL两个值,再加上一个SCHEDULED初始状态表示已安排未出成绩。
财务模块要注意金额字段不能用FLOAT或者DOUBLE,会有精度丢失。报名费、补考费、退款金额全部用DECIMAL(10,2),Java实体里对应BigDecimal。支付记录单独建一张payment表,和报名、补考这些业务解耦,每笔钱的流入流出都有记录,这样管理员做财务报表时直接查表汇总就行。我在实际开发中遇到过用double存金额然后对账对不上的情况,排查半天发现是精度的锅,所以这块从一开始就该写对。
3. 后端实现:SpringBoot加MyBatis的关键代码
3.1 后端项目结构是怎么组织的
我见过很多同学写SpringBoot项目,Controller里直接把业务逻辑全写了,Service层空壳,Mapper层只做简单的增删改查。这种写法项目小的时候还能跑,一旦业务复杂起来就完全失控。我把项目结构按职责分成了四层,每层只做自己该做的事:
com.drive ├── controller # 接收请求、参数校验、返回结果 ├── service # 业务逻辑、事务控制 ├── mapper # MyBatis接口 ├── entity # 数据库实体类 ├── vo # 前端展示对象,避免把数据库字段全暴露出去 ├── dto # 接口接收参数封装 ├── config # 跨域、拦截器、MyBatis配置 ├── common # 统一返回结果、异常处理、工具类 └── security # JWT生成与解析包结构一旦定下来,写代码的时候就知道某个类该放哪里,不用到处找。vo和dto这两个包是很多人会忽略的,特别是预约查询这种需要关联学员名、教练名的聚合查询,不可能直接把实体返回给前端,必须要有一个专门的查询结果对象来承接多表联查返回的数据。
3.2 统一响应与全局异常处理
前端需要统一的数据格式,否则每个接口返回结构都不一样,前端处理起来会很痛苦。我定义了一个Result类,所有接口都返回这个结构:
public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { return new Result<>(200, "操作成功", data); } public static <T> Result<T> error(Integer code, String msg) { return new Result<>(code, msg, null); } }但光有统一返回还不够,还必须配合全局异常处理,否则某个Service层抛了个空指针,SpringBoot默认会返回一堆看不懂的错误信息,前端拿到的也不是约定的JSON结构。我写了一个GlobalExceptionHandler,用@RestControllerAdvice做全局拦截,业务异常统一返回500,参数校验失败返回400,未登录或token过期返回401。这样前端只需要在Axios响应拦截器里判断这几个状态码,就能统一处理弹错和跳登录页的逻辑。
3.3 JWT登录认证与权限拦截
登录认证这块,我选择了JWT而不是传统的Session方案。前后端分离项目如果还用Session,会依赖Cookie和Session存储,跨域部署时处理起来很麻烦。JWT的核心思路是:用户登录成功后,后端生成一个token字符串返回给前端,前端每次请求都把token放在请求头里,后端通过拦截器解析token,就知道当前用户是谁、是什么角色。
JWT工具类我封装了创建和解析两个方法:
public class JwtUtil { private static final String SECRET = "drive-system-secret-key"; private static final long EXPIRE_TIME = 7 * 24 * 3600 * 1000L; public static String createToken(Long userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }拦截器这边,我继承了HandlerInterceptor,在preHandle方法里从请求头取Authorization字段,解析token,然后把用户信息放到ThreadLocal里,方便后面的Service层直接获取当前用户。拦截器注册的时候要排除登录、注册这两个接口,否则用户没登录就没法调登录接口了,这是新手很容易踩的坑。
权限控制上其实不需要引入一套完整的Spring Security框架,驾校系统只有三种角色,用拦截器加角色匹配就够了。我在自定义注解@RequireRole上写清楚允许访问的角色,在拦截器里取出token中的角色和注解要求匹配,不匹配就返回401提示无权限。这样每个接口的权限需求写得很清楚,运维起来也方便。
3.4 MyBatis多表联查和动态SQL实战
MyBatis的XML配置是面试爱问、项目里真正高频使用的地方。以预约记录分页查询为例,前端需要展示学员姓名、教练姓名、车牌号、预约时间、状态,这些数据分散在四张表里,必须用JOIN查出来。对应的Mapper XML长这样:
<select id="selectBookingPage" resultType="com.drive.vo.BookingVO"> SELECT b.id, s.name AS studentName, c.name AS coachName, v.plate_number, b.booking_date, b.start_time, b.end_time, b.status FROM booking b LEFT JOIN student s ON b.student_id = s.id LEFT JOIN coach c ON b.coach_id = c.id LEFT JOIN vehicle v ON b.vehicle_id = v.id <where> <if test="status != null and status != ''"> AND b.status = #{status} </if> <if test="studentName != null and studentName != ''"> AND s.name LIKE CONCAT('%', #{studentName}, '%') </if> </where> ORDER BY b.booking_date DESC, b.start_time DESC </select>这里用<where>标签而不是直接写WHERE 1=1,MyBatis会自动处理多余的前导AND,代码看起来干净不少。<if>标签是动态SQL的核心,有值时拼接条件,没值时忽略条件。再加上PageHelper的分页插件,查询前调用PageHelper.startPage(pageNum, pageSize),后面紧跟的这条查询SQL就会自动带上分页参数,非常方便。
还有一个细节是resultType我直接写VO类,前提是SQL里的列别名和VO字段名一致。如果字段名对不上,要么给列起别名,要么配置mapUnderscoreToCamelCase=true,这个配置在application.yml里打开,数据库的下划线命名就能自动映射成实体的驼峰属性,整个人都舒服了。
4. 前端实现:Vue页面与接口对接
4.1 前端工程结构与路由规划
Vue这边我用Vue CLI创建的标准工程结构,src下面分成api、router、views、components、store、utils几个目录。api目录里每个模块建一个文件,比如student.js、booking.js,把接口请求集中管理,页面里不直接写URL,这样后端接口一变,只需要改一个文件,不用全局替换。
路由规划是这套前端的关键。三种角色登录后看到的菜单完全不同,管理员能进学员管理和财务模块,教练看到的是带教任务和时段设置,学员只看得到约车和我的记录。我用动态路由的思路,在路由表里给每个页面配置meta.roles字段,登录后根据当前用户角色过滤出可见的路由,再通过router.addRoutes动态加入。
const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, meta: { requiresAuth: true }, children: [ { path: 'students', component: StudentList, meta: { roles: ['ADMIN'] } }, { path: 'coaches', component: CoachList, meta: { roles: ['ADMIN'] } }, { path: 'booking', component: Booking, meta: { roles: ['ADMIN', 'COACH', 'STUDENT'] } }, { path: 'exams', component: ExamManage, meta: { roles: ['ADMIN', 'COACH'] } }, { path: 'my-records', component: MyRecords, meta: { roles: ['STUDENT'] } } ]} ]前端路由守卫里再拦一道,判断用户没有登录就强制跳转登录页,没有角色权限就提示无权访问。前后端各拦一次,界面体验好,后端数据也安全。
4.2 Axios封装和登录状态统一处理
前端每个页面都直接调axios.get的话,重复代码会非常多。我把请求实例统一封装在utils/request.js里,利用Axios的拦截器统一处理token注入和响应处理:
const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) config.headers['Authorization'] = token return config }) request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg) if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(res) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )封装完之后,组件里调接口只需要写request.get('/booking/list'),返回的已经是对接过的数据,不用每个页面都写一遍错误处理。登录状态的统一管理也在这个文件里完成了,token失效自动跳登录页,页面里甚至不需要关心token何时过期。
4.3 预约练车页面:日历视图和时段选择
预约练车的前端页面是整个项目交互最复杂的部分,我用一个日历组件展示当前月份的可用日期,点了日期之后,下方加载该日期教练开放的时段列表。每个时段组件会显示状态属性:绿色表示可约,灰色表示已经被约满或者过期。学员点击时段,弹出确认框,确认后调用创建预约的接口。
这里有几个交互细节值得注意。时段列表在学员进入页面时就要加载,不能等点击了日期再请求,否则会有明显的加载空白期。提交预约时按钮要做防重复提交处理,我用一个loading状态控制,请求期间按钮不可点,避免用户手快点了两次,产生两条重复预约数据。成功提示之后要立刻刷新时段列表,把刚刚约掉的时段标记为灰色,避免用户再约一次。
教练端对应的页面是时段管理,教练选择日期后,系统根据他配置的规则自动生成可用时段。比如设置“每天上午9点到11点,每小时一档”,前端动态生成时段数组,教练可以删除某个不合适的时段。这些时段数据同步到预约页面的时段列表里,整个约车业务的前后端流程才完整闭环。
5. 打包部署与高频问题排查
5.1 从本地开发到线上部署的完整构建流程
本地开发时,后端SpringBoot跑在8080端口,Vue开发服务器默认跑在8081端口,Vue的跨域问题通过vue.config.js里的代理解决:
module.exports = { devServer: { port: 8081, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这样前端请求/api/booking/list时,开发服务器会代理到http://localhost:8080/booking/list,CORS问题在本地开发阶段完全不需要后端解决。
打包部署时,流程分两步。先在后端项目根目录执行mvn clean package -DskipTests,打包出一个可执行的jar包;再在前端项目根目录执行npm run build,生成dist静态目录。后端jar包传输到服务器后运行nohup java -jar drive-system.jar > logs.log 2>&1 &即可。前端dist目录放到Nginx的html目录或者自定义路径下。
如果暂时没有部署到服务器的条件,也可以先把dist目录里的文件拷贝到SpringBoot项目的src/main/resources/static目录下重新打包,这样Vue的页面和后端接口一起被打进jar包,单进程就能跑起来,适合本地演示用。但这种方案只适合小规模演示,正式环境还是强烈建议前后端分开部署。
5.2 Nginx配置前后端分离项目
Nginx在前后端分离项目里承担了两层职责:托管前端静态资源,以及反向代理后端的/api接口。我把配置文件贴在下面,直接可以用:
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /opt/drive/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态文件访问,比如学员头像、考试照片 location /uploads/ { alias /opt/drive/uploads/; } }这里最关键的是try_files $uri $uri/ /index.html;这一行。因为前端用了Vue Router的history模式,URL里没有#号,用户浏览器直接访问/booking刷新时,Nginx找了一圈发现没有这个文件,如果不加回退规则就会返回404,加了之后Nginx会把请求回退到index.html,由Vue Router接管路由,页面才能正常显示。
5.3 部署阶段遇到的十个常见问题
部署阶段的问题基本集中在环境配置和路径问题上,我整理了几个典型的场景,直接做成表格分享出来:
| 现象 | 根因 | 解决办法 |
|---|---|---|
前端请求接口报401 | 登录成功后token没有持久化,刷新页面就丢了 | 用localStorage保存token,Store初始化时重新读取 |
| 后端启动报时区错误 | 数据库连接URL缺少serverTimezone参数 | URL追加serverTimezone=Asia/Shanghai |
| MySQL 8连接报SSL错误 | 连接URL没有禁用SSL | 追加useSSL=false和allowPublicKeyRetrieval=true |
| 前端开发时接口一直404 | 代理路径没配对 | 确认请求前缀是/api且代理配置的target端口正确 |
| Vue刷新页面404 | Nginx缺少history模式回退规则 | 加try_files $uri $uri/ /index.html; |
| 后端8080端口被占用 | 本地调试残留进程 | 执行netstat -ano查端口对应PID后杀掉,或换端口启动 |
| 上传的图片访问不了 | 路径alias配置错误 | 确认Nginx的location /uploads/和实际存储目录对齐 |
| JWT token一重启就失效 | Secret直接写死但系统时间对不上 | 确认服务器时间正确,或者用固定Secret保证重启后token可解析 |
| 分页查询总数不对 | PageHelper依赖顺序问题 | 确保PageHelper.startPage()在查询SQL之前执行 |
| 学员列表搜索无效 | 动态SQL的条件没走到 | 检查<if>标签里字段名与参数名是否一致 |
这些问题大部分我都在实际部署时踩过一遍。第一行的token问题是我自己犯过的,一开始把token存在了Vue的Store里,一刷新Store从内存中清空,登录状态立刻丢失,前端要重新登录才能用。换成localStorage之后,刷新页面再从本地存储把token读回来就正常了。
这项目后面的路还能怎么走
整套系统跑通之后,回头看这个项目的价值,除了技术栈覆盖得够全面,更重要的是把业务状态流转和并发处理都想了一遍。预约练车的唯一索引加业务校验这种双保险思路,可以平移去处理会议室预订、工位预约之类的类似场景。多角色权限控制的实现方法,换到任何一个后台管理系统上同样适用。
如果后续想继续扩展,可以往两个方向深入。文件上传这块,目前学员头像和身份证照片还只能存在本地目录,接入MinIO做对象存储是很好的下一步,分布式部署时静态资源不会丢。缓存这块,教练排班和热门时段的查询频率很高,用Redis缓存能显著降低数据库压力,缓存失效策略和穿透保护也值得研究。再往后,如果业务量变大,预约接口要考虑Redis分布式锁替代现在的数据唯一索引兜底方案,整个系统就从单体练手项目慢慢长成了更像线上服务的架构。
最后说一句个人体会。停在我复现这个项目的过程中,最大的收获不是这些框架代码本身,而是养成了一个习惯:扩展任何功能之前,先把数据表结构想清楚,问自己如果并发出现会怎样。有了这个底层的思考习惯,写出来代码的可靠性差别非常大。如果第一次做没有头绪,不要急着锤代码,先照着文章里这套结构把表建好,把角色权限理顺,项目就已经成功了一半。