做了几个月的社区团购项目,从零开始完成了一个基于SpringBoot和Vue的前后端分离系统,整个项目涉及用户端、管理员端、商品管理、订单流程、部署上线等多个环节,踩了不少坑,也沉淀了一套完整的源码和部署方案。这篇文章把整个项目的设计思路、核心实现、联调要点和部署流程全部拆开讲清楚,想用这个项目练手前后端分离实战的,或者准备做毕业设计、简历项目的,可以直接参考。
这个项目选择的技术栈是SpringBoot + Vue + MyBatis + MySQL,是目前中小型前后端分离项目非常经典的一套组合,也是Java后端和前端开发岗位面试里最常问到的技术栈组合。项目本身围绕社区团购业务展开,包含用户浏览商品、下单、支付(模拟)、订单管理、后台商品发布与分类管理、订单配送状态流转等完整业务闭环,覆盖了前后端分离项目开发中最常见、最核心的技术点:RESTful API设计、Token鉴权、统一响应封装、动态SQL、跨域处理、打包部署等。适合有一定Java基础、想系统性提升前后端分离项目经验的开发者来跟做。
1. 项目整体设计与业务场景拆解
1.1 社区团购系统的真实业务链路
社区团购和普通电商系统最大的区别在于“团长”这个角色和“自提点”的概念。用户通过平台下单,商品不是普通快递发货,而是统一配送到社区自提点,用户到点自提。这个业务模型决定了系统在功能设计上不需要复杂的物流跟踪和运费模板,更多是把精力放在区域化的商品管理、开团时间、库存扣减、提货码校验这些环节上。
整个系统拆成用户端和管理员端两块。用户端(C端)提供的功能包括:注册登录、浏览商品分类、查看商品详情、加入购物车、提交订单、在线支付(模拟)、查看个人订单、确认收货。管理员端(B端)提供的功能包括:商品分类管理、商品上下架、库存维护、订单查看与发货、统计概览。
1.2 功能模块划分与角色权限设计
系统的核心角色只有两个:普通用户和管理员。但在实现时我并没有把权限判断做得很粗放,而是通过JWT Token中携带的用户角色字段,配合后端的自定义注解和拦截器做鉴权。
// 角色枚举 public enum RoleEnum { USER(0, "普通用户"), ADMIN(1, "管理员"); }管理员接口统一在Controller层通过@RequireAdmin注解标识,后端拦截器会解析Token,从Redis中取出用户角色,当角色不匹配时直接返回403。这种设计比在业务代码里到处写if (isAdmin) ... else ...要干净得多,前后端配合时也能根据返回的状态码统一跳转和处理。
功能模块的具体拆分如下表所示:
| 模块 | 用户端功能 | 管理员端功能 |
|---|---|---|
| 用户模块 | 注册、登录、个人信息 | 用户列表、禁用/启用用户 |
| 分类模块 | 按分类浏览商品 | 新增分类、编辑分类、删除分类 |
| 商品模块 | 商品列表、详情搜索 | 新增商品、编辑商品、上下架、库存管理 |
| 购物车模块 | 加入购物车、修改数量、删除、批量结算 | - |
| 订单模块 | 提交订单、取消订单、确认收货 | 订单查询、订单发货、配送状态管理 |
| 支付模块 | 模拟支付流程 | 对账查询 |
1.3 项目目录结构与代码组织方式
后端采用标准的Maven多级目录结构,按照controller - service - mapper - entity分层的模式组织。我特意把config、interceptor、common、exception这些基础设施目录放到了业务包之外,这样整个项目看起来更清晰,也方便以后扩展其他业务模块。
前端使用的是Vue 2 + Vue Router + Vuex + Axios + Element UI,这个技术组合好处是生态成熟,遇到问题能搜到大量现成方案。Vue 3 + Vite 虽然更现代,但对于这个项目里用到的功能和大多数开发者的熟练度来说,Vue 2 + Webpack 反而更稳妥。
community-group-buying ├── backend (SpringBoot 2.7.x) │ ├── src/main/java/com/community │ │ ├── common // 统一响应、常量、异常 │ │ ├── config // 跨域配置、WebMvc配置 │ │ ├── controller // 接口层 │ │ ├── entity // 数据实体 │ │ ├── interceptor // JWT拦截器 │ │ ├── mapper // MyBatis接口 │ │ ├── service // 业务层 │ │ └── utils // JWT、日期等工具类 │ └── src/main/resources │ ├── mapper // MyBatis XML文件 │ └── application.yml └── frontend (Vue 2 + Element UI) ├── src │ ├── api // 接口请求封装 │ ├── router // 路由配置与守卫 │ ├── store // Vuex状态管理 │ ├── views // 页面组件 │ ├── components // 公共组件 │ └── utils // axios实例、token工具 └── vue.config.js前后端分离项目的工程组织是一门学问,尤其是在多人协作或者后期维护时,一个合理的目录结构能省下大量沟通成本。
2. 技术选型背后的考量,为什么是这套组合
2.1 SpringBoot的价值:把项目从“配置地狱”里解放出来
在SpringBoot之前,搞一个SpringMVC项目要写一堆XML配置、配置数据源、配置事务管理、配置视图解析器,任何一个环节写错,项目启动直接报错。SpringBoot把绝大多数配置通过约定俗成的方式自动化了,内置Tomcat,加依赖就能跑,大幅降低了Spring家族的使用门槛。
这个项目选择SpringBoot 2.7.x,核心原因有三点:第一,这个版本的依赖管理比较稳定,网上公开的资料最多,遇到兼容性问题时基本能搜到答案;第二,内嵌Tomcat让Jar包可以直接通过java -jar启动,配合Nginx做静态资源代理,部署流程极其简单;第三,SpringBoot对MyBatis、MySQL、Redis这些主流组件的自动配置支持非常完善,省去了大量样板配置代码。
2.2 MyBatis为什么会是中小型项目的最佳选择
之所以在这个项目里选择MyBatis而不是JPA或者MyBatis-Plus,主要是想让大家真正掌握SQL的编写能力。现在很多开发者从MyBatis-Plus入门,写代码全是selectList、selectById,连最简单的多表关联查询都要去搜索引擎现搜SQL语法,这是很危险的。
MyBatis的核心价值在于“SQL与代码分离”。SQL写在XML文件里,意味着我可以随时把生产环境里的复杂SQL拿过来调整、优化、测试,而不需要重新编译Java代码。在这个项目里,订单列表的分页查询、分类商品的按条件搜索、销售统计的聚合查询,我都写了完整的SQL实现,每条SQL都值得反复看几遍。
另外,MyBatis还有一个容易被人忽略的功能——动态SQL。比如商品列表搜索,用户可能只选择了分类、价格区间、关键词中的某一个条件,用<where>加<if>标签可以实现条件自适应的SQL拼接,这个能力在写C端筛选接口的时候极其好用。我在后面的小节会给出具体例子。
2.3 MySQL表结构设计中的几个关键点
社区团购系统的数据表不算多,核心就是五张表:user(用户表)、category(商品分类表)、product(商品表)、cart(购物车表)、orders(订单表)。另外加了两张辅助表:order_detail(订单明细表)和banner(首页轮播图表)。
表结构设计上,有几个细节值得展开讲一下。
商品表在设计时,需要考虑不同商品的不同规格问题。如果是纯社区团购的果蔬生鲜类商品,单个商品通常只有一个价格,所以设计时直接在product表里放price和stock两个字段就够了。但如果以后要拓展到服装类商品,就需要拆出sku表来区分颜色、尺码、价格、库存,这个属于系统演进过程中的迭代设计。
订单表的设计则需要注意一个关键问题:用户下单时,必须把商品名称、商品图片、购买单价、购买数量这些信息冗余到order_detail表中。这是因为用户查看历史订单时,需要的价格应该是下单那一刻的价格,而不是商品现在的价格。如果通过商品表去关联查询,一旦后台改了商品价格,历史订单的数据就对不上了。这种冗余设计在电商系统里叫“快照”,是业务流程中一个很重要的细节。
订单状态字段我用的是status整数类型枚举:
| 状态值 | 含义 | 用户端显示 |
|---|---|---|
| 0 | 待付款 | 去支付 |
| 1 | 待发货 | 等待商家发货 |
| 2 | 待收货 | 查看物流 / 确认收货 |
| 3 | 已完成 | 查看详情 |
| 4 | 已取消 | 订单已取消 |
3. 核心功能实现:从零到一搭起完整业务闭环
3.1 后端工程搭建与基础配置
后端基础工程我使用了Spring Initializr生成,核心依赖包括spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok、jjwt(Java JWT库)等。
application.yml里的几个关键配置如下:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/community_group_buying?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&autoReconnect=true username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有两个细节值得新手注意。一是数据库连接URL里一定要带上serverTimezone=Asia/Shanghai,否则高版本MySQL驱动会因为时区问题抛异常。二是map-underscore-to-camel-case: true这个配置,它能把数据库表里的user_name自动映射到实体类的userName字段,省去大量手写resultMap的样板代码。
3.2 统一响应对象与全局异常处理
前后端分离项目中,前后端的沟通协议必须保持一致。如果后端有的接口返回{code:0, data:...},有的接口返回{success:true, result:...},前端封装时会非常痛苦。所以第一步就统一定义了响应对象R:
@Data public class R { private Integer code; private String msg; private Object data; public static R ok() { ... } public static R ok(Object data) { ... } public static R error(String msg) { ... } public static R error(Integer code, String msg) { ... } }在此基础上,全局异常处理器把所有业务异常、参数校验异常、系统异常统一转换为R对象返回。这样前端只需要在axios的响应拦截器里处理code === 200这个分支即可。
3.3 商品列表的分页与条件查询实现
商品列表是C端高频访问的接口,需要同时支持分页、分类筛选、价格排序、关键词搜索等多个参数。这里用到了MyBatis动态SQL的能力:
<select id="selectProductPage" resultType="com.community.entity.Product"> SELECT p.*, c.name as category_name FROM product p LEFT JOIN category c ON p.category_id = c.id <where> p.status = 1 <if test="categoryId != null and categoryId != 0"> AND p.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (p.name LIKE CONCAT('%', #{keyword}, '%') OR p.description LIKE CONCAT('%', #{keyword}, '%')) </if> </where> <if test="sortType == 1"> ORDER BY p.price ASC </if> <if test="sortType == 2"> ORDER BY p.price DESC </if> <if test="sortType == null or sortType == 0"> ORDER BY p.id DESC </if> </select>这个SQL里,如果某个参数为null,对应的查询条件就不会拼接进去,MyBatis会自动调整SQL。对于C端这种多条件组合筛选的场景,比Java代码里拼SQL字符串要安全得多,也能避免SQL注入风险。
分页我这里用的是最传统的方式——先查询总条数,再查当前页数据。项目里我没有引入PageHelper,原因是这个项目数据量并不大,手动处理分页逻辑反而能帮助理解分页原理,面试时被问到“分页是怎么实现的”,也能更有底气地讲清楚。
3.4 订单提交的分布式事务思考
订单提交是电商系统最核心、最容易出问题的环节。一个用户点击“提交订单”,后端需要做这些事情:校验商品库存、扣减库存、创建订单主表记录、创建订单明细记录、清空购物车中已结算的商品。
Java代码实现中,我把整个流程用@Transactional注解包裹,以保证上述操作要么全部成功,要么全部回滚。比如用户提交订单后,如果创建订单明细失败,之前扣减的库存必须回滚,否则就会造成“库存已经扣了但订单不存在”的严重数据一致性问题。
真实互联网环境下,订单系统还会引入消息队列(如RocketMQ、RabbitMQ)做异步解耦,但由于本项目是单体架构,数据库事务已经能满足需求。这一点也是面试拓展的好话题:分布式事务的最终一致性方案为什么需要消息队列,本地消息表又是怎么解决的。
3.5 模拟支付流程的技术设计
真实支付需要接入微信支付/支付宝支付,涉及商户号申请、证书下载、回调接口等一系列流程。个人开发者很难完成这些流程,所以项目中使用“模拟支付”方案:用户点击支付按钮,前端调用后端/order/pay/{orderId}接口,后端模拟银行网关扣款,将订单状态从“待付款”改为“待发货”,并写入支付流水记录。
模拟支付虽然简单,但支付回调的幂等性设计必须体现出来。如果用户重复点击支付按钮,同一个订单不能产生两条支付流水,也不能重复更新订单状态。这里我通过在payment表中对order_id添加唯一索引来保证幂等:如果插入重复的支付记录,数据库会抛出唯一约束异常,被全局异常处理器捕获后提示“订单已支付”。这个设计思路和真实支付回调处理是完全一致的。
4. 前端工程:Vue全家桶与后端接口的深度协作
4.1 axios封装与请求拦截器、响应拦截器
前端所有HTTP请求都必须经过统一的axios实例。封装的核心目的是统一处理Token注入、错误提示、登录过期跳转等逻辑,避免每个页面组件里都写一遍重复代码。
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器:自动携带 Token service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }, error => Promise.reject(error)) // 响应拦截器:统一处理业务状态码 service.interceptors.response.use(response => { const res = response.data if (res.code === 401) { localStorage.removeItem('token') router.push('/login') Message.error('登录已过期,请重新登录') return Promise.reject(new Error('unauthorized')) } if (res.code !== 200) { Message.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { Message.error('网络异常,请稍后重试') return Promise.reject(error) }) export default servicebaseURL: '/api'这里的处理很有讲究。开发环境下,通过vue.config.js的devServer.proxy把/api开头的请求代理到http://localhost:8080,这样本地开发时不需要处理跨域问题。生产环境部署时,Nginx层再做一次反向代理,把/api转发到后端Java服务端口。通过这种配置,前端代码里的接口地址就可以保持相对路径,不会以后端地址迁移而大面积报错。
4.2 JWT Token的生成、校验与刷新机制
用户登录成功后,后端生成一个JWT Token返回给前端。Token里封装了用户ID、用户名和角色信息,并通过HMAC-SHA256算法签名。实现中用到了jjwt库,核心代码如下:
public String generateToken(User user) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7天有效期 return Jwts.builder() .setSubject(user.getId().toString()) .claim("username", user.getUsername()) .claim("role", user.getRole()) .setIssuedAt(now) .setExpiration(expiryDate) .signWith(SignatureAlgorithm.HS256, jwtSecret) .compact(); }请求到达业务Controller之前,先经过拦截器。拦截器从请求头的Authorization字段取出Token,解析并校验签名和过期时间。如果校验失败,统一返回401状态码。这里要特别注意,拦截器在preHandle中必须放行登录、注册等公开接口,可以通过路径匹配的方式排除:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns("/user/login", "/user/register", "/product/**", "/category/**", "/banner/**"); }JWT的过期时间设置是7天。对于社区团购这种业务场景来说,7天已经是比较合理的周期。如果要做微信小程序/App客户端,可以额外设计Refresh Token机制,但Web端项目7天有效期的方案已经足够用。
4.3 Vue Router路由守卫与页面权限控制
前端路由根据用户角色分为两类:普通用户可访问的C端页面和管理员专属的B端页面。路由守卫里通过读取Vuex中的userInfo.role字段判断当前用户是否具备访问权限:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() return } if (!token) { next('/login') return } if (to.meta.requiresAdmin) { const role = store.state.user.userInfo?.role if (role !== 1) { Message.error('无权限访问') next('/') return } } next() })这种前端路由守卫有时候会被说“不安全”,因为用户可以通过修改本地存储的角色数据来绕过检查。但它的价值在于用户体验层面,防止用户进入与自己无关的页面。真正的权限安全必须依靠后端接口层面的拦截器校验,这两者是互相配合的关系,前端做展示层控制,后端做数据层控制。
4.4 购物车与订单确认页的复杂状态管理
购物车页面是C端交互最复杂的模块,涉及商品选择、数量增减、单选/全选、合计金额计算等多个状态。这里用Vuex管理购物车数据,页面的每个操作都通过mutation同步更新store,然后重新计算选中商品的总金额。
Vuex中的checkedItems这个字段专门记录选中的购物车商品ID列表。用户点击全选/取消全选、单选/取消单选时,都会触发对应的mutation更新这个列表。结算按钮点击时,后端接收购物车ID列表和用户优惠信息,重新计算价格后创建订单。
为什么结算要由后端重新计算价格,而不是直接信任前端传过来的金额?因为前端传过来的任何数据都可能是被篡改过的,商品单价、合计金额这些关键数值必须由后端从数据库查出后在Service层计算,才能保证安全性。这个“永远不要信任前端数据”的思维方式,是后端开发入门的必修课。
5. 数据库设计脚本与MyBatis操作细节
5.1 完整建库建表SQL脚本
数据库命名为community_group_buying,字符集使用utf8mb4。重点说明一下为什么用utf8mb4而不是utf8:MySQL中的utf8最多支持3字节字符,存储不了生僻字和emoji表情,而utf8mb4是完整的UTF-8实现,兼容性更好。
CREATE DATABASE community_group_buying DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE community_group_buying; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', password VARCHAR(255) NOT NULL COMMENT 'BCrypt加密后的密码', nickname VARCHAR(50) COMMENT '昵称', avatar VARCHAR(255) COMMENT '头像地址', phone VARCHAR(20) COMMENT '手机号', role TINYINT DEFAULT 0 COMMENT '0-用户 1-管理员', status TINYINT DEFAULT 1 COMMENT '1-正常 0-禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, sort_order INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, category_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL, main_image VARCHAR(255), detail TEXT, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sales INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT '1-上架 0-下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_category_id (category_id) ); CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id) ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单编号', user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, pay_amount DECIMAL(10,2) NOT NULL, pay_type VARCHAR(20) DEFAULT 'wechat', status TINYINT DEFAULT 0, receiver_name VARCHAR(50), receiver_phone VARCHAR(20), pickup_address VARCHAR(255), pay_time DATETIME, deliver_time DATETIME, finish_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (user_id), INDEX idx_status (status) ); CREATE TABLE order_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, product_name VARCHAR(100), product_image VARCHAR(255), product_price DECIMAL(10,2), quantity INT, INDEX idx_order_id (order_id) ); CREATE TABLE payment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, user_id BIGINT NOT NULL, pay_amount DECIMAL(10,2), pay_type VARCHAR(20), pay_status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_id (order_id) );在设计cart表时,我加了UNIQUE KEY uk_user_product (user_id, product_id)唯一索引。用户在购物车里点击同一个商品两次时,后端不再去看数据库里是否重复,而是直接利用唯一索引冲突,再把查询和更新转换成一条INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1语句,这样可以避免先查询再更新的并发问题。
5.2 MyBatis易错点:单个数字与字符串比较
搜索热词里有一条“mybatis 单个数字字符比较”,这个问题在实际开发中确实出现过,而且非常迷惑。MyBatis中的<if test="status != null and status != ''">这里,如果status是整型数字,一旦写成status != '',MyBatis会走OGNL表达式求值,在部分版本中会隐式地做字符串转换,导致预期外的结果。典型例子:
<if test="status != null and status != ''"> AND p.status = #{status} </if>当status为数字0时,OGNL会将0转成字符串"0",然后再与空字符串比较,此时判断结果可能是false,导致SQL里少拼了条件,最终查询出来的数据是错的。
解决办法就是分类进行处理:整型字段的判断只写!= null,不要追加!= '';字符串字段的判空才使用!= null and != ''。
<if test="status != null"> AND p.status = #{status} </if>这种细节问题不出现一次,真的很难从资料上学到。代码在本地测不出来,但是到生产环境数据量大了之后,某个特定条件组合下结果数据就不对了,排错成本特别高。
5.3 MyBatis一二级缓存的基本认知
关于MyBatis缓存,这个项目里我只保留了默认的配置,并没有做额外的缓存策略。但既然面试常问,也简单说下MyBatis的缓存机制:一级缓存是SqlSession级别的本地缓存,同一个SqlSession中执行相同的查询会直接返回缓存结果;二级缓存是Mapper级别的缓存,可以被多个SqlSession共享,但要注意缓存的失效条件和数据一致性。
在开发这个项目的过程中,我建议把二级缓存关闭,默认为不开启。原因很简单,社区团购这种场景下,商品库存、订单状态都是实时变化的数据,如果开启缓存,很容易出现用户查到的商品库存是旧缓存的情况。为了在界面显示“仅剩3件”的效果,库存数据需要尽量实时,所以这个项目里完全没有引入Redis做热点数据缓存,也是基于业务实时性的考虑。
存储过程在这个项目中也没有使用。有些人喜欢把复杂的业务逻辑写成存储过程放在数据库里,但维护起来非常痛苦。我倾向于把业务逻辑放在Java代码中控制,数据库只做简单的增删改查和基础约束,这样后续做代码审查、调试、单元测试都更容易。
6. 前后端分离项目的部署上线指南
6.1 本地开发环境启动完整流程
本地启动这个项目需要提前安装好以下工具:
| 工具 | 版本要求 | 作用 |
|---|---|---|
| JDK | 1.8+ | 编译运行SpringBoot |
| Maven | 3.6+ | 后端依赖管理与打包 |
| MySQL | 5.7+ / 8.0 | 数据库服务 |
| Node.js | 14.x / 16.x | 前端依赖安装与构建 |
| npm / yarn | 任意常用版本 | 前端包管理器 |
后端启动流程:先执行database.sql脚本完成建库建表,然后修改application.yml中的数据库用户名密码,最后运行mvn spring-boot:run或者直接在IDE中运行启动类。后端启动成功的标志是控制台出现Started CommunityApplication,并且8080端口正常监听。
前端启动流程:进入frontend目录,执行npm install安装依赖(这里建议用淘宝镜像源,速度快很多),然后执行npm run serve启动开发服务器。默认端口是8081,此时浏览器访问http://localhost:8081即可进入项目首页。
6.2 使用Nginx反向代理部署前后端
生产环境部署时,前端打包后变成一堆静态文件(HTML/CSS/JS),后端是一个可执行的Jar包。最常用的部署方式是Nginx托管前端静态资源,同时反向代理后端接口。
前端打包命令是npm run build,打包产物在dist目录。将这个目录里的所有文件上传到服务器,比如放在/var/www/community目录下。Nginx配置如下:
server { listen 80; server_name yourdomain.com; # 前端静态资源 location / { root /var/www/community; index index.html; try_files $uri $uri/ /index.html; # 处理Vue Router history模式 } # 后端API反向代理 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; } }后端Jar包通过mvn package打出来,或者在Linux上执行mvn clean package -DskipTests。然后使用nohup java -jar community-backend.jar > app.log 2>&1 &后台启动即可。注意生产环境的数据库配置直接用环境变量注入,不要把密码硬编码到配置文件里。
前端部署还有一个容易被忽略的地方:Vue Router默认是哈希模式(URL中带#),为了让URL更好看,在vue.config.js/router中开启mode: 'history'后,也需要配合Nginx的try_files配置。如果忘记配置,用户刷新/product/123这个页面时,Nginx找不到对应的文件会返回404,这个问题在部署时经常出现,一定要提前配好。
6.3 服务器环境准备说明
如果在阿里云、腾讯云等云服务器上部署,准备工作如下:在安全组规则中放行80端口(HTTP)和8080端口(后端服务);安装JDK、MySQL、Nginx,按需启动服务。MySQL数据库连接建议在服务器上直接导库,不要开放公网3306端口,尽量避免安全风险。后端服务的端口绑定建议为0.0.0.0,否则可能无法从公网访问。
7. 常见问题与排查技巧实录
7.1 常见问题速查表
下面这些坑,是我在开发、调试这个项目的过程中真实遇到过的,每一项都花了不少时间排查。整理成表格方便大家直接检索:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 前端访问接口报CORS跨域错误 | 开发环境未配置代理,或生产环境Nginx未做反向代理 | 开发环境配置devServer.proxy,生产环境配置location /api/转发 |
| 接口返回401,浏览器没有跳转登录页 | 响应拦截器的res.code === 401处理分支遗漏了,或者状态码放在HTTP header里没走到业务返回 | 确认后端未登录统一返回HTTP 401还是业务code 401,前后端约定保持一致 |
| 商品图片加载不出来 | 图片路径是后端的相对路径,前端请求的域名/IP不一致 | 数据库中图片字段存相对路径,前端拼接图片服务器的基础URL,或者后端配静态资源映射 |
| 数据库中文乱码 | 建库时字符集用成了utf8,部分生僻字无法存储;连接URL缺少characterEncoding=utf8 | 统一使用utf8mb4,连接URL带上字符集参数 |
| 登录接口在Postman测试正常,前端访问500 | 前端请求未携带Content-Type,后端参数接收失败 | axios默认会自动处理,检查是否覆盖过headers配置 |
java.lang.IllegalStateException: Cannot call sendError | 过滤器或者拦截器中出现循环转发 | 检查拦截器对/error路径是否也做了Token校验,需要放行错误转发路径 |
| 打包集成后图片/JS文件404 | dist目录中静态资源路径为绝对路径assets/...,部署到子路径下路径不匹配 | vue.config.js中设置publicPath: './',使用相对路径 |
使用MyBatis的<=符号报错 | XML中<=会被解析为标签开头 | 使用XML转义符<=,或写入SQL时用<![CDATA[ <= ]]> |
| 用户删除后,历史订单查询报错 | 关联查询使用inner join,用户删除后订单查不到 | 使用LEFT JOIN,订单查询时以订单数据为主表 |
7.2 排查技巧实录:前后端联调时的高效定位方法
前后端分离项目调试时,如果接口返回异常,建议按照“网络层 → 后端日志 → 业务层”的顺序逐步定位。首先打开浏览器开发者工具(F12),查看Network标签页中请求的状态码、请求参数、响应数据。如果接口返回500,直接去后端控制台看异常堆栈。
开发过程中我习惯在后端配置mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,这样每次执行SQL都能在控制台看到完整的SQL语句和传入的参数,排查SQL拼接错误、参数缺失等问题时非常直观。
遇到前端参数传过来但后端接收不到的情况,优先检查三项内容:一是后端实体类的属性名与前端提交的字段名是否一致;二是是否缺少@RequestBody注解;三是前端是否真的把数据传到了请求体里,而不是放在了URL参数中。
7.3 关于Vue与SpringBoot版本兼容性的避坑建议
最后说下版本选择的问题。我在项目里使用的是Spring Boot 2.7.x 与 Vue 2.6.x,这两个版本搭配非常默契。Spring Boot 3.x 要求JDK 17+、Jakarta EE规范,MyBatis、Spring Security等生态的兼容性需要额外适配;Vue 3 + Vite 虽然性能更好,但组件库、插件生态与Vue 2 存在较大差异,老项目升级改造成本不低。如果是从这个项目入门、或者准备做毕业设计,我建议先用Spring Boot 2.7 + Vue 2这套稳定组合跑通整个流程,后面再逐步升级。
Maven依赖版本冲突是SpringBoot开发中最容易让人抓狂的问题之一。比如spring-boot-starter-parent的版本管理已经指定了MySQL驱动的版本,但如果你手动引入mysql-connector-java时又写了一个不同的版本号,就会出现No suitable driver之类的奇怪报错。处理办法是能不加版本号就不加,尽量使用父级依赖管理统一控制版本。
8. 这个项目后续还能怎么扩展
项目做完之后,有几个方向可以继续拓展。
第一个方向是引入Redis缓存热点数据。目前商品列表每次查询都要走数据库,后续如果商品数量变大、用户访问量上来,可以把首页分类、商品详情等热点数据缓存到Redis,配合Spring Cache注解可以做到对业务代码零侵入。登录态的管理也可以改成JWT无状态方案和Redis缓存方案结合,实现Token主动失效能力。
第二个方向是做秒杀/限时购功能。社区团购经常有定时开团秒杀活动,这个场景需要额外处理超卖问题和接口限流问题,是很好的技术挑战。可以研究一下Redis的原子性扣减库存、分布式锁、消息队列削峰等方案,扩展的想象空间很大。
第三个方向是引入MyBatis-Plus提高开发效率。现在的项目接近原生MyBatis实现,很适合学习SQL底层原理。如果日常开发追求效率,可以在此基础上引入MyBatis-Plus,把通用CRUD交给框架搞定,只保留复杂查询写XML,两种搭配是当前企业项目的主流形态。
从我个人的实际经验来说,这个项目最值得研究的地方其实不只是代码本身,而是整个前后端分离的开发模式:怎么设计接口协议、怎么管理Token、怎么处理跨域、怎么打包部署。这些能力是“会不会写代码”和“能不能独立负责一个项目”的分水岭。把这套链路完整跑通一遍,后面再写其他任何业务系统,思路都会清晰很多。