简介:这是一套基于SSM框架开发的酒吧存酒系统完整项目资源,面向计算机专业学生、Java初学者及需要课程设计或毕业设计素材的开发者。项目采用B/S架构,后端整合Spring、SpringMVC、MyBatis与Maven,前端使用HTML与Vue,数据库为MySQL 5.7,JDK要求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 课程设计、毕业设计选这个题目,用 SSM(Spring + 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_time | status: 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, level | phone 建唯一索引,取酒靠它查 |
| staff | 员工 | id, store_id, name, role | role 区分吧台/服务员/店长 |
提示:
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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 17,Spring 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.version>5.2.8.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>1.3.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> </dependencies>逻辑说明:spring-jdbc是DataSourceTransactionManager的依赖,很多人只引spring-context和spring-webmvc,结果事务注解不生效,查半天以为是配置问题,其实是包没引全。MySQL 驱动 5.1.49 配 MySQL 8.0 也能用,但连接串要加serverTimezone=Asia/Shanghai,否则时间字段会差 8 小时,存酒时间全乱。
3.2 Spring 与 MyBatis 整合:三个必配项
整合的核心就三样:数据源、SqlSessionFactory、事务管理器。数据源用 Druid 或 HikariCP 都行,课程设计里 Druid 更常见因为有监控页面。SqlSessionFactory里要配mapperLocations指向 XML 文件,还要开mapUnderscoreToCamelCase,不然数据库的create_time映射不到 Java 的createTime,查出来全是 null。
<!-- applicationContext-dao.xml --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="url" value="jdbc:mysql://localhost:3306/bar_wine?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="your_password"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.bar.wine.mapper"/> </bean> <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="txManager"/>参数说明:maxActive=20对单店够用,酒吧同时在线终端撑死十来个。MapperScannerConfigurer会自动扫描接口生成代理,省得一个个配。tx:annotation-driven是让@Transactional生效的开关,漏了它事务注解就是摆设。mapUnderscoreToCamelCase这个配置能省掉大量resultMap手写映射,强烈建议开。
3.3 存酒登记接口:一个能直接抄的 Controller
存酒登记是系统的入口动作,吧台扫码或手输会员手机号,选酒、填量、生成取酒码。这个接口要处理的核心是取酒码唯一性和会员不存在时自动建档。
@RestController @RequestMapping("/api/deposit") public class DepositController { @Autowired private WineDepositService depositService; @PostMapping("/create") public Result<WineDeposit> 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,连接串加characterEncoding=utf8mb4,Tomcat 的server.xml里Connector加URIEncoding="UTF-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 namespace="com.bar.wine.mapper.WineQueryMapper"> <cache eviction="LRU" flushInterval="600000" size="512" readOnly="true"/> <select id="listByPhone" resultType="WineDepositVO"> 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>参数说明:eviction="LRU"最近最少使用淘汰,flushInterval="600000"十分钟自动清一次,size="512"缓存最多 512 个结果对象,readOnly="true"表示只读,MyBatis 会返回共享实例,性能更好但别去改它。注意readOnly="true"时如果对象被修改会出问题,所以这个 Mapper 只能查不能改。缓存和数据库的一致性靠flushInterval兜底,存酒这种场景十分钟延迟完全能接受。
5.2 取酒码做成二维码:一个前端配合的小技巧
客人取酒时报手机号容易报错(换号、记错),把取酒码做成二维码让客人存手机里,吧台扫码枪一扫就出来,体验直接上一个台阶。后端不用改太多,取酒码本身就是字符串,前端用任意二维码库生成即可。但有个细节:二维码里别只放deposit_code,要放一个带签名的短链或者 JSON,防止有人伪造。我一般放{"code":"S001202401010001","sign":"md5(code+secret)"},后端扫码后验签,验不过就拒绝。这样即使取酒码被猜到,没有 secret 也伪造不出来。
// 验签逻辑 public boolean verifyCode(String code, String sign) { String expect = DigestUtils.md5Hex(code + SECRET); return expect.equals(sign); }逻辑说明:SECRET放配置文件里,别硬编码。验签用 MD5 够用,存酒场景不需要上 HMAC。扫码接口拿到 code 后先验签,再查存酒单,最后走正常的取酒流程。这样前端生成二维码、后端验签,中间不用改数据库结构,改动量最小。
最后说个我自己的习惯:每次接手这类 SSM 存酒系统,我第一件事不是看代码,而是先把数据库脚本跑一遍,手动插几条存酒和取酒数据,用 SQL 把剩余量算出来对一遍。数据模型对了,代码再乱都能救;数据模型错了,代码写得再漂亮也是白搭。希望帮到你。
本文还有配套的精品资源,点击获取