
简介面向计算机专业课程设计与毕业设计场景的酒店管理系统完整项目源码采用VC/MFC实现围绕数据库管理系统的调查分析、逻辑模型构建、数据库架构到详细设计、编码与测试的完整流程展开。系统功能覆盖房间管理与房态查询、人员入住登记、入住人员查询管理、退房结算、房间预订以及系统用户管理等核心业务模块。压缩包共52个文件约10.63MB以17个.h头文件与16个.cpp源文件为主体配套sln、vcproj工程文件以及rc资源脚本、ico图标等可直接用Visual Studio打开编译另附软件工程报告书.doc、小组分工.txt与ReadMe说明便于理解设计思路与模块划分。已有151人学习。读者可从中获得一套可运行的MFC桌面系统参考实现、数据库表结构与业务逻辑组织方式以及课程设计所需的报告文档与分工材料适合用作毕业设计选题参考和MFC与数据库课程实践的学习范例。1. 一间房开给两拨客人酒店管理系统真正要守住的那条线答辩现场最容易被问住的不是用了什么框架而是“两个前台同时把最后一间大床房开给两拨客人会怎样”。酒店管理系统看着像一堆增删改查堆出来的课程设计难点全压在房态和订单这条同步线上一间房同一时刻只能住一拨人一张单子同一时刻只能有一种状态。这个模拟项目把范围收在四块——房型房间维护、在线预订、入住登记与换房、退房结算与房态回收外加一套基于角色的后台权限。适合正在做毕业设计、想要一个业务闭环完整、能讲清楚事务和并发的新手也适合想找个干净骨架练手的熟手。下面按怎么搭、怎么跑、怎么改一路拆。2. 工程骨架Spring Boot Vue 前后端分离的目录怎么摆2.1 三个候选方案摆一起为什么选这一套课程设计常见的三条路SSM JSP、Spring Boot Vue 前后端分离、以及 Python 的 MTV 模板渲染。第一套资料最多但 JSP 页面里嵌 Java 代码改一个字段要动三层答辩时演示效果也偏旧第三套上手最快可一旦被问到“前后端怎么分离、怎么鉴权”容易讲不圆。我一般直接推中间这套理由很实在维度SSM JSPSpring Boot VueMTV 模板环境搭建成本中XML 配置多低起步依赖一把梭低前后端边界模糊页面即接口清晰JSON 交互模糊讲并发和事务的余地有但被页面淹没大接口层单独可控一般演示观感传统表单房态看板、图表都能做传统表单具体版本我习惯锁在 Spring Boot 2.7 MyBatis-Plus 3.5 MySQL 8 Vue 2 Element UIJDK 用 8 或 11。这套组合的社区资料最厚出了问题搜得到答案这一点对赶进度的毕业设计比“技术先进”重要得多。数据库访问层用 MyBatis-Plus 而不是纯 JPA是因为房态流转那几个语句需要手写带条件的 UPDATEORM 自动生成的语句反而不好控制。2.2 工程目录前后端各管什么公共包放哪前后端拆成两个独立工程再单放一个 sql 目录交付和部署都干净hotel-system/ ├── hotel-server/ # 后端独立跑在 8080 │ ├── src/main/java/com/example/hotel/ │ │ ├── HotelApplication.java # 启动类 │ │ ├── common/ # Result 统一返回、BizException、枚举、常量 │ │ ├── config/ # 拦截器注册、跨域、MyBatis-Plus 分页插件 │ │ ├── controller/ # 只做参数校验和调用 service不写业务 │ │ ├── service/ service/impl/ # 业务与事务边界都在这一层 │ │ ├── mapper/ # 接口复杂 SQL 走 XML │ │ ├── entity/ # 与表一一对应 │ │ └── dto/ vo/ # 入参对象和出参对象不直接暴露 entity │ └── src/main/resources/ │ ├── application.yml │ └── mapper/*.xml ├── hotel-web/ # 前端开发期跑在 8081 │ ├── src/api/ # 按模块拆的接口函数 │ ├── src/views/ # room / order / checkin / report │ ├── src/router/ # 路由 角色守卫 │ └── src/utils/request.js # axios 实例与拦截器 └── sql/hotel.sql # 建库建表 初始房型房间数据有两个地方值得强调。一是dto和vo必须跟entity分开直接拿实体接前端参数早晚会出现“前端传个 status 把房态改了”的尴尬。二是common包里放ResultT所有接口返回统一结构{code, msg, data}前端拦截器只认这一种形状后面加接口不用再改前端。2.3 跑起来的三段式建库、起服务、连前端第一步先建库。用 MySQL 8 建一个hotel_db字符集一定选utf8mb4客人姓名里的生僻字和多音字才存得进去CREATE DATABASE hotel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 导入 sql/hotel.sql 之后先确认四张表都在 USE hotel_db; SHOW TABLES;第二步改application.yml把账号密码换成自己机器上的。下面这段里serverTimezone和map-underscore-to-camel-case两个配置少一个都会在后面翻车server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hotel_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: root mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # type_id 自动映射到 typeId log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期把 SQL 打出来第三步起前端。npm install之后跑npm run serve端口改成 8081避开后端 8080。开发期不要在后端到处加CrossOrigin用 devServer 代理更省事配置在vue.config.jsmodule.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } // 后端接口没有 /api 前缀转发时去掉 } } } }前后端能各自起来、登录页能拿到 token骨架这一步就算过了。接下来所有坑都出在业务上。3. 数据模型拆解房型、房间、订单、入住单四张表怎么定3.1 四张核心表的字段取舍很多人第一版只建两张表房间和订单。跑到“一个订单订了三间房其中一间要换房”就卡住了。这个项目从一开始就按四张表建模房型是销售单元房间是物理单元订单是交易凭证入住单是住宿事实。CREATE TABLE room_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(32) NOT NULL COMMENT 房型名如豪华大床房, price DECIMAL(10,2) NOT NULL COMMENT 挂牌单价, total_rooms INT NOT NULL COMMENT 该房型房间总数, available INT NOT NULL COMMENT 当前可售余量下单即扣, capacity TINYINT DEFAULT 2 COMMENT 可住人数, UNIQUE KEY uk_name (type_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT房型; CREATE TABLE room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(10) NOT NULL COMMENT 房间号, type_id BIGINT NOT NULL, floor TINYINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1已预订 2已入住 3待清扫 4维修, UNIQUE KEY uk_room_no (room_no), KEY idx_type_status (type_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT物理房间; CREATE TABLE hotel_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务单号前台报号查单用, guest_name VARCHAR(32) NOT NULL, phone VARCHAR(20) NOT NULL, type_id BIGINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, room_count INT NOT NULL DEFAULT 1, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待入住 1已入住 2已取消 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_type_date (type_id, check_in, check_out) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预订订单; CREATE TABLE checkin ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, room_id BIGINT NOT NULL, guest_name VARCHAR(32) NOT NULL, actual_in DATETIME NOT NULL, actual_out DATETIME NULL, deposit DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 押金, extra_fee DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 迷你吧、赔偿等杂费, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在住 2已退房, KEY idx_room_status (room_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT入住单;几个细节值得说。订单表叫hotel_order而不是order因为order是 SQL 保留字写原生 SQL 时到处要加反引号早晚出事。金额字段一律DECIMAL别用DOUBLE房价加押金减杂费的浮点误差在结算页面上会变成“退了 199.99999 元”。check_in/check_out用DATE而不是DATETIME因为酒店行业按间夜算时间点没有意义。3.2 房态状态机只允许这七种跳转房态是这套系统最容易被改乱的地方。用枚举把状态钉死业务代码里只允许状态机里定义过的跳转public enum RoomStatus { FREE(0, 空闲), BOOKED(1, 已预订), OCCUPIED(2, 已入住), DIRTY(3, 待清扫), REPAIR(4, 维修中); private final int code; private final String label; RoomStatus(int code, String label) { this.code code; this.label label; } public int getCode() { return code; } public String getLabel() { return label; } /** 判断从当前状态能否跳到目标状态非法跳转直接抛业务异常 */ public boolean canTransferTo(RoomStatus target) { switch (this) { case FREE: return target BOOKED || target OCCUPIED || target REPAIR; case BOOKED: return target OCCUPIED || target FREE; // 取消预订回到空闲 case OCCUPIED: return target DIRTY; // 退房必先进待清扫 case DIRTY: return target FREE || target REPAIR; // 保洁确认后才可再售 case REPAIR: return target FREE; default: return false; } } }OCCUPIED只能跳DIRTY这一条是整个设计的核心。如果退房时直接把房间置回FREE第二天早上同一间房被开给新客人、保洁还没进门这种投诉在真实酒店里是要赔钱的。这段状态机写成枚举方法而不是散在 service 里的 if-else好处是后面加“锁房”“自用房”两个状态时只需要改这一处。3.3 订单与入住单为什么必须拆开订单是「我要住」入住单是「我在住」。拆开的直接收益有三个一是订单可以一个对多个房间三间房的团队单退房时间可以不同二是换房只需要关闭旧入住单、开一张新的订单本身不动账目不乱三是取消预订只动订单不碰入住单历史数据干净。另一个只有踩过坑才知道的点room_type.available这个余量是粗粒度的它只保证「卖了 N 间」不保证「具体哪几间、哪几天」。真正严谨的做法是按日建库存表room_stock(type_id, biz_date, total, sold)每卖一晚扣一行。课程设计里如果时间紧先用available撑住主流程把这套按日库存作为加分项放到二次开发里做第 6 章会讲怎么加。4. 后端核心链路预订、入住、退房三个接口的落地细节4.1 预订下单一条带条件的 UPDATE 挡住超售新手最容易写成「先 SELECT 查余量判断够不够再 UPDATE 减一」。两个请求同时进来都查到余量 1都判断通过最后都减成功余量变 -1。这是最典型的翻车姿势。正确做法是把判断和修改压进同一条 SQL靠影响行数说话!-- RoomTypeMapper.xml条件更新余量不足时影响行数为 0 -- update iddeductAvailable UPDATE room_type SET available available - #{count} WHERE id #{typeId} AND available gt; #{count} /updateService RequiredArgsConstructor public class OrderService { private final RoomTypeMapper roomTypeMapper; private final OrderMapper orderMapper; /** 下单扣余量和落订单必须在同一个事务里 */ Transactional(rollbackFor Exception.class) public String create(OrderCreateDTO dto) { if (!dto.getCheckOut().isAfter(dto.getCheckIn())) { throw new BizException(离店日期必须晚于入住日期); } // 关键一步受影响行数为 0 说明余量被别人抢走了 int affected roomTypeMapper.deductAvailable(dto.getTypeId(), dto.getRoomCount()); if (affected 0) { throw new BizException(所选房型余量不足请换个日期或房型); } HotelOrder order new HotelOrder(); order.setOrderNo(OrderNoGenerator.next()); // 形如 H20240518000137 order.setTypeId(dto.getTypeId()); order.setCheckIn(dto.getCheckIn()); order.setCheckOut(dto.getCheckOut()); order.setRoomCount(dto.getRoomCount()); order.setTotalAmount(calcAmount(dto)); // 单价 × 间夜数 × 间数 order.setStatus(OrderStatus.WAIT_IN.getCode()); orderMapper.insert(order); return order.getOrderNo(); } }deductAvailable走的是room_type主键索引InnoDB 会给这一行加排他锁这一条 UPDATE 执行期间别的请求排队等着所以不存在中间态。affected是判断依据不是异常——条件不满足时 MySQL 不报错只是更新 0 行很多人漏了这个判断页面上就出现「下单成功但没有订单」。Transactional(rollbackFor Exception.class)里的rollbackFor别省Spring 默认只对RuntimeException回滚抛受检异常时事务不会回滚这是血泪经验。4.2 办理入住用 FOR UPDATE 锁住房间行下单扣的是房型余量入住要落到具体房间号。这一步的并发点是「两个前台同时把 8012 开给两拨客人」。做法是在事务里先锁住候选房间行再判断!-- RoomMapper.xml悲观锁必须放在事务里且 where 条件走索引 -- select idlockByIds resultTypeRoom SELECT * FROM room WHERE id IN foreach collectionids itemid open( separator, close)#{id}/foreach FOR UPDATE /selectTransactional(rollbackFor Exception.class) public void checkIn(CheckInDTO dto) { HotelOrder order orderMapper.selectById(dto.getOrderId()); if (order null || order.getStatus() ! OrderStatus.WAIT_IN.getCode()) { throw new BizException(订单状态不允许办理入住); } ListRoom rooms roomMapper.lockByIds(dto.getRoomIds()); // 加锁读 if (rooms.size() ! order.getRoomCount()) { throw new BizException(分配房间数与订单间数不一致); } for (Room room : rooms) { if (room.getStatus() ! RoomStatus.FREE.getCode() room.getStatus() ! RoomStatus.BOOKED.getCode()) { throw new BizException(房间 room.getRoomNo() 当前不可入住); } roomMapper.updateStatus(room.getId(), RoomStatus.OCCUPIED.getCode()); Checkin c new Checkin(); c.setOrderId(order.getId()); c.setRoomId(room.getId()); c.setGuestName(order.getGuestName()); c.setActualIn(LocalDateTime.now()); c.setDeposit(dto.getDeposit()); c.setStatus(1); checkinMapper.insert(c); } orderMapper.updateStatus(order.getId(), OrderStatus.CHECKED_IN.getCode()); }三个参数要盯住dto.getRoomIds()是前台在房态图上勾的房间条数必须等于订单间数dto.getDeposit()是押金必须大于 0 才好做后面的结算FOR UPDATE必须在事务内生效如果这个方法被同类内部直接调用没走代理锁会失效。换房就是在同一个事务里把旧入住单status置 2、actual_out置当前时间房间转DIRTY再给新房间走一遍上面的入住逻辑。旧房间如果客人当天就换、还没打扫可以走一个「换房免清扫」的分支直接置OCCUPIED这属于业务约定代码里加注释写清楚。4.3 退房结算押金、杂费与房态回收退房的账目关系是应退 押金 - 杂费。房费在下单时就收了这里只结算押金部分Transactional(rollbackFor Exception.class) public CheckoutVO checkOut(Long checkinId, BigDecimal extraFee) { Checkin c checkinMapper.selectById(checkinId); if (c null || c.getStatus() ! 1) { throw new BizException(该入住单已退房或不存在); } if (extraFee null) extraFee BigDecimal.ZERO; BigDecimal refund c.getDeposit().subtract(extraFee); if (refund.signum() 0) { throw new BizException(杂费超出押金需前台补收 refund.negate() 元); } c.setExtraFee(extraFee); c.setActualOut(LocalDateTime.now()); c.setStatus(2); checkinMapper.updateById(c); // 退房不直接回空闲先进待清扫保洁确认后才可再售 roomMapper.updateStatus(c.getRoomId(), RoomStatus.DIRTY.getCode()); // 订单下所有房间都退了订单才置为已完成 int living checkinMapper.countLivingByOrder(c.getOrderId()); if (living 0) { orderMapper.updateStatus(c.getOrderId(), OrderStatus.FINISHED.getCode()); } return new CheckoutVO(c.getDeposit(), extraFee, refund); }refund.signum() 0这句是防止杂费超过押金时系统默默算出一个负数退款。真实场景里客人打碎了东西要赔 300、押金只有 200前台得当场补收 100业务上不该让程序自己兜。countLivingByOrder这个查询容易被忘团队单三间房退了第一间就把订单置完成后面两间的账就挂空了。4.4 鉴权与联调JWT 拦截器 axios 封装登录成功后下发 JWT前端存 localStorage每次请求塞进 Header。拦截器负责解析和放行Component RequiredArgsConstructor public class JwtInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) return true; // 静态资源直接放行 String token req.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { writeJson(resp, 401, 未登录或登录已过期); return false; } Claims claims jwtUtil.parse(token.substring(7)); // 过期或签名错误会抛异常 UserContext.set(claims.get(uid, Long.class), claims.get(role, Integer.class)); return true; } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.clear(); // ThreadLocal 必须清线程池复用会串号 } }UserContext.clear()这行删掉会出玄学问题A 用户退房后B 用户的操作日志里记成了 A 的 id。原因就是 Tomcat 线程池复用线程ThreadLocal 没清。拦截器注册时记得排除/login否则连登录都进不去。前端把 axios 封一层业务代码里只关心「拿到 data 还是报错」import axios from axios import { Message } from element-ui import router from /router const request axios.create({ baseURL: /api, timeout: 8000 }) 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 body res.data // 后端统一返回 {code, msg, data}code 不是 200 就当业务失败 if (body.code ! 200) { Message.error(body.msg || 请求失败) return Promise.reject(body) } return body.data // 上层直接拿数据不用每次 res.data.data }, err { if (err.response err.response.status 401) { localStorage.removeItem(token) router.replace(/login) } return Promise.reject(err) } ) export default request房态看板页就是一次拉全量房间列表按status分组染色渲染点房间弹操作面板。数据量在几百间房的规模一次拉全量完全够用不需要做分页。5. 避坑与排查五个熬到凌晨才想明白的问题5.1 启动阶段的两个坑现象后端启动直接崩日志里一行The server time zone value ... is unrecognized or represents more than one time zone。原因MySQL 8 的驱动要求显式指定时区连接串里没写JDBC 就去猜服务器的时区中文环境下的时区名它认不出来。解决在 url 后面补serverTimezoneAsia/Shanghai顺手把useSSLfalseallowPublicKeyRetrievaltrue也加上MySQL 8 默认的caching_sha2_password插件会因为这个报连接失败。现象前端登录页能打开一点登录就 404 或 CORS 报错Network 里请求地址是http://localhost:8081/api/login。原因vue.config.js的 proxy 没生效或者pathRewrite把/api删了但后端 controller 上确实没有/api前缀两边对不上。解决先看 Network 里的实际请求 URL再对齐后端RequestMapping的真实路径。改完vue.config.js必须重启 devServer热更新不会重新加载代理配置。临时救急可以在 controller 上加CrossOrigin但别留着上线时是隐患。5.2 业务逻辑里最容易埋雷的两处现象客人退房当天下午同一间房被开给了新客人保洁阿姨还没上楼投诉到前台。原因退房代码里把房态直接写成了FREE状态机里少了「待清扫」这一环房间一退就立刻可售。解决退房只置DIRTY另做一个保洁确认接口把它转FREE。如果嫌多一步操作折中方案是退房后加一个「预计清扫完成时间」清扫完成前房态图置灰不可选——但状态本身还是得有。现象下单时余量扣了订单却没插进去日志里也没有异常查库发现available少了一个但订单表新增是 0 条。原因Transactional失效。常见两种一是事务方法被同一个类里的另一个方法用this.create()调用绕过了 Spring 的代理二是方法里try { ... } catch (Exception e) { log.error(...) }把异常吞了Spring 看不到异常自然不回滚。解决事务方法尽量放在独立的 service 上从 controller 直接调catch 之后必须throw new BizException(...)往外抛或者显式调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。自查办法是在方法里打一行日志看有没有Creating new transaction这类输出。5.3 联调与数据迁移的一个坑现象把项目拷到同学机器上sql/hotel.sql导入时报外键约束错误或者建表成功但插中文变成问号。原因脚本里表的创建顺序不对room先于room_type建、还带了外键或者建库时字符集用了默认的utf8那个长度只有 3 字节存不了部分生僻字和 emoji。解决脚本开头统一SET NAMES utf8mb4;建库语句显式写DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci建表顺序按「房型 → 房间 → 订单 → 入住单」排。外键在课程设计里其实可以不建靠代码保证一致性导入顺序的麻烦反而少一半。另外提一句时间字段check_in/check_out用DATEactual_in/actual_out用DATETIME两种类型混着用还拿去比较大小是跨天查询查不出数据的常见原因。6. 二次开发三个低成本改造让它从能跑变成能讲骨架跑通之后剩下的时间别急着加页面加这三个东西性价比最高。6.1 三个改造成本低、答辩加分的方向第一个是按日库存表。新建room_stock(type_id, biz_date, total, sold)下单时按入住日期区间逐天扣减sold条件写成AND sold total。这样一来「5 月 1 日满房但 5 月 2 日有空」这种真实场景才成立前面提到的available粗粒度问题也就解决了。改造点只集中在OrderService.create一处代价可控。第二个是操作日志。写一个Log注解加 AOP 切面拦checkIn、checkOut、cancelOrder三个方法把操作人、操作类型、订单号、时间落一张oper_log表。这类审计功能在真实酒店系统里是硬需求讲的时候也容易展开。第三个是超时订单自动取消。加一个Scheduled(cron 0 */10 * * * ?)的定时任务扫status 0且check_in已过当天的订单置为取消并把余量还回去。还余量这一步千万别忘否则取消得越多、可售越少。6.2 改完怎么验证别靠手点。用 JMeter 或ab对预订接口打 200 并发把同一房型的available初始值设成 10压完看两件事数据库里available必须正好是 0订单表必须正好 10 条。跑出 -1 或者 11 条就是没兜住。再写一条对账 SQL每天跑一次确认房态和入住单没有脱节-- 找出「房态说有人住但没有在住入住单」的房间正常应为 0 行 SELECT r.room_no, r.status FROM room r LEFT JOIN checkin c ON c.room_id r.id AND c.status 1 WHERE r.status 2 AND c.id IS NULL; -- 反向有在住入住单但房态不是已入住 SELECT c.id, c.room_id, r.status FROM checkin c JOIN room r ON r.id c.room_id WHERE c.status 1 AND r.status 2;这两条查询是我现在接手任何房态类项目的第一个动作比翻代码快得多。最后说个习惯。从那以后我每次改房态相关的代码不管是加换房还是加锁房都强制走一遍「状态机 → 事务边界 → 并发场景」这三问这个跳转在枚举里合法吗、这段逻辑回滚之后数据还一致吗、两个请求同时来会怎样。三问答不上来就先别提交。希望帮到你。本文还有配套的精品资源点击获取