尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Spring Boot驾校练车预约系统:从数据库建模到并发控制实战
做一个驾校练车预约系统是我带过最典型的计算机毕业设计题目之一。项目本身不大不小复杂度刚好卡在“工作量足够展示技术栈”和“难度不至于做不出来”之间非常适合作为Spring Boot入门后的完整实践项目。我最近刚好把一个完整的springboot驾校练车预约系统源码重新梳理了一遍从架构设计到业务逻辑踩了不少坑也总结出一套可以直接“抄作业”的落地思路。这篇就围绕这个系统的核心设计、数据库建模、关键代码实现和上线前最容易出问题的地方展开给正在做毕设或者准备做类似管理系统的朋友一个参考。1. 项目整体设计与技术选型1.1 需求背景传统人工约车到底痛在哪里多数驾校的练车预约还停留在微信群里喊话、教练手动排表的阶段。学员想约车得看教练在不在线教练排课靠手写本子驾校管理者想统计某个教练一月的带教学时要把记录翻个底朝天。这套系统要解决的就是让约车这件事从“人肉调度”变成“系统自动调度”。从业务角色上看驾校练车预约系统一共涉及三类用户学员、教练、系统管理员。学员端需要能查看教练的可约时段、提交预约、取消预约教练端需要能维护自己的可授课时间、查看预约学员列表管理员端则是全局视角管理教练信息、学员信息、课程类型以及核心的预约规则配置。这个角色模型基本覆盖了绝大多数驾校的业务场景也是毕设答辩时最能讲清楚需求的部分。1.2 为什么是Spring Boot选Spring Boot作为核心开发框架不是因为它是“最流行的”而是因为它和这个场景的匹配度太高。传统Java SSM项目最烦人的就是那一堆XML配置文件光是把Spring、SpringMVC、MyBatis三个框架整合起来就能耗掉毕业设计一半的时间。Spring Boot用自动配置加约定优于配置的思路把这一整块痛苦直接消除掉了。一个内嵌Tomcat一个application.yml依赖管理交给Maven或Gradle写业务代码的时间能翻倍。这个项目我用的具体版本组合是Spring Boot 2.7.xJDK 1.8MyBatis-Plus 3.5.xMySQL 5.7Maven 3.6Spring Boot 2.7.x是2.x系列的收尾版本稳定性经过了大量生产环境验证网上能找到的中文资料也最多。为什么要锁JDK 1.8而不是用最新版JDK 17或21因为大部分学校机房、老服务器和教学环境都还是JDK 1.8而且Spring Boot 2.x对JDK 1.8的支持是最完善的不需要额外处理模块化相关的兼容问题。等答辩结束想升级到3.x那是后话先把系统跑起来才是王道。1.3 技术栈组合的取舍思路MyBatis-Plus的选择也是经过考虑的。原生MyBatis写单表CRUD需要手写大量重复SQL而MyBatis-Plus把常用的增删改查、分页查询全部封装好了开发者只需要写业务逻辑相对复杂的多表关联和统计查询。这个项目里预约记录表、教练课时表、用户表之间的关联查询比较多用MyBatis-Plus的条件构造器能省下差不多三分之一的数据层代码量。更重要的是MyBatis-Plus的代码生成器可以直接根据数据表反向生成实体类、Mapper接口和Service层毕业设计里这个效率优势很关键。前端部分如果时间紧就用Thymeleaf做服务端渲染配合Bootstrap和jQuery不需要单独起Vue前端项目部署时一个jar包全搞定。如果时间和精力允许也可以做Spring Boot Vue的前后端分离但我个人建议毕设阶段优先保证业务完整度前后端分离项目在答辩演示时反而容易因为跨域、Node环境等问题翻车。这个项目的完整技术栈如下层级技术选型用途说明后端框架Spring Boot 2.7提供RESTful接口与Web服务ORM框架MyBatis-Plus 3.5简化数据库操作内置分页插件数据库MySQL 5.7存储用户、教练、预约等核心业务数据前端渲染Thymeleaf Bootstrap页面模板快速搭建管理界面安全组件Spring Security或拦截器登录认证与角色权限控制构建工具Maven依赖管理与项目打包数据库工具Navicat / SQLyog可视化操作数据库方便调试这套组合最大的好处是“全班最稳”就算答辩时电脑出了意外随便换一台装好JDK和MySQL的机器配置一下数据库连接java -jar就能把系统跑起来不依赖复杂的外部中间件也不需要额外启动前端项目。2. 核心业务设计与数据库建模2.1 用户-教练-时段 三者如何建立关系驾校练车预约系统的核心实际上是三张基础表加一张核心业务表的关系设计。我在设计数据库时先画了实体关系图把关键业务路径梳理清楚才开始建表这样可以避免后续开发时反复修改表结构。系统涉及的主体包括系统用户user包含学员和教练两种角色用角色字段区分。学员信息里有学员证编号教练信息里有执教证编号和准驾车型。教练车辆coach_car记录驾校的车辆信息包括车牌号、车型C1/C2等、所属驾校供排课时分配。课程类型course如科目二、科目三不同课程可能有不同的时长和收费标准。练车课时段training_slot教练可被预约的时间片这是整套系统的核心资源。预约记录booking_record学员与某个时段关联的预约关系包含状态流转。2.2 预约记录表的核心字段设计预约记录表是整个系统中最重要的表其核心字段包括预约人ID、教练ID、车辆ID、课程类型ID、预约日期、开始时间、结束时间、预约状态、创建时间和支付状态。这里要重点强调的是预约状态这个字段它不能只用一个简单的“已预约”来标记而是要设计成一个状态机。CREATE TABLE booking_record ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, user_id bigint(20) NOT NULL COMMENT 学员用户ID, coach_id bigint(20) NOT NULL COMMENT 教练ID, car_id bigint(20) DEFAULT NULL COMMENT 车辆ID, course_id bigint(20) DEFAULT NULL COMMENT 课程类型ID, slot_id bigint(20) NOT NULL COMMENT 课时段ID, booking_date date NOT NULL COMMENT 预约日期, start_time varchar(10) NOT NULL COMMENT 开始时间 如08:00, end_time varchar(10) NOT NULL COMMENT 结束时间 如09:00, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态: 1待练车 2已练车 3已取消 4爽约, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_booking_date (booking_date), KEY idx_coach_id (coach_id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT练车预约记录表;设计状态字段时我用的是整数类型而不是字符串这样后续做统计查询时效率更高代码里通过枚举类来映射可读名称public enum BookingStatus { PENDING(1, 待练车), COMPLETED(2, 已练车), CANCELED(3, 已取消), NO_SHOW(4, 爽约); private final int code; private final String desc; BookingStatus(int code, String desc) { this.code code; this.desc desc; } }这里有个我在开发中踩过的坑如果只用一个“已预约”状态就无法区分已经练完的车和预约了但不来的情况导致后续生成教练工作量报表时数据不准确。引入状态机之后管理员可以手动完成状态流转系统也可以通过定时任务自动把超时未练车的预约标记为爽约。2.3 课时段表的设计是预约系统的灵魂课时段training_slot表的设计直接决定了整个系统能不能真正运转起来。我在初始设计时犯过一个错误把开始时间和结束时间直接内嵌在预约记录里结果导致教练在某个时段被多人预约时很难做冲突检测。后来重构为正儿八经的“教练排课”思路CREATE TABLE training_slot ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, coach_id bigint(20) NOT NULL COMMENT 教练ID, car_id bigint(20) DEFAULT NULL COMMENT 关联车辆ID, booking_date date NOT NULL COMMENT 日期, start_time varchar(10) NOT NULL COMMENT 开始时间, end_time varchar(10) NOT NULL COMMENT 结束时间, max_students int(11) NOT NULL DEFAULT 1 COMMENT 最大可预约人数, booked_count int(11) NOT NULL DEFAULT 0 COMMENT 已预约人数, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态: 1可预约 2约满 3已锁定, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教练课时段表;教练先维护自己未来一周或几周的可用课时段管理员也可以代排学员预约时实际上是去抢占某个课时段的名额。每次预约成功后对应课时段的booked_count就加一当booked_count等于max_students时就自动把状态置为“约满”。为什么是脚本匹配而不是像科目一那样一招多练John的实践经验对总体设计也很有借鉴意义你如果真的按教练的时间片去排最大好处是在创建预约时天然就保证了教练冲突检测只发生在一个极小的时间窗口内。如果学员在8点到9点预约了教练1那另一位学员就无法在同一时间段再约到教练1系统只需要校验同一教练的课时段有没有重叠即可。如果把时间直接挂在预约记录上每次预约都要扫一遍预约记录表中同教练同日期下的所有时间区间数据量大之后效率堪忧。这个课时段的设计方案大大简化了冲突检测逻辑查询效率也高在答辩时讲清楚这个设计思路比堆砌再多的CRUD功能都更能说明你理解了业务。3. 核心实现细节与关键代码3.1 登录认证与角色权限控制系统里存在三类角色所以不能只做一个简单的登录判断。我用Spring Boot拦截器实现了一个轻量级的权限控制方案核心逻辑是登录成功后把用户对象放到Session中定义一个NeedLogin注解在需要权限控制的方法上标注角色类型拦截器中校验当前用户角色是否匹配。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; NeedLogin needLogin handlerMethod.getMethodAnnotation(NeedLogin.class); if (needLogin null) { return true; } User user (User) request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(/login); return false; } int requireRole needLogin.role(); if (requireRole ! UserRole.ADMIN user.getRole() ! requireRole) { request.setAttribute(errorMsg, 权限不足); request.getRequestDispatcher(/error/403).forward(request, response); return false; } return true; } }这个方案比引入Spring Security来得实在。毕业设计项目用Spring Security做复杂权限管理本身就需要大量配置和源码理解如果只为了给小程序接口加上登录校验那是典型的过度设计。用拦截器加自定义注解代码量小、逻辑直观、答辩时容易讲清楚也方便做扩展。3.2 核心预约逻辑的防冲突实现预约是本系统最核心的操作必须保证同一课时不能被重复预约。前面已经提到课时段表充当了资源锁的角色在代码层面我用的是“原子更新”的思路Transactional public BookingResult createBooking(BookingRequest request, User student) { // 1. 查询课时段并加锁 TrainingSlot slot trainingSlotMapper.selectByIdForUpdate(request.getSlotId()); if (slot null) { return BookingResult.error(课时段不存在); } // 2. 检查状态 if (slot.getStatus() SlotStatus.SOLDOUT.getCode()) { return BookingResult.error(该时段已被约满); } if (slot.getBookedCount() slot.getMaxStudents()) { return BookingResult.error(预约人数已达上限); } // 3. 防止重复预约同一天同一学员只能预约同一教练一次 Integer existing bookingRecordMapper .checkExistingBooking(request.getSlotId(), student.getId()); if (existing ! null existing 0) { return BookingResult.error(您已预约该时段请勿重复操作); } // 4. 更新课时段已预约人数 int rows trainingSlotMapper.increaseBookedCount(slot.getId()); if (rows 0) { return BookingResult.error(多人同时预约该时段已被抢走); } // 5. 创建预约记录 BookingRecord record new BookingRecord(); record.setUserId(student.getId()); record.setCoachId(slot.getCoachId()); record.setSlotId(slot.getId()); record.setBookingDate(slot.getBookingDate()); record.setStartTime(slot.getStartTime()); record.setEndTime(slot.getEndTime()); record.setStatus(BookingStatus.PENDING.getCode()); bookingRecordMapper.insert(record); return BookingResult.success(record); }第1步的selectByIdForUpdate是关键它使用的是数据库的行级锁。在高并发预约场景下多个学员同时点击同一个时段的预约按钮时数据库会将这些请求排队处理后到的事务会拿到最新的事务数据从而使更新操作互相感知。如果不加这个for update两个请求同时读到已预约人数为0都认为可以预约就可能出现约重的情况。这是典型的乐观锁和悲观锁取舍问题此处选择悲观锁是因为预约操作本身不是高频操作行锁带来的性能损耗微乎其微但数据准确性的保障确是最强的。3.3 取消预约与状态回退学员端还有取消预约的需求。取消预约不能只改一条状态还涉及课时段的预约人数回退。这里同样使用事务保证一致性Transactional public BookingResult cancelBooking(Long bookingId, Long userId) { BookingRecord record bookingRecordMapper.selectById(bookingId); if (record null) { return BookingResult.error(预约记录不存在); } if (!record.getUserId().equals(userId)) { return BookingResult.error(不能取消他人的预约); } if (record.getStatus() ! BookingStatus.PENDING.getCode()) { return BookingResult.error(当前状态不可取消); } // 回退课时段已预约人数 trainingSlotMapper.decreaseBookedCount(record.getSlotId()); // 更新预约记录状态 record.setStatus(BookingStatus.CANCELED.getCode()); bookingRecordMapper.updateById(record); return BookingResult.success(); }这里要注意一个细节取消预约需要检查预约时间距离当前时间是否足够提前。我在项目里配置了一个规则距离开课少于2小时的课时不允许取消这是我在实际调研发现驾校的常规做法——给教练留出准备时间也防止学员恶意占坑。这个业务规则可以单独抽出一个配置项方便管理员调整booking: cancel-deadline-hours: 23.4 教练端排课的时间校验教练维护课时段时最怕排课时间相互重叠。如果教练在8:00-10:00已经排了课就不能再插入一个9:00-11:00的时段。这类校验我用了一个简单但直观的SQLSELECT COUNT(*) FROM training_slot WHERE coach_id #{coachId} AND booking_date #{date} AND status ! 3 AND ( (start_time #{endTime} AND end_time #{startTime}) )只要这个查询结果大于0就说明教练在选定的时间段已经有排课需要提示冲突。我在实际测试中发现有些同学会忽略状态条件把已经“锁定”的旧时段也算进去导致教练明明没有空闲时间却无法排课。状态过滤一定要做好这是典型的小细节大问题。3.5 首页数据看板与统计图表管理员端我加了一个简易的数据看板统计今天的预约总数、学员总人数、教练总人数和今日完成课时数。这些统计都用MyBatis-Plus的聚合查询完成public DashboardVO getDashboardData() { DashboardVO vo new DashboardVO(); // 今日预约总数 QueryWrapperBookingRecord todayWrapper new QueryWrapper(); todayWrapper.eq(booking_date, LocalDate.now()); vo.setTodayBookingCount(bookingRecordMapper.selectCount(todayWrapper)); // 学员/教练总数 vo.setStudentCount(userMapper.selectCount( new QueryWrapperUser().eq(role, UserRole.STUDENT))); vo.setCoachCount(userMapper.selectCount( new QueryWrapperUser().eq(role, UserRole.COACH))); // 本周每日预约趋势用于前端ECharts展示 ListMapString, Object trendList bookingRecordMapper.selectWeeklyTrend(); vo.setWeeklyTrend(trendList); return vo; }前端用ECharts把周趋势数据渲染成折线图这一块在答辩时视觉效果很加分。数据不需要很复杂但要有直观的图表展示能给答辩评委留下一个直观印象觉得你考虑到了数据的可视化呈现。4. 部署上线与常见问题排查4.1 从源码到运行的完整部署步骤很多同学把代码写完结果在部署环节卡住了。我整理一份完整的实操流程按这个顺序操作一般不会出错第一步准备环境安装JDK 1.8并配置JAVA_HOME安装MySQL 5.7在MySQL中创建数据库并执行项目的sql脚本整个建表语句和初始化数据都放在项目根目录的sql文件夹下文件名一般是init.sql或database.sql。mysql -u root -p init.sql第二步修改配置文件打开src/main/resources/application.yml修改数据库连接spring: datasource: url: jdbc:mysql://localhost:3306/driving_school?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你自己的数据库密码 driver-class-name: com.mysql.cj.jdbc.Driver这里有个经常遇到的坑数据库密码如果是纯数字或者有等特殊字符需要在YAML配置文件里加上英文双引号否则会被解析成数字或作为注释符号导致连接失败。第三步项目打包mvn clean package -DskipTests构建完成后target目录下会生成一个以项目名命名的jar包。个人建议真正部署时使用命令行运行项目而不是依赖IDE中的main方法启动这样对实际运行的工程化理解也能加深不少。java -jar target/driving-school-0.0.1-SNAPSHOT.jar看到“Started”日志输出后浏览器访问http://localhost:8080就可以看到登录页面。4.2 高频踩坑问题汇总问题一端口被占用Tomcat默认使用8080端口如果本机装了其他服务占用了8080启动会直接报Port already in use。解决办法是改application.yml中的端口配置server: port: 8090问题二MySQL时区报错连接字符串里的serverTimezoneAsia/Shanghai这个参数不能省略。MySQL 5.7以后如果不指定时区Java 8的JDBC驱动会报错CST时区无法识别的异常。问题三数据库初始化数据不全有些版本的项目sql脚本里只含表结构没有初始数据。建议在导入数据后手动插入一条管理员账号例如admin/admin123方便第一时间登录系统验证功能。问题四Maven依赖下载缓慢国内网络直接从Maven中央仓库下载依赖会遇到慢或失败的情况。在pom.xml中或者settings.xml里配置阿里云Maven镜像下载速度能快十倍。mirror idaliyunmaven/id mirrorOfcentral/mirrorOf name阿里云公共仓库/name urlhttps://maven.aliyun.com/repository/central/url /mirror4.3 答辩现场演示准备的3条实战建议毕业设计做完代码只是起点答辩演示才是最关键的临门一脚。我总结了三条实战经验第一不要现场演示写代码或改配置。提前把系统跑起来整理好演示顺序管理员登录、查看看板、新增教练、排课、学员注册、预约、取消预约、补考流程。演示脚本要提前练习。第二准备一份备用环境。简历演示的时候最怕的是突然断网或者数据库连不上。确保系统是纯local运行不要依赖云端服务。如果怕笔记本有意外建议提前准备一份虚拟机环境或者另一台电脑做备用。第三把业务场景讲得具体。答辩时不要说“这是简单的一个预约功能”而是说“学员预约时会先检查课时段的剩余名额再使用行级锁保证并发场景下不会出现重复预约成功预约后状态流转到待练车教练可以按天查看自己当天的课时安排”。这样把技术点自然融入业务描述中评委会觉得你对项目真正有深入的理解。5. 项目扩展方向与个人经验总结做完了基础版的预约管理系统如果你想在答辩中脱颖而出有四个扩展方向可以选增加线上模拟练车功能。把科目二、科目三的考试线路图做成模拟练车模式学员可以提前在系统里走一遍流程。引入练车记录与评价系统。每次练车结束后教练可以填写带教评价学员也能给教练评分。双向评价机制在管理系统中是一个很好的亮点。部署成前后端分离架构接口用JWT做无状态认证前端用Vue3 Element Plus完成页面整个项目复杂度会上一个台阶。增加消息通知模块预约成功后推送站内信或集成邮件/短信发送提醒减少学员错过练车时间的概率。说回到开头的题目计算机毕业设计的关键从来不是“越复杂越好”而是“逻辑完整、技术合理、能讲清楚”。驾校练车预约系统背后涉及的并发控制、事务一致性、角色权限、状态机设计都是真实业务系统中的常见问题做好了这些基础功应付毕业设计绰绰有余同时也为后续工作中更复杂的业务场景打好了基础。这个项目我实际开发过程中最大的体会是数据库建模阶段多花的时间在开发阶段都会几倍地赚回来。把课时段单独抽成资源表的设计让后面所有业务逻辑都变得简洁清晰。如果你正在做类似的预约类系统不妨先停下来认真设计资源模型和状态机想清楚了再动手写代码。
RELATED

相关推荐

政府燃气安全监管平台是什么?5 大核心功能与应用价值详解

政府燃气安全监管平台是什么?5 大核心功能与应用价值详解

燃气安全监管平台正在从单一的信息化工具演变为城市治理体系的关键基础设施。长期以来,燃气安全监管面临数据分散、协同不畅、责任难压实等结构性难题,仅靠传统行政手段已难以应对日益复杂的城市燃气风险。随着物联感知、地理信息与人工智能技术的深度融…

📅 2026/9/18 20:51:30
C#机房重构中的密码模块设计与工业级实践

C#机房重构中的密码模块设计与工业级实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/18 20:51:30
Hydrogen v2 无头电商模板实战指南:基于 Remix 的 Shopify 店铺从零部署到 Vercel

Hydrogen v2 无头电商模板实战指南:基于 Remix 的 Shopify 店铺从零部署到 Vercel

Hydrogen v2 无头电商模板实战指南:基于 Remix 的 Shopify 店铺从零部署到 Vercel 【免费下载链接】examples Enjoy our curated collection of examples and solutions. Use these patterns to build your own robust and scalable applications. 项目地址: http…

📅 2026/9/18 20:51:30
MORE NEWS

更多资讯

📰

Node.js 19.8.0 (Current) 发布解析:八项新特性、底层实现与下载校验指南

Node.js 19.8.0 (Current) 发布解析:八项新特性、底层实现与下载校验指南 【免费下载链接】nodejs.org The Node.js Website 项目地址: https://gitcode.com/GitHub_Trending/no/nodejs.org Node.js v19.8.0 于 2023 年 3 月 14 日作为 Current 版本线发布&a…

📰

BP神经网络在认知无线电频谱预测中的应用与MATLAB实现

简介:此PDF是一篇面向通信工程、认知无线电与机器学习交叉领域研究者的学术论文,核心研究基于BP神经网络的智能认知频谱预测技术,意在解决频谱接入效率不足、预测准确性受限等问题。论文从BP神经网络“误差反向传播、最速下降法学习”的原理入…

📰

ESP32接入OneNet云平台:MQTT多设备联动实战与避坑指南

手头有两块ESP32,一块接DHT11负责采集温湿度,一块接继电器带着风扇和补光灯。刚开始我图省事,让两块板子直接走局域网通信,结果问题一堆:主控板不开机,采集板就把数据丢掉;两台设备不在同一个网…

📰

SRS 连接失败怎么修?Metahuman-Stream 三层排障完整指南

SRS 连接失败怎么修?Metahuman-Stream 三层排障完整指南 【免费下载链接】metahuman-stream Real time interactive streaming digital human 项目地址: https://gitcode.com/GitHub_Trending/me/metahuman-stream 部署数字人流式项目 Metahuman-Stream 时&a…

📰

大模型system prompt泄露风险与实战防御指南

1. 项目概述:什么是 system_prompts_leaks?它为什么值得一线开发者警惕“system_prompts_leaks”不是某个具体工具、插件或开源项目,而是一个正在快速发酵的技术现象级术语——它指代一类在大模型应用开发与部署过程中,因工程实现…

📰

Keil5双架构环境搭建:C51与STM32共存的底层逻辑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬