简介:基于SpringBoot与Vue的智能租房系统完整项目,属于前后端分离的全栈实践,面向毕业设计、课程设计与期末大作业场景,也适合想掌握主流Web开发流程的学习者。压缩包共565个文件,大小约55.77MB,包含116个java后端文件、97个js前端文件、69个dart移动端文件,以及png/svg界面资源、xml/json配置和Markdown文档,可同时覆盖Web端、移动端与后端服务。目前已有22人学习/浏览。项目目录按Mobile/Web、Back-end和Docs划分,模块清晰:后端采用RESTful API与前端通信,负责数据存储、用户身份验证、业务逻辑处理和交易数据安全;前端提供房源浏览、预约看房、房源发布和租约管理等操作界面,并支持网页端与移动端不同设备访问;文档部分记录系统架构、模块划分、接口定义、开发流程与常见问题解决方案。开发过程遵循最佳实践,采用迭代模式,代码可读性与可维护性较好,这套资源既能直接支撑二次开发,也可作为SpringBoot、Vue及跨端开发方法的完整参考。
1. 拿到「基于 SpringBoot 的智能租房系统」压缩包,先想清楚这三件事
"基于 SpringBoot 的智能租房系统"是 Java 后端里出现频率最高的题目组合之一,毕业设计、实训作业、小团队给中介做的内部工具,用的都是同一套骨架:房东发布房源,租客按城市、预算、户型筛房子,预约看房后走状态流转,管理员负责上下架审核。标题里的「智能」多数实现不接大模型,而是用条件权重算推荐分,把匹配度高、热度高的房源顶到前面。
收到这类 zip 项目,先想清楚三件事:表结构有没有给状态流转和并发留好字段,检索条件变多以后查询怎么拼不乱,多人同时抢一个看房时段怎么保证不超卖。这三个问题定了,剩下的 Controller 和页面只是工作量问题。下面按我接手这类 SpringBoot 项目的习惯顺序,从数据建模写到部署验证,代码可以直接改。
2. SpringBoot 租房系统的数据建模:从房源到订单的关系设计
2.1 实体关系先理清,再动手建库
大多数「智能租房系统」都用一张用户表同时承载房东和租客两种身份,用 role 字段区分。我一般把用户表设计为只存账号、密码密文、手机号和角色;身份相关的扩展资料,比如房东的实名信息、租客的通勤偏好,字段不多就冗余在用户表里,不要过早拆 profile 表。拆表越多,Mapper 层和事务边界越复杂,对这个体量没有收益。
房源表和预约表是另外两条主链。房源表围绕「一套房的一次挂牌」建模,注意是挂牌记录而不是物理房源——同一套房子到期后重新挂牌,应该另建一条记录,这样历史成交价和带看记录都能追溯。预约表是租客和房东之间的履约凭证,它的状态流转是 Service 层最需要保护的东西。把关系画清楚:house 对 appointment 是一对多,user 对 house 是一对多,appointment 同时关联 house、renter 和 landlord。
2.2 建表 SQL:字段类型、默认值与唯一约束
下面这份 SQL 是这类项目常用的底稿,去掉了权限、菜单等扩展表,只留核心链路。落库前定两个规则:金额用「分」不用「元」,避免浮点误差;状态字段用 TINYINT 加注释,不要用字符串枚举。
-- 用户表:房东与租客同表,用 role 区分 CREATE TABLE sys_user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT 'BCrypt 密文,禁止明文', phone VARCHAR(20) DEFAULT '', role TINYINT NOT NULL DEFAULT 1 COMMENT '1房东 2租客 3管理员', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 房源表:一次挂牌一条记录,到期重新挂牌就再加一条 CREATE TABLE house ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, landlord_id BIGINT UNSIGNED NOT NULL, title VARCHAR(100) NOT NULL, city VARCHAR(32) NOT NULL, district VARCHAR(32) NOT NULL, address VARCHAR(200) NOT NULL, price_cents INT UNSIGNED NOT NULL COMMENT '月租金,单位分', area_sqm DECIMAL(6,2) NOT NULL, bedroom_count TINYINT NOT NULL DEFAULT 1, livingroom_count TINYINT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 2下架 3已出租', score INT NOT NULL DEFAULT 0 COMMENT '推荐权重分', visit_count INT NOT NULL DEFAULT 0 COMMENT '被约看次数', expire_at DATETIME NOT NULL COMMENT '挂牌到期时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_city_district_price (city, district, price_cents), KEY idx_status_score (status, score DESC) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源表'; -- 预约表:同一租客对同一房源同一时段只能有一条 CREATE TABLE appointment ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, house_id BIGINT UNSIGNED NOT NULL, renter_id BIGINT UNSIGNED NOT NULL, landlord_id BIGINT UNSIGNED NOT NULL COMMENT '冗余房东ID,方便按房东查待确认', visit_time DATETIME NOT NULL COMMENT '预约看房时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已取消 3已完成', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_house_renter_time (house_id, renter_id, visit_time), KEY idx_landlord_status (landlord_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约看房表';建表逻辑说明:house 表把 status 和 score 组成联合索引,首页热门列表的查询是WHERE status = 1 ORDER BY score DESC,这个索引能同时覆盖过滤和排序,避免 filesort。appointment 表的唯一键防止同一个租客对同一房源同一时间提交多条预约,是防重复的第一道闸。price_cents 用 INT UNSIGNED,上限约 4294 万元,对租房场景绰绰有余;如果算成交总价,要换 BIGINT。expire_at 由 Service 层在发布时写入,后面定时任务做自动下架就依赖这个字段。
2.3 字段设计里最常见的两个坑
第一个坑是金额用 DOUBLE 或 Java 的 float。房源价格、押金一旦参与浮点运算,显示的 6800 元可能变成 6799.9999。把金额存成 integer 分,展示层除以 100,JSON 输出也干净,是我在这个场景下的默认做法。
第二个坑是时间字段混用。MySQL 5.7 之后 DATETIME 支持默认值和ON UPDATE CURRENT_TIMESTAMP,但连接串里必须配serverTimezone=Asia/Shanghai,否则 JDBC 驱动按 JVM 时区解析,差八小时的故障排查起来非常痛苦。下表是这三张表里建议锁死的字段约定,新加字段时先对照一遍。
| 约定项 | 取值规则 | 原因 |
|---|---|---|
| 金额 | integer,单位分 | 避免浮点误差,JSON 输出干净 |
| 状态 | TINYINT + 注释 | 占空间小,比字符串稳定 |
| 时间 | DATETIME + serverTimezone=Asia/Shanghai | 避免时区偏移问题 |
| 删除 | 逻辑删除字段 deleted | 房源和订单需要追溯,不物理删 |
| 版本号 | version INT DEFAULT 0 | 预约确认走乐观锁,必须有 |
逻辑删除在 MyBatis-Plus 里配置logic-delete-field: deleted之后会自动生效,查询统一带deleted = 0。这个字段对租房业务很有必要:租客取消预约、房东下架房源都不该真的删数据,后台审计要看完整履约过程。建表阶段留好它,比上线后补省事得多。
3. 用 SpringBoot 落地核心业务:房源发布、检索与预约
3.1 分层约定:Controller 只做参数接收,业务写在 Service
SpringBoot 项目好不好接手,先看包结构。我习惯分 controller、service、mapper、entity、dto、config 六层,controller 不写任何业务逻辑,只做参数绑定、JSR-303 校验和结果包装。这样做的直接收益是:拿到压缩包想找「房源发布到底做了什么」,只需要打开 HouseServiceImpl,不用在多层之间来回跳。新手常犯的错误是在 controller 里直接调 mapper,事务完全失效,预约确认写一半报错,状态就悬在中间。
这里有个体现 SpringBoot 自动装配特点的细节:类上标了@Service且构造器只有一个参数时,Spring 会走构造器注入,不需要@Autowired。新代码统一用构造器注入,单元测试时直接 new 一个 Service 传 mock 即可,不用反射改字段。
3.2 房源发布接口:DTO 校验与事务边界
发布房源是写操作里最典型的一条链路。DTO 用 validation 注解做第一道校验,Service 层做业务校验,数据库约束兜底。下面是发布接口的最小实现。
// HouseController.java @RestController @RequestMapping("/house") public class HouseController { private final HouseService houseService; public HouseController(HouseService houseService) { this.houseService = houseService; } @PostMapping public Long publish(@Valid @RequestBody HousePublishCommand cmd) { return houseService.publish(cmd); } } // HouseServiceImpl.java 中的核心方法 @Override @Transactional(rollbackFor = Exception.class) public Long publish(HousePublishCommand cmd) { // 1. 从登录上下文取房东 ID,不允许前端传 landlordId,防止越权挂到别人名下 Long landlordId = SecurityUtils.getCurrentUserId(); House house = new House(); BeanUtils.copyProperties(cmd, house); house.setLandlordId(landlordId); house.setStatus(HouseStatus.ON_SALE.getCode()); house.setScore(computeInitScore(cmd)); // 初始推荐分,定时任务再刷新 // 2. expireAt 与当前时间比较,防止写入过去的时间 if (house.getExpireAt().isBefore(LocalDateTime.now())) { throw new BusinessException("挂牌到期时间不能早于当前时间"); } houseMapper.insert(house); return house.getId(); }这段代码里三个点要交代清楚。第一,@Transactional(rollbackFor = Exception.class)必须显式声明 rollbackFor,否则只有 RuntimeException 触发回滚,受检异常会让事务提交半截数据。第二,landlordId 一定从服务端上下文取,前端传什么角色 ID 都不可信,这是这类系统越权漏洞最常见的入口;SecurityUtils 内部一般是从 ThreadLocal 或 Redis 会话里读登录态。第三,DTO 校验管格式,Service 校验管业务规则,比如到期时间不能在过去,两层各管各的。
3.3 多条件检索:LambdaQueryWrapper 的拼接逻辑
租客搜索页通常有城市、区域、价格区间、户型、朝向五六个筛选项,「智能」体现在筛选后的排序上。用 MyBatis-Plus 的 LambdaQueryWrapper 拼条件,核心技巧是「条件为 null 就不拼」。
public Page<House> search(SearchQuery query) { LambdaQueryWrapper<House> wrapper = Wrappers.lambdaQuery(House.class); wrapper.eq(House::getCity, query.getCity()) .eq(StringUtils.hasText(query.getDistrict()), House::getDistrict, query.getDistrict()) .ge(query.getMinPriceCents() != null, House::getPriceCents, query.getMinPriceCents()) .le(query.getMaxPriceCents() != null, House::getPriceCents, query.getMaxPriceCents()) .ge(query.getMinBedroom() != null, House::getBedroomCount, query.getMinBedroom()) .eq(House::getStatus, HouseStatus.ON_SALE.getCode()) .orderByDesc(House::getScore) .orderByDesc(House::getVisitCount); return houseMapper.selectPage( new Page<>(query.getPageNum(), query.getPageSize()), wrapper); }注意.eq、.ge、.le的第一个参数是布尔条件,为 false 时 MyBatis-Plus 自动跳过该条件,省掉一长串 if-else。排序上把 score 放第一位,让推荐分高的房源排在前面,visit_count 作为第二排序反映热度。分页用 Page 对象配合拦截器自动生成 LIMIT,不用手写分页 SQL。SearchQuery 里的 pageNum 和 pageSize 要设上限,pageSize 最大 50,防止前端传 10000 一次拖垮数据库。
3.4 预约下单:状态机与重复提交防护
预约看房是这套系统里状态最复杂的操作。很多毕设项目用一个字段硬记状态,没有任何约束,最后出现「已取消的预约被确认」这种脏数据。正确做法是画状态机:待确认只能转已确认或已取消,已确认只能转已完成,非法迁移在 Service 层直接拒绝。
@Override @Transactional(rollbackFor = Exception.class) public void bookVisit(Long houseId, LocalDateTime visitTime) { // 1. 幂等检查:同一租客对同一房源同一时间只能有一条预约 Long count = appointmentMapper.selectCount( Wrappers.<Appointment>lambdaQuery() .eq(Appointment::getHouseId, houseId) .eq(Appointment::getRenterId, SecurityUtils.getCurrentUserId()) .eq(Appointment::getVisitTime, visitTime)); if (count != null && count > 0) { throw new BusinessException("您已预约该时段,请勿重复提交"); } // 2. 房源必须处于上架状态 House house = houseMapper.selectById(houseId); if (house == null || house.getStatus() != HouseStatus.ON_SALE.getCode()) { throw new BusinessException("房源不可预约"); } // 3. 落库,version 字段留给后续确认/取消操作使用 Appointment appointment = new Appointment(); appointment.setHouseId(houseId); appointment.setRenterId(SecurityUtils.getCurrentUserId()); appointment.setLandlordId(house.getLandlordId()); appointment.setVisitTime(visitTime); appointment.setStatus(AppointmentStatus.WAIT_CONFIRM.getCode()); appointmentMapper.insert(appointment); houseMapper.update(null, Wrappers.<House>lambdaUpdate() .setSql("visit_count = visit_count + 1") .eq(House::getId, houseId)); }这里的幂等依赖两层:应用层 selectCount 拦住绝大多数重复请求,数据库唯一索引uk_house_renter_time兜底,两个请求同时穿过时,后到者撞唯一键抛异常。setSql让 visit_count 在数据库端自增,避免先查后改的并发覆盖。到这一步预约单只是待确认状态,真正的并发冲突出现在房东确认和租客取消同时发生时,放到下一章配合缓存一起讲。
4. 缓存与并发控制:Redis 在智能租房系统里的调优
4.1 热门房源列表为什么必须走缓存
首页热门房源和推荐列表是查询压力最大的接口,租客每次刷新首页都会打过来。即便有idx_status_score索引,也架不住首页尖峰流量。常见做法是把推荐列表的房源 ID 缓存到 Redis,设置 10 到 30 分钟过期,过期后第一个请求回源数据库再回填,也就是 Cache-Aside 模式。缓存里存 ID 列表而不是整个 JSON:列表接口只需要 ID,详情的大字段没必要占缓存内存。
@Service public class RecommendService { private static final String HOT_HOUSE_KEY = "rental:hot:house"; private final StringRedisTemplate redisTemplate; private final HouseMapper houseMapper; public RecommendService(StringRedisTemplate redisTemplate, HouseMapper houseMapper) { this.redisTemplate = redisTemplate; this.houseMapper = houseMapper; } public List<House> topHot(int topN) { // 1. 先读 Redis 里的 ID 列表 List<String> ids = redisTemplate.opsForList() .range(HOT_HOUSE_KEY, 0, topN - 1); if (ids != null && !ids.isEmpty()) { // 2. 按 ID 批量查库,再按缓存中的顺序重新组装 Map<Long, House> houseMap = houseMapper.selectBatchIds(ids) .stream().collect(Collectors.toMap(House::getId, h -> h)); return ids.stream() .map(id -> houseMap.get(Long.valueOf(id))) .filter(Objects::nonNull) .collect(Collectors.toList()); } // 3. 未命中则回源数据库,按推荐分取前 N 条 List<House> houses = houseMapper.selectList( Wrappers.<House>lambdaQuery() .eq(House::getStatus, HouseStatus.ON_SALE.getCode()) .orderByDesc(House::getScore) .last("LIMIT " + topN)); // 4. 回填缓存并设置 10 分钟过期 List<String> idList = houses.stream() .map(h -> String.valueOf(h.getId())) .collect(Collectors.toList()); if (!idList.isEmpty()) { redisTemplate.opsForList().rightPushAll(HOT_HOUSE_KEY, idList); redisTemplate.expire(HOT_HOUSE_KEY, Duration.ofMinutes(10)); } return houses; } }这里用 List 而不是 Set 存 ID,是因为 list 天然有序,score 排名本身就是顺序,读出来不用再排。selectBatchIds 返回的集合不保证顺序,所以要按缓存里的 ID 顺序重新组装,这一步新手很容易漏,漏了首页房源顺序会随机跳动。回填时先 rightPushAll 再 expire,保证不会出现写了一半就过期的情况。.last("LIMIT " + topN)存在注入风险,topN 必须是代码常量或内部传入,绝不能接受前端参数拼接。
4.2 Redis 连接池与序列化的参数设置
Spring Boot 2.x 和 3.x 默认都用 Lettuce 作为 Redis 客户端。很多项目报Unable to connect to Redis,不是 Redis 挂了,而是连接池太小或超时太短。下面是 application.yml 里的一组起点参数。
spring: data: redis: host: localhost port: 6379 password: ${REDIS_PASSWORD:} timeout: 3s lettuce: pool: max-active: 16 # 最大连接数,按接口 QPS 调整 max-idle: 8 # 最大空闲连接 min-idle: 2 # 最少保持的空闲连接,预热用 max-wait: 3s # 拿不到连接的最大等待时间这组参数的逻辑:max-active 16 对单机系统足够,Redis 本身是单线程处理命令,连接开太多反而增加上下文切换;min-idle 设 2 是为了避免突发流量来了才现建连接;max-wait 3 秒意味着高峰期等不到连接就直接报错返回,而不是无限阻塞拖垮整个 SpringBoot 线程池。序列化方面,如果用了 RedisTemplate 而不是 StringRedisTemplate,必须在配置类里把 key 的序列化器改成 StringRedisSerializer,否则 key 会带\xAC\xED前缀,缓存不命中时第一眼就该查这个。
4.3 预约确认的并发控制:乐观锁与 Redis 锁怎么选
房东确认预约和租客取消预约可能同时发生。典型场景:房东在后台点了确认,同一秒租客在手机端取消,两个请求都读到 status=0,各自更新成期望值,后提交的覆盖先提交的,状态机就乱了。
对于低频写操作,乐观锁成本最低:
@Transactional(rollbackFor = Exception.class) public void confirm(Long appointmentId, Long landlordId) { Appointment appointment = appointmentMapper.selectById(appointmentId); if (appointment == null) { throw new BusinessException("预约不存在"); } if (!appointment.getLandlordId().equals(landlordId)) { throw new BusinessException("无权操作该预约"); } // 状态机校验:待确认才能转已确认 if (appointment.getStatus() != AppointmentStatus.WAIT_CONFIRM.getCode()) { throw new BusinessException("当前状态不可确认"); } // 乐观锁:UPDATE 条件带上 version,影响行数为 0 说明已被其他请求修改 int rows = appointmentMapper.update(null, Wrappers.<Appointment>lambdaUpdate() .set(Appointment::getStatus, AppointmentStatus.CONFIRMED.getCode()) .set(Appointment::getVersion, appointment.getVersion() + 1) .eq(Appointment::getId, appointmentId) .eq(Appointment::getVersion, appointment.getVersion())); if (rows == 0) { throw new BusinessException("预约状态已变化,请刷新后重试"); } }乐观锁的前提是表里有 version 字段,建表时已经留好。UPDATE 把version = 旧值放进 WHERE,数据库行锁保证同一时刻只有一个事务能改成功,另一个 affected rows 为 0,业务层抛提示。它适合「读多写少、冲突少」的场景,预约确认一天几百次,完全够用。
如果需求变成「同一时段只能被一个租客约中」,那就得用 Redis setnx 做前置占位再落单,占位 key 要带 5 秒过期时间,防止应用宕机把时段锁死。我习惯把两种方案分开用:状态变更走乐观锁,时段抢占走 Redis 锁,不要动不动上分布式锁框架,那是杀鸡用牛刀。
4.4 推荐分计算:先跑通规则再谈模型
「智能推荐」起步阶段用不上算法,一个可解释的加权公式就够。常用公式是:
score = 基础分 + 价格匹配分 + 热度分| 因子 | 来源 | 权重 |
|---|---|---|
| 基础分 | 房源资料完整度 | 30 |
| 价格匹配 | 与同小区均价差,越接近越高 | 40 |
| 热度 | visit_count 按对数缩放到 0~30 | 30 |
计算逻辑放在 Service 里,由定时任务全量刷新;新发布的房源用初始分占位,避免列表里出现 score=0 排到最底的情况。这个公式的好处是向租客解释「为什么推荐这套」时,三个因子都能说清;后期想换向量召回,只需要替换 score 的来源,对外接口不变。
5. 围绕 SpringBoot 的部署与验证:配置清单和排查顺序
5.1 压缩包导入三步走
打开 zip 先别急着跑。第一步看 pom.xml 确定 SpringBoot 版本。2.7.x 对应 JDK 8 到 17,3.x 强制 JDK 17 起;如果你本机是 JDK 8 而项目是 3.x,直接升级 JDK 比硬降 SpringBoot 版本省事得多,3.x 底层依赖变动很大,强退版本会撞上一堆兼容性问题。第二步建数据库,按文件名顺序执行 sql 目录里的脚本,字符集用 utf8mb4。第三步改 application.yml 里的数据源和 Redis 地址,然后mvn spring-boot:run启动。
常见的启动失败基本逃不出下面这张表:
| 报错信息 | 原因 | 处理方式 |
|---|---|---|
| Failed to configure a DataSource | 数据源配置缺失或 YAML 缩进错误 | 检查 yml 层级与 spring.datasource 依赖 |
| Unable to connect to Redis | 地址、密码不对或连接池耗尽 | 先排查网络连通性,再调连接池参数 |
| Invalid bound statement | Mapper XML 没被扫描到 | 检查 @MapperScan 包路径 |
| Table doesn't exist | 建表脚本没执行或执行顺序错 | 按依赖顺序重新执行 SQL |
注意:在 IDE 里启动看到
Failed to configure a DataSource,大概率不是数据库没启动,而是 application.yml 缩进错了。YAML 层级错误是 SpringBoot 配置问题里最隐蔽的一种,先确认spring:下面每一层都对齐。
5.2 application.yml 的必调参数
server: port: 8080 servlet: context-path: /rental # 统一前缀,方便反向代理区分多个服务 spring: datasource: url: jdbc:mysql://localhost:3306/rental_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: rental password: ${DB_PASSWORD:rental123} hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 jackson: time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 management: endpoints: web: exposure: include: health,info各参数说明:数据源 URL 里 serverTimezone 必须显式指定,Spring Boot 3.x 的 MySQL 驱动在时区缺失时直接报错;密码用${DB_PASSWORD:rental123}环境变量占位,压缩包项目写死密码可以理解,但要意识到这是上线前的隐患。map-underscore-to-camel-case负责把price_cents自动映射到priceCents,没有它查出来的对象全是 null。log-impl配 StdOutImpl 能在开发期看到完整 SQL 和参数,排查问题最直接,上线前要换掉,否则 SQL 全量刷日志。配置项多了以后,可以用@ConfigurationProperties绑定到一个配置类,比散落的@Value好维护。
5.3 Actuator 端点暴露控制
SpringBoot 的 Actuator 是排查线上问题的利器,但默认配置如果暴露 heapdump、threaddump 这类端点,攻击者可以直接下载堆转储文件,从里面挖出数据源密码、Token 等敏感信息。生产环境只保留 health 和 info 就够了。
# 确认端点可见性,只应看到 health 和 info curl -s http://localhost:8080/rental/actuator | jq '._links | keys'Swagger 接口文档同理,开发环境方便调试,生产环境要么关闭要么加访问控制。压缩包项目最常见的交付问题就是生产环境开着 swagger-ui 和 heapdump,这两项在部署清单里必须检查。
5.4 冒烟验证:一组 curl 把主链路过一遍
# 1. 分页搜索上架房源 curl -s 'http://localhost:8080/rental/house/search?city=上海&pageNum=1&pageSize=10' | jq '.records | length' # 2. 发布一套房源,字段交给 DTO 校验 curl -s -X POST 'http://localhost:8080/rental/house' \ -H 'Content-Type: application/json' \ -d '{"title":"徐汇两居室","city":"上海","district":"徐汇区","priceCents":680000,"bedroomCount":2,"expireAt":"2026-12-31T23:59:59"}' | jq # 3. 预约看房 curl -s -X POST 'http://localhost:8080/rental/appointment' \ -H 'Content-Type: application/json' \ -d '{"houseId":1,"visitTime":"2025-08-20T10:00:00"}'验证顺序有讲究:先测搜索,确认数据源和表结构正常;再测发布,确认事务和参数校验生效;最后测预约,确认状态机和唯一约束起作用。如果项目接了登录拦截器,这些 curl 要加-H 'Authorization: Bearer <token>',开发阶段一般先在配置里放行 /house/search 这类只读接口。预约接口返回 500 时,优先看日志里最后一条 SQL——95% 的情况是表字段和实体类对不上,尤其注意逻辑删除字段 deleted 是否在实体里声明了,MyBatis-Plus 的全局逻辑删除要求实体必须有对应字段。
6. 一个值得试的进阶技巧:用 SpringBoot 定时任务自动流转房源状态
6.1 到期自动下架与热度分刷新
「智能」二字除了推荐排序,还有一层隐藏需求是系统自维护。房源挂三个月没人租,不能一直占着列表位;热度分跟着预约量走,不能发布后永远不变。SpringBoot 自带的 @Scheduled 足够解决这两件事,不需要引入额外的任务调度中间件。
先在主类上开启调度:
@SpringBootApplication @EnableScheduling public class RentalApplication { public static void main(String[] args) { SpringApplication.run(RentalApplication.class, args); } }然后写一个独立的 Job 组件,把任务逻辑和业务 Service 分开:
@Component @Slf4j public class HouseStatusJob { private final HouseMapper houseMapper; public HouseStatusJob(HouseMapper houseMapper) { this.houseMapper = houseMapper; } // 每天凌晨 2 点跑一次,把超过 expire_at 的上架房源统一转下架 @Scheduled(cron = "0 0 2 * * ?") public void autoOffShelf() { int rows = houseMapper.update(null, Wrappers.<House>lambdaUpdate() .set(House::getStatus, HouseStatus.OFF_SALE.getCode()) .eq(House::getStatus, HouseStatus.ON_SALE.getCode()) .lt(House::getExpireAt, LocalDateTime.now())); log.info("定时下架过期房源 {} 条", rows); } // 每 30 分钟刷新一次热度分,fixedDelay 表示上次执行完再等 30 分钟 @Scheduled(fixedDelay = 30 * 60 * 1000L, initialDelay = 60 * 1000L) public void refreshScore() { List<House> onSaleHouses = houseMapper.selectList( Wrappers.<House>lambdaQuery() .eq(House::getStatus, HouseStatus.ON_SALE.getCode())); for (House house : onSaleHouses) { int score = ScoreRule.calculate(house); houseMapper.update(null, Wrappers.<House>lambdaUpdate() .set(House::getScore, score) .eq(House::getId, house.getId())); } log.info("全量刷新房源推荐分完成,共 {} 套", onSaleHouses.size()); } }两个任务代表了 @Scheduled 的两种形态:cron 表达式适合「每天固定时刻」的批量维护,凌晨 2 点避开业务高峰;fixedDelay 适合状态收敛类任务,30 分钟一次,initialDelay让应用启动后先等 1 分钟,避免连接池没预热就全量刷分。全量刷分在小数据量下可以逐条 update,超过五万条要改成分批处理,每批 500 条,防止一次更新锁太多行拖慢主库。
验证任务是否按预期工作,最直接的方式是把 cron 临时改成0 * * * * ?(每分钟一次),观察日志输出的下架条数,确认无误再改回凌晨执行。score 只影响推荐列表排序,不影响已成交订单的历史记录,所以全量重算没有副作用。如果刷新过程中应用被手动停掉,事务回滚不会留下半更新状态——前提是任务方法也标注了@Transactional,这也是定时任务代码里容易被忽略的一个前提。
本文还有配套的精品资源,点击获取