首页 / 资讯中心 / 文章详情

Spring Boot + Vue构建医疗用品商城:全栈开发实战指南

Spring Boot + Vue构建医疗用品商城:全栈开发实战指南 ★ FEATURED ARTICLE
做医疗用品销售商城这个项目最初并不是因为有多宏大的规划纯粹是朋友那边需要一个能跑起来的电商系统要求不复杂能上架商品、用户能注册登录、能加购物车、能下单后台能管订单和库存。技术选型他们没硬性规定只说别整太冷门的框架方便后面找人维护。我最后定了 Spring Boot Vue 这套组合一是 Java 生态够稳二是 Vue 上手快、前后端分离的结构也清晰。这个项目做完之后我觉得这类业务系统其实很适合作为全栈开发的练手项目踩的坑不少但收获也实在下面把我的完整思路和实现过程整理出来给同样想做一个商城类系统的朋友做个参考。1. 项目定位与整体需求拆解1.1 医疗用品商城到底要做哪些事医疗用品销售和普通商品销售有一些本质差异直接影响系统设计。它不完全等同于卖衣服、卖数码产品商品往往带有效期、批号、医疗器械备案编号这类属性下单流程对库存准确度要求更高部分商品还有购买数量限制。所以做需求拆解时我把核心模块分成了四块商品管理、用户端购物流程、订单处理、后台管理。商品管理需要支持分类、品牌、规格比如口罩分不同层数、不同包装规格、图片、价格、库存、上下架状态还要能记录有效期和批号。用户端购物流程要覆盖注册、登录、浏览、搜索、购物车、下单、支付这里一般对接模拟支付或第三方支付沙箱、订单查询。订单处理是核心涉及订单状态流转、库存扣减、订单取消、物流信息。后台管理则包含商品维护、分类维护、订单审核、发货、用户管理、数据统计。1.2 为什么说这是一个典型的全栈业务系统技术角度看这类系统非常适合用来理解前后端分离开发的全过程。后端 Spring Boot 负责提供 RESTful API前端 Vue 负责页面渲染和交互两者通过 JSON 数据交互职责边界很清晰。对开发者来说可以从这个项目里同时练到接口设计、数据库建模、状态管理、权限控制等技能不一定非要追求高并发那些复杂的架构先把业务逻辑跑通、把代码结构理清价值就已经很大了。1.3 目标场景与适用人群这个项目比较适合三类人第一类是正在学 Spring Boot 和 Vue 的开发者可以用它作为综合练习项目把零散知识点串起来第二类是需要给某医疗机构、药店或者社区健康服务点做内部销售系统的小团队只需要简单改改就能上线第三类是刚转行做全栈开发的朋友想从头到尾独立完成一个完整系统这个体量不大不小正好能把每个环节都过一遍。2. 技术选型思路为什么是 Spring Boot Vue2.1 后端框架对比与选择逻辑选 Spring Boot首要原因是它把 Spring 体系里繁琐的配置大量自动化了。传统 Spring MVC 项目要写一堆 XML 配置Spring Boot 通过自动配置和 starter 机制大幅简化。比如要操作数据库引入 spring-boot-starter-data-jpa 或 mybatis-spring-boot-starter配置好数据源即可。你要做的就是把精力集中在业务逻辑上而不是和配置文件较劲。备选方案里还有 Node.js 的 Express、Python 的 Django 或 Flask。Express 开发轻巧但如果团队更熟悉 Java或者项目需要和已有的 Java 技术栈做集成Spring Boot 的稳定性、社区资料完善程度都更有优势。Django 自带后台管理做商城开发效率很高但国内不少公司后端主力语言还是 Java考虑到后续维护和招聘成本Spring Boot 是更稳妥的选择。2.2 前端框架对比与选择逻辑Vue 的核心优点在于渐进式和易上手。它的模板语法直观响应式数据绑定让 DOM 操作不再那么繁琐。React 同样优秀但 JSX 和组件生态对初学者来说有一定学习曲线。Vue 的中文文档和质量不错的教程资源很多遇到问题容易搜到答案。这个项目用到 Vue 全家桶中的 Vue Router前端路由和 Pinia 或 Vuex状态管理再配合 Element Plus 组件库后台管理界面基本上可以做到拿来即用。2.3 前后端分离架构带来的实际收益前后端分离后后端人员只需关注 API 的稳定性和安全性前端人员专注页面效果和交互体验。开发时可以用 Vite 或 Webpack 的代理功能做跨域调试生产环境通过 Nginx 统一转发请求。这样做还有个好处以后如果要出小程序端或移动端后端 API 可以复用不需要重复开发。3. 数据库设计与建模过程3.1 核心表结构与关系设计商城系统最少需要这些表用户表、商品表、商品分类表、购物车表、订单表、订单明细表。如果需要考虑支付和物流再增加支付记录表、物流信息表。下面是我在本项目里的核心表设计表名主要字段作用说明userid, username, password, phone, role, create_time用户与管理员登录categoryid, name, parent_id, sort商品分类支持两级分类productid, category_id, name, description, image, price, stock, status, batch_no, expire_date商品基础信息和医疗属性cartid, user_id, product_id, quantity, checked用户购物车ordersid, order_no, user_id, total_amount, status, address, receiver, phone, pay_time订单主表order_itemid, order_id, product_id, product_name, price, quantity订单商品快照3.2 为什么订单明细要冗余商品信息这是个容易忽略但很关键的细节。订单明细里我保存了 product_name 和 price 的快照而不只是 product_id。原因是商品可能改名、改价甚至被删除但订单历史必须保持下单时的真实信息。如果通过关联表实时查询一旦商品被删用户查看历史订单就查不到记录了。这个设计逻辑在做电商系统时几乎是必须的。3.3 逻辑外键还是物理外键我在早期版本用过物理外键数据库层面有 REFERENCES 约束后来改成逻辑外键了。物理外键能保证数据完整性但在删除商品或用户时容易产生约束冲突而且高并发场景下对外键的检查会影响性能。改用逻辑外键后关联关系由应用层来维护代码里做好级联处理比如删商品时同步删掉购物车里对应的商品数据灵活度会高很多。3.4 索引设计与查询优化订单表要按用户查询所以 user_id 建立索引商品表按分类筛选category_id 建索引搜索场景如果直接 LIKE %关键词%数据量大时会有性能问题。项目初期数据量不大LIKE 查询可以接受但我在设计时还是预留了优化空间如果后续买家人数多了可以引入 Elasticsearch 或者使用 MySQL 的全文本索引。商品列表页的分页查询要避免 count 全表扫描固定 limit 条件下尽量走索引。4. 后端核心模块实现4.1 项目目录结构与分层设计我采用的是经典分层结构controller、service、mapper或者 repository、entity、dto、vo。Controller 层只做参数接收和响应封装Service 层写业务逻辑Mapper 层操作数据库。为了避免 entity 直接暴露给前端我增加了 VO 类只返回需要的字段。这样做的价值在于接口不会因为数据库字段调整而频繁变动前端也不用跟着改。com.example.medical ├── controller │ ├── AuthController.java │ ├── ProductController.java │ ├── CartController.java │ └── OrderController.java ├── service │ ├── impl │ ├── UserService.java │ ├── ProductService.java │ └── OrderService.java ├── mapper │ ├── UserMapper.java │ └── ProductMapper.java ├── entity │ └── User.java ├── dto │ └── LoginDTO.java └── vo ├── UserVO.java └── OrderVO.java4.2 用户认证与 JWT 实现细节登录这块我用的 JWTJSON Web Token而不是传统的 Session。原因是前后端分离后客户端和服务端不在同一个域名下Session 管理和跨域处理都比较麻烦JWT 无状态、不需要服务端存储扩展性更好。Spring Boot 里集成 JWT 需要两个关键依赖jjwt-api 和 jjwt-impl。生成 Token 的过程如下用户登录成功后从数据库查出用户信息把 userId 和 role 放进 claim 里加上过期时间我设的是 24 小时然后用密钥签名。前端 axios 封装里在请求拦截器中把 Token 放到 Authorization 请求头里。后端在拦截器中解析请求头验证 Token 有效后再放行接口。Component public class JwtUtil { private String secret your-secret-key-at-least-256-bits; private long expire 86400000L; public String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(userId.toString()) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }注意密钥长度HS256 算法要求密钥至少 256 位太短会在运行时抛出弱密钥异常。这个坑我踩过第一次随便写了个短字符串运行直接报错。生产环境更稳妥的做法是把密钥放到配置中心或环境变量不写在代码里。4.3 商品接口与图片上传处理商品接口相对常规列表接口支持分类筛选、价格排序、分页。图片上传我用的本地存储方式配置一个文件上传目录Spring Boot 接收 MultipartFile 后保存到磁盘然后把文件访问路径返回给前端。开发调试方便但部署到服务器后要注意路径问题最好把文件存储路径改成配置项。另外要限制上传文件大小否则用户传一个大视频文件会把接口拖垮。我做了限制图片最大 5MB格式只允许 jpg、png、webp。PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) throws IOException { String originalName file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalName); if (!Set.of(jpg, png, webp).contains(ext)) { return Result.error(不支持的图片格式); } String fileName UUID.randomUUID() . ext; Path targetPath Paths.get(uploadDir).resolve(fileName); file.transferTo(targetPath); String url /upload/ fileName; return Result.success(url); }4.4 购物车与订单下单的事务处理购物车表的设计比较简单每个用户对同一个商品加购时我做了逻辑判断如果已经在购物车中只累加数量不重复插入记录。下单是整个系统最核心的部分涉及事务我加了 Transactional 注解。事务逻辑是校验购物车选中项、校验商品上架状态和库存、计算总价、创建订单主表记录、创建订单明细记录、扣减库存、清空购物车。任何一步抛出异常整体回滚不会出现下单后库存没扣或者购物车被清空的情况。Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, ListLong cartItemIds) { ListCart cartItems cartMapper.selectByIds(cartItemIds); if (cartItems.isEmpty()) { throw new BusinessException(请选择要购买的商品); } double total 0; String orderNo generateOrderNo(); Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setStatus(OrderStatusEnum.UNPAID.getCode()); ListOrderItem items new ArrayList(); for (Cart cartItem : cartItems) { Product product productMapper.selectById(cartItem.getProductId()); if (product null || product.getStatus() ! 1) { throw new BusinessException(商品已下架 cartItem.getProductId()); } if (product.getStock() cartItem.getQuantity()) { throw new BusinessException(商品库存不足 product.getName()); } // 更新库存 productMapper.decreaseStock(product.getId(), cartItem.getQuantity()); // 组装订单明细 OrderItem item new OrderItem(); item.setOrderId(order.getId()); // ... 赋值商品快照信息 items.add(item); total product.getPrice() * cartItem.getQuantity(); } order.setTotalAmount(total); orderMapper.insert(order); orderItemMapper.batchInsert(items); cartMapper.deleteBatchIds(cartItemIds); return orderMapper.selectByOrderNo(orderNo); }这里有个隐藏问题先插入订单还是先扣库存。我的做法是商品校验通过后立刻扣减库存然后插入订单。如果插入订单失败回滚扣减也一起回滚不会有问题。但在高并发场景下直接在 product 表上执行update product set stock stock - #{quantity} where id #{id} and stock #{quantity}是个更好的做法它利用数据库行锁来防止超卖。我项目里就是这么写的比先查库存再更新更安全。5. 前端核心页面与交互实现5.1 Vue 项目结构与页面划分前端项目我用 Vite 创建安装了 vue-router、pinia、axios、element-plus。页面划分为首页、商品列表页、商品详情页、购物车页、结算页、我的订单页、登录注册页、后台管理页。路由需要区分用户角色普通用户不能进入后台管理页所以我在路由配置里加了 meta.role然后在全局前置守卫里判断。router.beforeEach((to, from, next) { const token localStorage.getItem(token); const userInfo JSON.parse(localStorage.getItem(userInfo) || {}); if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }); return; } if (to.meta.role to.meta.role ! userInfo.role) { next(/); return; } next(); });5.2 状态管理与跨页面数据共享购物车徽标数量、用户登录状态这些在多个页面都会用到不能每个页面都去 localStorage 读一遍我放到 Pinia 里统一管理。核心是 userStore、cartStore。登录成功后把用户信息写入 store同时持久化到 localStorage刷新页面时再从 localStorage 恢复。购物车数量则在每次进入首页时拉取一次接口更新 store 中的数字。5.3 商品列表页筛选与分页商品列表页用了 Element Plus 的 el-select 做分类筛选、el-input 搜索关键字、el-pagination 做分页。筛选条件变化后重新调用接口前端把 categoryId、keyword、pageNum、pageSize 作为查询参数传给后端。后端用 PageHelper 或手写 limit 语句都能实现分页。要注意的是切换筛选条件时最好把页码重置为 1不然用户在第 5 页切换到另一个分类很可能看不到任何数据这个交互细节很影响体验。5.4 购物车与结算页的联动逻辑结算页设计比较简单从购物车勾选商品点击结算后把选中项的商品 ID 数组传到下单接口。前端需要在购物车页记录哪些商品被勾选用复选框组件绑定一个 checked 状态。结算页展示商品金额汇总用户填写收货地址和联系电话提交订单。提交成功后跳转到订单支付页这里对接的是模拟支付接口所以演示时可以手动点击模拟支付成功。6. 关键业务场景实操从注册到订单完成6.1 注册与登录流程我描述一下整个流程怎么走通。用户打开网站点击右上角注册前端拿到用户名、密码、手机号提交到/api/auth/register后端先检查用户名是否存在不存在就加密密码存入数据库。加密用的 BCrypt不用 MD5因为 MD5 已经不安全了。登录后返回 JWT前端存储到 localStorage之后所有接口都带上这个 token。PostMapping(/register) public Result register(RequestBody RegisterDTO dto) { if (userMapper.selectByUsername(dto.getUsername()) ! null) { return Result.error(用户名已存在); } User user new User(); user.setUsername(dto.getUsername()); user.setPassword(bcryptEncoder.encode(dto.getPassword())); user.setPhone(dto.getPhone()); user.setRole(USER); userMapper.insert(user); return Result.success(注册成功); }6.2 下单并模拟支付用户勾选购物车商品点击去结算填写收货信息后提交订单。后端创建订单后状态是待支付。前端跳转到支付页面展示订单金额和支付按钮。开发阶段没有真实支付接口我做了两个选项一是调用一个本地的模拟支付接口接口直接返回支付成功二是对接第三方支付沙箱环境。项目演示用第一种部署在生产环境时换成真实支付渠道回调验签逻辑需要另外开发。6.3 后台管理的商品发布流程管理员登录后进入后台点击商品管理再点新增商品填写名称、分类、价格、库存、有效期上传图片点击保存后商品状态默认为上架。这里我加了数据校验价格必须大于 0库存不能为负数有效期不能早于当天。这些校验前端可以做一层后端必须再做一次。因为接口可能被绕过前端直接调用只在前端校验等于没有校验。7. 部署上线与运维经验7.1 后端打包与启动配置Spring Boot 项目通过 Maven 打包成 jar 包mvn clean package后在 target 目录下生成可执行 jar。服务器上只需要安装 JDK 17 或 11然后用nohup java -jar medical-mall.jar --server.port8080 启动。生产环境数据库我用的 MySQL 8连接配置放在 application.yml 里密码用环境变量注入避免明文写死。server: port: 8080 spring: datasource: url: jdbc:mysql://${DB_HOST}:3306/medical_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: ${DB_USER} password: ${DB_PASSWORD} servlet: multipart: max-file-size: 5MB max-request-size: 20MB7.2 Nginx 反向代理与前端部署前端构建产物在 dist 目录文件是静态资源直接扔到服务器上。用 Nginx 配置反向代理静态资源由 Nginx 直接返回接口请求转发到后端的 8080 端口。这样做有几个好处解决了跨域问题浏览器访问的是同一个域名Nginx 处理静态文件的性能要比 Java 应用服务器强还能加一层负载均衡扩展点。server { listen 80; server_name your-domain.com; location / { root /var/www/medical-mall/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /upload/ { alias /var/www/medical-mall/upload/; } }7.3 日常运维注意点数据库备份是必须的我写了一个每天凌晨三点执行的 cron 脚本用 mysqldump 导出数据库文件保留最近 7 天。服务器上的日志要配置 logback 按天滚动不然一个日志文件越攒越大排查问题都不方便。上传的图片文件也要定期清理可以按月份建目录方便归档。8. 常见问题与排查技巧实录8.1 跨域请求报错前后端分离开发时最容易遇到的就是跨域问题。浏览器控制台提示 No Access-Control-Allow-Origin header is present就是服务端没有允许前端域名访问。Spring Boot 解决方式有两种一种写一个全局配置类实现 WebMvcConfigurer 的 addCorsMappings 方法另一种用 CrossOrigin 注解在 Controller 上。全局配置更推荐可以从根源避免在每个接口上重复加注解。8.2 JWT 过期导致接口 401用户在网站停留时间较长后Token 过期了前端请求接口返回 401但用户界面没有任何提示只有点操作的时候才突然报错体验很差。我的做法是在 axios 响应拦截器里统一判断状态码如果 401 则清空本地用户信息并跳转到登录页。同时拦截器里还要处理 token 刷新逻辑比较简单的模式是续签即每次请求成功时后端重新生成 token 返回前端更新本地存储。8.3 订单超时未支付怎么处理用户下单后一直不支付订单一直处于待支付状态占用库存。我的方案是加了定时任务扫描订单表中状态为待支付且创建时间超过 30 分钟的订单自动取消并把库存归还。Spring 的 Scheduled 注解可以轻松实现。这里有个细节取消订单时要先判断支付状态是否已经变更避免把刚支付成功的订单取消掉所以查询条件里要带上支付时间为 null 这个约束。8.4 商品图片不显示图片上传成功但访问不到多半是路径映射问题。开发模式下 Spring Boot 无法直接访问本地磁盘上的文件需要配置一个资源映射Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); } }8.5 常见问题速查表问题表现可能原因解决办法接口返回 500数据库连接失败或 SQL 错误查看日志中的异常堆栈检查数据源配置登录后访问接口 403Token 没有传递或校验失败检查 axios 拦截器是否正确添加 Authorization 头商品列表为空分类 ID 不匹配或商品未上架对照数据库记录检查筛选条件页面刷新 404前端路由没有配置 fallbackNginx 中配置 try_files 处理 SPA 路由9. 项目复盘与扩展方向9.1 我踩过的一些坑第一不要在 Service 里直接返回数据库实体类实体类字段变动会影响接口的稳定性。第二事务方法不能同类调用否则 Transactional 失效。这个问题很隐蔽因为我最初把生成订单号和下单逻辑放在同一个 Service 的不同方法里直接在类内部调用另一个方法事务代理不起作用后来把方法拆分到不同类或者用 self-injection 解决。第三分页查询时 count 查询和 list 查询的条件要完全一致否则总页数不对。9.2 项目后续可以怎么扩展这个项目的架构留了比较清晰的扩展点。商品搜索可以接入 Elasticsearch 提升检索能力和搜索相关性秒杀场景可以用 Redis 预扣库存异步落库支付回调需要加消息队列来保证最终一致性订单操作日志要单独建表为后续客服查询留下痕迹物流信息可以对接快递 100 等第三方接口。如果要做成多商户平台就要在商品表上增加 shop_id并把权限模型改造得更细。我在实际运营这个项目之后有一个很深的体会一个电商类系统核心不是页面多好看也不是功能多花哨而是订单流程和库存数据必须严谨。先守住正确性再谈体验和性能。如果只是做技术练习建议也可以从更小的场景切入比如只做商品管理和下单模块把事务、权限这些基本功练透比堆功能更有收获。
阅读完成 · 觉得有帮助?
咨询建站