简介面向计算机相关专业毕业设计和课程设计场景的房屋租赁系统项目包采用SpringBoot后端、Vue前端与MySQL数据库的B/S架构内含可运行源码、运行说明及数据库文件适合需要项目实战、期末大作业或毕设原型参考的学习者。压缩包为rar格式共1752个文件、约42.01MB主要由Java源码与class文件、Vue组件、HTML/CSS/JS页面资源、XML配置和SQL脚本构成并包含图片、字体与说明文档目录清晰便于检索与二次开发。系统包含用户管理、房屋信息、预约看房、在线签约、系统管理等管理员功能以及登录注册、房源浏览、交流论坛、个人中心等用户模块可帮助理解租房平台从数据建模到前后端联调的流程。已有1590人学习适合作为毕设原型、课程设计交付或功能扩展基础。1. 房屋租赁源码不是拿来就跑它真正省掉的是哪三天接手一套房屋租赁源码多数人的第一反应是解压、找启动类、点运行然后在数据库连接失败的红字里卡掉一整个下午。编号 761 这套包的定位其实很清楚一个能跑起来的房屋租赁业务系统带建库脚本和运行说明房源发布、租客预约、合同签订、租金账单、后台管理这条链路是通的。对做毕业设计的人来说省下的是从零画表结构、写 CRUD、拼页面的两三天对想练完整业务闭环的开发者来说价值在于能看到「状态流转」这种教程里永远讲不清的东西是怎么落到一个 tinyint 字段上的。前提是别把它当黑匣子双击运行先搞清楚数据从哪进、从哪出。2. 环境落地从建库到第一次启动成功这一章解决的是「跑不起来」的问题。源码包能不能用80% 的判断发生在前 30 分钟库建对了没有、连接串改对了没有、依赖下得下来没有。顺序错了后面全是连锁反应。2.1 先拆包别急着找启动类解压后先别打开 IDE用命令行把目录结构看一遍。判断三件事前后端是分离的两个工程还是打在一个 jar 里前端编译产物塞在resources/static数据库脚本在哪有没有README或运行说明.txt之类的文件。这个判断决定了你后面要不要单独起一个 Node 服务。# 看一级目录判断工程形态 ls -la # 找建库脚本通常是 .sql 结尾名字里带 db / sql / init find . -maxdepth 3 -iname *.sql -type f # 找后端构建文件判断是 Maven 还是 Gradle find . -maxdepth 3 -name pom.xml -o -maxdepth 3 -name build.gradle # 找前端工程有 package.json 且不在根目录基本就是前后端分离 find . -maxdepth 3 -name package.json -not -path */node_modules/*店铺里下来的这类包常见形态是「一个 Maven 后端 一个 Vue 前端 一份 sql 脚本 一份 Word 版运行说明」。目录里通常还有.git、target、node_modules这类可以直接删掉的垃圾先删掉能省几百兆。目录/文件作用要不要动sql/*.sql建库建表 初始数据必须执行src/main/resources/application.yml数据源、端口、上传路径必须改pom.xml依赖与 JDK 版本约束一般不碰web/或vue-ui/前端工程改请求地址运行说明.txt账号密码、启动顺序先读一遍有个细节容易忽略pom.xml里spring-boot-starter-parent的版本决定你用javax.servlet还是jakarta.servlet。2.x 是前者3.x 是后者这俩包名不兼容。如果你本地 JDK 是 17 以上又硬要跑 2.x 的工程编译能过但启动会有一堆反射警告我一般直接把本地 JDK 切到 8 或 11比改代码省事。2.2 建库导表字符集和时区是两个默认陷阱建库这一步很多人习惯性点开图形化工具右键「新建数据库」名字填对就完事。字符集留在默认值上后面的坑就埋好了。房源描述里只要出现一个 emoji或者租客昵称里带个特殊符号插入直接报Incorrect string value而且报错位置离你操作的地方很远排查起来很痛苦。-- 建库时就把字符集钉死utf8mb4 才是完整四字节 CREATE DATABASE rental_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE rental_db; -- 命令行里用 source 导入路径换成你解压后的实际位置 SOURCE /Users/you/Downloads/761/sql/rental_db.sql; -- 导完先确认三件事表数量、字符集、时区 SHOW TABLES; SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA rental_db; SELECT global.time_zone, session.time_zone, NOW();参数上要理解两件事。第一utf8mb4_general_ci里的ci是大小写不敏感房源标题搜索时LIKE不区分大小写这正好符合业务直觉别换成_bin。第二NOW()返回的是数据库服务器时区的时间如果它比你本地时间差 8 小时说明time_zone是 UTC 或者 SYSTEM 没配对后面所有「合同到期日」「账单应缴日」都会跟着错。SOURCE是 mysql 客户端的内置命令不是 SQL 语句所以末尾不加分号也行但必须用命令行客户端执行图形化工具的查询窗口里可能不认。导完之后用SELECT COUNT(*) FROM house;看一眼初始房源有多少条这个数字后面验收要用。2.3 配置文件至少要改三处打开application.yml重点看spring.datasource、server.port、以及文件上传相关配置。这三处不改基本起不来或者起来了也不正常工作。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/rental_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 换成你自己的密码 servlet: multipart: max-file-size: 10MB # 单张房源图上限 max-request-size: 20MB # 一次请求总上限多图上传要留余量 # 业务自定义配置源码里如果有这一段路径一定要写成绝对路径 rental: upload-path: /data/rental/upload/ cover-base-url: http://127.0.0.1:8080逐个说参数。driver-class-name用com.mysql.cj.jdbc.Driver是 Connector/J 8 的写法如果你本地是 5.1 的老驱动类名要去掉cj但老驱动对serverTimezone的支持不好还是建议直接换 8.x。serverTimezoneAsia/Shanghai是防时间偏移的关键少了它存进去的datetime读出来可能差 8 小时。useSSLfalse只是本地开发关掉握手生产要开。characterEncodingutf8这个写法看着像三字节但 Connector/J 8 里utf8会被映射到utf8mb4所以不用写成utf8mb4写了反而有驱动版本兼容问题。allowPublicKeyRetrievaltrue是 MySQL 8 默认caching_sha2_password认证方式下必须加的不加就报Public Key Retrieval is not allowed。上传路径这一项源码默认值经常是./upload/这种相对路径。相对路径的基准是 JVM 的工作目录你在 IDE 里跑和java -jar跑工作目录不是同一个图片就莫名 404。我一般第一天就改成绝对路径省得后面找。2.4 启动与第一次验证后端启动走标准 Maven 流程注意跳过测试这类包里自带的测试用例八成连不上库。# 先确认依赖能下全第一次会比较慢 mvn -q clean package -DskipTests # 用通配符启动避免 jar 名带版本号记不住 java -jar target/*.jar # 想临时覆盖端口和库地址用启动参数不用改配置文件 java -jar target/*.jar --server.port8081 --spring.datasource.urljdbc:mysql://127.0.0.1:3306/rental_db?serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue看到Started ... in x seconds只算成功一半还要验证接口能不能真正打到库上。# 1. 探活能返回 200 说明 Spring 容器起来了 curl -i http://127.0.0.1:8080/ # 2. 登录接口账号密码看运行说明或 user 表密码多半是加密存储的 curl -i -X POST http://127.0.0.1:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:运行说明里写的密码} # 3. 带 token 拉一次房源列表这一步才真正读写数据库 curl -i http://127.0.0.1:8080/api/house/page?pageNum1pageSize10 \ -H Authorization: Bearer 上一步返回的token第三步返回的数据条数应该和 2.2 里SELECT COUNT(*) FROM house对得上。对不上说明你连的不是同一个库或者列表接口带了status1这类过滤条件——后者是正常的已下架的房源不该出现在租客端列表里。3. 核心链路拆解房源、租约、账单是怎么串起来的能启动只是及格线。这套源码真正值得看的是三张表之间的关系house管状态rent_contract管钱和期限rent_bill管每一期收多少钱。这三者如果各写各的系统在演示时没问题一旦有人并发下单就会出事。3.1 房源状态机一个 status 字段撑起整条业务房源的status不是随便改的。草稿、上架、锁定、已出租、已下架这五个状态之间只有特定方向可以流转。我看过太多学生项目status在七八个地方被set最后出现「已出租的房源还能被再次下单」这种演示现场翻车。public enum HouseStatus { DRAFT(0, 草稿), ONLINE(1, 已上架), LOCKED(2, 已锁定), // 有租客提交了预约或签约申请先占住 RENTED(3, 已出租), OFFLINE(4, 已下架); private final int code; private final String desc; HouseStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } /** * 允许的流转方向集中在这里别散落到 Service 里 * 上架 - 锁定被预订- 已出租签约成功 * 锁定 - 上架取消预订 * 上架 - 下架已出租 - 上架退租后重新招租 */ public static boolean canTransfer(int from, int to) { if (from ONLINE.getCode()) return to LOCKED.getCode() || to OFFLINE.getCode(); if (from LOCKED.getCode()) return to ONLINE.getCode() || to RENTED.getCode(); if (from RENTED.getCode()) return to ONLINE.getCode(); if (from DRAFT.getCode()) return to ONLINE.getCode(); if (from OFFLINE.getCode()) return to ONLINE.getCode(); return false; } }状态机写成枚举而不是常量类好处是canTransfer是纯函数可以单独写测试也可以在设计评审时直接对着这一段把流程讲清楚。业务层的写法要配合带条件的更新不能先查再改。!-- HouseMapper.xml用 WHERE 带原状态做乐观锁并发时只有一个能成功 -- update idtransferStatus UPDATE house SET status #{toStatus}, update_time NOW() WHERE id #{id} AND status #{fromStatus} /updateaffected 0就说明这期间被别人改过了直接抛业务异常让前端刷新而不是覆盖别人的修改。这套写法比加version字段简单因为房源状态本身就是天然的版本号。3.2 签约事务合同快照为什么不能省签约是整个系统里唯一必须用事务包起来的地方。要做三件事把房源从「已上架」改成「已出租」、写一条合同记录、生成首期账单。任何一步失败都要整体回滚。合同表里存的是快照不是外键关联过去实时查。房东下个月把月租从 3000 调到 3500已经签了的合同金额不能跟着变。CREATE TABLE rent_contract ( id bigint NOT NULL AUTO_INCREMENT, contract_no varchar(32) NOT NULL COMMENT 合同编号业务唯一, house_id bigint NOT NULL, tenant_id bigint NOT NULL, monthly_rent decimal(10,2) NOT NULL COMMENT 签约时锁定的月租金快照, deposit decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 押金, address_snapshot varchar(255) NOT NULL COMMENT 房源地址快照, start_date date NOT NULL, end_date date NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待生效 1履约中 2已到期 3已解约, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_contract_no (contract_no), KEY idx_house (house_id), KEY idx_tenant_status (tenant_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租赁合同;金额字段用decimal(10,2)而不是double这一点没有商量余地。租金算账时double会出现0.1 0.2 0.30000000000000004这种问题累计到十二期账单对账时能差出几分钱虽然金额小但演示时解释不清。address_snapshot看起来冗余作用是把签约当时的地址固定下来后面房源信息被编辑过合同上显示的仍是签约时那份。对应的服务层代码关键在事务注解和状态检查的顺序。Service public class ContractService { Resource private HouseMapper houseMapper; Resource private ContractMapper contractMapper; Resource private BillMapper billMapper; /** * 签约锁房源 - 写合同快照 - 生成首期账单 * rollbackFor 必须显式写 Exception默认只回滚 RuntimeException */ Transactional(rollbackFor Exception.class) public Long sign(Long houseId, Long tenantId, LocalDate startDate, int months, BigDecimal deposit) { House house houseMapper.selectById(houseId); if (house null) { throw new BizException(房源不存在); } // 先改状态再写合同并发抢同一套房时输的一方在这里就被挡住 int affected houseMapper.transferStatus( houseId, HouseStatus.LOCKED.getCode(), HouseStatus.RENTED.getCode()); if (affected 0) { throw new BizException(房源已被预订或已下架请刷新后重试); } Contract c new Contract(); c.setContractNo(HT System.currentTimeMillis()); c.setHouseId(houseId); c.setTenantId(tenantId); c.setMonthlyRent(house.getRent()); // 快照后续改价不影响本合同 c.setDeposit(deposit); c.setAddressSnapshot(house.getAddress()); c.setStartDate(startDate); c.setEndDate(startDate.plusMonths(months)); c.setStatus(ContractStatus.PENDING.getCode()); contractMapper.insert(c); billMapper.insertIgnore(buildBill(c, 1)); // 首期账单 return c.getId(); } }rollbackFor Exception.class这一行经常被漏。Spring 默认只在抛出RuntimeException和Error时回滚如果你的业务异常继承自Exceptiontry-catch里一抛事务照样提交房源已经锁了但合同没写进去数据就脏了。3.3 账单生成定时任务要有幂等租金账单按合同周期逐月生成。最简单粗暴的做法是每天凌晨扫一遍所有履约中的合同到期就插一条账单。问题是任务重跑、机器重启、你手动执行一次都会重复插入。CREATE TABLE rent_bill ( id bigint NOT NULL AUTO_INCREMENT, contract_id bigint NOT NULL, period_no int NOT NULL COMMENT 第几期账单从 1 开始, amount decimal(10,2) NOT NULL, due_date date NOT NULL COMMENT 应缴日期, status tinyint NOT NULL DEFAULT 0 COMMENT 0未缴 1已缴 2逾期, PRIMARY KEY (id), UNIQUE KEY uk_contract_period (contract_id, period_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT租金账单;唯一索引uk_contract_period是幂等的最后一道保险。SQL 层面用INSERT IGNORE重复插入直接被数据库吞掉不报错也不产生脏数据。Scheduled(cron 0 30 1 * * ?) // 每天 01:30 执行避开业务高峰 public void generateMonthlyBill() { LocalDate today LocalDate.now(); // 只捞未来 7 天内需要出账的合同避免全表扫描 ListContract list contractMapper.selectActiveNearDue(today, today.plusDays(7)); for (Contract c : list) { int period ChronoUnit.MONTHS.between(c.getStartDate(), today) 1; Bill bill new Bill(); bill.setContractId(c.getId()); bill.setPeriodNo(period); bill.setAmount(c.getMonthlyRent()); bill.setDueDate(c.getStartDate().plusMonths(period - 1L)); bill.setStatus(0); billMapper.insertIgnore(bill); // 依赖唯一索引兜底 } }cron 0 30 1 * * ?六个字段依次是秒、分、时、日、月、周?表示不指定。ChronoUnit.MONTHS.between算的是整月差比用毫秒数除以 30 天靠谱得多跨大小月不会错。这套逻辑我一般会补一个管理端的手动触发接口方便演示时不用等到凌晨。4. 前后端对接登录鉴权、跨域与图片上传后端能跑通接口前端还是白屏这是第二阶段的常见卡点。分离式工程里登录态怎么带、跨域怎么配、上传的图片怎么访问这三个问题几乎每个包都会遇到。4.1 登录与会话三种角色怎么隔离房屋租赁系统至少有三个角色管理员管全局房东管自己的房源和合同租客看房、签约、查账单。源码里常见的实现是登录后返回一个 token后续请求放在 header 里带回来。Component public class AuthInterceptor implements HandlerInterceptor { Resource private TokenService tokenService; Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { // 预检请求直接放行否则跨域永远失败 if (HttpMethod.OPTIONS.matches(req.getMethod())) { return true; } if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod hm (HandlerMethod) handler; String token req.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); } LoginUser user tokenService.parse(token); if (user null) { writeJson(resp, 401, 未登录或登录已过期); return false; } // 方法上有 RequireRole 才做角色校验没标注的只要登录即可 RequireRole rr hm.getMethodAnnotation(RequireRole.class); if (rr ! null !Arrays.asList(rr.value()).contains(user.getRole())) { writeJson(resp, 403, 无权限访问); return false; } UserContext.set(user); return true; } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.remove(); // ThreadLocal 必须清线程池复用时会串号 } }OPTIONS放行这一行是不能省的。浏览器发跨域的非简单请求时会先发一个OPTIONS预检如果拦截器把它拦了并返回 401控制台里看到的是「CORS 错误」但根因在鉴权。这个坑我在两个项目里都踩过血泪经验。afterCompletion里的remove()也容易忘。ThreadLocal不清Tomcat 线程池复用线程时下一个请求可能拿到上一个用户的身份表现为「随机看到别人的数据」这类问题排查起来简直是玄学。4.2 跨域配置allowedOrigin 和 credentials 不能同时放开前后端分开跑的时候端口不一样就是跨域。两种解法前端配 dev server 代理或者后端配 CORS。我倾向前端代理因为不用改后端代码但很多包里的前端没配代理那就只能后端加。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) // 注意开 allowCredentials 时不能用 allowedOrigins(*)必须用 Pattern .allowedOriginPatterns(http://localhost:*, http://127.0.0.1:*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }allowCredentials(true)和allowedOrigins(*)是一对互斥项浏览器规范不允许。用allowedOriginPatterns可以带端口通配本地开发够用。maxAge(3600)是让浏览器缓存预检结果一小时减少OPTIONS请求数量。前端那边请求基地址最好抽到一个文件里别散落在每个axios调用中。// src/utils/request.js import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE || http://127.0.0.1:8080, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(err) } ) export default requestbaseURL用环境变量兜底部署到别的机器时只改.env不改代码。响应拦截器里统一处理 401避免每个页面都写一遍跳登录。4.3 房源图片上传路径拼错了最费时间上传功能看着简单出问题的地方集中在三处后缀白名单、文件大小、访问路径。PostMapping(/api/house/upload) public MapString, Object upload(RequestParam(file) MultipartFile file) throws IOException { String original file.getOriginalFilename(); String ext original null ? : original.substring(original.lastIndexOf(.) 1).toLowerCase(); // 白名单比黑名单安全只认这四种 if (!Arrays.asList(jpg, jpeg, png, webp).contains(ext)) { throw new BizException(只支持 jpg / jpeg / png / webp); } if (file.getSize() 5 * 1024 * 1024L) { throw new BizException(单张图片不能超过 5MB); } // 按年月分目录单目录文件太多时 ls 会很慢 String datePath LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM)); Path dir Paths.get(uploadPath, datePath); Files.createDirectories(dir); // 用 UUID 重命名避免中文名和同名覆盖 String name UUID.randomUUID().toString().replace(-, ) . ext; file.transferTo(dir.resolve(name).toFile()); // 返回相对路径换域名时不用改库里的数据 return Map.of(url, /upload/ datePath / name); }上传目录不在static下所以要额外配一个静态资源映射否则存进去了浏览器也访问不到。Configuration public class WebConfig implements WebMvcConfigurer { Value(${rental.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }file:前缀表示从文件系统读不是 classpath。uploadPath结尾必须带斜杠不带的话拼出来的路径是/data/rental/upload202405/x.jpg少一个分隔符死活访问不到。5. 排查清单五个最容易翻车的点下面五条是我在这类房屋租赁源码上实际撞过的按出现频率排序。每条都按「现象、原因、解决」写遇到了直接对号入座。第一条启动报Public Key Retrieval is not allowed或Access denied for user。现象是 Spring 启动到数据源初始化阶段直接抛异常日志里能看到完整的 JDBC 连接串。原因有两个方向一是 MySQL 8 默认用caching_sha2_password认证老驱动或者没开公钥检索就会失败二是密码里带、#这类特殊字符在 yml 里没加引号被截断了。解决顺序是先在命令行用mysql -uroot -p手动登一次确认账号密码本身没问题再在 JDBC URL 后面补allowPublicKeyRetrievaltrueuseSSLfalse密码含特殊字符时整个字符串用单引号包起来。还有一种情况是库名写错报错信息会指向Unknown database比认证失败好认得多。第二条房源描述保存后变成问号或者报Incorrect string value: \xF0\x9F...。现象是中文正常但 emoji 报错或者整段中文都变???。原因是库、表、连接三处字符集不一致建库时用了默认的latin1或三字节utf8而房源描述里出现了四字节字符。解决要三处一起查先用SHOW CREATE TABLE house看表的CHARSET再用SHOW VARIABLES LIKE character_set%看服务端配置最后确认连接串里有characterEncodingutf8。三处都对还报错说明表是后来ALTER过的得用ALTER TABLE house CONVERT TO CHARACTER SET utf8mb4;重建一遍。这一步会锁表数据量大时挑没人的时候做。第三条合同日期整体差 8 小时或者到期日算出来少一天。现象是数据库里存的时间看起来对页面上显示的时间早了或晚了 8 小时更隐蔽的是end_date比预期少一天。原因是 JVM 时区和数据库时区不一致加上实体里用了java.util.Date而不是LocalDate。解决分两步连接串加serverTimezoneAsia/Shanghai实体类里凡是表示「日期」的字段起租日、到期日、应缴日一律用LocalDate表示「时刻」的才用LocalDateTime。Date和LocalDate混用会在序列化时各自按不同规则处理前端拿到的时间戳再new Date()一次偏差就叠加了。第四条前端报 CORS 错误但接口用 Postman 调是通的。现象是浏览器控制台红字一片Network面板里能看到一个失败的OPTIONS请求状态码 401 或 403。原因是鉴权拦截器没有放行OPTIONS预检请求被拦了浏览器就认为跨域失败。解决是在preHandle最开头加if (HttpMethod.OPTIONS.matches(req.getMethod())) return true;。如果加了还不行检查CorsConfig里是不是同时开了allowCredentials(true)和allowedOrigins(*)这两个组合会被浏览器直接拒绝必须换成allowedOriginPatterns。第五条图片上传成功但列表页全部 404。现象是上传接口返回了 URL数据库里也存进去了页面上的img全是裂图。原因通常是上传目录用了相对路径而静态资源映射用的是另一套基准。IDE 里跑的时候工作目录是工程根目录java -jar跑的时候是 jar 所在目录两处对不上。解决是把rental.upload-path改成绝对路径并在addResourceHandlers里用file:前缀拼接结尾补斜杠。改完记得重启静态资源映射是启动时注册的热部署不一定生效。6. 二次开发把搜索和统计加进去再做一次验收拿到一套能跑的源码最终目的多半是改成自己的东西。改动最小的两个方向是房源多条件搜索和后台统计报表前者练 SQL 动态拼接后者练聚合查询都不需要动核心表结构。搜索接口的 Mapper 写法用if做动态条件比在 Java 里拼字符串安全。select idsearch resultTypecom.demo.vo.HouseVO SELECT h.id, h.title, h.address, h.rent, h.status, h.cover_img FROM house h WHERE h.status 1 if testkeyword ! null and keyword ! AND (h.title LIKE CONCAT(%, #{keyword}, %) OR h.address LIKE CONCAT(%, #{keyword}, %)) /if if testminRent ! null AND h.rent gt; #{minRent} /if if testmaxRent ! null AND h.rent lt; #{maxRent} /if if testlayout ! null and layout ! AND h.layout #{layout} /if ORDER BY h.update_time DESC LIMIT #{offset}, #{pageSize} /selectgt;和lt;是 XML 转义直接写和会让解析器报错。LIMIT #{offset}, #{pageSize}里两个参数都要在 Service 层算好offset (pageNum - 1) * pageSize别在 SQL 里做算术。这套写法有个已知边界LIKE %关键词%的前导通配符走不了索引房源少于五千条时感觉不到上万条之后列表页会明显变慢那时候再考虑全文索引或者把搜索拆到独立服务。统计报表用一条聚合 SQL 就够按月份统计签约量和租金总额。SELECT DATE_FORMAT(create_time, %Y-%m) AS ym, COUNT(*) AS contract_cnt, SUM(monthly_rent) AS total_rent FROM rent_contract WHERE status IN (1, 2) AND create_time DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY ym ORDER BY ym;DATE_FORMAT出来的ym是字符串前端画折线图时按字典序排就是时间序不用额外转换。DATE_SUB(CURDATE(), INTERVAL 12 MONTH)限定最近一年避免全表扫。如果某个月没有签约这条 SQL 不会返回那一行图表上会出现断点处理办法是在 Service 层补一个 12 个月的月份数组做左连接别指望 SQL 帮你补齐。验收我做一遍固定动作清空rent_bill表手动触发一次账单生成任务再执行一次对比两次的COUNT(*)必须完全相等这是幂等性的验证然后拿两个浏览器同时用不同租客账号抢同一套房源必须一个成功一个提示「已被预订」这是并发锁的验证最后把application.yml里的库地址故意改错确认启动时能明确报出数据源错误而不是启动成功后在业务层随机报空指针这是配置检查的验证。这三步走完我才认为这套包可以交出去。从那以后我每次拿到这类带 SQL 和运行说明的源码包都会先把原始压缩包复制一份存到旁边再动手改配置文件和 SQL 脚本单独拎出来做版本对比改坏了直接回滚不给自己留后悔药。希望帮到你。本文还有配套的精品资源点击获取