做医疗用品销售商城这个项目,前后折腾了大半年。本身是个前后端分离的小商城,但医疗用品的特殊业务属性,让它比普通电商系统多了不少隐藏要求:商品需要资质审核、库存要管批次和效期、订单要能查溯源……光这些点,就够把一个练手项目撑成一套完整工程。
技术选型上我比较保守,后端用 SpringBoot,前端用 Vue,都是团队最熟悉的组合。整套系统拆成前台商城和后台管理两个端,覆盖了用户注册登录、逛商品、加购物车、下单、模拟支付、订单跟踪的完整链路,后台则管商品、库存批次、订单、用户和资质审核。这个项目比较适合正在学 Java 和 Vue、想了解前后端分离商城怎么做、又不想只做个玩具 CRUD 的同学参考,哪怕你是从零开始,跟着拆解思路也能照着自己搭一套。
1. 项目整体设计思路:为什么是 SpringBoot + Vue
1.1 技术选型背后的考量
前后端分离是这个项目的地基。医疗用品商城的页面多、交互状态也多,如果还像老项目那样用模板引擎在后端拼页面,光是购物车、订单状态、库存变更这些联动逻辑就能把后端代码写成一团浆糊。让后端专心出接口、前端专心管页面状态,边界一下清晰了。
SpringBoot 的优势其实是“省心”。统一依赖管理、内嵌 Tomcat、自动配置,配合 MyBatis-Plus 这种增强工具,大部分常规接口半天就能出来。Vue 这边选它,是因为组件化开发特别适合商城这类模块复用强的场景:商品卡片、优惠信息、订单列表到处都要用,抽成组件后改一处就全变了。更有意思的是 Vue 的响应式状态,购物车增减数量时,页面汇总金额自动联动,这个体验用原生 JS 写要麻烦得多。
还要多说一句:医疗用品商城不能直接照搬普通电商。商品发布的源头就多一道资质校验,库存必须按批次管理,效期临期要有预警,订单最好记录到哪个批次出的货。所以设计阶段就把这些差异点烙进表结构里,后面就不会反复改表。
1.2 业务模块划分
整个项目按“端”切分,避免前后台逻辑混在一起。
前台功能:
- 用户体系:注册、登录、信息维护、收货地址管理
- 商品中心:分类浏览、关键词搜索、商品详情、资质证书展示
- 交易链路:购物车、下单确认、模拟支付、订单状态跟踪
- 辅助能力:轮播图、公告、购物车数量角标、历史订单查询
后台功能:
- 商品管理:分类维护、商品上下架、推荐位设置
- 库存批次:批次录入、效期维护、库存预警、出库记录
- 订单管理:订单列表、发货操作、售后状态标注
- 用户管理:用户列表、禁用/启用、收货地址管理
- 资质审核:商户提交资质材料、后台审核通过后允许发布商品
- 统计报表:销量排行、库存余量、低库存商品列表
前后台各自独立成模块,接口层面用前缀区分:前台接口走/api/portal/**,后台走/api/admin/**。这样过滤器、权限校验可以按前缀批量配置,语义上也清晰。整个项目的开发节奏基本是:先把数据库表和接口协议定好,然后前后端并行开发,最后花一周时间联调。
2. 数据库设计与核心表结构
2.1 数据库设计原则
医疗用品商城的数据模型,我的底线是:订单相关表要能追溯,库存相关表要能定位批次,商品相关表要能查到资质。所以表结构上比普通商城多了商品批次表和资质审核表。
字符集统一用utf8mb4,排序规则用utf8mb4_general_ci,引擎用 InnoDB,原因无非是中文存储和事务支持。所有表都带create_time、update_time两个字段,方便排查问题;逻辑删除用deleted字段,避免硬删除导致历史订单关联断裂。
建议字段类型上:金额用decimal(10,2),这是老经验了;数量用int;状态字段用tinyint别有太大负担;库存不要直接用float,扣减精度会出问题。
2.2 核心表结构详细拆解
商品表的核心字段大致这样:
CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(200) NOT NULL COMMENT '商品名称', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `price` decimal(10,2) NOT NULL COMMENT '销售价', `cost_price` decimal(10,2) DEFAULT NULL COMMENT '成本价', `image` varchar(500) DEFAULT NULL COMMENT '主图', `detail` text COMMENT '商品详情', `reg_cert_no` varchar(100) DEFAULT NULL COMMENT '注册证编号', `spec` varchar(100) DEFAULT NULL COMMENT '规格', `unit` varchar(20) DEFAULT NULL COMMENT '单位', `status` tinyint(4) DEFAULT 0 COMMENT '0下架 1上架', `sale_count` int(11) DEFAULT 0 COMMENT '销量', `deleted` tinyint(4) DEFAULT 0, `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';相对特殊的是批次表,因为医疗类目经常要按进货批次管理效期:
CREATE TABLE `product_batch` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) NOT NULL COMMENT '商品ID', `batch_no` varchar(64) NOT NULL COMMENT '批次号', `production_date` date DEFAULT NULL COMMENT '生产日期', `expiry_date` date DEFAULT NULL COMMENT '有效期至', `quantity` int(11) NOT NULL COMMENT '初始入库数量', `remain_quantity` int(11) NOT NULL COMMENT '剩余数量', `status` tinyint(4) DEFAULT 0 COMMENT '0正常 1冻结 2过期', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品批次表';订单主表:
CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL, `total_amount` decimal(10,2) NOT NULL COMMENT '订单总额', `pay_status` tinyint(4) DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `deliver_status` tinyint(4) DEFAULT 0 COMMENT '0未发货 1已发货 2已完成', `address_snapshot` varchar(1000) DEFAULT NULL COMMENT '地址快照', `pay_time` datetime DEFAULT NULL, `cancel_reason` varchar(255) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';订单明细表里必须记录batch_id和当时的价格快照,因为后面出库要按批次扣,对账也要按快照价格来,不能跟着商品表改价跑。
2.3 表关系说明
- 用户表与订单表:一对多
- 商品表与商品批次表:一对多
- 商品表与订单明细表:一对多(通过快照字段保证历史稳定)
- 订单表与订单明细表:一对多
- 商户资质表与用户表:一对一或一对多(一个用户可能提交多次审核)
外键我不建议物理建,应用层维护即可。否则线上改数据、批量导入库存会非常痛苦。代价是需要自己注意关联数据的一致性,但在小型商城项目里完全可控。
3. 后端接口设计与核心业务实现
3.1 认证与权限设计(JWT + RBAC)
登录这块没有用 Session,而是走 JWT。核心原因是前后端分离后,服务端不好记 Session 状态,用无状态 Token 更省心。密码存储必须用 BCrypt 加密,千万别存明文,这个没有可商量的余地。
角色模型比较简单:ROLE_USER普通用户,ROLE_ADMIN后台管理员。系统里还有一个“待审核商户”的概念,发布商品前必须先通过资质审核,所以在用户表里加了一个audit_status字段:0 未提交、1 审核中、2 通过、3 拒绝。
一个简化版的 JWT 工具类核心逻辑如下:
public String generateToken(String username, List<String> roles) { Map<String, Object> claims = new HashMap<>(); claims.put("username", username); claims.put("roles", roles); return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }校验 Token 时,解析出来放进 Spring Security 的上下文里,然后在配置类里给/api/admin/**加上hasRole('ADMIN')的规则,给/api/portal/order/**加上authenticated()。前台商品浏览这类公开接口就直接 permitAll,减少无意义的拦截。
这里有个容易踩的坑:JWT 的密钥一旦泄露,所有 Token 都能伪造。所以密钥长度至少 32 位,并且不要硬编码在代码里,放到配置文件中走环境变量注入。
3.2 商品与库存批次管理
商品上架流程:后台先维护分类,再新增商品,选择批次库存。批次表里的remain_quantity才是真正可卖的库存。所以前台展示“库存量”时,不是直接读 product 表里的 stock 字段,而是把该商品所有生效批次的remain_quantity汇总出来。
提审商品在status=1上架之前,系统会检查该用户是否audit_status=2,不满足直接拦截并提示“发布商品需先通过资质审核”。这个校验写在 Service 层,不要依赖前端按钮隐藏。
扣减库存的并发问题,我在这项目上做过两种方案。
第一种是数据库乐观锁。下单时执行类似:
UPDATE product_batch SET remain_quantity = remain_quantity - #{num}, version = version + 1 WHERE id = #{batchId} AND remain_quantity >= #{num} AND version = #{version}受影响行数是 1,说明扣减成功;是 0,说明库存不足或版本冲突,直接抛异常回滚订单。这个方案优点是简单、不需要额外中间件,缺点是高并发下会有部分请求重试。
第二种是引入 Redis 做预扣减。把批次库存预热到 Redis,下单时用 Lua 脚本扣减,异步同步回数据库。优点是抗压,缺点是系统复杂度明显提高,需要处理 Redis 与数据库的一致性。
如果只是演示项目或者日均订单量几千单,乐观锁足够了。我第一版就用的乐观锁,线上跑了几个月没出过超卖。
3.3 订单流程与支付对接
订单流程算整套系统里逻辑最绕的部分,我把状态机理清楚后实现起来就很顺手。
下单入口在/api/portal/order/create,内部步骤可以拆成五步:
- 校验用户登录态、收货地址是否有效
- 遍历购物车商品,逐条核对商品是否上架、库存是否充足
- 对购物车明细按商品分组,计算总金额,并记录价格快照
- 生成订单主表记录,状态为“待支付”,同时生成订单明细
- 清空已下单的购物车数据,返回订单号给前端
比较大的坑是“购物车勾选哪些商品下单”,前端一定要把最终选中的购物车项 ID 列表传给后端,后端只认这个列表,任何“反正购物车全选”的假设都会在测试阶段翻车。
支付流程用的是模拟支付。真实项目会对接第三方支付网关,但电商演示里只要做对状态流转就够了:
- 创建订单后,订单状态为待支付
- 后端提供一个“生成支付参数”接口,返回一个支付单号
- 前端跳转到一个模拟收银台页面,用户点击“确认支付”
- 支付回调用
payNotify接口,校验金额后把订单状态改成“已支付”,并记录支付时间
这里我强调一个细节:支付金额校验必须在后端做,前端传什么都不能信。模拟环境里,回参里的金额要和订单金额严格比对,不一致就拒绝更新状态。
未支付订单超时自动取消,我用的是 Spring 的定时任务:
@Scheduled(cron = "0 */5 * * * ?") public void cancelExpiredOrders() { List<Order> expiredList = orderMapper.selectExpiredUnpaid(30); for (Order order : expiredList) { order.setPayStatus(2); order.setCancelReason("超时未支付"); orderMapper.updateById(order); restoreStock(order); } }简单粗暴,但管用。要是订单量特别大,可以改成延迟队列或者消息中间件,但那种设计对医疗用品商城现阶段意义不大。
3.4 资质审核链路
资质审核会在用户选择“发布商品”前被触发。前端上传营业执照照片、经营资质编号等信息,后端生成一条审核记录:
public boolean submitAudit(AuditRequest req, Long userId) { // 校验用户是否已有审核中的记录 // 防止重复提交 Audit record = new Audit(); record.setUserId(userId); record.setCompanyName(req.getCompanyName()); record.setRegCertNo(req.getRegCertNo()); record.setLicenseUrl(req.getLicenseUrl()); record.setStatus(1); auditMapper.insert(record); return true; }后台管理端的审核列表集中展示这些记录,管理员审核通过后回写用户表的audit_status=2,并且给用户发站内信。这个环节看上去小众,其实是医疗用品商城合规性的核心体现,它让商品发布链路不只是“登录就能发”的普通论坛模式。
4. 前端 Vue 实现与页面交互
4.1 项目初始化和目录结构
前端我用的 Vue 3 + Vite,UI 组件库选的 Element Plus。脚手架没必要每次从零配,直接创建空项目再装依赖。
目录结构大致如下:
src/ api/ # 接口统一管理 assets/ # 静态资源 components/ # 公共组件 router/ # 路由定义 store/ # Pinia 状态 views/ portal/ # 前台页面 admin/ # 后台管理页面 utils/ request.js # axios 封装 auth.js # token 存取Vue 组件化开发在这里收益很大。比如商品卡片组件ProductCard.vue,前台首页、分类页、搜索结果页都用它,只是传入商品数据不同;购物车列表和订单详情里的商品行展示也能共用类似组件。改一处样式,所有页面同步变化。
4.2 路由与权限控制
路由分为三块:公开路由、需登录路由、后台管理路由。后台路由不直接写死在定义里,而是由路由守卫判断角色后动态注册,或者懒加载时判断。
核心的路由守卫逻辑:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.path.startsWith('/admin')) { const roles = JSON.parse(localStorage.getItem('roles') || '[]') if (!roles.includes('ROLE_ADMIN')) { next('/403') return } } next() })登录后前端把 token 和角色信息都存本地存储。Token 每次请求由请求拦截器自动带上,返回 401 时统一跳登录页。角色信息不要放太多敏感字段,毕竟本地存储是能改的,权限最终判断必须以后端为准。
4.3 核心页面拆解
首页:轮播图、分类入口、推荐商品列表。数据从/api/portal/product/recommended来,后端按销量或手动推荐位排序。这个接口我加了本地缓存缓存时长 5 分钟,避免每次刷新都打数据库。
商品列表页:支持分类筛选、关键词搜索、价格排序、分页。前端传categoryId、keyword、sortField、pageNum、pageSize,后端返回分页数据和总条数。筛选条件变化时重置页码到第一页,这个细节不做的话,一旦翻到第 3 页再筛选,列表经常是空页。
商品详情页:展示主图、价格、剩余库存、批次信息、注册证编号。剩余库存会汇总所有有效批次的remain_quantity。库存为 0 时按钮置灰不能加购。
购物车页面:购物车数据存后端,不存 localStorage。因为用户换设备也能看到购物车。好处是能对库存做实时校验,坏处是每次加购都要调接口,但这种规模的项目完全能接受。
结算页:回显勾选商品、计算总价、选择收货地址、提交订单。这里订单金额要前端显示 + 后端二次计算的组合,前端负责展示和确认,后端负责最终结算,防止用户篡改。
后台管理页面:分类管理、商品列表、商品编辑、批次管理、订单管理、用户管理、审核管理。后台我统一用 Element Plus 的表格 + 弹窗表单完成,表格列的显示尽量和接口返回字段对齐,减少联调时对字段名的争吵。
4.4 axios 封装与跨域联调
请求封装是前端工程质量的分水岭。最简单的方式是统一写一个request.js:
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 15000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) request.interceptors.response.use( res => { const code = res.data.code if (code === 200) { return res.data.data } if (code === 401) { location.href = '/login' return Promise.reject(res.data) } ElMessage.error(res.data.message || '请求失败') return Promise.reject(res.data) }, err => { ElMessage.error(err.message || '网络异常') return Promise.reject(err) } )开发环境的跨域,用 Vite 代理解决,在vite.config.js里配:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }生产环境下则是 Nginx 反向代理,后面部署章节会提。
5. 开发中高频踩坑与排查实录
5.1 跨域问题
前后端分离第一周,几乎所有人都会碰到跨域。前端在localhost:5173,后端在localhost:8080,直接请求必然被浏览器拦截。
解法分开发和生产两段。
开发期用 Vite 代理最省事,前端只管写/api/...相对路径,代理帮你转发到后端,浏览器看到的是同源请求。
生产期用 Nginx 反向代理,把/api转发到后端服务,前端静态资源直接由 Nginx 托管。这样整个站点同源,跨域问题根本不出现在生产环境。
如果你非要后端开 CORS,也不拦你,但要注意:生产环境 CORS 的allowedOrigins不要配成*,否则任何站点都能向你的后端发请求,安全上不划算。
5.2 时间字段返回格式问题
数据库里的时间是 datetime,后端返回给前端时,默认会被 Jackson 序列化成一串时间戳数字,前端展示就成了神秘数字。标准解法是在application.yml里做全局配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这样返回给前端的时间就是2025-05-20 14:30:00这类可读格式。另外LocalDateTime这种类型最好也全局注册JavaTimeModule,Java 8 时间类型经常是坑。
5.3 MyBatis-Plus 分页插件不生效
很多人把分页插件加上后,返回的数据还是全量,排查半天发现是自己在新版本里配置漏了。正确做法是在配置类里手动注册MybatisPlusInterceptor:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; }不注册插件,Page对象传进去只会当普通参数,SQL 不会拼LIMIT,这是 MyBatis-Plus 的老经典问题。
5.4 文件上传大小限制
后台要传商品图片和资质文件,测试的时候传了几张几 MB 的图,后端直接报文件大小超限。因为 SpringBoot 内嵌容器默认单次请求最大 1MB。配置:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB图片上传后建议不要直接存回数据库,要是用本地磁盘存储,记得配置静态资源映射,把上传目录映射成可访问 URL。生产更好的是对象存储,上传后返回一个 URL,前端直接用,但这个要按实际情况接入。
5.5 并发扣减库存导致超卖
这个坑在我给项目加压测的时候暴露过。数量不校验、版本不比较,直接把remain_quantity = remain_quantity - n,两个并发请求同时读到剩余 10,各扣 8,最后都成功,库存变负数。
解决办法上面已经提到:要么用乐观锁带WHERE remain_quantity >= #{num},要么用 Redis 预扣。还有一个细节是,即使库存够了,订单最终没支付被取消,要记得还库存。定时取消订单任务里已经有restoreStock,但用户手动取消订单的接口里也要有,否则库存越赔越多。
5.6 打包部署阶段的坑
前端打包用npm run build,生成dist目录。后端打包用mvn package,生成 jar 包。
常见问题是后端接口路径没带项目名,Nginx 转发了半天 404,一看是 location 写错。另一个常见问题是前端路由用的 history 模式,直接访问某个子路径 Nginx 返回 404,需要配置 try_files:
server { listen 80; server_name your-domain; root /opt/medical-mall/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }这样刷新/product/12这种页面,也不会被 Nginx 拦成 404。
6. 部署上线与后续扩展
6.1 部署流程
整个项目我建议按“先后端,再前端,最后联调”的顺序部署。
后端打包后的 jar 上传到 Linux 服务器,放/opt/medical-mall/目录,用 systemd 托管服务,比nohup java -jar强不少,至少崩溃了能自动重启。配置文件里数据库地址、Redis 地址、JWT 密钥都通过环境变量注入,不要在 jar 包旁边放明文配置文件。
前端dist目录上传到/opt/medical-mall/dist,Nginx 配置上面那段,重启 Nginx 就能访问了。数据库导入先执行建库脚本,再执行导入数据脚本,第一次访问先看后台能不能登录、商品能不能查到。
部署完第一件事不是逛页面,而是看日志:
journalctl -u medical-mall -f很多接口报错、数据库连接问题,都从这里最先暴露。
6.2 可能的优化方向
这个项目做完以后,其实还有几个可以自然延伸的方向:
- 订单超时取消从定时扫表升级成 RabbitMQ 延迟队列,减少无效扫描
- 商品搜索从 MySQL 模糊查询升级成 Elasticsearch,提升搜索体验
- 首页热点数据用 Redis 缓存,降低数据库压力
- 后台权限从简单的角色模型升级成细粒度权限点,比如“商品管理员只能改商品,不能改订单”
- 库存预扣和实际扣减之间加消息补偿,让库存数据更可靠
但升级的前提是业务真的需要。如果是毕业设计、学习项目、中小企业内部管理系统,现有方案已经足够,别为并不存在的并发量把架构搞得过度复杂。
6.3 一个小经验
做这个项目的过程中,我最深的体会是:商城类系统的难点,从来不在增删改查,而在“流程状态”和“数据一致性”。订单状态机、库存扣减、超时回滚,任何一个环节偷懒,都会在测试阶段被花式报 bug 打脸。医疗用品这个行业还叠加一层资质和批次效期的特殊逻辑,但换个角度看,这反而是项目区别于普通电商 demo 的亮点,面试和复盘的时候都能拿出来讲细节。
另一个小经验:开发阶段一定不要把前端依赖的接口字段名拍脑袋定,前后端先花一个小时理一份简易接口文档,字段名、类型、状态码都对齐了再动手,后面能省几天联调时间。真等到页面全部做完再去对接口,改字段的沟通成本会直线上升。
这套项目本身不算惊艳,但胜在完整。从表设计到权限、从订单到资质审核、从前端交互到部署上线,每个环节都有可交代的内容。如果你正准备做类似的商城系统,照着这个骨架填你自己的业务细节,会少走很多弯路。