前后端分离的宠物店项目,算是这几年毕业设计和自学练手里非常典型的一套组合。SpringBoot+Vue+MyBatis+MySQL,四个词拆开看都是Java全栈开发者最熟悉的日常三件套,合在一起就能拼出一个功能完整、结构清晰的线上商店系统。我陆陆续续帮人看过不少类似的项目,这套体系本身的成熟度已经很高了,真正难住的往往不是功能怎么实现,而是整个项目从零到一怎么搭、怎么跑起来、上线要踩哪些坑。这篇就以网上宠物店系统为例,把前后端分离项目的设计思路、核心模块、部署流程和常见问题完整过一遍,想拿来做毕设、写进简历,或者纯粹想练手整合全栈技术的,都可以当一份实操参考。
1. 项目拆解与技术选型:为什么是这四个组件
1.1 前后端分离到底在分离什么
很多人第一次接触前后端分离项目,概念上容易绕进去。说白了,传统的单体网站是后端把页面模板和数据揉在一起返回给浏览器,而后端分离项目是后端只负责输出数据接口,前端单独承担页面的渲染逻辑和用户交互。两者通过HTTP协议通信,前端拿到JSON数据自己决定怎么展示。
这个转变带来的直接好处是分工清晰,前端团队和后端团队可以并行开发,互不阻塞。前后的接口只需要约定好返回格式,比如{code: 200, data: {...}, msg: "success"},两端就能同时开工。另一个好处是部署灵活,前端打包成静态文件扔到Nginx,或者干脆托管到对象存储,后端打包成jar包放到服务器上,各自可以单独扩容。
在宠物店这个项目里,分离体现得最典型的就是商品列表页。后端提供一个/api/pet/list接口返回宠物信息数组,前端拿到数据后用v-for渲染成卡片。传统方式下这一整块页面逻辑后端都要参与,现在前端完全接管。
1.2 SpringBoot:让后端开发少想一步
选SpringBoot而不是原始的SpringMVC,理由非常务实:它能极大减少配置成本。老一代的Spring项目光是写web.xml、Spring配置、MyBatis配置就要折腾大半天,SpringBoot通过自动配置把这一堆默认逻辑都包好,你只需要在配置文件里写自己需要覆盖的项。
对于宠物店这种业务层级不深的项目,SpringBoot默认的web能力足够覆盖,不需要额外定制太多东西。启动的时候内嵌了Tomcat,本地跑起来就是一个main方法的事,部署也不需要单独装容器。这个特性在部署章节会体现出巨大优势。
1.3 Vue:渐进式框架对中小型项目的适配性
Vue在国内的生态地位不用多说。选它做前端,核心原因是它的学习曲线相对缓,模板语法和原生HTML接近,而且单文件组件让代码组织非常直观。宠物店系统的前端页面无外乎首页、商品列表、详情、购物车、订单、个人中心,这些页面用Vue Router做路由控制,用Vuex或Pinia管理登录态和购物车全局状态,用Axios封装请求,整条链路非常标准。
它在组件复用上也很有优势。宠物卡片这一组件,商品列表页要用、搜索结果页要用、推荐板块也要用,抽出来传不同数据就能复用,代码量能省不少。这一点在开发中期体会最深。
1.4 MyBatis:SQL控与灵活性
MyBatis在中小型业务系统里出现的频率依然极高,核心原因就两点:SQL代码可控、上手门槛低。比起JPA那种自动生成SQL的方式,MyBatis把SQL写到XML文件里,每个字段怎么映射、每张表怎么关联查询都是自己说了算。
宠物店系统的数据查询大部分是对单表或多表做条件查询,比如按品种筛选宠物、搜索宠物名字、分页查询订单,这些都是典型的MyBatis场景。它特别适合那种需要精细控制SQL性能的场景,同时也不妨碍用<where>、<if>这些标签动态拼SQL。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统整体架构与核心模块划分
2.1 前端页面结构与路由设计
网上宠物店系统从用户侧来看,涉及的页面就这么几类:
- 首页:轮播图、推荐宠物、分类入口
- 宠物列表页:支持分类筛选、关键词搜索、分页
- 宠物详情页:图片、品种信息、库存、价格、加入购物车按钮
- 购物车页:商品增减、勾选结算
- 订单确认页:收货地址选择、订单金额明细
- 订单列表页:订单状态展示、取消订单、确认收货
- 个人中心:注册登录、个人资料、地址管理
对应到Vue Router,设计大概长这样:
javascript复制const routes = [
{ path: '/', component: Home },
{ path: '/pets', component: PetList },
{ path: '/pets/:id', component: PetDetail },
{ path: '/cart', component: Cart },
{ path: '/checkout', component: Checkout },
{ path: '/orders', component: OrderList },
{ path: '/profile', component: Profile },
{ path: '/login', component: Login }
]
路由结构基本决定了前后端接口的划分粒度。一般来说前端有多少个页面,后端就要提供对应页面的数据支撑接口,两者一一对齐。
2.2 后端模块分层与接口划分
后端项目内部采用经典的三层架构:Controller层负责接收HTTP请求、Service层处理业务逻辑、Mapper层对接数据库。这个分层不是空架子,它有一个很实际的作用:当业务逻辑变复杂的时候,你能准确找到该修改的位置,而不是在一个几百行的Controller里漫无目的地找。
对于宠物店系统,后端接口可以整理成这几个模块:
| 模块 | 核心接口 | 说明 |
|---|---|---|
| 用户认证 | POST /api/auth/register、POST /api/auth/login | 注册登录,签发Token |
| 宠物管理 | GET /api/pets、GET /api/pets/ | 分页查询、详情 |
| 购物车 | GET /api/cart、POST /api/cart、PUT /api/cart/ | 购物车CRUD |
| 订单管理 | POST /api/orders、GET /api/orders、PUT /api/orders/{id}/cancel | 下单、查询、取消 |
这里想强调一个设计细节:购物车这个模块看起来简单,但它适合放在后端而不是纯靠前端LocalStorage实现。原因有两个,一是用户换设备后购物车数据丢失会影响购物体验,二是真正的下单流程必须在后端校验库存和价格,前端存的东西只能算预览。
2.3 业务流程串联:从浏览到下单的完整链路
一个用户从浏览到完成下单,背后其实串联了多个模块。我经常跟初学者说,不要孤立地看每个表每个接口,要把整条链路串起来理解。
用户先浏览宠物列表,把感兴趣的宠物加入购物车,购物车接口往数据库的购物车表插入记录,关联用户ID和宠物ID。提交订单的时候,后端拿到购物车里的商品快照,校验库存充足后生成订单主表和订单明细表,同时扣减库存、清空购物车。用户支付(或模拟支付)后订单状态改变,管理员后端可以发货,用户确认收货后整个流程闭环。
这条链路里最容易出问题的环节在生成订单这一步。因为它涉及多张表的写入操作,库存扣减、订单生成、购物车清空必须是一个事务。否则中途随便哪一步失败,都会留下脏数据。这个事务在Spring里实现不麻烦,Service层方法上加@Transactional即可,但前提是你心里清楚哪些操作必须放在同一个事务里。
3. 数据库设计:宠物店系统的表结构怎么规划
3.1 核心表与字段规划
网上宠物店系统抛开复杂的营销体系,核心表不超过十张。我按业务归属列一下:
第一组是用户域。user表算是整个系统的基础,字段包含id、username、password(存加密后的密文)、phone、email、avatar、status、create_time。密码这块必须强调,明文存密码这个错误我见过太多次了,不管是毕设还是真项目,至少要用BCrypt加密。
第二组是商品域。pet表存宠物基本信息:名字、品种、年龄、性别、价格、库存、图片地址、描述、上架状态。category表做宠物分类,两个表之间用category_id关联。另外特别建议加一个sales字段记录销量,用于首页排序和推荐功能。original_price字段用于展示划线价,提升购买欲。
第三组是交易域。cart表包含user_id、pet_id、quantity、checked,注意加唯一约束(user_id, pet_id)。order表存订单主信息,order_item表存订单明细快照。为什么要有两份数据?因为商品信息随时可能变价,而订单必须保留下单那一刻的快照,这就是冗余的必要性。
3.2 关键表设计的细节说明
订单表order的字段值得好好说。核心字段包括order_no(订单编号,别用自增ID当订单号对外展示)、user_id、total_amount、pay_amount、status、receiver_name、receiver_phone、receiver_address、create_time、pay_time。
order_no这块有个小知识点:实际项目里的订单号通常由雪花算法生成,或者用时间戳加随机数生成。原因很简单:直接暴露自增ID会暴露平台真实订单量,而且容易被人遍历爬取数据。毕设项目不必搞那么复杂,用yyyyMMddHHmmss + 随机数生成的唯一单号就够用。
status字段是订单状态机得以流转的关键,初学者容易把它当简单数字处理。这里建议定义枚举类:
java复制public enum OrderStatus {
UNPAID(0, "待支付"),
PAID(1, "待发货"),
SHIPPED(2, "待收货"),
COMPLETED(3, "已完成"),
CANCELLED(4, "已取消"),
REFUNDING(5, "退款中");
}
订单状态机是整个交易系统的核心逻辑之一,弄清楚每个状态之间允许哪些转换,比如待支付才能取消,已完成不能退款,比闷头写代码重要得多。
3.3 索引设计与SQL优化基础
表结构定义好以后,索引设计直接决定接口响应速度。宠物店系统数据量不至于巨大,但该建的索引不能省。
pet表里category_id建议建普通索引,因为分类筛选是最常见的查询场景。status也要建索引,上架/下架筛选会走它。order表里user_id必须加索引,我的订单列表查询就靠它,没有索引的话随着订单量增加性能会肉眼可见下滑。order_no也应该建唯一索引,既保证唯一性,也是查询订单的入口。
MyBatis里写查询时也有个性能问题容易踩:条件组合不确定的时候,SQL动态拼接要用<where>标签而不是简单写死where 1=1。虽然执行效果最终一样,但<where>会自动处理多余的条件前缀,SQL日志好看不出幺蛾子。分页建议直接用物理分页插件,不要手写LIMIT传参,尤其是大页面数据量上来之后,手写分页很容易算错页数。
4. 后端核心模块实现要拆开讲的几个点
4.1 登录认证:从Session到JWT
前后端分离架构下,登录认证方案的选择几乎不用纠结,JWT就是不二之选。传统的Session方案依赖服务器保存会话状态,一旦后端水平扩展部署多个实例,Session同步就是个大麻烦。JWT把用户信息签名后发给前端,后端无状态校验Token,天然适合前后端分离。
实现流程大致是:登录接口接收用户名密码,BCrypt校验密码成功后生成JWT返回前端。前端把Token存到LocalStorage里,之后每次请求都带在Authorization头里。后端写一个拦截器,对所有非白名单接口校验Token的合法性和有效期。
JWT密钥这个细节不要糊弄,项目代码里写死一个弱密钥在毕设里姑且能跑,但如果在意安全性请用HS256加足够长的随机密钥。密钥泄露的后果是整个认证体系崩塌。
带Token的请求校验拦截器长这样:
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
token = token.substring(7);
}
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
request.setAttribute("userId", claims.get("userId"));
return true;
} catch (Exception e) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}");
return false;
}
}
}
4.2 购物车接口:动态SQL和参数传递的小技巧
后端接口设计中,购物车模块虽然简单,但写起来有一些细节值得注意。前端每次调用接口传的参数往往不是一个简单对象,比如批量删除购物车项,前端传的就是一个ID数组。MyBatis处理这种参数数组要用foreach标签:
xml复制<delete id="deleteBatch">
DELETE FROM cart WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</delete>
Controller层的接收方式也要对应调整,用@RequestBody绑定DTO里的List<Long> ids,路径上的@PathVariable不适合传这种复合参数。
4.3 订单生成:事务处理和库存扣减的并发问题
订单模块是整个项目的核心难点,主要难在并发。两个用户同时下单同一件库存为1的商品,如果代码只是先SELECT库存再UPDATE,很可能两个人都在扣减前查到了库存为1,从而都下单成功,库存变负数。
解决这个问题的经典方案是乐观锁,或者叫条件更新。SQL改造一下:
sql复制UPDATE pet SET stock = stock - 1
WHERE id = #{petId} AND stock > 0
如果返回值是0,说明库存已经被抢完了,直接抛出业务异常。这个方法比SELECT后判断要安全得多,而且实现成本极低。
订单生成涉及的多个写操作,放在同一个事务方法里:
java复制@Transactional(rollbackFor = Exception.class)
public Order createOrder(OrderCreateDTO dto) {
// 1. 校验购物车商品
// 2. 扣减库存(条件更新)
// 3. 生成订单主记录
// 4. 生成订单明细
// 5. 清空对应购物车项
}
为什么rollbackFor要指定Exception.class?因为Spring默认只对运行时异常回滚,而很多业务异常是手动throws的受检异常。不指定的话,可能出现订单生成成功但异常信息却返回给用户的情况。
4.4 文件上传:宠物图片的本地存储方案
宠物店的宠物图片上传是管理后台的核心功能之一。SpringBoot接收文件上传很简单,MultipartFile类型参数就可以搞定,真正需要注意的坑都在细节里。
存储路径设计上,不要把所有图片堆在一个目录里,按日期分目录是好习惯。文件名也不要用原始文件名,容易被特殊字符攻击,而且会重名覆盖。用UUID或时间戳重命名最稳妥。
java复制String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
String filename = UUID.randomUUID() + ext;
String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date());
File dir = new File(uploadDir + "/" + datePath);
if (!dir.exists()) { dir.mkdirs(); }
file.transferTo(new File(dir.getAbsolutePath() + "/" + filename));
图片上传后,要处理的是前端怎么访问。开发环境简单,配置个静态资源映射就好,生产环境则通常由Nginx直接托管上传目录。这里有个常见的坑:很多人部署后图片显示不出来,多半是忘了在配置文件里加静态资源映射,或者路径映射和实际磁盘路径对不上。
5. 前端关键交互与权限路由处理
5.1 Axios拦截器:让每个请求自动带上Token
前端请求后端最让人头疼的事,就是每个接口都要手动写上请求头里的Token。解决办法是用Axios拦截器统一处理。
javascript复制axios.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = 'Bearer ' + token
}
return config
})
同理,响应拦截器可以统一处理401未授权的情况。后端Token过期会返回401状态码,前端收到后应该清空本地登录态并跳转登录页。这个处理可以让用户感知到会话过期,而不是在页面里看到一堆报错。
5.2 前端路由守卫:未登录不能进购物车
前后端分离项目里,前端守卫是安全第一道防线。Vue Router的beforeEach钩子可以控制页面访问权限。未登录用户访问购物车、订单、个人中心这些敏感页面,直接重定向到登录页。
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
next()
})
当然前端守卫只是体验层面的控制,真正的安全校验必须在后端接口上做。接口没有鉴权等于裸奔,绕过前端直接调接口照样能拿到数据。这两者必须同时存在。
5.3 购物车状态管理:为什么不用localStorage
购物车模块如果用Vuex或Pinia管理,需要想清楚状态同步问题。用户登录后拉取购物车数据,本地购物车只是内存中的数据副本,每次增删改都要调接口同步。
我这里分享一个在项目里比较顺手的做法:进入购物车页面时,从后端拉取最近数据初始化Store;购物车任何增删改操作,先调后端接口成功后再更新Store。这样数据始终保持一致,不会出现页面显示的下单量和后端实际收到的对不上的情况。
有一个细节:组件中使用Store数据时,建议通过getters而不是直接读取state,这样当你想在数据展示前做一层计算,比如数量乘单价的合计金额,改动只发生在getter里,不用每个组件都改一遍。
6. 部署上线:从本地跑通到服务器运行
6.1 后端打包与配置环境区分
本地开发一切正常,部署却爆出一堆问题,这几乎成了项目交付的最后一道坎。问题的根源大多是环境差异。后端项目里,数据库账号、Redis地址、服务器IP这些信息在本地和线上不一样,需要区分环境配置。
SpringBoot支持多环境分离配置,用application-dev.yml放本地开发配置,application-prod.yml放生产配置,主配置文件里通过spring.profiles.active切换。打包的时候,Maven默认打出来的包会包含所有配置文件,启动命令里指定环境就行:
bash复制mvn clean package -DskipTests
java -jar pet-shop-server.jar --spring.profiles.active=prod
6.2 前端打包与Nginx配置
Vue项目打包算是固定流程:
bash复制npm install
npm run build
打包完后,dist目录里就是纯静态文件。部署方式有两种,一种是把dist目录扔到Nginx的html目录下,另一种是把静态文件托管到对象存储走CDN。中小型项目用Nginx方案更多,因为同时解决了接口反向代理的问题。
Nginx配置是部署里的重头戏,尤其是前端路由的history模式。Vue Router如果在路由里用了createWebHistory,刷新页面时会请求对应的真实路径,而Nginx默认找不到这个路径会返回404。解决办法是配置try_files:
nginx复制server {
listen 80;
server_name pet.example.com;
root /usr/share/nginx/html;
index index.html;
location / {
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_pass后面建议不要随便加路径后缀,否则容易导致接口路径拼接错位。client_max_body_size要记得设置大一点,不然上传宠物图片会报413错误。WebSocket如果后续要做在线客服,还要配Upgrade和Connection两个字段。
6.3 部署遇到的服务端ClassNotFound问题
一个踩过很多次的坑:本地IDE跑得好好的,java -jar部署到服务器上却报ClassNotFoundException。这种情况绝大多数不是代码问题,而是打的包不对。SpringBoot项目的可用包是Maven的spring-boot-maven-plugin重新打包后的可执行jar包,里面包含依赖的第三方库。如果只打了普通的jar包,没有把依赖打进去,丢到服务器上自然就缺类了。
排查这个问题的办法很简单,看jar包大小。一个包含依赖的SpringBoot项目jar包通常至少几十MB,如果打出来只有几KB,那肯定只打入了自己的类代码,依赖没打进去,用java -jar启动找不到依赖里的类是非常正常的。
6.4 部署时数据库初始化与数据迁移
部署中另一个常见坑是数据库初始化。本地MySQL里的表结构、初始数据不会自动迁移到服务器上的数据库。手工执行SQL脚本很容易遗漏,尤其表多的时候。建议把完整的建表语句和初始数据整理成一个init.sql脚本,部署时一条命令导入:
bash复制mysql -u root -p pet_shop < init.sql
这个习惯带来的直接价值是,不管部署到测试服务器还是正式服务器,数据库结构都是完全一致的,不会出现本地能跑生产环境爆SQL错误的情况。
7. 开发到上线全过程常遇到的问题与避坑锦囊
7.1 跨域问题:前后端分离的第一课
前后端分离项目联调时,报错频率最高的一定是跨域问题和CORS相关。开发环境解决方式有两种,一是在后端配置全局CORS策略,二是在前端用Vite的proxy做代理转发。个人建议开发阶段用代理更舒服,后端不用写一堆跨域配置代码,部署阶段则交给Nginx反向代理,因为部署后前端静态资源和后端API在同一个域名下访问时,浏览器根本不会触发跨域问题。
7.2 数据库时间字段的时区差
数据库时间字段看起来是小事,真出了事特别折磨人。本地数据库连接时间正常,部署到服务器后,查出来的时间比实际时间早了8个小时,这是MySQL连接参数缺了时区配置的特征。
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/pet_shop?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
这个serverTimezone=Asia/Shanghai参数必须加上。同时前端拿到时间字符串渲染时,也要统一时区或统一用时间戳传递,否则前端又会显示一遍离线偏移。
7.3 中文乱码的完整解决方案
中文乱码是个系统工程,可能出现在好几个环节。数据库层面,建库时最好指定utf8mb4字符集,表结构继承库的默认字符集。后端连接参数里已经写了characterEncoding=utf8。前端方面,index.html里要有<meta charset="UTF-8">。
排查思路是定位乱码究竟发生在哪一段。往数据库里写之前就乱,大概率是后端读取请求参数时编码不对;写进数据库后再查出来乱,大概率是数据库或表的字符集不对;前端显示乱而后端返回的数据正常,则要检查前端页面的字符集声明。
7.4 服务端404和刷新白屏的区分
部署完成后遇到的前端访问问题,95%以上是Nginx配置问题。访问首页正常,但点击进入某个二级路由页面后刷新就白屏或者404,这是前端使用history模式但没有配置try_files的典型症状。在Nginx配置里加上try_files $uri $uri/ /index.html;问题就解决了。
如果接口访问返回404,而本地测试接口正常,就要检查Nginx里location /api/的配置以及后端服务是否真正启动了。先用curl http://127.0.0.1:8080/api/xxx直接探测后端服务,就能定位问题出在哪一层。
7.5 上线前必做的检查清单
把项目从开发环境迁到生产环境,以下检查点值得过一遍:
- 生产环境的数据库密码是否为强密码,不要沿用开发环境的弱口令
- 配置文件里的数据库连接、上传路径、日志路径是否改为服务器的实际路径
- 前端打包时是否通过环境变量切换了后端接口地址,而不是硬编码成localhost
- JWT密钥是否改成了足够长的随机字符串
- 服务器防火墙是否放行了Nginx端口和后端服务的端口
- 所有SQL脚本是否已执行,表结构和初始数据是否齐全
- 日志文件的输出路径是否有创建权限,磁盘空间是否充足
这套检查流程走完,上线出大问题的概率能降低不少。很多事故不是开发写出来的,而是部署时漏了某个环境配置差异。
8. 项目后续扩展的几个方向
宠物店系统如果定为毕业设计的话,交到这一步已经是一个完整可运行的项目。但如果你时间和精力允许,有几个扩展方向会让项目在答辩和面试里增色不少。
一个是引入Redis做缓存。宠物列表页的访问量远高于下单接口,把热门数据缓存到Redis里,能明显降低数据库压力,这也是面试官在项目里最喜欢深挖的技术点。另一个是新增管理员后台,把宠物上下架、订单发货从数据库手工操作变成后台界面操作,项目的故事完整性会提高很多。
再往下走,还可以考虑接入微信小程序端。后端接口已经按前后端分离的规范写好了,小程序端复用后端的大部分接口就行,前端加一个小程序项目就能打通双端。整体工作量可控,但项目量级听起来立刻不一样。
我个人在实际操作中的体会是,做这种全栈项目最忌讳的就是只盯着一块技术栈猛做、不关心整体链路。你其实不是在写一个管理系统或者一个网页,而是在构建一整套通过HTTP通信、数据落到数据库中的完整业务流程系统。把每个环节之间的衔接搞清楚,比多写几个CRUD接口有价值得多。收下这套流程思路,找个完整的项目源码对照着跑一遍,再自己动手改几个功能,你的收获会比单纯看教程大得多。
