第五次作业,是我这学期《Java Web应用开发》课程的期末项目,题目叫《在线图书借阅管理系统》。说句实话,刚看到题目的时候我真没当回事,因为前四次作业都是单页面的小练习,改改表单、连个数据库、跑出一个能用的页面就算完。结果这次作业我连续折腾了两个多星期,中间推翻了两次设计,还差点没赶上提交截止时间。回头复盘,问题基本都出在“把项目当成练习做,而没有把项目当成项目做”。
这次作业虽然名义上叫作业,但完整走了一遍需求分析、表结构设计、后端接口开发、前端页面联调、服务器部署的流程。所以这篇内容,也是给那些第一次做完整小项目的同学一个参考。如果你跟我一样,之前只写过零散的接口或者只做过静态页面,第一次被迫把一个前后端分离的系统从零搭起来,那我踩过的这些坑,应该能帮你省下至少两三个通宵。
1. 拿到作业后的第一件事:把题目拆成一个能排期的需求清单
刚看到题目的时候,我脑子里大概就是“图书增删改查 + 用户登录注册 + 借书还书记录”,感觉没多复杂,直接开工就完了。于是第一天写了两百行代码,把后端项目框架建好、把书表结构建好、写了一个查询所有图书的接口,感觉良好。
第二天开始写用户功能的时候发现不对劲:管理员和普通用户怎么区分?书被借走了之后,在列表里要不要隐藏?逾期状态是怎么算出来的?删除一本已经处于“已借出”状态的书,是连着借阅记录一起删掉,还是直接拒绝删除?这些问题在代码里不断冒出来,每一个都是一块补丁,打到最后我自己都分不清这个系统到底应该是什么行为了。
最后没办法,我把代码停掉,花了一个下午重新列需求清单。方法也不高级,就是拿“角色”当维度去拆:普通用户能做什么,管理员能做什么,每条列出来,再去数对应要写几个接口。
普通用户这边大概是:注册、登录、浏览图书列表、搜索图书、查看图书详情、借书、还书、查看个人借阅记录。管理员这边:登录、上架新书、修改图书信息、删除图书、查看所有借阅记录、处理归还。这么一列,其实核心功能并没有想象中那么多,后端接口大概十二三个就能覆盖。但问题是,这些接口背后的数据规则,必须先想清楚。
1.1 复述需求不等于理解需求
“图书增删改查”这几个字看起来简单,但真正往细了一问就露馅。删除图书时如果这本书正处于已借出状态怎么办?书名重复允许吗?搜索结果要精确匹配还是模糊匹配?图书分类是做独立表还是直接字符串存?“借阅中”的这本书,下一次搜索时还要出现在结果里吗?
这些问题不落到纸面上,写接口就是靠猜。我当时整理了一个简单的状态流转规则,类似下面这张表:
| 操作 | 前置条件 | 成功结果 | 失败提示 |
|---|---|---|---|
| 用户借书 | 书在馆、且有可借数量 | 可借数量减1,生成借阅记录 | 提示“当前无可借数量” |
| 用户还书 | 该书处于借阅中且是本人借的 | 可借数量加1,更新归还时间 | 提示“你没有借阅这本书” |
| 管理员删书 | 书没有被借走 | 删除图书及未关联的借阅记录 | 提示“存在借阅记录,无法删除” |
这个表我一共写了十几行。写完才意识到,代码里最难的并不是“怎么写”,而是“什么条件下才允许执行”。这个认知在后面写借还书接口的时候帮我少走了很多弯路。建议看到这里的你,拿到任何题目,先别开IDE,用一张纸把这类规则列出来,至少列到每个操作都有明确的前置条件和返回结果。
1.2 排期时最容易犯的错:把登录模块排在最前面
列需求之后的下一步自然是想先做什么。我们绝大多数人的第一直觉是:登录是入口啊,先做登录注册,不然我后端没法测啊。这个直觉,恰恰是排期里最大的坑。
登录模块看似简单,实际上依赖的东西一点也不少。用户表的设计要定好,角色的概念要清晰,密码加密方案要选好,TOKEN生成和校验逻辑要写好,拦截器要配好。这一整套东西,前后端加起来没个一天半天下不来。而等你费劲把这些全做完了,才刚刚开始接触这套系统的核心业务——图书的借还流程,还根本没碰到。如果中途卡在某个细节上,比如TOKEN解析失败、跨域问题、登录状态丢失,你会非常焦虑,因为核心业务一头都还没干,很容易有挫败感。
我第二次排期换了个思路:先做核心、再做边缘,登录不是入口而是安全护栏。顺序改成了这样:
- 先设计好数据库表结构,把SQL脚本写出来
- 做图书管理的增删改查接口,暂时不接登录权限,用Postman直接测
- 做前端图书列表和详情页,把数据打通
- 再做注册登录、JWT生成和拦截器
- 最后做借书还书、借阅记录这些依赖用户身份的业务
这样排的好处是,每一步做完都有东西能看、能验证,即使做到最后没时间了,至少核心的图书管理是能跑的。登录对业务本身而言只是“保护”它,并不会让图书查询变得更炫酷。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型不是越新越好,而是看你敢不敢为它兜底
这次作业课程本身没有强制技术栈。有的同学用了Flask配一个自带模板引擎,两天就把页面和接口全搞定了;也有同学跟我一样选了Spring Boot + Vue,最后在环境配置上耗了两天。室友写完了在那打游戏,我还在跟Maven下载依赖作斗争。
但说回来,选什么不选什么,核心不是越好越对,而是你有没有能力为这个选择兜底。Flask两天能写完,是因为模板渲染的页面直接把数据嵌进HTML,没有跨域、没有TOKEN拦截器、没有前后端分离联调这些工程化问题。但代价是,你不太会了解现在主流的团队开发模式长什么样。Spring Boot + Vue,工程量确实翻倍,但它逼着你接触分层架构、依赖注入、拦截器、跨域配置、接口联调、打包部署,这些东西是真正到公司里天天要用的。
所以我的态度比较直白:如果你只是为了拿学分,那就老老实实选最顺手的轻量方案,把时间花在功能完整性和答辩准备上;如果你想借一门课多学点东西,那就选一个稍微超出舒适区的技术栈,提前体验一下真实项目是怎么协作的。这两种选择都不丢人,怕的是明明时间很紧,还硬要选一个自己搞不定的重框架,最后项目都没跑起来。
2.1 为什么最终选了 Spring Boot + Vue 而不是更轻的方案
我最后定的技术栈是后端Spring Boot 3 + MyBatis-Plus,前端Vue 3 + Vite + Element Plus,数据库MySQL 8,认证方案用JWT。选择理由其实很简单,不是因为它最好,是因为它在我能搜到资料的范围内,几乎每个坑都有人踩过、有人写过解决方案。哪怕你在群里问一句“Spring Boot拦截器怎么放行登录接口”,都能收获一串热心人发的代码片段,这就叫生态兜底。
当时我也纠结过要不要用Node写后端,毕竟JavaScript前后端一套语言,理论上更省事。但想了想,Java EE和SSM在后续课程里还是会遇到,与其指望到时候再补,不如这次作业就把它啃下来。而且Spring Boot天然自带Tomcat内嵌容器,一个jar包就能跑起来,部署也不复杂。前端用Vue 3 + Vite,是因为Vue的模板开发比手写原生DOM要舒服很多,Element Plus提供现成的表格、表单、弹窗组件,做后台管理页面效率特别高。
如果你也打算抄这套作业,建议先确认几件事:JDK版本是不是17(我踩过javax改成jakarta的坑,这个后面细说);Maven仓库有没有配阿里云镜像;Node版本是不是18以上。这三个检查完,后面环境基本能少半天折腾。
2.2 环境准备阶段的两个“隐形杀手”
第一个杀手是JDK版本。Spring Boot 3要求JDK 17及以上,但很多教程、很多老代码还是Java 8的写法。最大的区别是包名从javax.变成了jakarta.。我第一次导入项目的时候,看着网上的Servlet教程复制代码,一直报“找不到javax.servlet”的错,愣是看了一晚上才明白是包名迁移了。这个坑对新手来说特别不友好,因为报错信息看起来像是依赖没引进去,其实只是代码本身不匹配。
第二个杀手是前端依赖安装。Vue 3 + Vite项目用npm install装依赖,理论上一条命令就行,但对Node版本有要求。Vite 4以上版本要求Node 18+,机器上要是Node 14,装完依赖启动直接报错。我当时一个室友用的还是Node 12,npm install的时候遇到node-sass这个老古董,下载二进制包失败,耗了一个多小时也没装成功。后来我把依赖里的node-sass替换成sass,才好使。建议新项目直接用官方脚手架创建,别从旧的模板项目里扒一个package.json过来改,不然各种旧依赖残留真的会逼疯人。
3. 数据库设计:作业里返工率最高的一环
数据库设计这个环节最伤筋动骨,因为表结构一旦定了,后端的接口就相当于定了一半。改一列字段,意味着Mapper、Service、Controller、前端表格列头全部要跟着动,牵一发动全身。
我第一版表设计就犯了一个典型错误:把图书分类单独抽了一张category表,又把每个用户的“昵称”字段设计成可空,还把密码字段设计成varchar(50),后来加密后发现BCrypt加密后的字符串是60位,50位根本存不下,只能改表。这些不算大灾,但每一条都切切实实浪费了时间。
第二版里,我收敛到三张核心表:
- user:id、username、password、role、create_time
- book:id、title、author、isbn、publisher、category、total_count、available_count、description、cover、status
- borrow_record:id、user_id、book_id、borrow_time、due_time、return_time、status
有人可能会问,为什么没有单独建一个分类表?因为这是个作业系统,不是真实生产系统,分类维度就几种,直接用字符串冗余在book表里反而更好写、更好维护。等分类多到需要维护层级关系,或者需要按分类统计报表时,再拆表完全来得及。这就是我对方不方便的判断:多一张表,就要多一套增删改查接口和一套前端管理页面,没有必要的复杂度,不要自己给自己加戏。
3.1 三张表怎么来,以及每张表为什么长这样
用户表最核心的字段就三个:登录名、密码、角色。密码字段不能明文存储,这是底线,哪怕只是一个课程作业也要有这个习惯。role字段我用了字符串类型,值为“USER”或者“ADMIN”,查询和管理比较直观。创建时间加上一个DEFAULT CURRENT_TIMESTAMP,不用在代码里手动set,少写很多重复代码。
图书表里比较值得说的是available_count这个字段,它是“当前可借数量”。我第一版只设计了total_count,想着借书的时候现算一下已借数量再减一减,后来发现每次查询图书列表都要关联借阅记录做一次子查询,麻烦而且没效率。直接冗余一个可借数量字段,借阅成功减一、还书成功加一,前端展示也直观。这个字段的取舍我一直很推荐,因为它在作业场景下正确性完全够用,性能还更好。
借阅记录表是整个系统的核心业务。这块我把status设计成一个字符串状态,取值“BORROWED”或“RETURNED”,没有用“OVERDUE”这种状态。因为逾期其实是通过due_time和return_time计算出来的业务结果,而不是一种需要持久化的状态。什么时候显示逾期,前端比对一下时间就知道,后端也可以在接口里计算后返回一个字段。这个想清楚之后,借还逻辑简单了很多。
提示:设计表结构的时候,先问问自己“这个字段在什么接口里会被用到,别人看着字段名能猜到含义吗”。如果不确定,就用更直白一点的名字,比如available_count就比count好懂一百倍。
3.2 外键到底建不建,以及一个让我踩坑的类型问题
外键要不要建,这是个挺老的话题。真实大项目里,很多团队为了高并发写入性能会放弃数据库层外键,把关联关系全交给应用层去保证。但作业不是高并发业务,你的数据量就几千条,外键带来的那一点点性能损失完全可以忽略。反而是外键在删除时的约束提醒很有价值:如果借阅记录表还有某本书的关联,数据库会直接拒绝删除这本书,这就逼着你在代码里提前处理异常情况,想好删除的判定条件。
所以我的建议是:作业系统该建外键就建,不要有什么心理负担,数据库约束能把你的数据保护得很好,老师检查时一看外键关系清晰,印象分也会高一些。
但我还要说一个真正让我卡了半个多小时的坑:字段类型不一致。user表的主键是BIGINT,book表的主键也是BIGINT,但在做“按图书id查询”的时候,前端Axios发来的参数类型是字符串,Spring Boot框架里我用@PathVariable Long id接收,按理说是能自动转换的,但我有一次写成了@PathVariable String id,然后在SQL里直接用这个字符串去比较主键,结果因为字符编码和隐式转换的问题,几条数据查出来了,几条却查不出来,看起来就像数据“丢失”了。排查了半天最后发现是类型没对上,教训就是:前后端联调时的参数传递,能明确类型就明确类型,不要指望框架帮你处理任何隐式转换。
4. 登录认证才是这次作业的隐形大头
我最初的认知是:“登录不就是查一下用户名密码吗,最多一下午搞完。” 结果前前后后花了两天。
原因是第一次我天真地用了Session方案。后端登录成功后把用户信息放进HttpSession,前端请求的时候靠Cookie里的SessionId维持身份。在传统服务端渲染页面时这套没毛病,但放到前后端分离的场景里,登录是登录了,紧接着请求图书列表接口却被拦截器拦下来,提示未登录。我在Postman里调试明明是好的,因为Postman自动保存了Cookie,但浏览器里前端跑在不同的端口,这个Cookie的SameSite策略直接把跨域请求的Cookie给吞了。这就导致一个特别“灵异”的现象:登录成功的数据都返回了,但刷新一下页面就又让你登录,登录状态完全保持不住。
后来我换成了JWT方案,才明白为什么现在前后端分离项目普遍用它。
4.1 从 Session 到 JWT:一个“翻车”后的复盘
JWT的核心流程并不复杂,登录成功之后,后端把一个包含用户id和角色的JSON对象经过签名生成一个长字符串返回给前端,前端把Token存在本地,之后每次请求在请求头里带上Authorization: Bearer
实现JWT我拆成三件事:生成、校验、注入用户信息。
生成发生在登录接口里,用户输入用户名密码,校验成功后用秘钥签发Token,设置一个过期时间,比如2小时。校验发生在一个拦截器里,每次请求先看有没有Token,没有就返回401,有就尝试解析,解析成功就把用户id和角色塞到请求上下文里,方便后续的Service层直接用。这段关键逻辑大概是:
java复制// 伪代码,略去import
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new UnauthorizedException("未登录或Token缺失");
}
Claims claims = JwtUtil.parseToken(token.replace("Bearer ", ""));
if (claims == null) {
throw new UnauthorizedException("Token无效或已过期");
}
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("userRole", claims.get("role"));
return true;
}
代码很短,但当时有个点耗了很久:登录接口本身不应该被拦截,注册、获取图书列表、搜索这些公开接口也应该放行,否则用户还没登录根本看不到书,就更不可能借书了。所以拦截器里要维护一个白名单路径,比如 /api/auth/login、/api/books 的查询接口。这个白名单一开始我写死活匹配不上,后来直接把路径写成了精确字符串,反而最省心。
4.2 密码加密、过期失效与“越权访问”这堂安全课
密码处理这件事我必须单独说:一定不要明文存储,也不要用简单逻辑拼接字符串做加密。我在作业里直接用了Spring Security提供的一个工具类BCryptPasswordEncoder,它内部会自动加随机盐,每次加密同一个密码得到的密文都不一样,但验证时能正确匹配。这是经过大量实践验证的标准做法,不需要自己发明“加盐”逻辑。
然后是有效期的问题。JWT的一个短板是它签发之后,在过期之前是没法主动让它失效的。作业要求里如果写了“退出登录后Token立即失效”,SSO方案真不是作业能简单搞定的。我的处理方式是:前端退出时直接把本地Token删掉,后端设置一个比较短的过期时间(比如2小时),这样即使Token被截获,影响范围也有限。诚恳讲,作业场景这个处理足够合理,你要真被问起来,就把“Token有效期”的安全理由说清楚,老师一般都会认可。
比Token过期更隐蔽的问题,是越权访问。我提交前自查时发现一个漏洞:用户A登录后,只要把请求里的userId改成用户B的id,就能查询到用户B的借阅记录。原因是我只校验了“有没有Token”,根本没有校验“这个用户是不是本人”。修复的思路很简单:在Service层从请求上下文里取出当前登录用户的id,跟传入的userId比对,不一致就抛401,后台管理的接口还要额外校验role是不是ADMIN。这一课让我意识到,权限控制是两件事:一是防未登录的人,二是防已登录但权限不够的人,前者很容易做到,后者才真正考验系统设计。
5. 前后端联调和部署:提交前最容易翻车的地方
功能写完之后,我以为最大的坎已经过去了,结果联调阶段又冒出一堆问题。本地开发环境里,前端跑在5173端口,后端跑在8080端口,两个端口不一样,浏览器就会触发跨域请求。你在后端写完接口,用Postman测得好好的,放到前端页面上却报错,好歹排查了快一下午,才发现原来Axios发出的请求被跨域策略拦了。解决方法是后端加一个全局的跨域配置,允许前端来源访问,一次配好,省得每个Controller都加@CrossOrigin注解:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true);
}
}
5.1 联调阶段被我低估的三类问题
除了跨域,联调阶段最折腾我的还有两类问题。
字段命名不统一就是其中之一。后端Java习惯用驼峰命名,比如bookName,数据库列名习惯用下划线,比如book_name,MyBatis-Plus默认开启了驼峰转换,所以后端Java对象和数据库之间本来没有问题。但到了返回给前端的时候,一旦我手写了一个VO类,有的字段叫availableCount,有的字段叫available_count,前端拿到数据就是一段莫名其妙的东西,列表页全是undefined。我的习惯后来改成了:所有传给前端的字段统一都用驼峰命名,如果发现前端某个字段不对,第一反应就是去看后端返回的JSON长什么样,而不是在前端猜。
日期格式是第二个坑。数据库里存的是datetime类型,Java对象里也可以用LocalDateTime,但Jackson框架序列化返回给前端时,默认是数组或者带字母T的ISO格式,比如2025-06-08T18:00:00,前端一看这个T就懵了,显示出来非常不自然。我在后端application.yml里配置了一个全局的日期格式:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
这样返回给前端的时间就统一成了2025-06-08 18:00:00,表格里直接展示,省去前端二次格式化。这看起来是个很小的配置,但是真实开发里几乎每个项目都要处理,早配早轻松。
5.2 部署到服务器:本地能跑和线上能跑是两回事
作业提交前一晚,我把项目部署到云服务器上,发现了本地环境下根本不会出现的问题。
第一个问题是数据库连接地址。本地开发时,数据库地址是localhost:3306,放到服务器上必须改成服务器的地址,否则后端启动时连不上数据库。如果MySQL也装在服务器上,那就在服务器本地用127.0.0.1,但前提是你得确认MySQL真的装了、服务真的启动了、密码真的对。我在服务器上第一次启动jar包,报错“Access denied for user”,当时就是密码记错了,花了很长时间排查。
第二个问题是端口。Spring Boot默认端口是8080,Nginx默认端口是80。如果服务器防火墙或云服务商的安全组没有放开80和8080,那么你本地curl能看到后端返回数据,但其他人访问你的域名或公网IP就是超时。我当时忘了在安全组里放行8080,调试了很久才发现。
第三个问题是构建和启动方式。前端打完包之后是一堆静态文件,得放到Nginx配置的root目录下。后端打包是mvn clean package,生成一个jar包,然后通过nohup java -jar app.jar > app.log 2>&1 &启动。如果你没把日志重定向到文件里,那你连着服务器终端开启jar包,一关终端进程就退出了;即使没退出,出了问题你也看不见报错信息,只能干瞪眼。把日志写进文件之后,用tail -f app.log实时看日志,排查问题效率能快一倍。
5.3 提交与演示前的一次“暴力自查”
作为一名过来人,我强烈建议在正式提交之前,抽出一下午做一轮完整的全流程自查。不要觉得自己开发时已经测过了就没问题,开发时测的往往是理想路径,而漏掉的恰恰是异常路径。
我提交前做的第一件事,是清空数据库重新初始化,然后从注册一个全新账号开始,完整走一遍:注册 → 登录 → 添加图书 → 搜索图书 → 借书 → 查看借阅记录 → 还书 → 管理员登录 → 查看所有借阅记录。走完这个流程之后,我顺手把权限做了自测:用一个普通用户的Token去调管理员的接口,看是不是被拦截器拦下来。这个问题我在自查时才抓到,因为开发时习惯了用管理员的身份登录,压根没有切回普通用户测过。
第二件事,是检查前端打包时用的接口地址。很多前端项目里,开发环境请求的是http://localhost:8080,如果你生产部署时忘了改成线上地址,打包出来的页面在用户那里没有任何数据。一个比较好用的做法是,在前端代码里用一个环境变量或者一个全局配置文件来维护API地址,打包前确认这个配置是线上地址,而不是本地的。
第三件事,是整理README和数据库脚本。我提交作业的时候,附了一个sql初始化脚本、一个README文档,里面写了启动步骤、默认账号密码、测试用的图书数据。老师拿到项目,能按照文档一步步跑起来,这比你说一百句“这个系统很完整”都有说服力。一个跑不起来的项目,代码写得再漂亮,评分首先就要打一半折扣。
里面有一句话我当时写在README开头,现在看也挺合适:“如果你能在五分钟内不看代码就把这个项目启动起来,说明这份文档是合格的。”把这句话反向递给未来的自己,就是做交付时真正该有的心态。
这次作业之后我最大的变化,是改掉了一个特别不好的习惯:拿到需求不规划就往代码里冲。以前总以为效率高就是快,后来发现最快的其实是把需求、表结构、接口约定都想清楚之后,一把写对,反复返工才真的费时间。如果你也是第一次做一个前后端分离的项目,给你一条我的经验:把数据库脚本、接口字段说明和部署步骤写进README,因为答辩或交作业的时候,这份文档往往比代码更能看出你到底是否掌握了整个项目——这真的不是套话,是我这次作业拿分的关键。
