做这类前后端分离项目最怕的不是写代码而是写到一半发现架构选错了或者做完之后不知道怎么部署给人家看。SpringBootVueMyBatisMySQL这套组合算是当前Java全栈项目里最稳的搭配之一用在一套手机销售网站上既能覆盖完整的电商业务闭环又不会复杂到一个人啃不下来。这个项目本质上就是一个“小型电商系统”的样板工程前端用Vue负责页面交互和路由控制后端用SpringBoot提供RESTful接口MyBatis作为数据库访问层把MySQL的数据映射成Java对象。适合正在学Java全栈的人拿来练手也适合毕业设计、课程设计直接作为基础改造更重要的是它能让你把“开发”和“部署”这条路完整走一遍而不是只停留在IDEA里跑个本地项目。1. 项目定位与整体架构设计1.1 为什么选这套组合而不是其他方案很多人做前后端分离项目时会纠结后端用不用微服务前端上不上TypeScript数据库要不要用Oracle我的建议是如果目标不是研究架构而是把一个能用的系统做出来并且顺利部署上线那就老老实实选最主流、最不容易踩坑的技术栈。SpringBoot解决了Java后端配置繁琐的问题内嵌Tomcat让项目直接以jar包运行不需要额外安装外部容器。Vue的核心优势是组件化开发和响应式数据绑定写后台管理页面、商品列表这种交互密集的场景特别顺手。MyBatis在SQL控制上非常灵活电商系统里查询条件多、统计报表杂把SQL写在XML里比JPA自动生成的SQL更可控。MySQL则是最通用、文档最多、部署资料最全的开源数据库遇到任何连接问题都能很快搜到解决方案。这套技术栈还有一个潜在好处岗位需求量大。无论是外包公司、传统软件企业还是互联网企业SpringBootVue的组合覆盖率都很高做完这个项目简历上写的东西和实际工作内容能对上号。1.2 项目功能模块与技术边界划分手机销售网站这个业务范围我拆成了四个核心模块用户端商品浏览与搜索包括首页轮播、商品列表、按品牌/价格筛选、商品详情展示购物车与订单流程加购、购物车数量修改、下单、订单列表、订单状态流转待支付→已支付→已发货→已完成用户中心注册登录、个人资料、收货地址管理、我的订单后台管理商品上下架与库存管理、订单管理、用户管理、销售数据统计前后端分离的分工方式是前端只负责渲染数据和收集用户操作所有业务判断和数据处理都放在后端接口里。比如“添加购物车”这个操作前端只需要把商品ID和数量POST给后端后端去检查商品状态、库存余量、用户登录状态然后把结果返回给前端。这种边界能保证项目结构清晰前端不用碰数据库后端不用关心页面展示。更重要的是接口写好了前端可以并行开发不需要等后端全部完成。2. 数据库设计与核心表结构2.1 业务流梳理与表结构总览写SQL建表之前先把业务数据流理清楚。手机销售网站的核心链条是用户注册→浏览商品→加入购物车→提交订单→支付→管理员发货。围绕这条链数据库至少需要这些表。我先整理一下完整建表清单表名作用关键字段user用户账号信息username、password、roleuser_address收货地址user_id、receiver、mobile、province/city/district/detailcategory商品分类name、sort_ordergoods手机商品name、brand、category_id、price、stock、sales、cover、images、detailgoods_spec商品规格可选goods_id、spec_name、stock、pricecart_item购物车条目user_id、goods_id、quantity、checkedorder订单主表order_no、user_id、total_amount、pay_type、status、receiver_infoorder_item订单明细order_id、goods_id、goods_name、price、quantitybanner首页轮播图image_url、link_url、sort_order有两点需要注意一是订单相关的表一定要和商品表做数据冗余比如order_item里存储goods_name和当时的price快照因为商品价格调整后历史订单里的金额不能被改变二是订单状态字段不要用字符串直接存“待支付”“已发货”这种中文用整数状态码维护前端根据状态码去映射显示文案这样后续做状态机判断更清晰。2.2 三张核心表的字段设计与约束重点讲三张最核心表的建表思路。用户表CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), phone VARCHAR(20), avatar VARCHAR(255), role TINYINT DEFAULT 0 COMMENT 0普通用户 1管理员, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;password字段存的是BCrypt加密后的字符串长度不能只留32位BCrypt生成的密文是60位左右所以VARCHAR(100)比较保险。username加唯一索引防止重复注册。create_time用DATETIME而不是TIMESTAMP因为TIMESTAMP有2038年问题DATETIME的范围大得多。商品表CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(200) NOT NULL, brand VARCHAR(50), category_id BIGINT, price DECIMAL(10,2) NOT NULL, original_price DECIMAL(10,2), stock INT DEFAULT 0, sales INT DEFAULT 0 COMMENT 销量, cover VARCHAR(255), images TEXT COMMENT 轮播图JSON数组, detail TEXT COMMENT 富文本详情, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;价格字段必须用DECIMAL而不是FLOAT或DOUBLE二进制浮点数在金额比较时会有精度问题比如0.10.2不等于0.3。销量字段每次下单成功后在事务里累加不要在查询时用COUNT去统计order_item那样数据量大后会很慢。images字段存JSON字符串读取后在Java里转成List。订单表CREATE TABLE order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, pay_type TINYINT DEFAULT 1, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, receiver_name VARCHAR(50), receiver_phone VARCHAR(20), receiver_address VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, delivery_time DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order是MySQL的保留字建表时必须用反引号包起来为了省事也可以把表名取成t_order或者orders。订单号建议用时间戳用户ID随机数的组合生成要保证全局唯一不要用数据库自增ID直接对外暴露避免被遍历。3. SpringBoot后端核心实现3.1 后端工程结构与接口分层后端我用的是标准的Controller-Service-Mapper三层结构加上entity、dto、vo、common几个辅助包。com.example.phoneshop ├── common // 统一响应体、异常处理、常量 ├── config // WebMvc配置、拦截器注册 ├── controller // 接收请求返回结果 ├── service // 业务逻辑 ├── mapper // MyBatis数据访问接口 ├── entity // 数据库实体 ├── dto // 入参对象 ├── vo // 出参对象 └── interceptor // JWT拦截器Controller层的职责是参数接收和数据返回不写业务逻辑。Service层是业务核心事务控制也标注在Service层的方法上。比如下单这个方法需要连续操作多次数据库校验库存、生成订单、减库存、加销量只要有一个失败就要整体回滚所以要加上Transactional注解。接口设计遵循RESTful风格方法路径说明POST/api/user/login登录GET/api/goods商品分页列表GET/api/goods/{id}商品详情POST/api/cart添加购物车GET/api/order我的订单列表POST/api/order提交订单所有接口统一使用/api前缀方便后面Nginx做反向代理时按路径转发。3.2 统一响应体、Token鉴权与拦截器前后端分离项目必须约定一套统一的数据返回格式否则前端处理各种接口返回值时会很痛苦。我定义了一个Result类public class ResultT { private Integer code; // 200成功 400参数错误 401未登录 500服务器异常 private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }登录鉴权用的JWT核心思路是用户登录成功后后端生成一个包含用户ID和登录时间的Token返回给前端前端把Token存起来每次请求在Header里带上后端拦截器验证Token有效后才放行。生成Token用jjwt库在application.yml里配置一个密钥串和过期时间jwt: secret: abc123def456ghi789jkl012mnop345qrs expire: 604800 # 7天单位秒拦截器继承HandlerInterceptor在preHandle方法里获取Header中的token解析失败或过期就返回401不放行。注意要把登录、注册、商品列表、商品详情这些接口放进白名单不需要登录就能访问。3.3 MyBatis的XML映射与动态SQL写法MyBatis的最大价值是动态SQL。手机销售网站的商品列表有多种搜索组合品牌、价格区间、分类、排序方式都是在页面上动态选择的如果每个组合都写一个Mapper方法那代码量就爆炸了。用where和if标签可以把多个条件拼接在一个SQL里。商品列表分页查询的XML写法select idselectGoodsList resultTypecom.example.phoneshop.entity.Goods SELECT id, name, brand, category_id, price, cover, stock, sales, status FROM goods where if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) /if if testbrand ! null and brand ! AND brand #{brand} /if if testcategoryId ! null AND category_id #{categoryId} /if if testminPrice ! null AND price gt; #{minPrice} /if if testmaxPrice ! null AND price lt; #{maxPrice} /if AND status 1 /where if testsortField ! null and sortField ! ORDER BY ${sortField} /if LIMIT #{offset}, #{pageSize} /select需要注意两个细节。第一个if里的条件判断拼SQL时要防止“11”这种写法用where标签可以自动去掉第一个多余的AND。第二个排序字段soryField用${}而不是#{}因为#{}会被预编译成占位符?如果你写ORDER BY ?数据库会直接报错。但${}有SQL注入风险所以排序字段必须在前端传参时做白名单校验只允许传predefined的几个字段名而不是直接把用户输入拼进去。MyBatis还容易踩的坑是字段映射。默认情况下数据库的create_time映射不到Java实体里的createTime属性需要开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true也可以在Mapper.xml里用resultMap手动指定映射关系但全局开启驼峰转换更省事。4. Vue前端核心实现4.1 前端环境搭建与项目骨架Vue前端我用的是Vue CLI创建的项目技术栈包含Vue Router做前端路由、Axios发HTTP请求、Element Plus做UI组件库、Pinia管理登录态和购物车状态。创建项目命令npm install -g vue/cli vue create phone-shop-frontend npm install axios vue-router4 pinia element-plus进入项目后src目录下按这个结构组织src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── stores // Pinia状态管理 ├── views // 页面组件 │ ├── admin // 后台管理 │ └── mall // 商城端 └── utils // 工具函数Vue2和Vue3在路由和状态管理API上有不少差异我推荐直接用Vue3因为Element Plus的组件库比Element UI更现代Composition API写业务逻辑也更顺手。4.2 路由守卫、Axios封装与登录态管理前端每个需要登录的页面都要做访问控制如果用户没有登录直接跳转到登录页。这个功能用Vue Router的全局前置守卫实现。// router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.path /login token) { next({ path: / }) return } next() })Axios封装的核心是请求拦截器和响应拦截器。请求拦截器统一从localStorage里取Token加到请求头里响应拦截器统一处理后端返回的code如果是401就清掉本地Token并跳转登录页如果是200就返回data部分其他错误码直接弹出提示。// utils/request.js 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) { return res.data } if (res.code 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )Token存localStorage虽然简单但要注意XSS攻击风险如果项目上线生产环境建议用HttpOnly Cookie存Token后端把Cookie设置成Secure和HttpOnly。不过作为学习项目localStorage的方案完全够用。4.3 用户端与管理端的核心页面实现用户端商品列表页商品列表页是这个项目最重要的页面集成了搜索框、品牌筛选、价格排序、分页。页面加载时调用getGoodsList接口把分页参数和筛选条件传给后端。筛选条件变更时重置页码并重新请求。价格排序直接在排序下拉框里触发了一个方法把排序字段更新后重新请求。这里有个体验细节如果每次切换筛选或排序都让用户手动点击“查询”按钮交互就会很别扭所以我直接用了watch监听筛选条件的变化条件一变立即刷新数据。后台商品管理页后台商品管理是典型的增删改查页面用表格展示商品数据点击编辑按钮弹出一个表单Dialog提交时调用保存接口。商品图片处理用的是本地上传后端接收MultipartFile保存到服务器的一个固定目录再把访问路径返回给前端。如果要更专业的做法可以把图片存到MinIO对象存储上SpringBoot整合MinIO不算复杂这样可以避免应用重启后图片丢失的问题。购物车与订单页面购物车的状态数据我用Pinia管理保证不同页面切换后购物车数据不丢失。下单时把购物车勾选的商品ID列表和收货地址ID传给后端后端在事务中创建订单和明细返回给前端订单号。前端拿到订单号后跳转到支付页面支付页面这里是模拟实现点一下“确认支付”调用后端支付接口把订单状态改成已支付。5. 前后端联调与部署全流程5.1 本地联调跨域配置与接口调试前端开发服务器默认跑在8080端口后端SpringBoot跑在8080端口两个服务端口不同必然会产生跨域问题。解决跨域有两种方案。第一种是后端开启CORSConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(Registry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意如果用了allowCredentials(true)就不能用allowedOrigins(*)必须用allowedOriginPatterns(*)否则浏览器会拦截。第二种是用Vue CLI的devServer代理把/api开头的请求转发到后端开发时请求没有跨域问题代码也干净// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }我实际开发中两种方式都开了不冲突。生产部署时走Nginx反向代理就彻底没有跨域问题了。联调时一定要先打开浏览器F12看Network面板确认请求路径、请求头、响应体都正常不要只看页面效果。5.2 前端打包与Nginx托管前端本地开发完成后执行构建命令生成生产包npm run build构建完会生成一个dist目录里面是纯静态文件index.html、CSS、JS。把dist目录上传到服务器后用Nginx托管。Nginx配置要点是静态文件直接返回带/api前缀的请求转发到后端服务前端路由用history模式时配置try_files防止页面刷新404。server { listen 80; server_name your_domain_or_ip; # 前端静态文件 root /opt/phone-shop/dist; index index.html; # history路由刷新支持 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有个关键点proxy_pass后面到底要不要带/api/路径取决于后端接口路径。本项目后端Controller的路径映射是/api/xxx如果前端请求的是/api/goods那么Nginx转发到后端的路径也应该包含/api/goods。写成http://127.0.0.1:8080/api/Nginx会把/api/后面的部分拼上再转发。5.3 后端打包、JVM参数与进程管理后端打包前先确认application.yml里的数据库连接配置指向服务器MySQL然后执行mvn clean package -DskipTests打包结果在target目录下的xxx.jar。上传到服务器后运行nohup java -jar -Xms256m -Xmx512m phone-shop.jar app.log 21 -Xms256m -Xmx512m意思是最小堆内存256MB最大堆内存512MB。小项目给512MB完全够用不要贪多服务器内存有限时调大堆内存反而会拖垮其他进程。日志输出到app.log方便排查问题。如果服务器内存比较紧张可以用java -jar配合systemd管理服务这样进程挂了会自动重启数据库在项目里是MySQL。MySQL部署要点MySQL部署到Linux上按官方教程装好之后有几个必改项数据库编码设置为utf8mb4才能正常存储emoji和生僻字root用户远程登录要授权或者创建一个新账号并授权访问项目数据库密码强度不要用默认的validate_password插件默认等级否则设置个弱密码很费劲运行项目前先把SQL脚本导入MySQL脚本里包含建库、建表、初始化数据几款手机和默认管理员账号然后用navicat连上去验证一下表是否正确创建。线上运行踩过一个比较隐蔽的坑是时区问题。如果MySQL连接串没加serverTimezoneAsia/Shanghai而服务器MySQL是UTC时区那插入的时间会和本地时间差8个小时。所以连接串一定写成这样jdbc:mysql://localhost:3306/phone_shop?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb46. 常见问题与排查实录6.1 高频报错速查表做这类项目遇到报错是非常正常的我把常见的几个整理成一张速查表方便对照排查。报错现象可能原因解决办法前端请求后端接口报CORS错误后端未配置跨域或配置方法冲突检查后端CorsConfig本地开发优先用proxy代理登录接口返回401Token缺失或过期检查请求拦截器是否正常附带Token检查后端拦截器白名单刷新页面404Vue Router history模式和Nginx未配置try_files在Nginx的location /里加上try_filesMyBatis报Invalid bound statementMapper接口和XML没有绑定检查XML的namespace是否和接口全限定名一致商品列表查询报SQL语法错误动态SQL拼接问题把MyBatis日志打开打印实际执行的SQL检查图片上传后刷新页面无法显示图片存在应用本地应用重启后丢失图片存到独立目录或对象存储服务如MinIOMySQL连接报错Public Key Retrieval is not allowed连接串缺少allowPublicKeyRetrieval参数连接串加上allowPublicKeyRetrievaltrue打开MyBatis SQL日志很实用在application.yml里配置logging: level: com.example.phoneshop.mapper: debug这样在日志里能看到MyBatis执行了哪条SQL、传入的参数是什么。很多“查不到数据”的问题看一眼SQL和参数基本就明白了。6.2 部署后的性能与安全调优项系统能跑起来只是第一步如果这个项目要作为毕设答辩或者简历项目下面几项调优建议最好做一下。性能层面商品列表接口加缓存用Spring Cache或者直接Redis都行。这个项目Redis用得比较浅缓存商品详情即可库存和价格不要缓存太久下单时强制读数据库。SQL层面goods表的brand和category_id字段加索引订单查询user_id加索引否则数据量上来后查询会慢。安全层面数据库密码不要写死在application.yml里用环境变量注入。后端拦截器要加一个接口防刷的逻辑比如同一个IP一分钟内下单次数限制。管理员接口需要单独鉴权不能只靠普通用户的Token检查用户角色字段是否为admin。部署层面Nginx开启gzip压缩前端JS和CSS体积能减少70%左右页面加载明显变快gzip on; gzip_types text/plain text/css application/javascript application/json; gzip_min_length 1k;日志文件要定期清理nohup生成的app.log会越来越大。最简单的做法是logrotate定时把日志切割成按天备份保留最近7天就够。以我自己带这类项目的经验来说最值得花时间的不是把功能写完而是把部署流程跑通。很多人开发了两个月最后部署阶段各种翻车。我的建议是项目开发到三分之一的时候就先做一次完整的部署演练把Nginx、MySQL、jar包运行这套流程提前验证一遍后面所有改动都基于这个已部署的环境去迭代。这样最后交付的时候输入http://服务器IP就能看到网站跑起来那种成就感跟IDEA里点运行是完全不一样的。另外提一句这个项目后续如果想扩展方向很多。加一个Redis缓存层可以大幅优化商品详情和高频读接口的性能接入MinIO做对象存储可以解决图片丢失和分布式部署问题引入WebSocket可以做订单状态实时通知。但每一步都要注意系统复杂度会随之上升先把当前这套主干流程做到熟练再逐步扩展也不迟。
阅读完成 · 觉得有帮助?