SpringBoot手工艺品销售系统开发:从数据库设计到订单闭环实践

发布时间:2026/10/2 4:05:19
SpringBoot手工艺品销售系统开发:从数据库设计到订单闭环实践
做手工艺品销售系统这件事最初是因为长期接触这类小规模电商项目后发现很多人把注意力全放在“功能能不能跑”上却很少想清楚后端设计为什么要这样做。这次我用 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 JPASpringBoot 看到这些类存在就会自动帮你配置数据源如果你再引入 MyBatis 的 starter它又会自动创建 SqlSessionFactory。这套机制的好处是“约定大于配置”坏处是当你改动部分配置时必须知道它在哪个自动配置类里生效。我在实际项目里就遇到过spring.datasource.url没配对导致启动失败的情况其实只要确定存在 HikariCP 和 MySQL 驱动报错信息几乎直接指向数据源配置所以了解自动装配原理对排错非常有用。2. 数据库设计与关键模型2.1 六张核心表一次说清数据库表设计决定了后续所有业务逻辑怎么写。我这套系统一共用了六张核心表分别是用户表、分类表、商品表、购物车表、订单表、订单明细表另外加一张管理员操作日志表用来记录后台关键操作方便排查问题。表名关键字段说明t_userid, username, password, role, phone, avatar, created_atrole 区分普通用户与管理员t_categoryid, name, sort, status分类用于前台导航和后台管理t_productid, category_id, name, description, cover_image, price, stock, status, versionstatus 控制上架/下架version 做乐观锁t_cartid, user_id, product_id, quantity, checkedchecked 表示结算时是否选中t_orderid, order_no, user_id, total_amount, status, receiver_name, receiver_phone, receiver_address, create_time, pay_time, delivery_time主订单记录收货信息快照t_order_itemid, 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-size10MB然后按日期分目录存储避免一个文件夹堆太多文件。最简单的方式是存本地磁盘然后映射成静态资源。我在application.yml里配置了一个上传路径file: upload-path: /data/craft/uploads/Controller 里接收MultipartFile后生成 UUID 文件名PostMapping(/admin/product/upload) public ResultVOString 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 是开源的社区版免费接口兼容 S3SpringBoot 里用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为 falsecontentType为 false否则文件流会被序列化成字符串后端收到的MultipartFile就是空对象。用普通表单提交反而没这个问题浏览器会自动生成 multipart 格式。第四是静态资源版本缓存。浏览器会缓存 CSS 和 JS改过文件后用户看到的还是旧的。我开发时把 Thymeleaf 的缓存关了spring.thymeleaf.cachefalse生产环境再打开。前端资源路径后面加上版本参数style.css?v20250313也是个稳妥办法直接改版本号就能强制刷新。5. 常见问题排查与实战技巧5.1 版本连环坑javax 与 jakartaSpringBoot 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连接串加characterEncodingutf-8管理端能访问用户端接口拦截器只拦截了/admin/**按角色配置多套拦截路径排查这类问题有个很通用的技巧先看控制台完整堆栈再对照配置文件和数据库数据。尤其遇到 500 错误不要只看最后一行报错要把异常链里的Caused by一层一层点开真正的根源通常被包在里面。5.3 安全性防 SQL 注入与 XSS 的基本姿势手工艺品销售系统虽然业务不复杂但安全性不能省。我强烈建议所有 SQL 都用 MyBatis 的#{}参数占位符绝对不要为了省事用${}拼接字符串。#{}会被预编译成?占位符用户输入的恶意内容只会被当成参数值而不会改变 SQL 结构。比如用户搜索 or 11 --时#{}会把它当普通字符串匹配而${}则可能改变了查询条件。页面展示用户输入内容时比如收货地址、用户昵称Thymeleaf 默认会对 HTML 转义这是它比某些前端模板更安全的地方。如果你用了th:utext一定要确认内容来源可信不要直接渲染用户提交的原始内容。后端接口里再补一个统一的输入过滤移除script标签、控制字段长度多一层防护总没有坏处。密码安全再次强调一次注册时用 BCrypt 加密存储登录时用加密比较数据库管理员看到的也不能是明文。权限方面拦截器做好路径级别的控制管理端和用户端的接口要分开设计不能一个/order/list既返回自己的订单又返回所有人的订单很容易被别人越权访问到不是自己的数据。6. 个人经验与扩展方向6.1 开发中最值得沉淀的几点经验如果只让我说一条体会那就是“先把一个业务闭环走通再去堆花活”。我在做这个系统时最早一周优先完成了商品浏览、加入购物车、下单、订单管理这四个主链路后端跑通后才开始做图片上传优化、定时取消订单、管理端图表这些外围功能。这样每一步都有可演示的成果出了问题时定位范围也小。另一个经验是要舍得在数据库设计上花时间。表结构一旦定下来后面改字段的成本非常高尤其是订单、库存这类核心数据。我的做法是先画一张简单的 ER 图哪怕只是手写草稿把表和表之间的关联关系列清楚再动手建表。手工艺品的分类可能有多级但一开始先做成单级分类等后续确实需要多级了再做调整不要让过度设计拖慢开发节奏。6.2 如果想继续迭代优先做这几件事如果接下来要在这个系统基础上继续扩展我建议优先做三件事。第一是把支付环节接到真实第三方支付沙箱模拟支付和真实支付在参数签名上有不少差异考虑接入支付功能的项目越早碰越好。第二是给商品模块加上搜索增强比如基于名称和描述的全文检索手工艺品的用户经常按材质、工艺去搜传统LIKE查询在数据量大时性能会比较差。第三是把前端页面从服务端渲染升级为前后端分离前提是团队已有明确需求不要为了技术炫而换架构。还有一个值得做的方向是引入消息队列处理订单超时比如用 RabbitMQ 的延迟队列代替定时扫描订单量起来之后定时扫描的延迟和数据库压力会更明显。但在现阶段定时任务完全够用不必让系统复杂度提前升高。我在实际开发中经常提醒自己凡是当前规模下用简单方案就能稳定解决的问题就不要急着上重型组件等业务量铺开再优化也不晚。最后再分享一个小技巧把系统里所有的金额、状态、配置项都用常量类收敛起来不要散落在代码各处。比如订单状态常量、支付状态常量、默认分页大小、图片上传目录统一放在一个Constants类里。这样后续改需求时只需要改动一个地方全局引用的地方都会跟着生效排查问题也会轻松很多。这类小习惯看起来不起眼却是这个项目做完后让我觉得最有价值的沉淀。