恒爱药房网上订购管理系统这套毕业设计,我从选题到最终跑通差不多花了一个月。Spring Boot做后端,MySQL存数据,前端用Vue搭页面,整体是当下毕业设计里非常主流的技术路线。先说清楚这套内容的价值:它不是给你堆一堆代码,而是把从数据库设计、登录权限、药品上下架,再到下单事务和库存扣减这条核心链路全部串起来讲通,让你拿着这套东西从开题报告、中期检查一路扛到最终答辩都有话可说。适合正在做类似“网上商城/订单管理系统”题目的同学,也适合想快速上手Spring Boot业务开发的初学者。
我尽量把每一步为什么这么设计、哪里容易踩坑、答辩时怎么解释都写明白。这里没有花架子,全是实际操作过的经验。
1. 项目整体设计与技术选型逻辑
1.1 双向角色:用户下单端和管理后台为什么必须拆分
恒爱药房这个系统,表面看是一个“网上买药”的商城,但真正的核心其实是一个双角色的业务管理平台:普通用户负责浏览药品、加购物车、下单、查看订单;管理员负责药品维护、库存管理、处理订单、看销售数据。
为什么必须拆开?因为这两个角色对系统的需求完全相反。用户端讲究下单爽快、流程简单,管理端讲究信息完整、操作可控。如果混在一个页面里,权限容易混乱,比如随便一个用户就能调接口把药品价格改了,这在答辩演示的时候就是致命伤。
实际设计里我用了一张user表,通过role字段区分角色,0是普通用户,1是管理员。后端的接口统一走拦截器校验,登录后返回一个JWT令牌,请求接口时带上这个令牌,拦截器解析出身份和角色,再决定放不放行。这个做法既是主流方案,也比较容易在答辩时讲清楚安全控制思路。
1.2 Spring Boot为什么是毕业设计的最优解
选题用Spring Boot,真不是跟风,而是它确实适合这个场景。第一,内置Tomcat,不用额外配置服务器,写一个main方法就能跑起来。第二,和MyBatis-Plus配套使用,写单表的增删改查几乎不需要手写SQL,这对毕设时间紧的同学太友好了。第三,Spring Boot的自动配置把大量繁琐的XML配置消掉了,项目结构清爽,答辩演示时Launch一下就起来了,不会出现配置半天起不来的尴尬。
有人会纠结用不用Spring Cloud或者微服务,我的建议是别加。毕设的核心是证明你掌握了完整业务开发链路,单体应用加良好模块划分就已经足够。强行上微服务反而会被分布式事务、服务注册这些问题拖住,得不偿失。
1.3 开发前需要准备的东西
在动手写之前,先把环境确认好。我实际用过的是JDK 8和JDK 17两套方案,都能稳定跑,建议按你本机现有版本选,没必要为了新而新。数据库用MySQL 8.0,连接驱动要选对版本。IDE用IDEA,社区版也行。前端我用的是Vue 2加Element UI,主要是组件现成,表格、表单、弹窗这些管理端页面常用的东西拖出来就能用,省下大量写样式的时间。
注意:如果电脑配置一般,跑前后端两个服务加上MySQL,内存占用大概在2G左右,建议装之前确认一下内存余量,免得打开几个窗口就卡到怀疑人生。
1.4 系统功能模块全览
| 角色 | 功能模块 | 核心操作 |
|---|---|---|
| 普通用户 | 注册登录 | 手机号/用户名注册、登录、退出 |
| 普通用户 | 药品浏览 | 分类筛选、按名称搜索、查看详情 |
| 普通用户 | 购物车 | 加入购物车、修改数量、删除、清空 |
| 普通用户 | 订单管理 | 提交订单、在线支付模拟、取消订单、确认收货 |
| 普通用户 | 收货地址 | 新增、编辑、删除、设置默认地址 |
| 管理员 | 药品管理 | 新增药品、编辑信息、上架下架、传图片 |
| 管理员 | 库存管理 | 查看库存、手动调整库存、低库存提醒 |
| 管理员 | 订单处理 | 查看订单、发货操作、处理取消申请 |
| 管理员 | 数据统计 | 销售总额、订单量趋势、热销药品排行 |
这套功能覆盖了电商类系统最常见的业务场景,既不会太少显得单薄,也不会太多导致做不完,是性价比很高的功能划分。
2. 数据库设计与核心取舍
2.1 八张核心表怎么设计
数据库是整个系统的基础,设计得合不合理,直接决定后面写代码的顺畅程度。我最终采用了8张表:用户表、药品分类表、药品表、购物车表、订单表、订单明细表、收货地址表,外加一个管理员操作相关的映射。这里重点说订单和订单明细为什么要拆成两张表。
订单表记录一次下单的整体信息,比如订单编号、总金额、下单用户、订单状态、收货人信息和下单时间。订单明细表记录这次订单里到底买了哪些药,每样药买了几盒、单价多少、当时快照的药名和图片。拆开的原因很简单:一次订单可能包含多盒不同药品,如果不拆,订单表里就得用逗号拼接药品ID,查询时要拆字符串,统计销售数据时更是灾难。而且明细表里冗余保存药品名称和图片,是为了防止管理员修改药品信息后,历史订单显示错乱。这是一种典型的“用空间换稳定”的设计思路。
药品表是另一个重点,字段里必须包含商品规格(比如“30片/盒”)、生产厂家、零售价、库存数量、销量和上下架状态。药品和分类之间用category_id关联,药品表只存分类的ID,不存分类名称,查询时再关联。这样分类改名时药品不用动,也是正规的范式设计。
2.2 三个容易让答辩老师皱眉的字段设计问题
第一个是金额。药价这个字段一定不能用float或double,必须用decimal(10,2)。浮点数在计算机里是近似存储,0.1加0.2都可能变成0.30000000000000004,药价算错一个小数点是大事。decimal是精确小数类型,完全符合金额计算的场景。
第二个是库存。库存字段我用int,并且在扣减库存时配合“版本号”机制。这里先埋个伏笔,后面下单部分详细展开。版本号存在的意义是防止两个人同时买最后一盒药时出现超卖,这个概念在电商项目里是非常经典的面试点。
第三个是订单状态。数据库里不存字符串,比如“待付款”,而是用一个tinyint存数字,0代表待付款,1代表已付款待发货,2代表已发货,3代表已完成,4代表已取消。代码里用枚举或者常量映射。这样存储省空间,查询更快,状态流转也更明确。
2.3 订单号的生成不能瞎写
订单号我用的是“时间戳+随机数”的组合:取当前时间的yyyyMMddHHmmss格式字符串,再加上4位随机数。实际代码里为了保证并发不重复,我还会把当前用户的ID尾数拼进去,生成的订单号是24位左右的长字符串,既好看最关键的是不会重复。
有人会用数据库自增ID直接当订单号,我劝你别这么干。订单号在电商场景里是会给用户看、给物流用的,自增ID容易暴露系统每天的订单量,而且多表合并时容易撞。用时间戳+随机数的方式,天然有序,看起来也专业。
3. 核心后端模块的实现过程
3.1 登录认证与接口拦截
登录接口的逻辑不复杂:用户提交用户名和密码,后端查user表,对密码做MD5加盐处理后比对,匹配成功就生成一个JWT令牌返回给前端。这里的细节在于密码绝对不能明文存储。我用的是MD5加盐,简单够用;如果你想让安全层级更高,可以用BCrypt,Spring Security里自带这个工具,只不过那套体系对毕设来说稍微重了一点。
JWT的生成代码大致如下:
String token = Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim("role", user.getRole()) .claim("username", user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();拦截器才是这里的关键。我用的是Spring MVC的HandlerInterceptor,在preHandle方法里先判断请求路径。像登录接口、注册接口、药品列表这些公开接口直接放行,其余接口都要校验请求头里的Authorization字段。解析JWT失败就返回401,让前端跳回登录页。
这里有个特别容易掉的坑:拦截器的放行路径写错了,导致登录接口自己被拦截,前端一调登录接口就401。我第一次写的时候就犯过这个错,排查了半天。建议放行路径写成接口的完整前缀加通配符,比如/api/auth/,然后其他接口统一走/api/前缀,在拦截器里排除掉/api/auth/就行。
3.2 药品分页和条件查询
用户端的第一屏就是药品列表,这里要做成“分类筛选+关键字搜索+分页”的组合查询。MyBatis-Plus的分页插件非常省事,配置一个PaginationInnerInterceptor,然后调用分页方法即可。
Page<Drug> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Drug> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Drug::getStatus, 1) .eq(categoryId != null, Drug::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Drug::getName, keyword) .orderByDesc(Drug::getSales); IPage<Drug> result = drugMapper.selectPage(page, wrapper);这段代码里有两个细节值得留意。第一,status必须等于1,也就是只展示上架药品。如果管理员下架了某款药,用户那边不管怎么搜都不应该再出现。第二,排序按销量倒序,销量越高的药排在越前面,这符合药店“热销推荐”的业务习惯,演示的时候也不会出现一堆销量为零的药品霸屏的尴尬。
分页插件有个小坑:如果MyBatis-Plus版本比较老,需要在启动类上加@MapperScan注解扫描Mapper接口,否则分页插件无法生效,会出现查出来的数据不对、总数永远是0之类的问题。配置好之后,返回给前端的数据结构里包含records(当前页数据)、total(总条数)、pages(总页数),前端直接用分页组件绑定就行。
3.3 下单事务与库存扣减
下单是整个系统技术含量最高的一段,也是答辩时老师大概率会追问的地方。核心流程是:校验登录态、查询药品最新价格、校验库存是否充足、生成订单和订单明细、扣减库存、清空购物车对应商品。这整个流程必须在一个事务里完成——要么全部成功,要么全部回滚,不能出现用户下单成功了库存却没扣掉的情况。
我在Service里的下单方法加了这个注解:
@Transactional(rollbackFor = Exception.class) public OrderInfo createOrder(Long userId, Long addressId, List<CartItem> items) { // 1. 计算总金额 // 2. 生成订单主表 // 3. 遍历明细,逐条扣减库存 // 4. 保存明细 // 5. 清空购物车 }扣减库存这一步,与其先查库存再更新,不如直接在SQL里做判断,这是我强烈推荐的做法。核心SQL是这样写的:
update drug set stock = stock - #{quantity}, version = version + 1 where id = #{drugId} and stock >= #{quantity}注意看where条件里的stock >= #{quantity},这一句保证了只有在剩余库存足够时才会执行更新。如果受影响的行数是0,说明库存不够,直接抛异常,整个事务回滚,用户就能看到“库存不足”的提示。这种写法避免了“先查再扣”过程中的时间差,能有效防住超卖问题。后面我在常见问题里专门展开讲一次。
订单状态我用起始值0(待付款)。这里我做了个简单处理:支付环节用模拟支付页面,用户点击“立即支付”后调用一个支付回调接口,把订单状态从0改成1。对于毕业设计来说这个完整闭环已经足够了,真去接入微信支付反而需要商户资质,不是学生项目能搞定的。
3.4 购物车模块的两个贴心细节
购物车我设计了两个前端觉得“理所当然”但后端很容易忽略的细节。第一个是添加购物车时,如果购物车里已经有这个药品,应该自动把数量累加,而不是再插一条新纪录。第二个是在购物车列表里显示药品当前价格和库存状态,如果药品已经下架或库存为零,对应行要置灰并提示“已失效”,不让用户勾选提交。
这两个细节看似简单,但实现时要小心。查购物车列表时不能只返回cart表本身,必须联查drug表拿最新的价格和上下架状态。我在写购物车列表SQL时使用了LEFT JOIN,这样即使药品被删了,购物车记录还在,只是前端多一个失效标识。
4. 管理后台的关键实现与数据统计
4.1 管理端页面怎么做到简洁又能撑住场面
管理端的UI我用的还是Element UI,布局采用常见的左侧菜单加右侧内容区。菜单项包含药品管理、库存管理、订单管理、数据统计。这里不需要花哨的设计,重点是把表格、分页、表单弹窗、状态切换这些操作做顺,这也是最标准的管理后台交互模式。
药品管理页面里,图片上传是一个绕不开的点。我建议图片不要存到数据库里,而是在服务器上放一个静态资源目录,上传后把图片的访问路径存到数据库。Spring Boot里配置一个虚拟路径映射,把本地磁盘目录映射成/image/开头的URL,这样前端就能用http://localhost:8080/image/xxx.jpg直接展示。
提示:图片上传后如果刷新就没了,大概率是虚拟路径映射没配,排查方向在WebMvcConfigurer的addResourceHandlers方法里。
4.2 订单状态流转和取消时的库存回补
管理端订单处理的核心是状态流:待付款可以取消,已付款可以发货,已发货可以完成。我在接口层做了严格的状态判断,防止前端乱跳状态。比如发货接口只接受当前状态为1的订单,如果状态已经是2,接口直接返回错误。这种状态校验在后端必须做,前端隐藏按钮只是体验优化,后端的校验才是真正的安全边界。
这里有一个特别容易被忽视的逻辑:取消订单后必须回补库存。用户下单买了2盒感冒灵,付款前取消了,数据库里的订单状态从0变成4,但如果库存不恢复,那2盒药就“凭空消失”了,后面再有用户来买就会提示库存不足。回补库存的SQL很简单,就是把库存加回来:
update drug set stock = stock + #{quantity} where id = #{drugId}这段逻辑要写在取消订单的同一个事务里,和扣库存一样,要么一起成功,要么一起回滚。
4.3 数据统计怎么做出能讲五分钟的内容
数据统计是管理端最能体现“完整度”的模块,也是答辩时很加分的部分。我用的是ECharts图表,后端提供统计数据接口,前端绘制柱状图和饼图。三个统计亮点分别是:近7天订单量趋势、本月的销售总额、销量前五的药品排行。
统计SQL其实都很直白。比如近7天订单量,就是按下单日期做分组,统计每天的订单数量:
select date(create_time) as day, count(id) as count from orders where create_time >= date_sub(curdate(), interval 6 day) group by date(create_time)再比如热销药品排行,从order_item表里按药品ID分组,sum(quantity)排序后取前5。这里订单明细表的冗余设计就体现出好处了,统计时不需要关联药品表也能拿到数据,SQL简单明了。
5. 常见问题与排查技巧实录
5.1 库存超卖问题,是答辩高频问题
库存超卖是网上商城系统里最经典的问题。简单描述就是:药品库存只剩1盒,用户A和用户B同时下单,两个人都查询到了库存大于0,都执行了扣减,结果库存变成-1,订单却都成功了。
如果只是“先查再扣”的普通写法,在并发下确实会出现超卖。我采用的方案是上面提到的“SQL条件更新”,用stocks >= #{quantity}作为更新的条件,数据库的行锁保证同一时刻只有一个请求能更新成功,另一个请求的更新受影响行数为0,然后抛出“库存不足”异常。这样从根本上杜绝了超卖。
答辩时老师可能会继续追问:“如果请求量特别大怎么办?”这时候你可以补充说,高性能场景还可以引入Redis预减库存、消息队列异步扣减,但毕业设计采用数据库层面的乐观锁方案已经足够严谨。这种回答既展示了你深入思考的能力,又不会给自己挖坑。
5.2 金额精度漂移
如果药价字段用了double,展示出来就是9.990000000000002这种奇怪数字,用户看到绝对会觉得系统有bug。解决方法是统一用BigDecimal运算,不要再拆成double来加减。数据库字段对应decimal(10,2),Java实体用BigDecimal,前端保留两位小数。这是一条完整的链路,任何一个环节变成浮点数都会出问题。
5.3 LocalDateTime序列化报错
Java 8的时间类型在Jackson序列化时,如果没配格式,返回给前端的可能是“2024-05-20T10:30:00”这种带T的格式,或者直接报不支持序列化的错。解决办法是在日期字段上添加@JsonFormat注解,统一成“yyyy-MM-dd HH:mm:ss”。如果你用的是Spring Boot 2.3以上版本,也可以在application.yml里配置全局的Jackson日期格式。
5.4 分页插件不生效
分页插件不生效的排查方向有两个。第一,确认MyBatis-Plus的分页拦截器是否在配置类里注册了。只引入依赖是不够的,如果没有注册PaginationInnerInterceptor,selectPage方法查出来就是全表数据,只是内存截取。第二,确认Mapper接口是否被扫描到,没加@MapperScan的话,Mapper根本不会注入到Spring容器里,启动就会报错。
5.5 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 登录接口报401 | 拦截器放行路径没配好 | 检查放行路径前缀是否正确 |
| 下单后库存没变 | 事务没生效或没走扣减SQL | 检查@Transactional注解,看是否是同类调用 |
| 图片上传后刷新失效 | 静态资源映射未配置 | 检查addResourceHandlers配置 |
| 前端日期带“T” | LocalDateTime序列化格式问题 | 加@JsonFormat指定格式 |
| 分页总数恒为0 | 分页插件没注册 | 注册PaginationInnerInterceptor |
| 取消订单后库存少了 | 没有回补库存 | 取消事务里增加库存回补SQL |
| 数据库时间差8小时 | 连接串时区问题 | 在jdbc url上添加serverTimezone=Asia/Shanghai |
5.6 事务不生效这个坑一定要知道
@Service方法里的所有数据库操作想在一个事务里,前提是Spring代理能拦截到这个方法。一个经典的坑是同类内部调用:类A有一个方法method1,内部直接调用this.method2,那么method2上的@Transactional注解不会生效,因为事务是靠AOP代理实现的,内部调用不走代理。
解决办法是把需要事务的代码抽到另一个Service类里,让方法从外部调用,或者更简单的做法是直接把整个业务逻辑放在一个方法内完成。写下单Service时一定要注意这一点,不然扣库存和保存订单是两次独立提交,一旦后半段出错,库存就被扣了但订单没生成,整个数据就乱了。
个人实操中的几点体会
这套系统做下来,我最深的感受是:毕设项目不是代码越多越好,而是流程越完整、逻辑越闭合越好。恒爱药房这个题目,表面上只是“网上卖药”,但真正把它做成一个能展示的系统,需要把商品、购物车、订单、库存、权限、统计这些模块全部串起来,每一步都踩在真实业务逻辑上。
最后给做这个题目的同学一个建议:先别急着写代码,把订单状态机画出来,把数据库表结构确认好,再把下单和库存扣减这段事务链路写通,之后再去填充剩余功能。核心链路通了,这个项目的骨架就立住了,后面再遇到任何问题都不会慌。我也把自己实际配置过的关键代码和数据表设计融入到了上面的内容里,可以直接照着搭。祝顺利跑通。