ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue实战:华强北二手手机商城管理系统设计与实现

SpringBoot+Vue实战:华强北二手手机商城管理系统设计与实现 华强北一个很多人听到就觉得水很深的地方也是全国电子元器件和手机流通最密集的市场。二手手机又是这里面信息最不透明、交易链条最长的品类。我做的这个基于SpringBootVue的华强北二手手机商城管理系统目标很朴素让一部手机从进货、质检、定价、上架到被用户浏览下单、完成交易整条链路都有清晰的数据记录商家后台管得住用户前台看得到、买得安心。如果你是正在做Java全栈项目、或者想搞清楚SpringBootMyBatisVue这套组合怎么承载一个真实业务场景这篇项目复盘应该能给你一些实在的参考。这套系统技术选型并不花哨后端Java SpringBoot持久层MyBatis数据库MySQL前端Vue。但正是这种朴素的组合把业务逻辑暴露得很彻底。下面我从需求拆解、数据库设计、后端接口实现、前端交互、踩坑过程这几个维度把这个项目完整讲一遍。1. 二手手机交易场景里做管理系统最难的从来不是CRUD1.1 为什么普通电商那套模板直接套到二手手机上会翻车很多人一听到商城管理系统第一反应就是照搬普通电商的商品表、订单表、库存表。但二手手机根本不是标准商品。同一批进货的iPhone成色能差出好几个等级电池健康度从85到100都有可能有没有原装配件、是否在保、有没有换过屏幕都会直接影响定价。普通电商一个SKU对应一个固定产品字段稳定二手手机一个商品背后是一堆动态属性。如果不做这些扩展字段那你在后台就只能写一部9成新iPhone 13256G蓝色价格4500至于电池健康度、维修记录这些关键信息要么塞进备注要么干脆丢掉。所以这个项目里商品表没有做成简单的分类型号价格库存而是走了商品主表扩展属性的路线。主表存手机的分类、品牌、型号、存储、颜色、价格、库存等稳定字段扩展属性单独记录成色等级、电池健康度、维修历史、配件情况。这样前端筛选可以按这些属性直接过滤后台展示也能一句话说清一部手机的真实状态。1.2 用户端和后台端功能边界必须先划清楚项目需求拆下来很多时候混乱是因为没分清楚给谁用。这个系统实际上有两个使用方用户端要的是逛和买。注册登录之后浏览商品列表按品牌、成色、价格区间筛选搜索关键词找到目标机型点进详情看成色描述和图片加购物车下单查看订单状态。这个链路本身不复杂但每一步都要给用户足够的判断依据。比如详情页里电池健康度、外观成色等级这种信息必须放在显眼位置因为二手手机买卖双方最大的矛盾就是信息不对称。后台端要的是管。商家上架新到的手机填基础信息和质检结果传多张实拍图设置价格和库存定期调整价格上下架商品处理订单把待发货改成已发货最终完成交易。后台的表格要支持搜索和分页操作要顺手不然几十上百部手机在库里根本没有办法管理。功能清单整理出来大概是下面这样模块用户端后台端账号注册、登录、个人信息管理员登录、用户列表商品浏览、搜索、筛选、详情上架、编辑、下架、调价购物车加购、改数量、删除无订单下单、查看状态订单列表、发货、取消数据统计无商品数量、订单总数这个划分在开发时非常关键。前端路由按这个边界拆后端接口按这个边界设计不会出现用户端突然冒出管理接口的情况。1.3 项目的实际业务价值这套系统做完最直接的价值是让二手手机的非标信息变得可查询、可筛选、可追溯。买家看到的成色描述、电池信息、是否换过配件都是由商家在后台上架时按照固定字段填写的而不是一句话带过。这相当于把华强北档口里的口说无凭变成了系统里的结构化数据。对于做毕设或者学习用途的同学来说这个项目还有一个额外的好处它覆盖了完整的业务闭环不是那种只有一个CRUD列表的玩具项目。从前台商城到后台管理、从购物车到下单扣库存每一步都涉及真实业务逻辑拿来练手或者二次开发都合适。2. 技术选型与项目整体分层设计为什么是SpringBootVue2.1 SpringBootMyBatis的组合图的是SQL可控后端选SpringBoot没什么悬念社区生态大、配置省心、内嵌Tomcat打一个jar就能跑。真正有讲究的是持久层为什么不用Spring Data JPA而选MyBatis。JPA在简单CRUD上确实省代码但二手手机商品筛选这个场景查询条件多变品牌要匹配、成色要过滤、价格要区间、分类要嵌套还可能同时按库存排序、按上架时间排序。这种组合SQL用JPA写会出现大量Specification或JPQL拼接阅读起来很痛苦。MyBatis的XML里写动态SQLwhere、if一组合多条件筛选一目了然后期要加一个筛选维度改起来也直接。另外MyBatis对已有SQL优化更友好。比如订单查询要关联商品、用户一个相对复杂的联表分页SQL可以直接在XML里调优而不是靠框架生成后再去看它到底执行了什么。2.2 Vue做前端前后端分离让协作更舒服前端用Vue主要原因是组件化适合商城这类界面重复度高的项目。商品卡片、搜索条件栏、表格操作列这些在页面里反复出现。Vue的组件机制可以把它们抽出来一处封装多处复用。配合生态里的Element UI后台管理界面基本不用自己造轮子。表格、弹窗、表单校验、分页组件开箱即用。用户端页面想要更灵活的话Vue的插槽和自定义样式也能满足。整个项目我用Vue CLI初始化路由用Vue Router状态管理用Vuex。虽然在当前规模下Vuex不是必须的但购物车和用户登录状态放进去模块逻辑会清晰很多。这套技术栈最大的好处是前后端可以完全并行开发。我先定好接口返回格式后端负责实现前端用Mock数据调页面。等后端跑起来了切换axios的baseURL就能联调效率比传统模板渲染高不少。2.3 项目目录结构一开始就分层后面少流泪后端我按经典三层结构拆包com.example.phone ├── controller // 控制层接收前端请求 ├── service // 业务层处理核心逻辑 ├── mapper // MyBatis持久层接口 ├── entity // 数据库对应实体类 ├── dto // 接口前后端交互参数对象 ├── config // 配置类跨域、静态资源等 ├── common // 统一返回结果、异常处理 └── utils // 工具类前端目录src ├── api // 每个模块的axios接口 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // 状态管理 ├── views // 页面级组件 │ ├── home // 用户端页面 │ ├── admin // 后台管理页面 │ └── common // 登录注册等 └── utils // 请求封装、工具这样说起来好像没什么大不了但当你项目写到一半突然要加一个后台商品批量上下架功能时分层清晰的后端只需在Controller加一个接口、Service加一个方法、Mapper加一条SQL而不会顺着代码找半天这逻辑到底写在哪了。3. 数据库设计怎么把一部手机的前世今生存清楚3.1 二手手机的扩展属性怎么建模数据库设计是这个项目里最不能省的环节。二手手机需要记录的信息很多但大部分不需要拆成一张庞大的表。我采用两张核心表的思路phone表商品主表存稳定属性比如品牌brand、型号model、存储storage、颜色color、价格price、库存stock、封面图image等。phone_detail表扩展属性表一对一关联主表存成色等级grade、电池健康度battery、有无维修记录repair_history、配件说明accessory、质检备注remark等。为什么要拆拆开的好处是主表不要太大列表查询的时候不需要频繁读大字段。而且成色、电池这些属性在二手交易里是动态的后续如果增加屏幕是否原装边框磨损程度这样的新属性只需要在扩展表加字段完全不影响已有业务。另外还加了一个category分类表把品牌和机型层级管理起来。比如苹果下挂iPhone 13iPhone 14查询的时候按分类ID关联比直接在商品表硬编码品牌名称要规范得多。3.2 核心表结构实例用户表CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, phone varchar(20) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;手机商品表CREATE TABLE phone ( id int NOT NULL AUTO_INCREMENT, category_id int NOT NULL, brand varchar(50) NOT NULL, model varchar(50) NOT NULL, storage varchar(20) DEFAULT NULL, color varchar(20) DEFAULT NULL, price decimal(10,2) NOT NULL, stock int NOT NULL DEFAULT 0, image varchar(255) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 0下架 1上架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;扩展属性表CREATE TABLE phone_detail ( id int NOT NULL AUTO_INCREMENT, phone_id int NOT NULL, grade varchar(20) DEFAULT NULL COMMENT 99新/95新/9成新/8成新, battery int DEFAULT NULL COMMENT 电池健康度100表示100%, repair_history varchar(255) DEFAULT NULL, accessory varchar(255) DEFAULT NULL, remark text, PRIMARY KEY (id), KEY idx_phone_id (phone_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单表CREATE TABLE orders ( id int NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL, user_id int NOT NULL, total_price decimal(10,2) NOT NULL, status tinyint NOT NULL COMMENT 0待付款 1待发货 2已发货 3已完成 4已取消, address varchar(255) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单明细表order_item就不完整贴了结构上就是记录订单ID、商品ID、购买数量、成交单价。为什么需要明细表因为一个订单可能买多部手机每部在购买那一刻的价格和成色都要快照保存后续哪怕商家改价或者下架订单里仍然能看到当时的交易信息。3.3 订单状态和库存扣减的联动这个项目的订单状态流转是待付款 → 待发货 → 已发货 → 已完成另外有已取消状态兜底。库存扣减有一个值得注意的细节应该在下单时就扣减还是支付成功后再扣我为了避免用户下单了但一直不付款导致库存被占死选择了下单扣库存、超时自动释放的方式。但更简单的做法同时也是很多教学项目里的习惯是支付或提交订单时直接扣库存。我在代码实现里用了下单时先检查库存如果充足就扣减然后创建订单如果后续取消订单再把库存加回去。这样做需要事务保证。创建订单、扣库存、生成订单明细必须在一个事务里完成不然订单建了库存没扣或者库存扣了订单没创建都会出事。4. 后端开发手记把SpringBootMyBatis的核心接口串起来4.1 统一的返回结果和全局异常处理项目前后端分离之后接口返回格式必须统一不然前端每一个请求都要自己处理不同的数据结构。我定义了一个泛型返回类ResultTpublic class ResultT { private Integer code; // 200成功 private String msg; // 提示信息 private T data; // 数据 public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMsg(操作成功); r.setData(data); return r; } public static T ResultT error(String msg) { ResultT r new Result(); r.setCode(500); r.setMsg(msg); return r; } }配合RestControllerAdvice做全局异常捕获。业务异常统一抛自定义的BusinessException参数校验异常、数据库异常统一拦截保证前端拿到的永远是一个结构完整的JSON。这一步前期不做好联调的时候会发现这个接口返回的是{code:200}那个接口报错时返回的是{status:500, error:...}前端接口封装根本没法写。4.2 MyBatis动态SQL实现多条件商品筛选商品列表页前端的搜索条件很多品牌、分类、成色、价格区间、存储大小、排序方式。如果每个条件都写一个Mapper方法那排列组合根本写不完。我用Map传递查询参数在XML里写动态SQLselect idselectPhoneList resultTypecom.example.phone.entity.PhoneVO SELECT p.*, pd.grade, pd.battery FROM phone p LEFT JOIN phone_detail pd ON p.id pd.phone_id where p.status 1 if testbrand ! null and brand ! AND p.brand #{brand} /if if testcategoryId ! null AND p.category_id #{categoryId} /if if testminPrice ! null AND p.price gt; #{minPrice} /if if testmaxPrice ! null AND p.price lt; #{maxPrice} /if if testkeyword ! null and keyword ! AND (p.model LIKE CONCAT(%, #{keyword}, %) OR p.brand LIKE CONCAT(%, #{keyword}, %)) /if /where if testorderBy ! null and orderBy ! ORDER BY ${orderBy} /if /select这里有一个新手很容易踩的坑${orderBy}和#{}的区别。排序字段因为不能做预编译占位只能用${}拼接但这样有SQL注入风险。我的处理是把前端传过来的排序字段做成白名单映射比如传price_asc后端映射成price ASC绝不直接拼接前端传来的字符串。LEFT JOIN phone_detail这种方式在数据量不大的情况下没问题。如果数据量很大可以把成色、电池这些筛选字段冗余到主表空间换性能。4.3 分页查询用PageHelper但用法有讲究分页插件我选了PageHelper。用法很偷懒在查询前调用一行PageHelper.startPage(pageNum, pageSize)之后的第一个查询就会自动拼上LIMIT。但它有几个坑很关键第一startPage后面必须跟着你想分页的那条查询中间不能有任何其他查询语句。第二写SQL时要保证有ORDER BY否则不同页的数据顺序不稳定用户翻页可能会看到重复数据。第三查询列表和查询总条数不能拆成两个SQL手动执行PageHelper会在分页时自动执行count查询你只要把返回结果用PageInfo包装就行PageHelper.startPage(pageNum, pageSize); ListPhoneVO list phoneMapper.selectPhoneList(query); PageInfoPhoneVO pageInfo new PageInfo(list);这样返回给前端的对象里包含total、pageNum、pageSize、list等字段前端分页组件直接用。4.4 下单事务与并发扣库存下单接口是这个系统里业务逻辑最重的地方伪代码大概是Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, ListCartItem items, String address) { BigDecimal totalPrice BigDecimal.ZERO; ListOrderItem orderItems new ArrayList(); for (CartItem item : items) { Phone phone phoneMapper.selectByIdForUpdate(item.getPhoneId()); if (phone null || phone.getStatus() ! 1) { throw new BusinessException(商品已下架); } if (phone.getStock() item.getQuantity()) { throw new BusinessException(库存不足); } phone.setStock(phone.getStock() - item.getQuantity()); phoneMapper.updateStock(phone.getId(), phone.getStock()); totalPrice totalPrice.add(phone.getPrice().multiply(new BigDecimal(item.getQuantity()))); // 组装订单明细 } // 生成订单号保存订单和明细清空购物车 return order; }注意selectByIdForUpdate这里用了悲观锁。为什么不查出来再更新因为多用户同时下单时两个请求都查出库存是1然后都去扣减最终库存可能变成-1。加FOR UPDATE把这一行锁住第二个请求必须等第一个事务提交后才能读取从而避免超卖。这个方案在商品量不大、并发量不高的场景下完全够用。如果要提升并发能力可以改成乐观锁更新时带上库存版本号影响行数为0则重试。但那个逻辑比悲观锁复杂而且在这个项目里确实用不上。订单号生成我也花了点心思。没有用数据库自增ID当订单号而是用时间戳用户ID随机数拼接目的就是让订单号看起来更正式也避免订单号被猜到导致隐私问题。4.5 跨域和静态资源配置前后端分离开发跨域是绕不开的。我在WebMvcConfig里加了一个CorsRegistry允许本地前端的地址跨域访问。注意跨域配置不要allowedOrigins(*)和allowCredentials(true)同时用浏览器会直接拒绝这个是SpringBoot的硬限制。图片上传和静态资源访问也在这个类里处理registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /);上传的图片不能存在前端项目里否则下次重新build就没了。我把它放到了后端的可写目录再用/upload/**映射出来。这样后台传图片、前台显示图片都走同一个后端服务。5. 前端Vue实战让商城真正看起来能卖货5.1 Vue项目初始化和页面路由前端我用Vue CLI创建安装依赖之后先把Element UI和axios装上。这一步看起来基础实际上很多人忽略了一个问题Vue 2和Vue 3的Element UI版本完全不一样。我项目用的是Vue 2所以对应安装element-ui如果是Vue 3就要装element-plus。这个搞错的话代码还没写报错先糊一脸。路由划分直接按照用户端和后台端const routes [ { path: /, component: Home, children: [ { path: , component: PhoneList }, { path: detail/:id, component: PhoneDetail }, { path: cart, component: Cart, meta: { auth: true } }, { path: orders, component: OrderList, meta: { auth: true } } ]}, { path: /login, component: Login }, { path: /admin, component: AdminLayout, meta: { auth: true, admin: true }, children: [ { path: phones, component: AdminPhoneList }, { path: orders, component: AdminOrderList } ] } ];路由守卫里做了两件事判断是否登录判断是否是管理员。未登录访问购物车、订单页时直接跳转登录页非管理员访问/admin时拦截并提示无权限。5.2 axios封装统一接口入口每写一个页面就axios.get一把的做法维护起来很痛苦。我封装了一个request工具统一设置baseURL、请求超时时间、请求头token以及响应拦截器service.interceptors.response.use( response { const res response.data; if (res.code ! 200) { Message.error(res.msg); return Promise.reject(new Error(res.msg)); } return res.data; }, error { Message.error(网络错误); return Promise.reject(error); } );在请求拦截器里把登录后存在localStorage里的token加到请求头。后端再写一个简单的拦截器对非登录接口校验token这就构成了完整的登录态闭环。实际上出于安全考虑前端存token用的是localStorage还是sessionStorage各有取舍我用的是localStorage好处是刷新不丢登录态。5.3 商品列表页和筛选组件是如何配合的商品列表页是用户最常看到的页面布局大概是左侧筛选区、右侧商品网格。筛选条件我做成独立组件组件里维护品牌、成色、价格区间的表单数据点击搜索后把条件通过$emit抛给父页面。父页面拿到条件后更新查询参数并调用接口。这样筛选组件可以复用后续要做手机对比、做特定规格筛选都更方便。商品卡片组件接收一个phone对象展示封面图、品牌型号、关键参数和价格。卡片底部是查看详情和加入购物车两个按钮。用户端页面不需要花哨但信息密度要够二手手机买家最想看到的是成色描述和电池信息所以我在卡片上额外显示了成色等级标签比如95新电池89%。商品详情页除了展示图片和基础参数还把phone_detail表里的维修记录、配件信息、质检备注放了进来。这些信息是二手手机交易体验的核心。哪怕页面上简单排个版用户都会觉得这个平台比那些只写成色良好的渠道专业。5.4 后台管理表格操作与状态联动后台管理页面用Element UI的el-table就能撑起大部分功能。商品管理表格的列包括图片、品牌型号、价格、库存、成色、上下架状态、操作按钮。操作列里有编辑“上架/下架”“查看详情”。这里有一个交互细节上下架操作我不用弹窗直接在操作列放一个带确认的按钮。点一下调接口成功后刷新表格。为什么这样设计因为商家在批量处理商品时弹窗会打断节奏操作越少越好。表单页面则是新增和编辑共用的一个组件。商品名、价格、库存这些基础字段就不说了二手手机特有的成色等级、电池健康度、维修记录、图片上传都集中在表单下半部分。图片上传我用Element UI的上传组件提前把图片传到后端拿到URL后存到表单提交。这里不建议把图片转base64直接存数据库数据库会很快膨胀而且查询性能变差。订单管理页就是表格加状态操作。待发货订单可以点发货已发货订单可以点完成。每笔订单关联的用户信息、商品信息、金额都要展示清楚。6. 踩坑复盘这个项目让我记住的几个关键时刻6.1 MyBatis分页插件失效问题出在SQL拼接第一次用PageHelper的时候我遇到过明明调了startPage但SQL没有分页的情况。排查到最后发现是Mapper查询前多执行了一个别的查询语句。PageHelper的原理是通过拦截器在当前线程上下文里保存分页参数然后拦截最近的一条需分页的SQL如果之前不小心执行了其他查询分页参数就被无效消耗了。还有一种情况是子查询导致count语句报错。我列表SQL里有多个LEFT JOIN和WHERE条件PageHelper自动生成的count SQL如果识别不了复杂语句就要手动写一个countSuffix或者提供单独的count查询。这也是为什么我一直提醒自己分页查询SQL要尽量保持结构简单复杂业务拆成临时表或VO查询不要指望框架无脑兜底。6.2 图片上传成功但访问404静态资源映射的坑图片上传功能刚写完时文件明明落到了磁盘但前端访问http://localhost:8080/upload/xxx.jpg总是404。这个问题的根源是SpringBoot默认只把classpath:/static/下的内容当静态资源磁盘路径不在它的管辖范围。解决方式就是上面提到的实现WebMvcConfigurer里的addResourceHandlers把/upload/**映射到本地上传目录。另一个小坑是Windows和Linux路径分隔符不一样直接用File.separator拼接更稳妥。6.3 事务没生效因为方法内部自己调自己订单服务里我最初把创建订单扣库存写在同一个方法并加了Transactional但创建订单时有一段逻辑被抽取到了另一个方法里并且在同类中直接调用导致目标方法没有走Spring代理事务完全没生效。测试时故意抛异常数据库里订单还在库存却扣了数据直接错乱。排查方法很简单日志里看到调用链不对检查事务代理。解决办法是把事务放在外部入口方法上或者把被调方法放到另一个Bean里。这个点几乎是Spring事务入门必踩的经典坑项目里遇到数据不一致第一时间就要怀疑它。6.4 并发下单的库存超卖超卖问题其实在普通商城项目里很经典但在二手手机这种库存量本身很小的场景里更要小心。一部手机可能只有一两台库存一旦同时有两个人下单很容易出现两个人都下单成功但库存只够卖一台的情况。我先用SELECT ... FOR UPDATE锁住商品行再检查库存、扣库存实测下来没有出现负数库存。代价是会阻塞其他事务但在这个项目的访问量下完全可接受。如果后续要提升性能可以把行锁改成版本号乐观锁或者用Redis做库存预扣。但那就是另一个量级的问题了。6.5 一些可以继续扩展的方向这个系统做完如果想继续深入有几个方向可以走。商品维度可以增加验机报告模块上传检测报告图片甚至细化到屏幕、摄像头、主板检测结果。交易维度可以增加买家评价让成色描述受到市场监督。后台可以增加简单的销售统计图按月或者按品牌统计销量和销售额这样商家能知道什么机型走量快。支付接口也可以接入真实支付渠道但作为学习项目模拟支付也算够用。我做完这个项目的最大体会是技术本身不复杂复杂的是把业务逻辑理清楚并且让代码结构配得上这些逻辑。SpringBoot、Vue、MyBatis、MySQL这套组合看起来很常见但正是这种常见组合才能让你把精力放在真正值得思考的地方。
返回列表