很多人做校园类项目,上来就堆功能,做完发现代码没法维护、老师一问三不知、部署更是噩梦。这个“Spring Boot + Vue + MyBatis + MySQL”的校园商铺管理系统,其实是一个非常适合用来打通前后端全流程的实战项目。我基于实际开发经验,把这套系统的前后端分离架构、数据库设计、核心模块的实现思路,以及从本地到云服务器的完整部署过程,全部拆开讲清楚。文章里涉及的关键配置、踩坑点、性能优化思路都是实测过的,代码可以照搬,流程可以参考,希望能帮你少走弯路。
1. 项目整体设计与功能模块拆解
1.1 这个系统到底解决了什么问题
很多高校里都存在这样的场景:学生想买二手教材、小电器,校园内的小店想发优惠券,社团要卖活动门票。传统做法就是拉微信群、发朋友圈,信息混乱且没有交易闭环。校园商铺管理系统想解决的,就是“学生——商家——平台管理”三方之间的信息流和交易流问题。
从技术本质上说,这更像是一个简化版的电商中台。它不是一个简单的CRUD堆砌,而是涉及商品、订单、购物车、支付(模拟)、营销、用户权限等多个核心业务域。所以做完这个项目,你对当前企业级Java开发的主流模式会有非常直观的理解。
1.2 角色与权限设计的三层模型
整个系统的用户分成三层,我用“前台用户——商家端——平台端”来表示,这也是电商系统的通用模型。
前台用户(学生/普通用户):浏览商品、加入购物车、下单、模拟支付、查看个人订单、管理收货地址、使用优惠券。注册时默认是普通用户,登录后能看到店铺列表和商品详情。
商家端:这里的商家,我建议指入驻校园的个体商户或学生创业团队。商家可以管理店铺信息、发布/上下架商品、处理订单(发货)、查看销售统计、设置店铺优惠券。
平台端(管理员):负责审核商家入驻、管理商品分类、处理举报/反馈、查看全平台数据报表、管理所有用户的启用/禁用状态。
这个三层结构非常关键,因为它决定了后面的数据库表设计、路由守卫控制、接口权限校验。实际操作中,很多人把用户表做成一个大表然后用role字段区分,这对小型项目没问题,但如果希望系统可扩展,我建议用户基础表 + 角色表 + 用户角色关联表,提前为RBAC(基于角色的访问控制)做好准备。
1.3 功能模块的四象限划分
按照业务域,我把功能拆成四个核心象限:
- 商品域:商品SPU/SKU管理、分类管理、商品检索、库存管理、商品轮播图/详情图、上下架状态管理。
- 交易域:购物车(Redis或数据库存储)、订单生成与状态流转、订单超时取消(延迟队列/定时扫描)、模拟支付回调、退款申请。
- 营销域:店铺优惠券(满减券/折扣券)、平台满减活动、优惠券领取与核销。
- 用户域:注册/登录(含验证码)、JWT鉴权、用户收货地址管理、个人信息修改、密码加密存储。
这四个象限基本覆盖了一个中型电商项目的核心业务。如果做毕业设计或简历项目,这个功能量是足够的。如果只是学习,我建议优先做透交易域,这是最能体现技术深度的地方。
2. 技术选型背后的核心考量
2.1 Spring Boot:为什么不是SSH也不是Spring Cloud
标题里写了Spring Boot,这是目前Java后端的事实标准。选它有两个原因:一是生态成熟,公司里用得最多,面试也最认;二是开发效率高,内置Tomcat、自动装配、Starter机制,不需要再花大量时间配置XML。
我需要特别说一下版本选择。spring-boot-starter-parent,我用的是2.7.x系列,因为3.x需要JDK17,且很多老教程和第三方依赖还有兼容性问题。如果你跟着本文做,建议用Spring Boot 2.7.18 + JDK8 + MyBatis Plus 3.5.x,这是目前最稳定的黄金组合。
版本组合要锁定,不要“最新版主义”。Spring Boot 3.x虽然性能更好,但很多云厂商的SDK、旧版MyBatis插件还没跟上,踩坑成本高。
2.2 Vue:为什么采用前后端分离而不是模板引擎
很多校内系统还在用Thymeleaf,但实际企业开发中,页面渲染逻辑已经基本从前端剥离。采用Vue+Vue Router+Axios+Element UI的纯前后端分离方案,后端只提供JSON数据接口,好处非常明显:前端交互更流畅,不会整页刷新;后端只关注API,逻辑更清晰;前端可以独立部署到Nginx,后端部署到服务器,方便扩展。
约定了接口规范后,前后端可以并行开发。前端用Mock数据,后端用Postman/Swagger调试。这在大团队是刚需,在小项目里也能让你养成好习惯。
2.3 MyBatis + MySQL:数据持久层与数据库选型
MyBatis的灵活SQL控制,加上MySQL在中小型项目中的绝对统治地位,这个组合没毛病。我这里用的是MyBatis Plus,它在MyBatis基础上增强了单表操作的CRUD方法,内置分页插件、代码生成器、逻辑删除,能省掉大量重复的XML编写。
数据库方面要特别说两点:
- 引擎用InnoDB:支持事务、行级锁、外键约束。订单和库存表一定要用事务保证一致性。
- 字符集统一utf8mb4:不仅支持中文,还兼容Emoji表情,避免用户昵称带Emoji报错。
2.4 完整依赖清单(pom.xml直接可抄)
以下是一份可直接使用的核心依赖,去掉了无关注释,版本号都经过实测兼容。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> <mybatis-plus.version>3.5.5</mybatis-plus.version> <jjwt.version>0.11.5</jjwt.version> </properties> <dependencies> <!-- Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis Plus --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <!-- MySQL驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- 数据校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- JWT Token --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>${jjwt.version}</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>${jjwt.version}</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>${jjwt.version}</version> </dependency> <!-- Hutool工具类 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency> <!-- Lombok --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>3. 数据库设计的核心思路与建表实践
3.1 核心表的ER关系梳理
先说清楚,数据库设计是这个项目的地基。表设计不好,后面写SQL就是灾难。我把核心表分为四组,每组之间用外键逻辑关联(不强制物理外键,但保留关联字段):
用户组:user(用户主表:用户名、密码、昵称、手机号、头像、状态、角色ID)、user_role(角色:学生/商家/管理员)、address(收货地址:用户ID、收件人、电话、省市区、详细地址、默认标记)。
店铺商品组:shop(店铺:商家ID、店铺名、店铺logo、评分、公告、状态)、category(商品分类)、product(商品:店铺ID、分类ID、名称、主图、轮播图JSON、描述、详情富文本、价格、库存、销量、状态)、product_sku(规格:如颜色/尺码,可先简化为单一规格)。
交易组:cart(购物车:用户ID、商品ID、数量)、orders(订单主表:订单号、用户ID、店铺ID、商品总金额、运费、优惠金额、实付金额、状态、收货信息快照)、order_item(订单明细:订单ID、商品ID、商品名、单价、数量、小计)、payment(支付流水:订单ID、支付方式、交易号、金额、状态、回调时间)。
营销与运营组:coupon(优惠券:店铺ID、名称、类型、满减条件、折扣、总量、已领量、有效期)、user_coupon(用户领取记录:用户ID、优惠券ID、状态)、feedback(反馈:用户ID、内容、联系方式、处理状态)、banner(首页轮播图)。
3.2 核心建表语句(可直接执行)
这里给出最核心的orders和order_itemDDL,注意我在设计时包含了逻辑删除和自动填充时间字段:
CREATE TABLE `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `shop_id` BIGINT NOT NULL COMMENT '所属店铺ID', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '商品总额', `discount_amount` DECIMAL(10,2) DEFAULT '0.00' COMMENT '优惠金额', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `freight_amount` DECIMAL(10,2) DEFAULT '0.00' COMMENT '运费', `receiver_name` VARCHAR(50) NOT NULL COMMENT '收货人', `receiver_phone` VARCHAR(20) NOT NULL COMMENT '收货电话', `receiver_address` VARCHAR(255) NOT NULL COMMENT '收货地址快照', `status` TINYINT NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付,1已支付,2已发货,3已完成,4已取消,5售后中', `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', `delivery_time` DATETIME DEFAULT NULL COMMENT '发货时间', `finish_time` DATETIME DEFAULT NULL COMMENT '完成时间', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted` TINYINT DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_shop_id` (`shop_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `order_no` VARCHAR(32) NOT NULL COMMENT '冗余订单编号,方便排查', `product_id` BIGINT NOT NULL, `product_name` VARCHAR(100) NOT NULL COMMENT '商品名称快照', `product_image` VARCHAR(255) DEFAULT NULL COMMENT '商品图', `price` DECIMAL(10,2) NOT NULL COMMENT '成交单价', `quantity` INT NOT NULL COMMENT '数量', `total_price` DECIMAL(10,2) NOT NULL COMMENT '小计', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';3.3 数据冗余与快照设计的解释
订单表里有receiver_name、receiver_phone、receiver_address这三个字段,这里不是简单的字段冗余,而是冗余地址快照。用户下单后如果改地址,订单不能跟着变,否则就会出现纠纷。同理,订单明细里冗余了product_name和product_image,商品改名、删图,订单历史照样可追溯。这是电商系统里非常经典的设计思路。
4. 后端核心业务实现与代码解析
4.1 JWT鉴权与拦截器实现
重点说鉴权。系统采用JWT(JSON Web Token)做无状态认证,用户登录成功返回Token,前端存到localStorage,每次请求在Header里带Authorization。
后端拦截器逻辑如下:
@Component public class JwtInterceptor implements HandlerInterceptor { @Resource private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if ("OPTIONS".equals(request.getMethod())) { return true; } // 从请求头获取 token String token = request.getHeader("Authorization"); if (StrUtil.isBlank(token)) { throw new BusinessException(401, "未登录或登录已过期"); } // 校验并解析 token Claims claims = jwtUtil.parseToken(token.replace("Bearer ", "")); // 将用户ID和角色放入ThreadLocal,供后续业务使用 UserContext.set(claims.get("userId", Long.class), claims.get("role", String.class)); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 防止内存泄漏 UserContext.clear(); } }有一个非常容易踩坑的点是:JWT是无状态的,服务端无法主动让Token失效。所以我额外查了用户的禁用状态,在拦截器里校验用户状态是否为1(正常),否则返回“账号已被禁用”。类似“退出登录”功能,要让前端删除本地Token,同时后端有请求时验状态来兜底。
JWT密钥与过期时间配置:
# application.yml jwt: secret: campus-shop-secret-key-please-change-in-production-2024 expire-hours: 24生产环境密钥必须足够长且不能写死在代码里,可以放到环境变量或配置中心;过期时间别设太长,一天足够,交互频繁的场景加刷新机制。
4.2 商品分页查询与条件检索
商品列表用得最多的是分页检索,这里我用MyBatis Plus的分页插件,支持多条件组合查询。注意前端传参过来不要直接拼SQL,用Wrapper构造器可以预防SQL注入。
@Override public PageResult<ProductVO> searchProducts(ProductQuery query) { Page<Product> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); // 商家ID(可选) if (query.getShopId() != null) { wrapper.eq(Product::getShopId, query.getShopId()); } // 分类ID(可选) if (query.getCategoryId() != null) { wrapper.eq(Product::getCategoryId, query.getCategoryId()); } // 关键字模糊查询名称/描述 if (StrUtil.isNotBlank(query.getKeyword())) { wrapper.and(w -> w.like(Product::getName, query.getKeyword()) .or().like(Product::getDescription, query.getKeyword())); } // 价格区间 if (query.getMinPrice() != null) { wrapper.ge(Product::getPrice, query.getMinPrice()); } if (query.getMaxPrice() != null) { wrapper.le(Product::getPrice, query.getMaxPrice()); } // 只查上架商品 wrapper.eq(Product::getStatus, 1); // 排序 wrapper.orderByDesc(Product::getCreateTime); Page<Product> result = productMapper.selectPage(page, wrapper); return new PageResult<>(result.getTotal(), convertToVO(result.getRecords())); }这里稍微解释下为什么状态=1只查上架。很多新手容易犯的错误是后端展示列表时不过滤状态,把下架商品也查出来。下架商品必须对前端不可见,这个过滤一定要放在后端,纯靠前端控制不安全。
4.3 下单与库存扣减的事务处理
这是整个项目里事务要求最高的代码,涉及多个表的写入和库存并发控制。
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto, Long userId) { // 1. 生成订单号(时间戳+随机数) String orderNo = generateOrderNo(); // 2. 查询购物车选中项或直接按商品下单 List<CartItem> items = cartService.getSelectedItems(dto.getCartItemIds()); if (items.isEmpty()) { throw new BusinessException("请选择要下单的商品"); } // 3. 计算金额、校验库存(使用悲观锁或乐观锁,这里用乐观锁) BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> orderItems = new ArrayList<>(); for (CartItem item : items) { Product product = productMapper.selectById(item.getProductId()); // 校验商品状态和库存 if (product.getStatus() != 1) { throw new BusinessException("商品已下架:" + product.getName()); } if (product.getStock() < item.getQuantity()) { throw new BusinessException("库存不足:" + product.getName()); } // 构造订单明细 OrderItem orderItem = new OrderItem(); // 省略setter赋值 orderItems.add(orderItem); // 累加金额 } // 4. 考虑优惠券 BigDecimal discountAmount = couponService.calcDiscount(dto.getUserCouponId(), totalAmount); // 5. 计算实付金额 BigDecimal payAmount = totalAmount.subtract(discountAmount); // 6. 插入订单主表和明细 // 7. 扣减库存 int updated = productMapper.deductStock(product.getId(), quantity); if (updated == 0) { throw new BusinessException("库存扣减失败,请重试"); } // 8. 清空购物车对应项 cartService.removeItems(dto.getCartItemIds()); return buildOrderVO(orderNo); }关键在这段库存扣减SQL:
<update id="deductStock"> UPDATE product SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{productId} AND stock >= #{quantity} </update>用库存充足条件作为乐观锁的核心校验,受影响行数为0时说明并发下库存不够了,触发重试或报错。禁止先用select查询库存,再用update回写,高并发场景一定会超卖。
4.4 订单超时取消与定时任务
订单超过30分钟未支付,需要自动取消把库存还回去。我用Spring的@Scheduled做定时扫描,生产环境如果单表数据量大,可以改用RabbitMQ延迟队列,但校内项目600万以内的数据量,定时扫描够用了。
@Component public class OrderTimeoutTask { @Resource private OrderService orderService; /** * 每30秒扫描一次待支付订单,超时30分钟自动取消 */ @Scheduled(cron = "0/30 * * * * ?") public void cancelTimeoutOrders() { // 查询超时未支付的订单 List<Order> timeoutOrders = orderService.findTimeoutOrders(30); for (Order order : timeoutOrders) { try { orderService.cancelTimeoutOrder(order); } catch (Exception e) { log.error("取消超时订单失败: {}", order.getOrderNo(), e); } } } }定时任务里单条异常不能影响整体扫描,所以每单try-catch。另外需要给订单表status和create_time建联合索引,否则扫描全表性能会出问题。
5. 前端Vue核心实现与页面交互
5.1 项目工程结构与路由
前端我用的Vue CLI创建的工程。核心目录结构:
campus-shop-web/ ├── public/ ├── src/ │ ├── api/ # 所有接口请求封装 │ │ ├── product.js │ │ ├── order.js │ │ ├── cart.js │ │ └── user.js │ ├── router/ # 路由配置 │ │ └── index.js │ ├── store/ # Vuex状态管理 │ │ └── modules/ │ ├── views/ # 页面 │ │ ├── home/ │ │ ├── product/ │ │ ├── cart/ │ │ ├── order/ │ │ ├── user/ │ │ └── shop/ │ ├── components/ # 通用组件 │ ├── utils/ │ │ ├── request.js # Axios封装 │ │ └── auth.js # Token存取 │ └── App.vue └── package.json5.2 Axios请求拦截与响应处理
统一封装Axios是我觉得最值得写的部分,它解决了80%的前后端联调问题。
// utils/request.js import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // /api timeout: 10000 }) // 请求拦截器:自动携带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 // 业务码200表示成功,401表示未登录 if (res.code === 200) { return res.data } if (res.code === 401) { // 清理本地凭证并跳转登录 localStorage.removeItem('token') router.push('/login') Message.error('登录状态已过期,请重新登录') return Promise.reject(new Error(res.message)) } Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } ) export default service响应拦截器里统一处理401这个设计非常重要。后端Token过期、用户被禁用,前端就能自动踢回登录页,而不是弹出满世界看不懂的报错。
5.3 商品列表和购物车的核心交互
商品列表页的数据我用了Vuex管理购物车数量,避免多个页面之间数据不同步。页面交互上有一个小技巧:每次加入购物车成功,底部工具栏的购物车角标动态+1,这个用Vuex的state和getters实现起来最顺手。
// store/modules/cart.js const state = { cartCount: 0, cartList: [] } const mutations = { SET_CART_COUNT: (state, count) => { state.cartCount = count }, SET_CART_LIST: (state, list) => { state.cartList = list } } const actions = { async fetchCartCount({ commit }) { const count = await getCartCount() commit('SET_CART_COUNT', count) } }购物车页面有一个交互细节一定要处理好:勾选商品后实时计算合计金额,用computed配合cartList里每项的checked状态,比手动绑定事件再更新要少很多bug,而且对用户更友好。
5.4 前端路由守卫控制权限
前后端分离项目中,路由守卫是前端的第一道安全防线,它的意义在于未登录用户无法访问需要登录的页面,不同角色进入不同菜单。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') // 需要登录的页面 if (to.meta.requiresAuth && !token) { next('/login') return } // 角色限制(如商家中心只允许商家进入) if (to.meta.roles && to.meta.roles.length > 0) { const userRole = localStorage.getItem('userRole') if (!to.meta.roles.includes(userRole)) { next('/403') return } } next() })但我要强调一句:前端路由守卫只提供用户体验,真正的安全校验必须靠后端。前端守卫的作用是避免用户看到无权限页面的丑陋报错,而后端接口的鉴权拦截才是底线,两者缺一不可。
6. 完整部署教程与云端上线
6.1 本地环境准备
先准备好所有运行环境,所有版本号均为实测最稳组合。
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 1.8(建议8u202+) | 后端运行环境 |
| Maven | 3.6.3 | 后端构建工具 |
| Node.js | 14.x 或 16.x | 前端构建工具 |
| MySQL | 5.7 或 8.0 | 数据存储 |
| Redis | 可选 | 购物车若用缓存时需要 |
6.2 后端打包与启动
后端打包前修改配置文件,数据库连接、JWT密钥等按实际部署环境调整:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_shop?useUnicode=true&characterEncoding=utf8mb4&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver执行打包命令,跳过单元测试:
mvn clean package -DskipTests打包成功后,在target目录下会生成campus-shop.jar,启动命令:
nohup java -jar campus-shop.jar --spring.profiles.active=prod > app.log 2>&1 &查看启动日志确认端口有没有起来:
tail -f app.log6.3 前端打包与Nginx部署
前端打包前需要配置接口地址,在项目根目录创建.env.production:
VUE_APP_BASE_API = /api执行构建:
npm install npm run build构建后生成dist目录,把它上传到服务器。用Nginx做静态托管和反向代理。因为前端请求/api开头的接口,要通过Nginx转发到后端的8080端口。
server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /usr/local/campus-shop/dist; index index.html; try_files $uri $uri/ /index.html; # 解决前端路由history模式刷新404 } # 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; } # 上传的图片等静态资源 location /upload/ { alias /usr/local/campus-shop/upload/; } }这里有一个必须避开的坑:try_files $uri $uri/ /index.html;一定要写。Vue Router 默认用的history模式,不配置的话,刷新任何子页面都会出现404。
6.4 数据库初始化与导入
把建表SQL脚本campus_shop.sql通过命令行导入MySQL:
mysql -u root -p campus_shop < campus_shop.sql导入之前先创建数据库:
mysql -u root -p CREATE DATABASE campus_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;注意:数据库编码只能utf8mb4,如果用了utf8,小程序/APP端用户输入的特殊字符(Emoji)会报错“Incorrect string value”。
6.5 服务器防火墙与安全组配置
部署到云服务器后,最常见的现象是本地能跑通,服务器上访问不了。原因基本都是安全组没放行端口。一般需要给安全组添加三个规则:HTTP 80端口(若用HTTPS则要443)、后端接口端口8080(如果你不想让用户直接访问,也可以设置内网访问,那就别对公网开放8080,只允许Nginx所在IP访问即可)、MySQL 3306端口(这个强烈建议不对公网开放,只在本地连接)。
我强烈建议你把8080端口设为内网访问或不开放,开发调试时再用SSH隧道,这是最安全的方式。MySQL的3306端口无论如何也不要对公网开放,否则很容易被扫描爆破。
7. 常见问题与排查技巧实录
7.1 前端请求跨域报错
现象:浏览器控制台出现Access-Control-Allow-Origin错误。
原因:前后端端口不同(前端5173,后端8080),跨域了。
两种解法,我建议用第二种:
第一种,后端允许跨域:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二种,走Nginx代理,前端访问/api,由Nginx转发到后端。这种方式上线后不用改代码,我推荐这个。
7.2 中文乱码或表情符号报错
现象:用户昵称/收货地址里有表情符号,保存数据库直接报错。
原因:表或连接的字符集是utf8,不是utf8mb4。
排查:检查数据库表字段字符集,检查JDBC连接字符串是否带characterEncoding=utf8mb4。
修复:统一库、表、字段字符集为utf8mb4,JDBC连接串补上参数,必要时ALTER TABLE转换。
7.3 MyBatis Plus 分页失效
现象:selectPage返回的records只有一页数据,total是0。
原因:没有配置分页插件拦截器,MyBatis Plus默认没有注册分页插件。
解决:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }7.4 部署上去后前端刷新页面404
这个前面提到了,就是Nginx没有配置try_files,按上一节的Nginx配置即可解决。补充说一下:上线前如果你不想暴露/upload路径,可以把上传目录放到Nginx的某个精确匹配的location下并禁止目录列表,能减少信息暴露面。
7.5 定时任务不执行或重复执行
现象:订单一直没有超时取消,或者运行多个实例时重复执行。
原因一:@EnableScheduling没加在启动类或配置类上。
原因二:部署了多个后端实例(例如多节点),没有分布式锁,每个实例都会执行扫描。
简单解决方案:单实例部署就好;多实例必须引入分布式锁(Redis的setnx),不能靠傻等和幂等处理。
7.6 数据库连接数爆掉
现象:运行一段时间后接口报错“Too many connections”。
原因:连接池默认配置过大,或一段时间内有慢SQL把连接占满了。
HikariCP连接池生产配置建议:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 idle-timeout: 300000 connection-timeout: 30000 max-lifetime: 1800000同时排查慢SQL,订单表、订单明细表、商品表的索引一定要建好,这是最根本的办法。
8. 项目扩展与进阶方向
这个项目做完,不只是“能交差”,它的扩展空间很大。我根据实际项目演进经验,给几个升级方向。
8.1 引入Redis做热点缓存和购物车
当前购物车用数据库表存储,数据量小时没问题。如果要做“车”和“推荐”相关的功能,可以引入Redis:购物车直接用Hash结构存,键cart:{userId},字段productId,值count,大大减轻MySQL压力。“首页热销”“店铺排行”这些热点数据,也可以缓存进Redis,设置过期时间,减轻高并发查询压力。
8.2 对接真实支付
现在的模拟支付是本地生成支付流水。想做更深,可以对接第三方支付平台的沙箱环境:生成预支付单、支付回调接口、退款接口、对账逻辑。注意支付回调接口为了保证安全性,一定要做签名验签,回调验签失败必须记录日志并拒绝处理。
8.3 引入消息队列做订单超时
前面说得比较远,如果你对高并发中间件感兴趣,可以把RabbitMQ的延迟交换机引进来实现订单超时取消。下单后投递一条延迟消息(TTL=30分钟),延迟时间到了消费端去检查订单状态,未支付就关单。这是目前电商行业最常用的方案,简历上写这个进阶点会很有分量。
8.4 增加数据统计与图表展示
管理端需要看交易额、订单量趋势、商品销售排行。可以在后端增加统计接口,使用SQL聚合函数分组查询;前端接入ECharts绘制折线图、饼图、柱状图。这块不会有太多复杂的算法,主要考察SQL能力以及前端图表组件使用的熟练度,是性价比很高的扩展点。
9. 一些写在最后的经验
我在反复做这类校园系统项目的过程中,最大的感受是:千万别贪功能。一个项目能跑通、能讲清楚、能应对提问,比功能多而讲不清值钱得多。这里有几个收尾时值得留心的地方。
第一,数据库脚本一定要附带初始数据。很多同学部署到一个新环境,数据库是空的,页面什么都没有,就以为系统坏了。我在部署文档里会附上管理员账号、商家账号、演示商品数据和测试订单,让部署者第一时间看到“能跑的样子”。这个细节,说实话很多人做不到。
第二,README文档比代码还重要。写出包含环境版本、部署步骤、账号信息、功能列表、目录结构说明的README,对你后续维护以及别人部署项目有巨大帮助。我强烈建议你花两小时把README写完,而不是仓促收尾。
第三,答辩或面试时的表现重心别背代码,讲设计决策。为什么订单要存收货地址快照?为什么商品库存扣减用乐观锁?为什么前端刷新子页面要配try_files?把这些决策背后的原因讲清楚,比背几百行代码有效太多。
第四,提前准备故障演练。真到了演示环境,偶尔会连不上数据库、端口被占用、前端白屏。写一份部署检查清单,按清单快速排查,能避免全场尴尬。我个人的习惯是:上线前先在干净的云服务器上完整部署一遍,把文档里的每一步验证过,这才是最稳的收尾。
如果你决定用这套方案作为基础去搭建你的校园商铺系统,从数据库导入开始,按我写的流程走一遍,基本一到两天就能把前后端完整跑起来。剩下的时间,建议全部投入在订单核心链路上,深挖一两个亮点,这个项目的价值就会远超“又一个课程设计”。