简介这是一套基于SSM框架开发的酒吧存酒系统完整项目资源面向计算机专业学生、Java初学者及需要课程设计或毕业设计素材的开发者。项目采用B/S架构后端整合Spring、SpringMVC、MyBatis与Maven前端使用HTML与Vue数据库为MySQL 5.7JDK要求1.8及以上可在IDEA或Eclipse中直接运行。系统区分管理员与用户两种角色涵盖酒吧信息、酒水信息、酒水购买、存酒取酒、酒水类型、留言板及通知公告等模块用户可自主存酒取酒并等待审核管理员负责各类数据维护业务闭环完整。资源包共1828个文件包含127个Java源文件、96个Vue组件、84个HTML页面、92个CSS样式及4个SQL脚本另有jar依赖与配置文件压缩包约61.68MB源码、数据库脚本与毕业论文一并提供。已有47人学习适合作为毕设参考或SSM入门实战案例。1. 酒吧存酒系统为什么还在用 SSM一个被低估的落地场景酒吧存酒这件事外行听着像记个账内行才知道它是个典型的高频写入 强一致 多角色协作场景。客人存半瓶威士忌吧台要登记、贴标、入库下次来取服务员要能按手机号或存酒码秒查到位置月底老板要看损耗、看寄存周转、看哪些酒躺了三个月没人领。这套流程用 Excel 撑不过三个月用现成的 SaaS 又常常卡在改一个字段要等排期上。所以每年都有一批 Java 课程设计、毕业设计选这个题目用 SSMSpring SpringMVC MyBatis搭一套 B/S 架构的酒吧存酒系统源码、数据库脚本、论文一条龙。我前后帮人调过几套这类系统说句实在话SSM 在今天不算新但它对存酒这种业务反而是合适的。业务表结构清晰、事务边界明确、并发量不大一家店同时在线也就几个终端SSM 的 XML 映射和声明式事务能把存酒—取酒—核销这条链路管得死死的。你要是拿它去套微服务纯属给自己找罪受。这篇就按一线做法把基于 SSM 的酒吧存酒系统从选型、建表、核心接口到踩坑讲透新手能照着跑熟手能对着参数改。2. 存酒业务建模先想清楚一瓶酒的一生2.1 为什么存酒系统不能只建一张订单表很多人第一版就建一张deposit表字段塞满客人姓名、电话、酒名、数量、存入时间、取出时间。跑起来没问题但一遇到同一瓶酒分两次取就崩了——客人存了一整瓶第一次喝掉三分之一第二次再来取剩下的。这时候你那张表要么加个remain字段硬扛要么就得拆表。正确的建模思路是把存酒单和取酒记录分开。存酒单记录这瓶酒入库时的完整状态取酒记录记录每一次出库动作剩余量是算出来的而不是存出来的。这样对账、追溯、算损耗全都顺了。核心三张表wine_deposit存酒主单、wine_takeout取酒流水、wine_storage库位/货架。再配member会员、staff员工、store门店做基础数据。表名作用关键字段说明wine_deposit存酒主单id, member_id, wine_name, total_ml, deposit_code, status, create_timestatus: 0在存 1已取完 2过期wine_takeout取酒流水id, deposit_id, take_ml, operator_id, take_time每次取酒插一条不更新主单数量wine_storage库位id, store_id, shelf_no, deposit_id一单一库位方便吧台找酒member会员id, phone, name, levelphone 建唯一索引取酒靠它查staff员工id, store_id, name, rolerole 区分吧台/服务员/店长提示deposit_code是给客人看的取酒码建议用门店编号 日期 4位随机生成别用自增 id否则客人一眼看出你店里存了多少酒。2.2 SSM 三层怎么切别把事务写在 Controller 里SSM 的分层是老生常谈但存酒系统有个坑取酒这个动作必须是一个事务。它要同时做三件事——插一条取酒流水、更新主单状态取完了就置 1、如果取完还要释放库位。这三步任何一步失败都得回滚否则会出现流水记了但主单没更新的脏数据客人下次来查剩余量就对不上。所以事务边界必须放在 Service 层Controller 只做参数校验和返回封装。我一般这么切// WineDepositServiceImpl.java Service public class WineDepositServiceImpl implements WineDepositService { Autowired private WineDepositMapper depositMapper; Autowired private WineTakeoutMapper takeoutMapper; Autowired private WineStorageMapper storageMapper; // 取酒插流水 更新主单 释放库位三步一个事务 Override Transactional(rollbackFor Exception.class) public TakeoutResult takeWine(Long depositId, Integer takeMl, Long operatorId) { WineDeposit deposit depositMapper.selectById(depositId); if (deposit null || deposit.getStatus() ! 0) { throw new BizException(存酒单不存在或已取完); } int remain deposit.getTotalMl() - takeoutMapper.sumTakenMl(depositId); if (takeMl remain) { throw new BizException(取酒量超过剩余量剩余 remain ml); } // 1. 插取酒流水 WineTakeout takeout new WineTakeout(); takeout.setDepositId(depositId); takeout.setTakeMl(takeMl); takeout.setOperatorId(operatorId); takeout.setTakeTime(new Date()); takeoutMapper.insert(takeout); // 2. 取完了更新主单状态 if (takeMl remain) { depositMapper.updateStatus(depositId, 1); // 3. 释放库位 storageMapper.releaseByDepositId(depositId); } return new TakeoutResult(remain - takeMl); } }逻辑说明Transactional(rollbackFor Exception.class)里的rollbackFor一定要写Spring 默认只对RuntimeException回滚你自定义的BizException如果继承的是Exception不写这行就会抛了异常但数据没回滚这是血泪经验。参数上takeMl用毫升而不是瓶数因为存酒场景里客人经常只喝一部分用瓶数根本没法表达。2.3 数据库脚本索引和字符集两个细节建表脚本里有两个地方新手最容易翻车。第一是member.phone必须建唯一索引否则同一个手机号能注册出两个会员取酒时selectOne直接抛TooManyResultsException。第二是字符集统一用utf8mb4因为酒名里可能有 emoji 或者生僻字比如某些洋酒的中文译名utf8存不下四字节字符会直接报错。CREATE TABLE wine_deposit ( id BIGINT NOT NULL AUTO_INCREMENT, member_id BIGINT NOT NULL COMMENT 会员ID, wine_name VARCHAR(128) NOT NULL COMMENT 酒名, total_ml INT NOT NULL COMMENT 存入总量(ml), deposit_code VARCHAR(32) NOT NULL COMMENT 取酒码, status TINYINT DEFAULT 0 COMMENT 0在存 1已取完 2过期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_code (deposit_code), KEY idx_member (member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT存酒主单; CREATE TABLE member ( id BIGINT NOT NULL AUTO_INCREMENT, phone VARCHAR(20) NOT NULL, name VARCHAR(64) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会员;参数说明total_ml用INT够用一瓶 750ml就算存一箱也才几千。deposit_code建唯一索引防止并发下生成重复码。idx_member是为了按会员查所有存酒这个高频查询走索引不然会员存了十几瓶酒列表页会慢得肉眼可见。3. 从零跑通环境、配置与第一个接口3.1 环境版本别乱升SSM 有它的舒适区SSM 这套东西对版本很敏感尤其是 Spring 和 MyBatis 的搭配。我一般锁死这套组合JDK 8、Spring 5.2.x、MyBatis 3.5.x、MySQL 5.7 或 8.0、Tomcat 8.5。别上 JDK 17Spring 5.2 对高版本 JDK 的模块化支持不完整跑起来各种IllegalAccessError新手根本查不出来。Maven 依赖里mybatis-spring的版本要和 MyBatis 主版本对齐1.3.x 配 MyBatis 3.4/3.5 都行2.0.x 要求 Spring 5 以上。!-- pom.xml 关键依赖 -- properties spring.version5.2.8.RELEASE/spring.version mybatis.version3.5.6/mybatis.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version1.3.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency /dependencies逻辑说明spring-jdbc是DataSourceTransactionManager的依赖很多人只引spring-context和spring-webmvc结果事务注解不生效查半天以为是配置问题其实是包没引全。MySQL 驱动 5.1.49 配 MySQL 8.0 也能用但连接串要加serverTimezoneAsia/Shanghai否则时间字段会差 8 小时存酒时间全乱。3.2 Spring 与 MyBatis 整合三个必配项整合的核心就三样数据源、SqlSessionFactory、事务管理器。数据源用 Druid 或 HikariCP 都行课程设计里 Druid 更常见因为有监控页面。SqlSessionFactory里要配mapperLocations指向 XML 文件还要开mapUnderscoreToCamelCase不然数据库的create_time映射不到 Java 的createTime查出来全是 null。!-- applicationContext-dao.xml -- bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property nameurl valuejdbc:mysql://localhost:3306/bar_wine?useUnicodetrueamp;characterEncodingutf8mb4amp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword valueyour_password/ property nameinitialSize value5/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ /bean /property /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.bar.wine.mapper/ /bean bean idtxManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertxManager/参数说明maxActive20对单店够用酒吧同时在线终端撑死十来个。MapperScannerConfigurer会自动扫描接口生成代理省得一个个配。tx:annotation-driven是让Transactional生效的开关漏了它事务注解就是摆设。mapUnderscoreToCamelCase这个配置能省掉大量resultMap手写映射强烈建议开。3.3 存酒登记接口一个能直接抄的 Controller存酒登记是系统的入口动作吧台扫码或手输会员手机号选酒、填量、生成取酒码。这个接口要处理的核心是取酒码唯一性和会员不存在时自动建档。RestController RequestMapping(/api/deposit) public class DepositController { Autowired private WineDepositService depositService; PostMapping(/create) public ResultWineDeposit create(RequestBody DepositDTO dto) { if (dto.getPhone() null || dto.getTotalMl() null || dto.getTotalMl() 0) { return Result.fail(手机号和存酒量必填); } WineDeposit deposit depositService.createDeposit(dto); return Result.ok(deposit); } }逻辑说明Controller 只做参数校验业务全丢给 Service。DepositDTO里带phone、wineName、totalMl、storeId。Service 里先按 phone 查会员查不到就插一条新会员再生成取酒码、插主单、分配库位。取酒码生成用storeId yyyyMMdd 随机4位生成后要select一次确认没重复虽然概率低但并发下真会撞加个唯一索引兜底最稳。4. 避坑与排查存酒系统最容易翻车的五个地方4.1 取酒量算错剩余量到底该存还是该算现象客人第二次取酒系统显示的剩余量和实际对不上有时候多有时候少。原因第一版把remain_ml当成字段存在主单里每次取酒update remain_ml remain_ml - takeMl。并发下两个终端同时取读到的都是旧值扣完就少扣了。解决剩余量永远用total_ml - sum(take_ml)实时算主单不存剩余量。查询时用LEFT JOIN或者子查询聚合虽然多一次计算但数据永远是对的。存酒这种场景读多写少这点开销完全值得。4.2 事务不生效注解加了但数据没回滚现象取酒时故意让释放库位那步抛异常结果流水插进去了、主单也更新了就是没回滚。原因九成是Transactional加在了 Controller 上或者 Service 类没被 Spring 管理自己new出来的或者异常类型不在回滚范围内。解决确认注解在 Service 实现类的方法上、类被Service标注、rollbackFor Exception.class写上。还有个隐蔽的同类内部方法调用不走代理事务也不生效得通过注入自身或拆到另一个 Service。4.3 中文乱码从数据库到页面的全链路排查现象酒名存进去是问号或者页面显示乱码。原因字符集在四个地方可能出问题——数据库建库、表、连接串、Tomcat 的URIEncoding。解决建库建表统一utf8mb4连接串加characterEncodingutf8mb4Tomcat 的server.xml里Connector加URIEncodingUTF-8。四个地方缺一个都可能乱码排查时从数据库SHOW VARIABLES LIKE character%开始一层层往上查。4.4 取酒码重复并发下的唯一性保障现象极少数情况下两个客人拿到同一个取酒码取酒时查到两条记录。原因生成码的逻辑是查一下有没有重复没有就插入查和插之间有间隙并发下两个请求都查到没重复。解决deposit_code建唯一索引插入时捕获DuplicateKeyException重试生成。别指望应用层查重能百分百防住数据库唯一约束才是最后一道防线。4.5 过期存酒处理定时任务别用错线程池现象存酒超过三个月要自动置为过期状态但定时任务跑着跑着就不执行了。原因用了Scheduled默认的单线程调度器一个任务卡住后面全堵。或者任务里抛了异常没捕获Spring 的调度器直接把该任务停掉。解决配一个ThreadPoolTaskScheduler池大小设 2 到 3任务体里try-catch包住异常记日志别往外抛。过期处理这种批处理任务还要注意分批查比如每次 500 条别一次性select全表。5. 让存酒系统真正好用两个进阶技巧5.1 用 MyBatis 二级缓存扛住查存酒高频读存酒系统里最高频的操作是按手机号查这个会员所有在存的酒吧台一天要查几百次。这个查询结果变化不频繁只有存酒和取酒时才变非常适合上缓存。MyBatis 的二级缓存按 namespace 生效在 Mapper XML 里加一行cache/就能开但要注意任何 insert/update/delete 都会清空整个 namespace 的缓存所以取酒流水表的写操作会误伤存酒单的缓存。我的做法是给存酒单单独建一个只读的查询 Mapper和写操作的 Mapper 分开 namespace这样写操作不会清掉查询缓存。!-- WineQueryMapper.xml 只读查询独立 namespace -- mapper namespacecom.bar.wine.mapper.WineQueryMapper cache evictionLRU flushInterval600000 size512 readOnlytrue/ select idlistByPhone resultTypeWineDepositVO SELECT d.id, d.wine_name, d.total_ml, d.total_ml - IFNULL(SUM(t.take_ml), 0) AS remain_ml, d.deposit_code, d.create_time FROM wine_deposit d LEFT JOIN wine_takeout t ON t.deposit_id d.id WHERE d.member_id #{memberId} AND d.status 0 GROUP BY d.id /select /mapper参数说明evictionLRU最近最少使用淘汰flushInterval600000十分钟自动清一次size512缓存最多 512 个结果对象readOnlytrue表示只读MyBatis 会返回共享实例性能更好但别去改它。注意readOnlytrue时如果对象被修改会出问题所以这个 Mapper 只能查不能改。缓存和数据库的一致性靠flushInterval兜底存酒这种场景十分钟延迟完全能接受。5.2 取酒码做成二维码一个前端配合的小技巧客人取酒时报手机号容易报错换号、记错把取酒码做成二维码让客人存手机里吧台扫码枪一扫就出来体验直接上一个台阶。后端不用改太多取酒码本身就是字符串前端用任意二维码库生成即可。但有个细节二维码里别只放deposit_code要放一个带签名的短链或者 JSON防止有人伪造。我一般放{code:S001202401010001,sign:md5(codesecret)}后端扫码后验签验不过就拒绝。这样即使取酒码被猜到没有 secret 也伪造不出来。// 验签逻辑 public boolean verifyCode(String code, String sign) { String expect DigestUtils.md5Hex(code SECRET); return expect.equals(sign); }逻辑说明SECRET放配置文件里别硬编码。验签用 MD5 够用存酒场景不需要上 HMAC。扫码接口拿到 code 后先验签再查存酒单最后走正常的取酒流程。这样前端生成二维码、后端验签中间不用改数据库结构改动量最小。最后说个我自己的习惯每次接手这类 SSM 存酒系统我第一件事不是看代码而是先把数据库脚本跑一遍手动插几条存酒和取酒数据用 SQL 把剩余量算出来对一遍。数据模型对了代码再乱都能救数据模型错了代码写得再漂亮也是白搭。希望帮到你。本文还有配套的精品资源点击获取