做手工艺品销售系统这件事,最初是因为长期接触这类小规模电商项目后,发现很多人把注意力全放在“功能能不能跑”上,却很少想清楚后端设计为什么要这样做。这次我用 SpringBoot 做了一个完整的 Web 手工艺品销售系统,从用户注册登录、商品展示、购物车到下单选款,再搭一套后台管理,一路做下来踩了不少坑,也沉淀出一套可以直接照着搬的方案。这篇文章会把设计思路、核心实现细节、实际操作步骤和排错过程都摊开讲,适合正在搭类似系统的开发者参考,也能帮新手理解一个 SpringBoot Web 项目从零到落地需要经历哪些环节。
这套系统的价值不在于代码量多大,而在于把电商领域的典型场景浓缩到了一个项目里。手工艺品本身有两类特点:一是商品的图片、分类、“手艺人故事”这类描述内容往往比较多,二是价格波动和库存变化相对频繁,这对后台数据模型和前端展示都有要求。下面直接从整体设计开始,我会按我实际开发的顺序讲。
1. 项目整体设计与技术选型
1.1 需求梳理:前后台两种角色的功能边界
手工艺品销售系统本质上是一个小型的 B2C 商城,用户进来能看能买,管理员能管商品和订单。在动手写代码之前,我先把功能拆成了两条线:
- 用户端:注册登录、商品的分类浏览和关键词搜索、商品详情、加入购物车、购物车管理、结算下单、订单列表、取消订单、个人资料维护。
- 管理端:商品分类管理、商品上下架与库存维护、商品图片上传、订单列表与发货处理、订单状态管理。
这个功能拆法看起来平平无奇,但它的意义在于确定了后端接口的边界。比如用户端的商品列表要支持分页和搜索,管理端的商品列表同样要支持,但管理端需要返回上下架状态、库存等字段,两者不能共用同一个简单列表接口硬扛,否则前端会拿到一堆用不上的字段,接口语义也会越来越乱。我最后把用户端商品列表和管理端商品列表拆成了两个 Service 方法,底层共用查询条件构造逻辑,这样既避免重复劳动,又不会让接口变得臃肿。
1.2 技术选型:为什么没有硬上前后端分离
这套系统我选的是 SpringBoot + MyBatis + Thymeleaf + MySQL,前端模板用 Thymeleaf,配合 Bootstrap 之类的前端库做页面。项目编号 11785 里的“基于 Web”指的就是这种浏览器访问模式,并不强制要求 Vue、React 那套前后端分离架构。
很多人一上来就堆前后端分离,我建议不要盲目跟风。原因很现实:这类系统的核心价值在业务闭环,也就是登录、购物、下单、发货这一整条链路是否严谨,而不在于是不是用了 Vue 3 组合式 API。用 Thymeleaf 做服务端渲染,天然避免了跨域问题、Token 过期刷新问题,页面之间的关系也更直观,管理端和用户端都可以在 Controller 里直接跳转。对单机部署、访问量不大的系统来说,服务端渲染反而更稳、更省事。
要选分离式架构也可以,但对应付出的成本更高,你得处理跨域、接口鉴权、前端打包、部署环境等一系列问题。如果你只是要完成一个能演示、能跑通完整流程的系统,Thymeleaf 这套组合能让开发效率高很多。当然,如果团队里有人已经熟练使用 Vue,并且明确要把前台做成动态交互很强的单页应用,那再走分离方案也不迟。
1.3 关于 SpringBoot 自动装配,你需要知道的那点事
使用 SpringBoot 时,最常被问到的就是自动装配原理。它并不神秘,核心就是@SpringBootApplication这个注解,它由三部分组成:@Configuration、@EnableAutoConfiguration、@ComponentScan。启动时,SpringBoot 会扫描依赖中的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件(老版本是spring.factories),把列出的自动配置类读进来,再配合@ConditionalOnClass、@ConditionalOnProperty这些条件注解做判断。
举个例子,pom 里引入了 MySQL 驱动和 Spring Data JPA,SpringBoot 看到这些类存在,就会自动帮你配置数据源;如果你再引入 MyBatis 的 starter,它又会自动创建 SqlSessionFactory。这套机制的好处是“约定大于配置”,坏处是当你改动部分配置时,必须知道它在哪个自动配置类里生效。我在实际项目里就遇到过spring.datasource.url没配对导致启动失败的情况,其实只要确定存在 HikariCP 和 MySQL 驱动,报错信息几乎直接指向数据源配置,所以了解自动装配原理对排错非常有用。
2. 数据库设计与关键模型
2.1 六张核心表,一次说清
数据库表设计决定了后续所有业务逻辑怎么写。我这套系统一共用了六张核心表,分别是用户表、分类表、商品表、购物车表、订单表、订单明细表,另外加一张管理员操作日志表用来记录后台关键操作,方便排查问题。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| t_user | id, username, password, role, phone, avatar, created_at | role 区分普通用户与管理员 |
| t_category | id, name, sort, status | 分类用于前台导航和后台管理 |
| t_product | id, category_id, name, description, cover_image, price, stock, status, version | status 控制上架/下架,version 做乐观锁 |
| t_cart | id, user_id, product_id, quantity, checked | checked 表示结算时是否选中 |
| t_order | id, order_no, user_id, total_amount, status, receiver_name, receiver_phone, receiver_address, create_time, pay_time, delivery_time | 主订单,记录收货信息快照 |
| t_order_item | id, order_id, product_id, product_name, product_image, price, quantity | 冗余商品名称和价格快照 |
商品表里的价格字段我用了DECIMAL(10,2),坚决不用FLOAT或DOUBLE,因为浮点类型在做金额计算时会出精度问题,比如 0.1 加 0.2 变成 0.30000000000000004,这在涉及钱的项目里是不能接受的。订单明细表里冗余了商品名称、商品图片和下单时的价格,这里的思路是做快照。商品以后可能改名、改价,甚至被删掉,但订单的历史记录必须保持下单那一刻的状态,否则用户查自己买过什么时看到的是改过的信息,会产生纠纷。
2.2 价格、库存、订单,三个容易埋坑的地方
价格设计前面说了,用DECIMAL是基本底线。我这里再补充一点:所有前端传入的金额,后端都要自己重新计算,不能相信前端算好的总价。否则有人会通过修改请求参数把商品单价改了,导致支付金额异常。正确做法是后端根据商品 ID 重新查库、重新乘数量、再算总价,前端展示的价格只做视觉用途。
库存这块我用的是乐观锁。每个商品都有version字段,扣库存时执行的是UPDATE t_product SET stock = stock - #{num}, version = version + 1 WHERE id = #{productId} AND stock >= #{num} AND version = #{version},如果影响行数为 0,说明要么库存不足,要么商品被其他人并发修改过,此时直接抛异常回滚。这个方案在并发量不高的场景下完全够用,代码也比分布式锁简单得多。
订单状态我定义成整型常量:0 待支付、1 已支付待发货、2 已发货、3 已完成、4 已取消。这里要注意,订单金额、收货信息、商品明细都必须在t_order和t_order_item里存档,不要只在页面上展示。下单时用@Transactional(rollbackFor = Exception.class)把创建订单主表、插入明细、扣减库存、清空购物车放在同一个事务里,任何一个环节失败都会整体回滚,避免出现库存扣了但订单没生成,或者订单生成了但库存少了的诡异情况。
3. 后端核心模块实现
3.1 登录认证与权限控制
用户登录用的是 Session 方案:登录成功后把用户对象放到 Session 里,后续请求通过拦截器判断用户是否登录。虽然现在大家都在说 JWT,但在服务端渲染的 Thymeleaf 项目里,Session 反而是最简单可靠的方案,页面跳转时天然携带会话标识,不需要手动管理 Token 刷新。
注册登录有一个基本功必须做:密码不能明文存库。我用的 BCrypt 加密,Spring Security 里直接有BCryptPasswordEncoder,也可以单独引入spring-security-crypto依赖。BCrypt 每次生成的哈希串都带有随机盐,相同密码两次加密结果不同,能有效对抗彩虹表攻击。登录校验时用matches(rawPassword, encodedPassword)判断,后端接口里永远不返回密码字段。
拦截器这部分我贴一段核心代码,学过 SpringMVC 的都看得懂:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect("/login"); return false; } return true; } }注册拦截器时,要特别注意排除静态资源路径,否则 CSS、JS 会被拦截导致页面裸奔:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/cart/**", "/order/**", "/user/**") .excludePathPatterns("/login", "/register", "/product/**", "/category/**", "/css/**", "/js/**", "/images/**", "/uploads/**"); } }管理端接口除了判断登录,还要判断role字段是否为管理员。我在拦截器里直接对/admin/**路径做了角色校验,这样普通用户访问后台地址时会被强制跳转,从入口就把权限卡住。
3.2 商品图片上传:本地存储与 MinIO 两种做法
图片上传是商品管理中绕不开的功能。手工艺品对图片质量要求高,一张商品主图往往要好几百 KB,所以我先限制了上传大小,spring.servlet.multipart.max-file-size=10MB,然后按日期分目录存储,避免一个文件夹堆太多文件。
最简单的方式是存本地磁盘,然后映射成静态资源。我在application.yml里配置了一个上传路径:
file: upload-path: /data/craft/uploads/Controller 里接收MultipartFile后生成 UUID 文件名:
@PostMapping("/admin/product/upload") public ResultVO<String> upload(@RequestParam("file") MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = originalFilename != null ? originalFilename.substring(originalFilename.lastIndexOf(".")) : ".jpg"; String filename = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return ResultVO.success("/uploads/" + filename); }然后重写资源映射,把/uploads/**指向本地目录:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadPath); }这一步最容易踩坑的是路径末尾的斜杠:addResourceLocations的file:路径必须以/结尾,否则映射不生效,页面图片全部 404。如果你一开始把图片存到了另一个磁盘目录,后面改配置时不要忘了重启服务,静态资源映射是启动时加载的。
如果项目要部署到云服务器,而且图片量会涨到几个 GB,我更建议引入 MinIO 做对象存储。MinIO 是开源的,社区版免费,接口兼容 S3,SpringBoot 里用minio官方 SDK 对接非常方便。上传接口的逻辑基本相同,只是把file.transferTo(...)换成minioClient.putObject(...),然后把返回的对象 URL 存到数据库。用 MinIO 的好处是图片不占应用所在磁盘,备份和迁移也简单。从热词里也能看到不少人把 minio 集成到 springboot web 项目里,这正是这个阶段最实用的扩展方向。
3.3 购物车、下单与库存扣减的完整逻辑
购物车表设计成t_cart,一个用户对应多个商品,重复添加同一商品时做数量累加,不能傻乎乎地插新记录。添加购物车时先查这个用户是否已经把这个商品放进去了,如果有就UPDATE quantity = quantity + 1,没有才走INSERT。
结算下单的流程是这样的:用户勾选购物车中的若干商品,点击结算,后端接收一个商品 ID 和数量的集合,然后逐条校验商品状态、库存,计算总金额,再次进入事务创建订单。我在这一步碰到了最经典的一个问题:页面提交两次,订单创建了两条。原因是用户连续点击下单按钮,两个请求几乎同时到达,第一个请求还在事务里没提交,第二个请求又进来了。
解决方法有两种:一种是在前端给下单按钮加 loading 状态,点击后禁用;另一种是在后端生成一个唯一的下单令牌,页面加载时获取,提交时带上,后端判断该令牌是否已经被使用。更轻量的后端方案是在 Redis 里存一个防重 key,没有 Redis 的话也可以用数据库唯一索引兜底,比如订单号order_no做唯一约束,重复创建时数据库会直接报错,服务层捕获后告诉用户“正在处理中”。我实际验证过,两种方法配合使用才是比较稳的。
扣库存时用的乐观锁前面提过,这里再强调一下stock >= #{num}这个条件必须写在 SQL 里,而不是先查库存再在 Java 里判断,因为“先查再判断”中间存在时间差,两个人同时下单就可能超卖。
3.4 订单状态流转与定时任务
订单状态看起来只是几个数字,但流转逻辑要理清楚:用户下单后进入待支付,支付完成后进入待发货,管理员发货后变成待收货,用户确认收货后变成已完成。取消订单只能发生在待支付状态,如果管理员已经发货,用户不能再随便取消。
为了让待支付订单不长期占用库存,我写了一个定时任务,每五分钟扫描一次超过 30 分钟未支付的订单,把它们自动置为已取消,同时恢复对应商品的库存。主启动类上要加@EnableScheduling,然后:
@Component public class OrderCancelTask { @Scheduled(cron = "0 */5 * * * *") public void cancelExpiredOrders() { // 查询状态=0 且 create_time < now-30分钟 // 批量更新状态为4,并逐单恢复库存 } }这个定时任务看起来简单,实际有一个隐蔽的坑:恢复库存时不能直接stock + quantity,因为订单被取消时商品可能已经被管理员改过库存,甚至下架了。所以恢复库存也要先判断商品是否存在,存在才更新库存,不存在就只记录日志。日志要记得写上订单号和商品 ID,方便后续审计。
4. 前端渲染与交互细节
4.1 Thymeleaf 页面如何组织不混乱
用 Thymeleaf 做服务端渲染,页面组织一定要有层次感,否则几十个 HTML 文件堆在一起根本维护不了。我的做法是建一个templates/common目录放公共片段,比如header.html、footer.html,页面里用th:replace引入:
<header th:replace="~{common/header :: header}"></header>商品列表页、购物车页、订单页分别放独立目录,Controller 里直接 return 对应的模板名。公共片段里需要显示登录用户名,Thymeleaf 可以直接从 Session 里取:${session.loginUser.username}。这个细节很实用,不用每次在 Controller 里 ModelAndView 传一遍用户信息。
在写列表页时,我一开始用了原生分页,后来觉得太繁琐,干脆引入了 PageHelper 插件。PageHelper 的使用方式和 MyBatis 结合得非常自然,PageHelper.startPage(pageNum, pageSize)之后,紧接着执行的查询就会被自动拦截并生成分页 SQL。返回 Page 对象时还能拿到总记录数getTotal(),前端能据此生成分页按钮。要注意的是,PageHelper.startPage只对下一条查询生效,如果你在中间插了别的查询,分页就可能失效。
4.2 前后端联调最常见的四个问题
第一是日期格式化。如果直接SELECT *把LocalDateTime返回给 Thymeleaf,页面上显示的是很丑陋的2025-03-13T10:30:00字符串。我采用的方案是在实体类的时间字段上标注@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),同时前端模板里用#temporals.format(order.createTime, 'yyyy-MM-dd HH:mm:ss')做显示层的格式化,两边都不耽误。
第二是数量为空的处理。购物车商品数量如果直接放大文本框,用户清空输入再提交,后端会收到 null 或空字符串,导致数量变成 0 或直接报错。我在后端统一加了一个工具方法,空值一律按 1 处理,超过库存则按库存上限处理,再用@Validated做参数校验。
第三是文件上传的请求头。如果你在前端用了 AJAX 提交文件,记得让processData为 false,contentType为 false,否则文件流会被序列化成字符串,后端收到的MultipartFile就是空对象。用普通表单提交反而没这个问题,浏览器会自动生成 multipart 格式。
第四是静态资源版本缓存。浏览器会缓存 CSS 和 JS,改过文件后用户看到的还是旧的。我开发时把 Thymeleaf 的缓存关了:spring.thymeleaf.cache=false,生产环境再打开。前端资源路径后面加上版本参数style.css?v=20250313也是个稳妥办法,直接改版本号就能强制刷新。
5. 常见问题排查与实战技巧
5.1 版本连环坑:javax 与 jakarta
SpringBoot 3.x 之后,底层包名从javax换成了jakarta,这是很多旧项目升级时翻车的第一站。如果你用 SpringBoot 3.x,那么HttpServletRequest、ServletContext等导入的包都变成了jakarta.servlet.*。网上很多资料还是基于 SpringBoot 2.x 的,复制过来代码会报“找不到符号”之类的错误。最直接的排查方式是看启动日志里打印的 SpringBoot 版本,再根据版本决定 import 哪一套包。
我的项目最终用了 Java 17 + SpringBoot 2.7.x,因为手头一些依赖对 SpringBoot 3 的适配还不完善,比如老版本 PageHelper 的 starter 在 SpringBoot 3 下会出兼容问题。如果你跟着教程走,遇到版本冲突不要慌,一起把 parent 版本、依赖版本、JDK 版本都列出来对比,通常问题就出在这三者之间不匹配。
5.2 运行期问题速查表
我在这里整理了一份我实际踩过的问题速查表,日常排查基本够用:
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 启动时报数据源相关异常 | spring.datasource.url配置错误或驱动缺失 | 检查 yml 配置与 pom 依赖,确认 MySQL 驱动版本 |
| 页面访问 500,后台报模板找不到 | Thymeleaf 模板路径错误 | 检查 Controller 返回的模板名与 templates 目录结构 |
| 图片上传后预览 404 | 静态资源映射路径末尾少斜杠 | 确认addResourceLocations的file:路径以/结束 |
| 分页不生效,所有数据都返回 | PageHelper 方法被后续查询覆盖 | 确保 startPage 后紧跟目标 Mapper 查询 |
| 购物车重复添加,数量不累加 | 查询条件少用户 ID 或商品 ID | 检查 Mapper 的 where 条件是否完整 |
| 下单成功但库存没减 | 事务未提交或扣库存 SQL 条件不生效 | 在@Transactional方法内核对 SQL 影响行数 |
| 前端显示乱码 | 页面编码或数据库连接编码不一致 | 统一使用 UTF-8,连接串加characterEncoding=utf-8 |
| 管理端能访问用户端接口 | 拦截器只拦截了/admin/** | 按角色配置多套拦截路径 |
排查这类问题有个很通用的技巧:先看控制台完整堆栈,再对照配置文件和数据库数据。尤其遇到 500 错误,不要只看最后一行报错,要把异常链里的Caused by一层一层点开,真正的根源通常被包在里面。
5.3 安全性:防 SQL 注入与 XSS 的基本姿势
手工艺品销售系统虽然业务不复杂,但安全性不能省。我强烈建议所有 SQL 都用 MyBatis 的#{}参数占位符,绝对不要为了省事用${}拼接字符串。#{}会被预编译成?占位符,用户输入的恶意内容只会被当成参数值而不会改变 SQL 结构。比如用户搜索' or 1=1 --时,#{}会把它当普通字符串匹配,而${}则可能改变了查询条件。
页面展示用户输入内容时,比如收货地址、用户昵称,Thymeleaf 默认会对 HTML 转义,这是它比某些前端模板更安全的地方。如果你用了th:utext,一定要确认内容来源可信,不要直接渲染用户提交的原始内容。后端接口里再补一个统一的输入过滤:移除<script>标签、控制字段长度,多一层防护总没有坏处。
密码安全再次强调一次,注册时用 BCrypt 加密存储,登录时用加密比较,数据库管理员看到的也不能是明文。权限方面,拦截器做好路径级别的控制,管理端和用户端的接口要分开设计,不能一个/order/list既返回自己的订单又返回所有人的订单,很容易被别人越权访问到不是自己的数据。
6. 个人经验与扩展方向
6.1 开发中最值得沉淀的几点经验
如果只让我说一条体会,那就是“先把一个业务闭环走通,再去堆花活”。我在做这个系统时,最早一周优先完成了商品浏览、加入购物车、下单、订单管理这四个主链路,后端跑通后才开始做图片上传优化、定时取消订单、管理端图表这些外围功能。这样每一步都有可演示的成果,出了问题时定位范围也小。
另一个经验是要舍得在数据库设计上花时间。表结构一旦定下来,后面改字段的成本非常高,尤其是订单、库存这类核心数据。我的做法是先画一张简单的 ER 图,哪怕只是手写草稿,把表和表之间的关联关系列清楚,再动手建表。手工艺品的分类可能有多级,但一开始先做成单级分类,等后续确实需要多级了再做调整,不要让过度设计拖慢开发节奏。
6.2 如果想继续迭代,优先做这几件事
如果接下来要在这个系统基础上继续扩展,我建议优先做三件事。第一是把支付环节接到真实第三方支付沙箱,模拟支付和真实支付在参数签名上有不少差异,考虑接入支付功能的项目越早碰越好。第二是给商品模块加上搜索增强,比如基于名称和描述的全文检索,手工艺品的用户经常按材质、工艺去搜,传统LIKE查询在数据量大时性能会比较差。第三是把前端页面从服务端渲染升级为前后端分离,前提是团队已有明确需求,不要为了技术炫而换架构。
还有一个值得做的方向是引入消息队列处理订单超时,比如用 RabbitMQ 的延迟队列代替定时扫描,订单量起来之后,定时扫描的延迟和数据库压力会更明显。但在现阶段,定时任务完全够用,不必让系统复杂度提前升高。我在实际开发中经常提醒自己:凡是当前规模下用简单方案就能稳定解决的问题,就不要急着上重型组件,等业务量铺开再优化也不晚。
最后再分享一个小技巧:把系统里所有的金额、状态、配置项都用常量类收敛起来,不要散落在代码各处。比如订单状态常量、支付状态常量、默认分页大小、图片上传目录,统一放在一个Constants类里。这样后续改需求时只需要改动一个地方,全局引用的地方都会跟着生效,排查问题也会轻松很多。这类小习惯看起来不起眼,却是这个项目做完后让我觉得最有价值的沉淀。