如果让我挑一个最适合初学 JavaWeb 的完整案例,网上购物商城系统绝对能排进前三。这个基于 JSP 和 SSM(Spring、SpringMVC、MyBatis)的商城项目,覆盖了用户从注册、浏览商品、加购、下单到后台管理的完整链路,既有前端页面交互,又有后端事务处理,还有数据库的多表关联,用来练手再合适不过。这篇文章我把做这类系统时沉淀下来的完整思路、核心代码片段和各种坑总结出来,给打算做类似实训项目或毕业设计的同学做参考。尤其是那些已经选定了“JSP + SSM”组合、但不知道从哪里下手的同学,这套流程可以直接照着走。1. 项目整体设计与技术选型解析1.1 为什么选择 JSP + SSM 这套组合很多人一上来就问,现在不都是前后端分离了吗,怎么还在用 JSP?这个问题我当时也纠结过。但仔细想想,商城系统是个典型的“管理系统 + 前台页面”二合一项目,JSP 最大的优势是能在同一套工程里直接通过 EL 表达式和 JSTL 标签把后端数据渲染出来,不用额外搭建前端工程。对一个人开发、周期有限的项目来说,这种“快速出活”的能力比什么都重要。SSM 框架这边也各有分工:Spring 负责对象管理和事务控制,SpringMVC 负责请求路由和参数绑定,MyBatis 负责把 SQL 和 Java 方法映射起来。三者合在一起,正好把数据访问、业务处理、请求响应这三层拆得明明白白。学习这个项目,等于同时把 Java 生态里最常用的三板斧过了一遍。另外,这套技术栈对运行环境的要求也非常友好。只要本机装了 JDK 8、MySQL 5.7 以上、Tomcat 8/9,基本就能跑起来,部署成本低。相比动不动就要 Docker、微服务、云原生的项目,这种传统 Web 应用反而更适合用来验证业务逻辑和基础知识。1.2 商城系统的核心功能边界动手之前一定要把功能边界画清楚,否则写代码时很容易想到哪写到哪。一个完整的网上购物商城通常分成两部分:前台用户端和后台管理端。前台的核心用户流程是:注册登录 → 浏览商品分类 → 搜索关键词 → 查看商品详情 → 加入购物车 → 生成订单 → 在线支付或货到付款 → 查看订单状态。后台的核心管理流程是:商品分类管理 → 商品上下架与库存维护 → 订单处理(发货、取消、退款)→ 用户状态管理 → 基础数据统计。我当时做的时候,把功能表做成了 Excel,每个模块对应哪些菜单位置、哪些数据库表、哪些权限,都提前列出来。这样做的好处是后面写接口时基本不需要反复改表结构,前后台都能顺畅联调。下面是我整理后的最小功能集:前台模块:用户注册、用户登录、商品列表(分页)、商品按分类筛选、商品关键词搜索、商品详情、购物车增删改查、订单确认、订单提交、我的订单列表。后台模块:管理员登录、商品分类管理、商品信息管理(含图片上传)、库存管理、订单管理(发货、取消)、用户管理、数据看板。后台不需要做太复杂,能支撑商品的日常维护和订单流转就够了。千万别为了追求“全”加一堆不常用的功能,比如积分、秒杀、优惠券,那是给自己挖坑。1.3 目录结构与分层思路工程目录我建议使用标准的 Maven Web 项目结构,不要图省事用普通 Web 项目。Maven 管理依赖特别方便,尤其是 SSM 这个组合有一堆 JAR 包,手动导包容易版本冲突。分层上我做了四个包:controller 放 SpringMVC 的 Controller,service 放业务接口和实现类,dao 放 MyBatis 的 Mapper 接口,entity 放数据库表对应的实体类。另外再加一个 common 包放工具类、常量类、通用返回结果。src/main/java com.example.shop controller UserController.java GoodsController.java CartController.java OrderController.java AdminGoodsController.java AdminOrderController.java service UserService.java GoodsService.java CartService.java OrderService.java dao UserMapper.java GoodsMapper.java CategoryMapper.java OrderMapper.java OrderItemMapper.java entity User.java Category.java Goods.java Cart.java Order.java OrderItem.java common Constants.java MD5Util.java Result.java src/main/resources mapper UserMapper.xml GoodsMapper.xml CategoryMapper.xml OrderMapper.xml OrderItemMapper.xml jdbc.properties spring-mybatis.xml spring-mvc.xml src/main/webapp WEB-INF views user, goods, cart, order, admin web.xml分层清晰之后,代码交接和调试都方便。举例来说,用户下单后需要扣减库存、生成主订单、生成订单明细、清空购物车,这些操作全部放在 OrderService 的一个事务方法里,Controller 只负责接收参数和返回视图。这样无论测试还是排查问题,都能很快定位到业务逻辑所在的层。2. 环境准备与数据库设计2.1 开发环境与版本对应关系网上购物商城系统对环境的要求不高,但版本对应关系还是要重视。我使用的是 JDK 8、MySQL 5.7、Tomcat 9、Maven 3.6,框架版本用 Spring 5.1.x、SpringMVC 5.1.x、MyBatis 3.5.x。这里有一个容易踩的坑:Tomcat 10 以上把 Java EE 包从 javax 换成了 jakarta,很多老项目代码直接编译报错。如果没把握,就用 Tomcat 9 最稳。工具方面我用的是 IntelliJ IDEA,虽然 Eclipse 也能跑,但 IDEA 对 Maven 和 Spring 的支持更顺手。创建项目时不要选错类型,要选 Maven 项目的 Web 骨架,或者直接新建一个普通 Maven 模块再手动添加 Web 支持。如果是 IDEA,一定要检查 Project Structure 里的 Artifacts,把项目依赖的 JAR 包加入打包输出,否则 Tomcat 启动后经常会报 ClassNotFound。还有一个小技巧:MySQL 连接驱动用mysql-connector-java8.0.16 以上,但注意这和 MySQL 5.7 的驱动类名、URL 参数都有变化。连接池我用的是 Druid,配置简单,还自带了监控页面,适合做日志排查。2.2 数据库表设计核心思想数据库表设计是整个商城系统的地基。表数量不用太多,但每张表的字段和关系一定要提前想清楚。我最终设计了六张核心表:用户表、商品分类表、商品表、购物车表、订单表、订单明细表。有的同学还会加一张管理员表,但管理员功能少,可以和用户表并用一个 role 字段区分。用户表存的就是账号、密码、昵称、手机号、邮箱、角色、创建时间。密码字段我建议用varchar(64),不存明文,统一存 MD5 加盐后的值。分类表字段很简单:分类名、父级分类、排序值、状态,一般做两级分类就够。商品表是最关键的,字段包括商品名称、所属分类、商品主图、详情描述、市场价、销售价、库存、销量、上下架状态、创建时间。这里要特别注意商品详情的存储方式,我一开始用text字段直接存富文本编辑器的 HTML 片段,后期发现如果图片用的是相对路径,部署后容易丢图。最好在富文本上传接口里把图片路径转成完整可访问的 URL 再入库。订单表和订单明细表是一对多的关系。订单表里除了订单号、用户 ID、总金额、收货地址、联系人信息、下单时间,还要有一个订单状态字段。订单明细表则要保存商品 ID、商品名称、商品图片、购买单价、数量、小计。为什么明细表里要冗余商品名称和图片?因为商品信息后续可能修改或下架,但历史订单必须保持当时的快照。这是个非常实用的设计经验。2.3 关键 SQL 与数据初始化脚本建表完成后,可以先用一些测试数据验证查询效果。会员表中我测试时会插入几十条模拟用户,商品表则准备四个分类共几十件商品,价格从几十到几千不等。商品列表页最常用的查询就是按分类和关键词分页:SELECT id, goods_name, goods_pic, market_price, sell_price, stock, sales FROM goods WHERE status = 1 AND category_id = #{categoryId} AND goods_name LIKE CONCAT('%', #{keyword}, '%') ORDER BY sales DESC LIMIT #{offset}, #{pageSize};这个 SQL 包含了筛选、模糊搜索、排序和分页四个核心要素,能覆盖前台商品列表 80% 的场景。统计销售排行时,可以直接按sales字段排序;后台看库存告警时,再加一个stock < 10的判断。购物车表我建议设计成独立表,字段是用户 ID、商品 ID、数量、加入时间。可能有人会觉得放 Session 里更简单,但实际做下来,独立表能处理掉线的用户下完订单还丢购物车的尴尬,也能为后台分析用户收藏偏好留数据。3. 核心模块实现与实操要点3.1 用户注册登录与登录拦截用户模块是系统入口,逻辑不算复杂,但注册时的细节容易漏。注册接口除了校验用户名是否重复,还要对密码加密。我封装了一个工具类,用 MD5 做盐值哈希,盐可以用用户名或固定字符串拼接,避免直接用原始密码摘要。具体加密写法如下:public static String md5WithSalt(String password, String username) { String raw = password + "{" + username + "}"; StringBuilder sb = new StringBuilder(); MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8)); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); }登录成功之后,把用户 ID 和用户名放到 Session 中,退出时调用session.invalidate()清除。拦截器是个容易忽略但必须做的模块。我用 SpringMVC 的HandlerInterceptor拦截所有/user/**、/cart/**、/order/**路径,如果 Session 中没有用户信息就重定向到登录页。这个拦截器写好后一定要在 spring-mvc.xml 里声明拦截路径,否则前台用户没登录也能直接提单,这个问题在联调的时候特别隐蔽。后台管理同样需要拦截,但角色不同。我通常在拦截器里加一个角色判断,只有role=admin的账号放行到/admin/**。学员做项目时常把登录状态和角色权限混在一起,建议还是拆开,否则后面加超级管理员会非常痛苦。3.2 商品分类展示与分页查询商品展示没有太多高级算法,核心就是分页查询做得顺手。我用的方案是 MyBatis 的 PageHelper 插件,在 Service 层查询前设置当前页码和每页条数,然后查询返回的结果自动带出PageInfo,里面包含了总条数、总页数、当前页等所有分页数据。PageHelper.startPage(pageNum, pageSize); List<Goods> goodsList = goodsMapper.selectByCondition(categoryId, keyword); PageInfo<Goods> pageInfo = new PageInfo<>(goodsList);在 JSP 页面里,遍历pageInfo.list渲染商品卡片,底部生成分页条。分页条的高亮逻辑用 JSTL 做一个循环,遍历1..pageInfo.pages,当前页加active样式。这里要注意,PageHelper 和 SQL 中的LIMIT不要混用,否则会出现第一次查询正常、翻页后数据错乱的情况。商品详情页要展示图片、价格、库存和购买数量。我建议用商品 ID 直接拼一个restful风格的 URL:/goods/detail/${goodsId},这样搜索引擎和分享都更友好。如果库存不大于 0,前端要置灰“立即购买”按钮,后端在下单时也要再次校验库存,双重保障。商品图片存在服务器上传目录,数据库只存相对路径,页面上展示时通过统一的FileController读取本地文件流返回。这样能避免图片文件被打进 WAR 包、重新部署丢失的问题。3.3 购物车方案比较与最终实现购物车是商城系统里最容易被低估的模块。最初我用 Session 保存购物车,一个 Map 结构,key 是商品 ID,value 是数量,代码写起来是很快。但测试时发现两个痛点:一是没登录也能加购物车,但登录后购物车数据不在;二是用户清浏览器缓存,购物车也跟着没。所以最后我换成了数据库购物车。购物车表包含用户 ID、商品 ID、数量、更新时间三个核心字段。前端加购时调用/cart/add接口,后端先判断购物车中是否已经有该商品,有则数量累加,没有则插入新记录。查询购物车时关联商品表一起查出商品名称、图片和价格:SELECT c.id, c.goods_id, c.quantity, g.goods_name, g.goods_pic, g.sell_price, g.stock FROM cart c LEFT JOIN goods g ON c.goods_id = g.id WHERE c.user_id = #{userId} ORDER BY c.update_time DESC;购物车里商品数量修改和删除都是基于 ID 操作,接口也不复杂。需要强调的是,每次修改数量后最好重新计算一次总价,后台最好重新从数据库读取最新价格,不要信任前端传过来的单价,防止有人直接改请求参数把价格改低。3.4 订单生成与库存扣减事务处理订单模块是整个系统的“心脏”,最容易出现并发问题。创建订单至少包含这几步:校验商品库存 → 生成主订单 → 批量插入订单明细 → 扣减商品库存 → 清空购物车。这五步必须在一个事务里,任何一步失败都要整体回滚。我使用的是 Spring 声明式事务,在OrderService的创建方法上加@Transactional,传播行为默认就行。但是在扣库存的时候要注意,不能只做“先查库存再 update”,否则高并发下会出现库存超卖。更稳妥的做法是把扣减条件写进 SQL:UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity};这条 SQL 如果影响行数为 0,说明库存不足,直接抛出运行时异常,整个事务回滚。这也是目前大多数商城项目用的“乐观锁式扣减”,实现简单又有效。订单号建议用时间戳 + 随机数生成,避免自增主键暴露订单量。订单状态我用数字表示:1 待支付、2 已支付(待发货)、3 已发货、4 已完成、5 已取消、6 退货/退款中。后台管理员操作就是改状态,但要记录状态变更日志,不然用户投诉时说不清楚。我在订单表加了update_time和remark字段,处理异常订单时直接备注原因,这对实际运营很有帮助。3.5 后台管理端的商品上传与订单处理后台管理端主要是给管理员用的,页面不需要太花哨,但功能要完整。商品上传包含基础信息表单和多图片上传。多文件上传我用 CommonsMultipartResolver 配置一个拦截器,接收MultipartFile[]文件数组,然后保存到服务器目录。保存文件时要生成新文件名,防止中文文件名乱码和重名覆盖。我习惯用UUID + 原文件扩展名:String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFilename = UUID.randomUUID() + ext;图片落地之后,把/upload/goods/xxxx.jpg这样的相对路径存入数据库。页面展示时在前端用一个全局前缀拼完整 URL。这个方案在本地和部署到 Linux 服务器都通用。后台订单管理页就是表格加查询条件,支持按状态筛选、按订单号搜索。管理员点“发货”时,用 Ajax 请求发送订单 ID 和物流单号,后端更新状态和备注。这个操作非常简单,但要注意用 JSP+Ajax 返回 JSON 时,Controller 方法要加@ResponseBody,并且返回值类型尽量封装成统一的{code, msg, data},方便前端统一处理。4. 常见问题与排查技巧实录4.1 Tomcat 启动后首页 404这个坑在实训项目里出现率非常高。如果项目启动后访问http://localhost:8080/是 404,首先检查web.xml里配置的欢迎页,如果配置的是index.jsp,要确认这个文件是否在webapp根目录下。如果是 IDEA 部署的问题,大概率是 Artifacts 没有加入项目依赖,导致 JAR 没被一起打包。解决办法是打开 Project Structure → Artifacts → Output Layout,把 “Available Elements” 里的依赖包右键添加到 WEB-INF/lib 下。还有一种情况是项目部署名不对,默认部署名可能带_war_exploded后缀。可以直接在 Tomcat 配置里把 Application context 改成/,或者改成一个稳定的项目名,否则每次启动访问路径都不一样。4.2 数据库查询结果中文乱码中文乱码来自三层:数据库、JDBC 连接、JSP 页面。遇到乱码先看数据库字符集:SHOW CREATE DATABASE shop_db;如果显示的不是utf8mb4,可以执行修改语句把数据库和表都改成utf8mb4。然后检查 jdbc.properties 里连接地址是否带参数:jdbc.url=jdbc:mysql://localhost:3306/shop_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai这个参数一定要加,否则即使数据库是 utf8,连接时也可能按系统默认编码读。最后检查 JSP 页面头部是否写了pageEncoding="UTF-8"和contentType="text/html; charset=UTF-8"。这三层都对了,中文一般不会乱码。我在实际调试中发现,用 Tomcat 8 以上默认编码是 UTF-8,但如果用 POST 提交表单,取参数时中文乱码,需要配置 CharacterEncodingFilter:<filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>4.3 MyBatis 映射文件结果返回 null用 SSM 开发时,实体类字段通常是驼峰命名,比如sellPrice、goodsName,数据库字段是下划线命名sell_price、goods_name。如果 MyBatis 开启了驼峰映射,查询结果才自动转换;没开启,整列全返回 null,页面全空。解决办法是在 spring-mybatis.xml 里加一个全局配置:<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> </configuration>另外还要检查实体类有没有无参构造器,MyBatis 底层用反射创建对象,如果类里写了带参构造器却没提供无参构造,查询也会报错。4.4 事务不生效,数据部分回滚这是订单模块最常见的坑。明明加了@Transactional,但扣库存失败了主订单还是插入进去。原因一般是下面几个:第一,方法没有被 Spring 代理。如果createOrder方法在同一个类里被另一个方法直接调用,事务拦截器不会生效。要确保事务方法是从 Controller 层进去,而不是同类内部自调用。第二,事务没有加在 public 方法上,或异常被吞掉。我在创建订单时一开始 catch 了异常打印日志,事务以为方法正常返回,直接提交了。正确的做法是让 RuntimeException 往上抛,由 Spring 捕获回滚,或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。第三,MyBatis 的 Mapper 执行 SQL 时如果用到了 MySQL 的 MyISAM 引擎,因为该引擎不支持事务,即使代码写了事务也一样无效。建表时统一用 InnoDB 才能在项目里正常回滚。4.5 图片上传成功但展示不出来图片上传后,如果页面上<img src="/upload/xxx.jpg">访问 404,先看两个位置。一个是上传路径是否和读取路径一致。很多人本地跑时用绝对路径保存,比如D:/upload/,页面上却用相对路径访问,Tomcat 默认能访问的 webapp 根目录下并没有这个文件,所以 404。最好的做法是把上传目录配置成和项目 webapp 同级的静态资源目录,然后在 spring-mvc.xml 里配置资源映射:<mvc:resources mapping="/upload/**" location="file:D:/upload/"/>部署到 Linux 时,把D:/upload/换成/opt/shop/upload/,映射配置不变。这样本地和线上路径都统一用/upload访问,几乎没有偏差。5. 部署上线与安全加固5.1 打包部署到服务器项目跑通本地后,上线部署也不算难,但有几件事必须做。先改数据库连接配置为公网地址,然后使用 Maven 的 package 命令打成 WAR 包。在pom.xml里确认打包方式是 war:<packaging>war</packaging>打包后把 target 目录下的shop.war拷贝到服务器 Tomcat 的 webapps 目录下,启动后 Tomcat 会自动解压。如果服务器上已有运行中的 Tomcat,最好先停掉再扔 war 包,避免解压到一半出问题。如果觉得直接访问 Tomcat 不够安全,可以在前面加一层 Nginx 做反向代理,把 80 端口转发到 Tomcat 的 8080 端口。Nginx 配置里只需要一个 proxy_pass:server { listen 80; server_name example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }5.2 常见安全风险与对应处理商城系统涉及用户资金,安全这一块不能只求“能跑”。最容易出问题的三个点分别是 SQL 注入、XSS 和非授权访问。MyBatis 天然使用预编译语句,只要业务代码不拼接字符串到 SQL 里,SQL 注入风险就很小。但还需要防止一些工具同学直接把order by字段拼到查询语句里,这种做法会破坏预编译。前端搜索框和富文本内容要有 XSS 过滤,比如把<script>标签进行转义,否则别人在商品评价里插入脚本,就可能窃取其他用户 Session。后台接口必须校验管理员权限。除了前面说的拦截器,还可以在敏感操作(修改价格、删除商品、处理订单)时,不仅要校验登录态,还要校验当前用户角色,避免普通用户直接调用后台接口。最后是密码不能明文入库,这个前面已经提过。支付功能如果只是模拟,状态直接由订单模块控制,那也要注意在订单状态变更时加上简单状态机校验,防止从“待支付”直接被人改到“已完成”。5.3 项目扩展与后续优化方向如果做完基础功能还有余力,可以从这几个方向往上扩展,让项目在答辩或简历上更出彩。性能方面,商品列表页加 Redis 缓存是性价比最高的优化。把热门分类的商品列表缓存到 Redis,缓存过期时间设成 5 分钟,能明显降低数据库压力。查询 SQL 也要分析慢日志,给category_id、goods_name这些常用查询字段加索引。功能方面,可以增加简单的秒杀限流模拟、用户积分、头像上传、轮播广告、库存告警提醒。这些功能都不需要改动现有表结构,只要在原表上加字段,再加几个接口就能完成。当然,我个人的建议是先把现有模块的质量做扎实,把事务、分页、权限、上传这些基础功能调得没有隐患,再去加花活。基础流程顺畅了,这个项目的学习价值才算真正拿到手。我个人做完这个商城项目最大的体会是:技术框架只是工具,真正难的是想清楚业务状态和边界。比如订单状态怎么流转、库存怎么扣得安全、购物车怎么保证数据一致,这些问题想透之后再去敲代码,效率会高很多。另外还有一个小技巧,就是每次改完数据库表,马上把实体类和 Mapper XML 同步更新,不要等到运行时发现字段对不上才回去补,那样会让联调过程非常痛苦。希望这份实操记录能帮你少走一些弯路。
如果让我挑一个最适合初学 JavaWeb 的完整案例,网上购物商城系统绝对能排进前三。这个基于 JSP 和 SSM(Spring、SpringMVC、MyBatis)的商城项目,覆盖了用户从注册、浏览商品、加购、下单到后台管理的完整链路,既有前端页面交互,又有后端事务处理,还有数据库的多表关联,用来练手再合适不过。这篇文章我把做这类系统时沉淀下来的完整思路、核心代码片段和各种坑总结出来,给打算做类似实训项目或毕业设计的同学做参考。尤其是那些已经选定了“JSP + SSM”组合、但不知道从哪里下手的同学,这套流程可以直接照着走。1. 项目整体设计与技术选型解析1.1 为什么选择 JSP + SSM 这套组合很多人一上来就问,现在不都是前后端分离了吗,怎么还在用 JSP?这个问题我当时也纠结过。但仔细想想,商城系统是个典型的“管理系统 + 前台页面”二合一项目,JSP 最大的优势是能在同一套工程里直接通过 EL 表达式和 JSTL 标签把后端数据渲染出来,不用额外搭建前端工程。对一个人开发、周期有限的项目来说,这种“快速出活”的能力比什么都重要。SSM 框架这边也各有分工:Spring 负责对象管理和事务控制,SpringMVC 负责请求路由和参数绑定,MyBatis 负责把 SQL 和 Java 方法映射起来。三者合在一起,正好把数据访问、业务处理、请求响应这三层拆得明明白白。学习这个项目,等于同时把 Java 生态里最常用的三板斧过了一遍。另外,这套技术栈对运行环境的要求也非常友好。只要本机装了 JDK 8、MySQL 5.7 以上、Tomcat 8/9,基本就能跑起来,部署成本低。相比动不动就要 Docker、微服务、云原生的项目,这种传统 Web 应用反而更适合用来验证业务逻辑和基础知识。1.2 商城系统的核心功能边界动手之前一定要把功能边界画清楚,否则写代码时很容易想到哪写到哪。一个完整的网上购物商城通常分成两部分:前台用户端和后台管理端。前台的核心用户流程是:注册登录 → 浏览商品分类 → 搜索关键词 → 查看商品详情 → 加入购物车 → 生成订单 → 在线支付或货到付款 → 查看订单状态。后台的核心管理流程是:商品分类管理 → 商品上下架与库存维护 → 订单处理(发货、取消、退款)→ 用户状态管理 → 基础数据统计。我当时做的时候,把功能表做成了 Excel,每个模块对应哪些菜单位置、哪些数据库表、哪些权限,都提前列出来。这样做的好处是后面写接口时基本不需要反复改表结构,前后台都能顺畅联调。下面是我整理后的最小功能集:前台模块:用户注册、用户登录、商品列表(分页)、商品按分类筛选、商品关键词搜索、商品详情、购物车增删改查、订单确认、订单提交、我的订单列表。后台模块:管理员登录、商品分类管理、商品信息管理(含图片上传)、库存管理、订单管理(发货、取消)、用户管理、数据看板。后台不需要做太复杂,能支撑商品的日常维护和订单流转就够了。千万别为了追求“全”加一堆不常用的功能,比如积分、秒杀、优惠券,那是给自己挖坑。1.3 目录结构与分层思路工程目录我建议使用标准的 Maven Web 项目结构,不要图省事用普通 Web 项目。Maven 管理依赖特别方便,尤其是 SSM 这个组合有一堆 JAR 包,手动导包容易版本冲突。分层上我做了四个包:controller 放 SpringMVC 的 Controller,service 放业务接口和实现类,dao 放 MyBatis 的 Mapper 接口,entity 放数据库表对应的实体类。另外再加一个 common 包放工具类、常量类、通用返回结果。src/main/java com.example.shop controller UserController.java GoodsController.java CartController.java OrderController.java AdminGoodsController.java AdminOrderController.java service UserService.java GoodsService.java CartService.java OrderService.java dao UserMapper.java GoodsMapper.java CategoryMapper.java OrderMapper.java OrderItemMapper.java entity User.java Category.java Goods.java Cart.java Order.java OrderItem.java common Constants.java MD5Util.java Result.java src/main/resources mapper UserMapper.xml GoodsMapper.xml CategoryMapper.xml OrderMapper.xml OrderItemMapper.xml jdbc.properties spring-mybatis.xml spring-mvc.xml src/main/webapp WEB-INF views user, goods, cart, order, admin web.xml分层清晰之后,代码交接和调试都方便。举例来说,用户下单后需要扣减库存、生成主订单、生成订单明细、清空购物车,这些操作全部放在 OrderService 的一个事务方法里,Controller 只负责接收参数和返回视图。这样无论测试还是排查问题,都能很快定位到业务逻辑所在的层。2. 环境准备与数据库设计2.1 开发环境与版本对应关系网上购物商城系统对环境的要求不高,但版本对应关系还是要重视。我使用的是 JDK 8、MySQL 5.7、Tomcat 9、Maven 3.6,框架版本用 Spring 5.1.x、SpringMVC 5.1.x、MyBatis 3.5.x。这里有一个容易踩的坑:Tomcat 10 以上把 Java EE 包从 javax 换成了 jakarta,很多老项目代码直接编译报错。如果没把握,就用 Tomcat 9 最稳。工具方面我用的是 IntelliJ IDEA,虽然 Eclipse 也能跑,但 IDEA 对 Maven 和 Spring 的支持更顺手。创建项目时不要选错类型,要选 Maven 项目的 Web 骨架,或者直接新建一个普通 Maven 模块再手动添加 Web 支持。如果是 IDEA,一定要检查 Project Structure 里的 Artifacts,把项目依赖的 JAR 包加入打包输出,否则 Tomcat 启动后经常会报 ClassNotFound。还有一个小技巧:MySQL 连接驱动用mysql-connector-java8.0.16 以上,但注意这和 MySQL 5.7 的驱动类名、URL 参数都有变化。连接池我用的是 Druid,配置简单,还自带了监控页面,适合做日志排查。2.2 数据库表设计核心思想数据库表设计是整个商城系统的地基。表数量不用太多,但每张表的字段和关系一定要提前想清楚。我最终设计了六张核心表:用户表、商品分类表、商品表、购物车表、订单表、订单明细表。有的同学还会加一张管理员表,但管理员功能少,可以和用户表并用一个 role 字段区分。用户表存的就是账号、密码、昵称、手机号、邮箱、角色、创建时间。密码字段我建议用varchar(64),不存明文,统一存 MD5 加盐后的值。分类表字段很简单:分类名、父级分类、排序值、状态,一般做两级分类就够。商品表是最关键的,字段包括商品名称、所属分类、商品主图、详情描述、市场价、销售价、库存、销量、上下架状态、创建时间。这里要特别注意商品详情的存储方式,我一开始用text字段直接存富文本编辑器的 HTML 片段,后期发现如果图片用的是相对路径,部署后容易丢图。最好在富文本上传接口里把图片路径转成完整可访问的 URL 再入库。订单表和订单明细表是一对多的关系。订单表里除了订单号、用户 ID、总金额、收货地址、联系人信息、下单时间,还要有一个订单状态字段。订单明细表则要保存商品 ID、商品名称、商品图片、购买单价、数量、小计。为什么明细表里要冗余商品名称和图片?因为商品信息后续可能修改或下架,但历史订单必须保持当时的快照。这是个非常实用的设计经验。2.3 关键 SQL 与数据初始化脚本建表完成后,可以先用一些测试数据验证查询效果。会员表中我测试时会插入几十条模拟用户,商品表则准备四个分类共几十件商品,价格从几十到几千不等。商品列表页最常用的查询就是按分类和关键词分页:SELECT id, goods_name, goods_pic, market_price, sell_price, stock, sales FROM goods WHERE status = 1 AND category_id = #{categoryId} AND goods_name LIKE CONCAT('%', #{keyword}, '%') ORDER BY sales DESC LIMIT #{offset}, #{pageSize};这个 SQL 包含了筛选、模糊搜索、排序和分页四个核心要素,能覆盖前台商品列表 80% 的场景。统计销售排行时,可以直接按sales字段排序;后台看库存告警时,再加一个stock < 10的判断。购物车表我建议设计成独立表,字段是用户 ID、商品 ID、数量、加入时间。可能有人会觉得放 Session 里更简单,但实际做下来,独立表能处理掉线的用户下完订单还丢购物车的尴尬,也能为后台分析用户收藏偏好留数据。3. 核心模块实现与实操要点3.1 用户注册登录与登录拦截用户模块是系统入口,逻辑不算复杂,但注册时的细节容易漏。注册接口除了校验用户名是否重复,还要对密码加密。我封装了一个工具类,用 MD5 做盐值哈希,盐可以用用户名或固定字符串拼接,避免直接用原始密码摘要。具体加密写法如下:public static String md5WithSalt(String password, String username) { String raw = password + "{" + username + "}"; StringBuilder sb = new StringBuilder(); MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8)); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); }登录成功之后,把用户 ID 和用户名放到 Session 中,退出时调用session.invalidate()清除。拦截器是个容易忽略但必须做的模块。我用 SpringMVC 的HandlerInterceptor拦截所有/user/**、/cart/**、/order/**路径,如果 Session 中没有用户信息就重定向到登录页。这个拦截器写好后一定要在 spring-mvc.xml 里声明拦截路径,否则前台用户没登录也能直接提单,这个问题在联调的时候特别隐蔽。后台管理同样需要拦截,但角色不同。我通常在拦截器里加一个角色判断,只有role=admin的账号放行到/admin/**。学员做项目时常把登录状态和角色权限混在一起,建议还是拆开,否则后面加超级管理员会非常痛苦。3.2 商品分类展示与分页查询商品展示没有太多高级算法,核心就是分页查询做得顺手。我用的方案是 MyBatis 的 PageHelper 插件,在 Service 层查询前设置当前页码和每页条数,然后查询返回的结果自动带出PageInfo,里面包含了总条数、总页数、当前页等所有分页数据。PageHelper.startPage(pageNum, pageSize); List<Goods> goodsList = goodsMapper.selectByCondition(categoryId, keyword); PageInfo<Goods> pageInfo = new PageInfo<>(goodsList);在 JSP 页面里,遍历pageInfo.list渲染商品卡片,底部生成分页条。分页条的高亮逻辑用 JSTL 做一个循环,遍历1..pageInfo.pages,当前页加active样式。这里要注意,PageHelper 和 SQL 中的LIMIT不要混用,否则会出现第一次查询正常、翻页后数据错乱的情况。商品详情页要展示图片、价格、库存和购买数量。我建议用商品 ID 直接拼一个restful风格的 URL:/goods/detail/${goodsId},这样搜索引擎和分享都更友好。如果库存不大于 0,前端要置灰“立即购买”按钮,后端在下单时也要再次校验库存,双重保障。商品图片存在服务器上传目录,数据库只存相对路径,页面上展示时通过统一的FileController读取本地文件流返回。这样能避免图片文件被打进 WAR 包、重新部署丢失的问题。3.3 购物车方案比较与最终实现购物车是商城系统里最容易被低估的模块。最初我用 Session 保存购物车,一个 Map 结构,key 是商品 ID,value 是数量,代码写起来是很快。但测试时发现两个痛点:一是没登录也能加购物车,但登录后购物车数据不在;二是用户清浏览器缓存,购物车也跟着没。所以最后我换成了数据库购物车。购物车表包含用户 ID、商品 ID、数量、更新时间三个核心字段。前端加购时调用/cart/add接口,后端先判断购物车中是否已经有该商品,有则数量累加,没有则插入新记录。查询购物车时关联商品表一起查出商品名称、图片和价格:SELECT c.id, c.goods_id, c.quantity, g.goods_name, g.goods_pic, g.sell_price, g.stock FROM cart c LEFT JOIN goods g ON c.goods_id = g.id WHERE c.user_id = #{userId} ORDER BY c.update_time DESC;购物车里商品数量修改和删除都是基于 ID 操作,接口也不复杂。需要强调的是,每次修改数量后最好重新计算一次总价,后台最好重新从数据库读取最新价格,不要信任前端传过来的单价,防止有人直接改请求参数把价格改低。3.4 订单生成与库存扣减事务处理订单模块是整个系统的“心脏”,最容易出现并发问题。创建订单至少包含这几步:校验商品库存 → 生成主订单 → 批量插入订单明细 → 扣减商品库存 → 清空购物车。这五步必须在一个事务里,任何一步失败都要整体回滚。我使用的是 Spring 声明式事务,在OrderService的创建方法上加@Transactional,传播行为默认就行。但是在扣库存的时候要注意,不能只做“先查库存再 update”,否则高并发下会出现库存超卖。更稳妥的做法是把扣减条件写进 SQL:UPDATE goods SET stock = stock - #{quantity} WHERE id = #{goodsId} AND stock >= #{quantity};这条 SQL 如果影响行数为 0,说明库存不足,直接抛出运行时异常,整个事务回滚。这也是目前大多数商城项目用的“乐观锁式扣减”,实现简单又有效。订单号建议用时间戳 + 随机数生成,避免自增主键暴露订单量。订单状态我用数字表示:1 待支付、2 已支付(待发货)、3 已发货、4 已完成、5 已取消、6 退货/退款中。后台管理员操作就是改状态,但要记录状态变更日志,不然用户投诉时说不清楚。我在订单表加了update_time和remark字段,处理异常订单时直接备注原因,这对实际运营很有帮助。3.5 后台管理端的商品上传与订单处理后台管理端主要是给管理员用的,页面不需要太花哨,但功能要完整。商品上传包含基础信息表单和多图片上传。多文件上传我用 CommonsMultipartResolver 配置一个拦截器,接收MultipartFile[]文件数组,然后保存到服务器目录。保存文件时要生成新文件名,防止中文文件名乱码和重名覆盖。我习惯用UUID + 原文件扩展名:String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFilename = UUID.randomUUID() + ext;图片落地之后,把/upload/goods/xxxx.jpg这样的相对路径存入数据库。页面展示时在前端用一个全局前缀拼完整 URL。这个方案在本地和部署到 Linux 服务器都通用。后台订单管理页就是表格加查询条件,支持按状态筛选、按订单号搜索。管理员点“发货”时,用 Ajax 请求发送订单 ID 和物流单号,后端更新状态和备注。这个操作非常简单,但要注意用 JSP+Ajax 返回 JSON 时,Controller 方法要加@ResponseBody,并且返回值类型尽量封装成统一的{code, msg, data},方便前端统一处理。4. 常见问题与排查技巧实录4.1 Tomcat 启动后首页 404这个坑在实训项目里出现率非常高。如果项目启动后访问http://localhost:8080/是 404,首先检查web.xml里配置的欢迎页,如果配置的是index.jsp,要确认这个文件是否在webapp根目录下。如果是 IDEA 部署的问题,大概率是 Artifacts 没有加入项目依赖,导致 JAR 没被一起打包。解决办法是打开 Project Structure → Artifacts → Output Layout,把 “Available Elements” 里的依赖包右键添加到 WEB-INF/lib 下。还有一种情况是项目部署名不对,默认部署名可能带_war_exploded后缀。可以直接在 Tomcat 配置里把 Application context 改成/,或者改成一个稳定的项目名,否则每次启动访问路径都不一样。4.2 数据库查询结果中文乱码中文乱码来自三层:数据库、JDBC 连接、JSP 页面。遇到乱码先看数据库字符集:SHOW CREATE DATABASE shop_db;如果显示的不是utf8mb4,可以执行修改语句把数据库和表都改成utf8mb4。然后检查 jdbc.properties 里连接地址是否带参数:jdbc.url=jdbc:mysql://localhost:3306/shop_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai这个参数一定要加,否则即使数据库是 utf8,连接时也可能按系统默认编码读。最后检查 JSP 页面头部是否写了pageEncoding="UTF-8"和contentType="text/html; charset=UTF-8"。这三层都对了,中文一般不会乱码。我在实际调试中发现,用 Tomcat 8 以上默认编码是 UTF-8,但如果用 POST 提交表单,取参数时中文乱码,需要配置 CharacterEncodingFilter:<filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>4.3 MyBatis 映射文件结果返回 null用 SSM 开发时,实体类字段通常是驼峰命名,比如sellPrice、goodsName,数据库字段是下划线命名sell_price、goods_name。如果 MyBatis 开启了驼峰映射,查询结果才自动转换;没开启,整列全返回 null,页面全空。解决办法是在 spring-mybatis.xml 里加一个全局配置:<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> </settings> </configuration>另外还要检查实体类有没有无参构造器,MyBatis 底层用反射创建对象,如果类里写了带参构造器却没提供无参构造,查询也会报错。4.4 事务不生效,数据部分回滚这是订单模块最常见的坑。明明加了@Transactional,但扣库存失败了主订单还是插入进去。原因一般是下面几个:第一,方法没有被 Spring 代理。如果createOrder方法在同一个类里被另一个方法直接调用,事务拦截器不会生效。要确保事务方法是从 Controller 层进去,而不是同类内部自调用。第二,事务没有加在 public 方法上,或异常被吞掉。我在创建订单时一开始 catch 了异常打印日志,事务以为方法正常返回,直接提交了。正确的做法是让 RuntimeException 往上抛,由 Spring 捕获回滚,或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。第三,MyBatis 的 Mapper 执行 SQL 时如果用到了 MySQL 的 MyISAM 引擎,因为该引擎不支持事务,即使代码写了事务也一样无效。建表时统一用 InnoDB 才能在项目里正常回滚。4.5 图片上传成功但展示不出来图片上传后,如果页面上<img src="/upload/xxx.jpg">访问 404,先看两个位置。一个是上传路径是否和读取路径一致。很多人本地跑时用绝对路径保存,比如D:/upload/,页面上却用相对路径访问,Tomcat 默认能访问的 webapp 根目录下并没有这个文件,所以 404。最好的做法是把上传目录配置成和项目 webapp 同级的静态资源目录,然后在 spring-mvc.xml 里配置资源映射:<mvc:resources mapping="/upload/**" location="file:D:/upload/"/>部署到 Linux 时,把D:/upload/换成/opt/shop/upload/,映射配置不变。这样本地和线上路径都统一用/upload访问,几乎没有偏差。5. 部署上线与安全加固5.1 打包部署到服务器项目跑通本地后,上线部署也不算难,但有几件事必须做。先改数据库连接配置为公网地址,然后使用 Maven 的 package 命令打成 WAR 包。在pom.xml里确认打包方式是 war:<packaging>war</packaging>打包后把 target 目录下的shop.war拷贝到服务器 Tomcat 的 webapps 目录下,启动后 Tomcat 会自动解压。如果服务器上已有运行中的 Tomcat,最好先停掉再扔 war 包,避免解压到一半出问题。如果觉得直接访问 Tomcat 不够安全,可以在前面加一层 Nginx 做反向代理,把 80 端口转发到 Tomcat 的 8080 端口。Nginx 配置里只需要一个 proxy_pass:server { listen 80; server_name example.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }5.2 常见安全风险与对应处理商城系统涉及用户资金,安全这一块不能只求“能跑”。最容易出问题的三个点分别是 SQL 注入、XSS 和非授权访问。MyBatis 天然使用预编译语句,只要业务代码不拼接字符串到 SQL 里,SQL 注入风险就很小。但还需要防止一些工具同学直接把order by字段拼到查询语句里,这种做法会破坏预编译。前端搜索框和富文本内容要有 XSS 过滤,比如把<script>标签进行转义,否则别人在商品评价里插入脚本,就可能窃取其他用户 Session。后台接口必须校验管理员权限。除了前面说的拦截器,还可以在敏感操作(修改价格、删除商品、处理订单)时,不仅要校验登录态,还要校验当前用户角色,避免普通用户直接调用后台接口。最后是密码不能明文入库,这个前面已经提过。支付功能如果只是模拟,状态直接由订单模块控制,那也要注意在订单状态变更时加上简单状态机校验,防止从“待支付”直接被人改到“已完成”。5.3 项目扩展与后续优化方向如果做完基础功能还有余力,可以从这几个方向往上扩展,让项目在答辩或简历上更出彩。性能方面,商品列表页加 Redis 缓存是性价比最高的优化。把热门分类的商品列表缓存到 Redis,缓存过期时间设成 5 分钟,能明显降低数据库压力。查询 SQL 也要分析慢日志,给category_id、goods_name这些常用查询字段加索引。功能方面,可以增加简单的秒杀限流模拟、用户积分、头像上传、轮播广告、库存告警提醒。这些功能都不需要改动现有表结构,只要在原表上加字段,再加几个接口就能完成。当然,我个人的建议是先把现有模块的质量做扎实,把事务、分页、权限、上传这些基础功能调得没有隐患,再去加花活。基础流程顺畅了,这个项目的学习价值才算真正拿到手。我个人做完这个商城项目最大的体会是:技术框架只是工具,真正难的是想清楚业务状态和边界。比如订单状态怎么流转、库存怎么扣得安全、购物车怎么保证数据一致,这些问题想透之后再去敲代码,效率会高很多。另外还有一个小技巧,就是每次改完数据库表,马上把实体类和 Mapper XML 同步更新,不要等到运行时发现字段对不上才回去补,那样会让联调过程非常痛苦。希望这份实操记录能帮你少走一些弯路。