简介:这套Java毕业设计源码实现了一个基于机器人问答的智能房源推荐租房系统,面向毕业设计学生与Java全栈开发者。项目基于Spring Boot构建后端,整合RESTful API、MyBatis/Hibernate持久层,并引入自然语言处理与机器学习技术,让用户通过聊天机器人提出需求,系统即可生成相似房源、热门房源等多维度推荐结果。资源共2913个文件,压缩包27.27MB,内部Java源码、XML配置、Vue前端、CSV训练数据、Scala推荐脚本、JAR依赖一应俱全,目录结构按数据采集、模型训练、接口服务、前端展示划分,便于按模块研读。目前已有232人学习,适合作为毕业设计参考或全栈实践案例。通过该项目,可深入理解Word2Vec房源特征化、协同过滤推荐、MongoDB存储、JWT安全校验、Rasa式问答交互等具体实现,并掌握从需求分析到部署测试的完整开发流程。丰富的图片与日志文件还能辅助研究调试思路,对想要构建智能推荐系统的开发者很有帮助。
1. 大学毕设做租房系统,为什么“机器人问答+智能推荐”比普通 CRUD 更吃香
临近毕业,手头的 Java 毕设选题十个里有八个是“某某管理系统”,你把增删改查做得再端正,答辩老师最多给个“工作量不足”的评语。而“基于机器人问答的智能房源推荐的租房系统”这个标题,一眼就能看出它有三个记忆点:租房业务真实、机器人问答有交互感、智能推荐带算法。哪怕它是从网盘里下载的既有项目源码,只要你能在原项目基础上把这两块逻辑讲透、改出自已的版本,它就是一份能写进简历、能应对“java面试题”里常见项目深挖的合格作品。
我在帮几个学弟学妹改这类毕设时发现,大多数人卡在同一个地方:不知道“机器人问答”到底要做到什么程度,“智能推荐”到底用哪套算法才不至于像个黑匣子。其实这套系统落地起来并不神秘,前端给用户一个对话入口,后端把用户的话拆成意图和关键词,再跟房源数据匹配,最后把结果按推荐打分排序返回。本文就按这个路径,把技术选型、数据表设计、Java 实现、避坑点和答辩验证方法一次拆透,让新手的每一步都能照着写,让熟手能直接拿去改参数。
2. 拆解这个毕设:四层结构、三张表和两条核心逻辑链
2.1 技术选型:Spring Boot + MyBatis + Vue,稳到能顺利演示
整个项目的骨架,最常见的组合是 Spring Boot 2.x 做后端、MyBatis 做数据库映射、MySQL 8.0 存业务数据,前端用 Vue 2 或 3,配合 Element UI 搭后台管理页,另找一张页面做租房浏览和问答对话窗口。选这套不是因为它多惊艳,而是因为市面上能找到的参考代码最多,出了问题百度也最好搜。单元测试用 JUnit 5,构建工具选 Maven,因为大部分网上下载的源码包都是 Maven 结构,用 IDE 打开直接等依赖下完就能起动。
后端包结构我建议按 com.rental 分模块:
controller放 REST 接口,/api/chat处理问答,/api/recommend处理推荐;service放问答匹配、推荐评分、用户偏好采集;mapper放 MyBatis 接口,配合application.yml里的数据库连接配置;entity放房源、用户、问答记录、收藏记录等实体类;utils放分词工具、相似度计算、权重配置读取工具。
这样的分层,能保证后面的代码不是一堆方法堆在 Controller 里。做毕业设计时,面试官或答辩老师几乎一定会问“如果问答模块并发高了怎么办”,你可以回答“把分词和匹配逻辑放到 service 层,controller 只转参数,后面加 Redis 缓存命中结果,加线程池隔离”。哪怕你只是口头说出来,印象分也会不一样。
2.2 业务模型:房源、用户、问答意图、推荐分值怎么互相引用
任何租房系统,核心都绕不开三张主表:house、user、intent。我见过不少自学的项目把房源信息直接写在 JSON 里,答辩几轮追问就露馅。正确做法是把房源拆成结构化字段,至少包括:house_id、title、area(所在区域/商圈)、layout(户型,比如“两室一厅”存成字符串)、rent_price(月租金,Decimal)、area_size(面积,Int)、orientation(朝向)、subway_distance(到最近地铁站步行分钟数)、tags(如“整租”“近地铁”“朝南”)、status(0下架1上架)、create_time。
用户表是推荐系统的数据来源,字段要有:user_id、username、phone、budget_min、budget_max、preferred_area(意向商圈,多个用逗号分隔)、preferred_layout、create_time。很多项目把“用户偏好”做成用户自己填表,其实不如把收藏和行为当隐式信号来得真实,后面第四章我会专门讲评分逻辑。
第三张“隐含的表”是问答意图表,你可以叫faq_pattern,它存的是用户问句模板和对应的回复动作。比如用户问“有没有朝阳两居室”,这个模板会把意图分类到search_house,并抽取约束“朝向=东/南、户型=两居”。每个意图对应一个“处理策略”:要么返回一段说明文字,要么触发一次房源查询 SQL。
用一句话记住这几张表的关系:用户通过问答对话说出需求,问答模块把需求变成结构化查询条件,推荐模块在查询基础上打分排序,最终把结果送去前端。
2.3 机器人问答不是聊天,是“意图识别 + 实体抽取 + 查询”
刚接触这类系统的人最容易被“机器人”三个字带偏,以为得接大模型、用深度学习,甚至租服务器跑模型。作为毕业设计,完全没必要。实际常用的套路是“词典分词 + 规则匹配”,也就是:先把用户输入的中文句子按词切开,然后把这些词和事先维护好的词典、正则模板比对,判断用户究竟是想“找房”“问租金”还是“约看房”。这个方案在限定域里效果不差,而且代码量少,面试时你能讲清楚“为什么不用 BERT”反而是一个加分项。
比如用户问“帮我搜一下浦东两室一厅、预算五千以内”。分析下来,意图是“搜索房源”,实体有“浦东、两室一厅、五千以内”,把这些实体组装成 SQL 条件,就完成了问答到查询的闭环。真正的难点在于中文没有空格,词与词之间的边界要靠分词工具。常见做法是引入 HanLP 或者 Ansj 的 jar 包,自己再维护一份租房词典,比如“两室一厅”“整租”“近地铁”必须作为一个词切出来。
2.4 智能推荐不是玄学,是“权重打分 + 排序”
推荐模块容易陷入两种极端:一种是把最近浏览的房源直接返回来,这顶多叫“浏览历史”,不叫推荐;另一种是强行写一个协同过滤,结果用户-行为矩阵稀疏得一塌糊涂,算出来的相似度全是 0。对租房项目来说,最务实的是多因子评分模型:把和用户决策最相关的几个维度——预算差、面积、区域、户型、朝向、地铁距离、行为热度——分别计算出匹配分,再乘上权重求和。这个模型既能让答辩老师听懂,也能在有限的毕设数据里稳定产出结果。
推荐结果最后要走一个“解释”步骤:告诉用户“因为您偏好浦东且预算 5000,这套房朝南且月租 4800,所以推荐度最高”。这件事听起来像附加功能,实际上是答辩时最能体现工程思维的亮点,最后一章我会给一个完整的实现思路。
3. 把机器人问答做进租房系统:从 intent 表到问答接口
3.1 设计 FAQ 语料库和意图词典
先建一张faq_pattern表。我一般在 MySQL 里这样建:
CREATE TABLE `faq_pattern` ( `id` int NOT NULL AUTO_INCREMENT, `intent` varchar(32) NOT NULL COMMENT '意图名,如 search_house / ask_price / ask_layout', `pattern` varchar(255) NOT NULL COMMENT '匹配模板,如 想找{area}的{layout}', `keywords` varchar(255) DEFAULT NULL COMMENT '逗号分隔的触发关键词,如 找,搜,看', `action_type` varchar(16) NOT NULL COMMENT 'reply 或 query', `reply_text` varchar(500) DEFAULT NULL COMMENT 'action_type=reply 时的静态回复', `query_template` varchar(500) DEFAULT NULL COMMENT 'action_type=query 时的 SQL 模板或查询类型', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;为什么建这张表而不是硬写在代码里?因为项目源码交付后,你自己或后来接手的人要改问答规则,不需要重新编译 Java,直接改数据库即可。而且答辩时你可以说“这种设计把知识库和业务逻辑解耦了,运营人员可维护”,这就是工程化的加分项。
常用模板数据可以准备这么几条:
| intent | pattern / keywords | action_type |
|---|---|---|
| search_house | 推荐、找、搜、看房 | query |
| ask_price | 多少钱、租金、价格 | query |
| ask_layout | 几室、户型、几居 | query |
| ask_schedule | 约看、预约、看房时间 | reply |
| bye | 再见、拜拜 | reply |
3.2 中文分词与关键词匹配的 Java 实现
我常用的分词方案是 HanLP,因为它对自定义词典支持比较好。在pom.xml里引入依赖:
<dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>portable-1.8.4</version> </dependency>然后把自己的租房词典放进resources下的rental.txt,格式是一行一个词:
两室一厅 近地铁 整租 朝南 浦东 预算在 Java 里用CustomDictionary加载词典后,写一个拆“关键词”的小工具:
// 从用户输入中抽取用于查询的关键词,后续做意图识别与条件拼装 public class KeywordMatcher { /** * 对一句话分词,并过滤掉停用词。 * 停用词列表太短会导致“的”“了”参与匹配,建议至少准备 30 个。 */ public static List<String> segment(String input) { // 先用自定义词典切分,再过滤长度小于2的无意义字 List<String> words = HanLP.segment(input) .stream() .map(term -> term.word) .filter(word -> word.length() > 1) .collect(Collectors.toList()); // 把“两室一厅”这类自定义词合并,防止被切散 List<String> merged = new ArrayList<>(); for (String word : words) { // 这里可按业务扩展:把“两”+“室”+“一厅”拼成“两室一厅” merged.add(word); } return merged; } /** * 判断一句话是否包含某个意图的触发关键词。 * 命中次数越多,越认为是核心意图。 */ public static MatchResult matchIntent(String input, List<FaqPattern> patterns) { List<String> words = segment(input); String bestIntent = "unknown"; int bestScore = 0; for (FaqPattern pattern : patterns) { String[] keywords = pattern.getKeywords().split(","); int score = 0; for (String kw : keywords) { if (input.contains(kw) || words.contains(kw)) { score++; } } if (score > bestScore) { bestScore = score; bestIntent = pattern.getIntent(); } } return new MatchResult(bestIntent, bestScore, words); } }这段逻辑看着朴素,但它已经把选择题想清楚了:用关键词命中次数来判断意图,而不是第一个匹配到就去执行。很多初版代码写的就是if (input.contains("找")) { ... } return;,用户问“我再找找别的”也会被打回搜索意图,所以记住“比较后再返回”这条规则。
3.3 问答接口:接收提问、解析意图、回填参数、查库返回
接口设计中,我一般让前端传给后端的参数只有userId和message。后端做四件事:分词、意图识别、从原文抽条件、执行业务查询。
// Controller 层只做参数接收和异常包装,不写业务逻辑 @RestController @RequestMapping("/api/chat") public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; } /** * 用户在前端输入一段话,返回问答结果或推荐房源列表。 * 比如 “帮我找浦东两室一厅预算5000” 进入 search_house 意图。 */ @PostMapping("/send") public Result sendMessage(@RequestBody ChatRequest request) { // request.userId 用于记录用户的行为日志,给推荐模块留数据 return chatService.handleMessage(request.getUserId(), request.getMessage()); } }问题在于“抽实体”这一段。我的做法是先写好一个HouseQueryCondition对象,字段包括 area、layout、maxPrice、minPrice、orientation,然后写一个规则抽取类:
// 把用户问句中的价格/户型/区域/朝向抽出来,拼成查询条件 public class ConditionExtractor { public static HouseQueryCondition extract(List<String> words, String rawInput) { HouseQueryCondition condition = new HouseQueryCondition(); // 1. 区域:在词典里维护了一个 areaList,顺序优先的长词优先 for (String area : Dict.areaList) { if (rawInput.contains(area)) { condition.setArea(area); break; } } // 2. 户型:优先匹配 “X室X厅”,用正则把数字从文字里抠出来 Pattern layoutPattern = Pattern.compile("([一二两三室])室([一二两厅])?厅?"); Matcher matcher = layoutPattern.matcher(rawInput); if (matcher.find()) { condition.setLayout(matcher.group(0)); } // 3. 预算:出现“预算”“万”“以内”时,把数字串拿出来 Pattern pricePattern = Pattern.compile("预算?([0-9一二三五六七八九十]+)"); Matcher pMatcher = pricePattern.matcher(rawInput); if (pMatcher.find() && rawInput.contains("以内")) { condition.setMaxPrice(Integer.parseInt(pMatcher.group(1))); } log.info("抽取结果: {}", condition); return condition; } }这里最值得注意的坑是“数字单位”。用户说“五千”和“5k”和“5000”,程序要统一处理成整型元。建议在抽出数字后立刻归一化,不要等到拼 SQL 再处理,否则查询日志里全是“预算五千以内”拼不上的记录。
3.4 参数怎么调:阈值、同义词、兜底话术
整个问答模块能调的就三个地方:关键词触发阈值、同义词扩展、兜底话术。我提供一组实测后比较稳的初始值:
- 意图匹配分数最低为 1 才触发;如果有多个意图同分,优先顺序是
search_house>ask_price>ask_layout。 - 兜底话术至少要写三种不同文案,随机返回,避免用户觉得系统死板。比如“我还不太明白,您可以试试说‘推荐浦东两室一厅’”。
- 同义词表建议放 Redis 或一份 properties 里,不要写死在 Java 中。因为你要在答辩现场演示“我加一个叫‘咨询’的同义词,不用重启项目就能生效”,这就是运行时配置的价值。
下面是一个问答 service 的整合流程,不复杂,但能说明整个模块是怎么串起来的:
// 问答主流程:识别意图 → 抽参数 → 查询或回复 → 记录日志 public Result handleMessage(Long userId, String message) { // 1. 从数据库或缓存中加载意图模板 List<FaqPattern> patterns = faqPatternMapper.findAll(); // 2. 识别意图 MatchResult match = KeywordMatcher.matchIntent(message, patterns); // 3. 抽参数并查询房源 if ("search_house".equals(match.getIntent())) { HouseQueryCondition condition = ConditionExtractor.extract(match.getWords(), message); List<House> houses = houseMapper.queryByCondition(condition); // 4. 如果命中房源太少,就放宽价格条件再查一次 if (houses.size() < 3) { condition.setMaxPrice(condition.getMaxPrice() == null ? 6000 : condition.getMaxPrice() + 500); houses = houseMapper.queryByCondition(condition); } return Result.success(houses); } // 4. 其他意图按模板返回静态话术 FaqPattern pattern = matchPattern(match.getIntent(), patterns); return Result.success(pattern.getReplyText()); }如果你认真看这段代码,会发现它故意做了一个“放宽价格”的动作。这是很多问答机器人翻车的地方:用户问“预算 4000 以内”,数据库里没有,系统直接回复“没有找到”,用户马上失去信任。实际方案应该做两段式召回:先严格查,数据量少于 3 条就放宽价格限制或去掉一个条件再查,返回时还要加一句“已经帮您放宽了价格范围”。
4. 智能房源推荐:用多因子打分给用户排一个“最合适”的列表
4.1 推荐算法选型:为什么毕设不做协同过滤
谈到智能推荐,稍微关注过算法的人第一反应是“基于用户的协同过滤”和“基于物品的协同过滤”。但租房场景有个现实问题:普通毕设数据库中用户数也就几十到几百,每个用户收藏或浏览的房源数量也少得可怜,user-item 矩阵极度稀疏。你算出来的相似用户可能只有一条共同记录,推荐结果基本是随机,答辩时自己都没底。而且协同过滤需要一个“获取用户隐式反馈”的循环,毕业设计没那么多时间攒数据。
因此我建议用“多因子加权评分”,也叫基于知识或规则的推荐。原理是拆出影响租房决策的特征,把每个特征和用户偏好的匹配程度换算成分值,再按权重加权求和。这种方案的优势有三个:一是可解释性好,你能明确说出为什么推荐第 3 套房;二是代码实现用纯 Java 就能跑,不依赖机器学习库;三是冷启动问题容易解决——新用户没行为数据就把权重压到预算、区域这些注册信息上。
4.2 用户偏好画像怎么来:注册、浏览、收藏三个入口
推荐模型没数据就是空转,所以要先设计数据入口。第一个入口是注册表单,用户在“找房意向”里填预算区间、选择意向商圈和户型,这些字段直接写进user表。第二个入口是房源详情页的浏览事件,每次打开详情都要向后端发一条日志“userId + houseId”。第三个入口是收藏和约看动作,权重最大,因为这是强意向信号。
我会为推荐单独建一张临时画像表user_preference,字段如下:
CREATE TABLE `user_preference` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `preferred_area` varchar(64) COMMENT '高频浏览区域,取浏览记录中次数最多的商圈', `preferred_layout` varchar(16) COMMENT '高频户型', `budget_min` int DEFAULT NULL, `budget_max` int DEFAULT NULL, `preferred_orientation` varchar(8) DEFAULT '南北' COMMENT '通过注册和收藏统计', `max_subway_minutes` int DEFAULT 30 COMMENT '用户最远接受的地铁时间', `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;更新画像的时机很重要。我的习惯是放在 recommend 接口被调用之前,先跑一次增量计算。因为毕设阶段没有消息队列,每次定时全量刷太慢,增量刷库的方式更容易讲清楚。
// 根据用户最近20条行为记录,刷新用户偏好画像 public void refreshPreference(Long userId) { List<HouseBrowseLog> logs = browseLogMapper.findRecentByUser(userId, 20); if (logs.isEmpty()) { log.info("用户 {} 无行为记录,使用注册默认画像", userId); return; } // 统计高频区域、高频户型,以及行为所在房源的平均价格 String topArea = logs.stream() .map(log -> houseMapper.findById(log.getHouseId()).getArea()) .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())) .entrySet().stream() .max(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElse(null); preferenceMapper.updateArea(userId, topArea); }这里需要提醒:如果用户行为记录只有一两条,不要急着更新区域,因为噪声太大。我会加上同步规则“少于 5 次的行为记录只累加收藏行为,不覆盖注册时的偏好”。
4.3 多因子评分代码:价格、区域、户型、面积、朝向五维度
推荐的核心是一个RecommendScorer类。我把五个维度的打分规则列成表格,方便你直接抄:
| 因子 | 计算方式 | 分数范围 |
|---|---|---|
| 价格匹配 | 用户在预算区间内给 100,超出但不超过 15% 给 60,否则给 0 | 0~100 |
| 区域匹配 | 命中偏好区域给 100,同区相邻商圈给 70,其他给 30 | 30~100 |
| 户型匹配 | 完全一致 100,居室数一致但厅数不同 70,否则 40 | 40~100 |
| 面积匹配 | 用户在注册时填过面积偏好时,面积在±10 平米内给 100 | 0~100 |
| 行为热度 | 近 7 天被收藏次数 top10 房源给 100,top30 给 80,其余按比例 | 40~100 |
Java 实现就按这套规则计算总得分:
// 多因子加权评分:分数越高越靠前;权重来自配置,不要写死 public class RecommendScorer { /** * 计算单套房源对某个用户的推荐分值 */ public static double score(UserPreference pref, House house) { double weightPrice = 0.4; double weightArea = 0.25; double weightLayout = 0.15; double weightAreaSize = 0.1; double weightHot = 0.1; double priceScore = calcPriceScore(pref.getBudgetMin(), pref.getBudgetMax(), house.getRentPrice()); double areaScore = calcAreaScore(pref.getPreferredArea(), house.getArea()); double layoutScore = calcLayoutScore(pref.getPreferredLayout(), house.getLayout()); double sizeScore = calcSizeScore(pref.getAreaSize(), house.getAreaSize()); double hotScore = calcHotScore(house.getViewCount(), house.getCollectCount()); // 总分 = 各因子线性加权,权重越高对排序影响越明显 double total = priceScore * weightPrice + areaScore * weightArea + layoutScore * weightLayout + sizeScore * weightAreaSize + hotScore * weightHot; log.debug("房源 {} 得分: 价格{} 区域{} 户型{} 面积{} 热度{} -> {}", house.getId(), priceScore, areaScore, layoutScore, sizeScore, hotScore, total); return total; } }值得强调一下“线性加权”在毕设里够用,但如果你把价格权重调到 0.6,排序结果会不稳定。我一般把价格、区域之和控制在 0.6 以内,剩下的留给户型、面积和行为热度,这样推荐结果不会每一套房都在价格上最优,称为“太挤”。
4.4 推荐列表生成:让 SQL 先过滤,再用 Java 排序
这是最常见的实现误区。有人会让推荐模块遍历全表房源,每套都调 score 方法,数据量到几千条时接口响应就明显变慢。正确姿势是:先用 SQL 粗过滤掉完全不符合基本条件的房源,再用 Java 打分排序。我一般用三条 SQL 规则:
-- 先粗过滤:状态上架、价格不超过用户上限的1.5倍、已绑定区域必填 SELECT * FROM house WHERE status = 1 AND IF(#{maxPrice} IS NOT NULL, rent_price <= #{maxPrice} * 1.5, 1=1) ORDER BY rent_price ASC LIMIT 100;为什么前面用 setMaxPrice 时约束是 “预算 + 500”,到了推荐模块却放宽到 1.5 倍?因为问答是“用户有明确指令”,必须严格;推荐是可以“探索”的,你让用户看到一点超出预算但其他方面极好的房子,购买意向可能更高。这两个模块边界不一样,代码要分开写。
粗过滤完成后再执行打分排序:
// 过滤出候选后,用评分器打分并倒序返回 public List<House> recommendForUser(Long userId, int topN) { UserPreference pref = preferenceMapper.findByUserId(userId); if (pref == null) { // 新用户无画像,返回默认房源,按热度排序 return houseMapper.findHotHouses(topN); } List<House> candidates = houseMapper.findFilteredCandidates(pref.getBudgetMax()); List<ScoredHouse> scored = candidates.stream() .map(house -> new ScoredHouse(house, RecommendScorer.score(pref, house))) .collect(Collectors.toList()); // 从大到小排,取前 topN;分数相同的按收藏数再排 scored.sort(Comparator.comparing(ScoredHouse::getScore).reversed() .thenComparing(ScoredHouse::getCollectCount).reversed()); // 一定要用 ArrayList 包一下,否则返回的不可变列表不能加推荐原因 List<House> result = scored.stream() .limit(topN) .map(ScoredHouse::box) .collect(Collectors.toList()); // 写日志:每个用户每次推荐都留痕 recommendLogMapper.insert(userId, houseIdsToString(result)); return result; }排序代码里有几个细节:thenComparing后还要再reversed()是因为前一个排序已经倒序,后面想按收藏数倒序,但 Java 里二次排序比较器天然是升序,所以必须再反转一次,否则会出现相同的分数但收藏数最少排前面。这属于“代码看着好看,实际顺序反了”的经典坑。
4.5 权重标定与人机对比验证
推荐参数不是拍脑袋定的。我建议做法是把评分字段单独做成一张rec_weight配置表,里面有weight_price等字段,界面上加一个后台管理页面,方便答辩时现场改参数、前后对比列表变化。展示效果是最好的答辩素材:你点一下“价格权重从0.4调到0.6”,推荐列表立刻重新排序,整个过程都发生在数据库配置层面,不需要重新部署。
5. 避坑指南:6 个高频问题,附现场排查步骤
5.1 中文提问分词全错:“两室一厅”被切成“两室”、“一厅”
现象:用户输入“推荐两室一厅的房子”,后端日志里分词结果是“推荐”“两室”“一厅”“的房子”,户型匹配永远失败。原因:你不改 HanLP 自带的词典,它默认认为“两室一厅”不属于常用词。解决:在自定义词典rental.txt里加“两室一厅”“三室两厅”“整租”“押一付三”,并且注意词典文件编码选 UTF-8,否则一加载全是乱码。另外,如果你用的是 Spring Boot 内嵌 Tomcat 跑 jar,修改了 resources 下的词典文件一定要重新打包,否则命中的还是旧的。
5.2 推荐结果永远返回同一批房源,用户收藏没起作用
现象:用户收藏了几套高价位房源后,推荐列表依旧全是便宜房。原因:收藏行为没有写进偏好画像,或者画像刷新只在refreshPreference里统计了浏览记录,没有统计收藏。解决:把收藏行为的权重提升到浏览行为的 3 倍,在user_preference表加一个preferred_tag,把收藏房源的tags字段拆出来统计词频,按词频刷新画像。推荐列表里还要再用一条id NOT IN (SELECT house_id FROM user_action WHERE user_id=? AND action_type IN ('dislike','already_looked'))排除用户已经看过的房源。不排除的话,用户每次看推荐都是那几套,很快就会被发现“智能推荐不智能”。
5.3 MySQL 数据表字符集导致中文回复乱码
现象:问答接口返回的中文在 Postman 里正常,到前端页面变成问号。原因:数据库连接 URL 没加characterEncoding=utf8,或者表不是utf8mb4。解决:在application.yml里检查数据库连接串:
jdbc:mysql://localhost:3306/rental?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai同时把表改成 utf8mb4 或建表时指定默认字符集。特别注意“千万不能只改 server 端环境变量”,你本地数据库系统默认可能是 latin1,不改上面的连接串照样乱码。修改完成后重启应用,然后先查SHOW CREATE TABLE user;确认 charset 字段,不要查个SELECT就以为没问题。
5.4 高德地图 key 在前端请求里裸奔,答辩被追问
现象:前端 Vue 项目把AMap.key直接写死在main.js里,然后把源码提交到 GitHub 仓库。答辩老师问“你这个 key 泄露了怎么办”,答不上来。原因:前端页面必须拿到 key 才能加载地图,但 key 如果一直写在代码里,别人可以在控制台里看到你的安全凭证。解决:把 key 放进 Spring Boot 后端配置,提供一个/api/config/mapKey接口返回给前端,前端从后端拿 key 后再初始化地图。这样虽然前端控制台还是能看到以后端返回的 key,但至少源码仓库里不裸奔。更稳妥的做法是限制 key 的域名白名单,毕设阶段做到“key 不硬编码、接口统一从后端取”就能说服答辩老师。
5.5 兜底话术太死,用户连续问几次就问崩了
现象:用户输入“有没有性价比高的房子”,后端识别成 unknown,返回“我不明白”,用户连续问三句,系统回三句“我不明白”,体验极差。原因:兜底逻辑只用了一个话术,也没有把 unknown 的问题收集起来形成数据。解决:兜底话术最少写 5 种,用随机数切换;遇到 unknown 意图时,把用户原话写入unresolved_question表,后续你可以定时在后台看这些 “bad case”。搞定这些 bad case 后,问答命中率才会真实提升,这也是答辩里能讲“闭环迭代”的资本。
5.6 Spring Boot 启动失败或端口被占用,项目跑不起来
现象:双击运行主类,控制台报Port 8080 was already in use。原因:本地有微信开发者工具或者别的 Java 服务占用了端口。解决:临时改端口用启动参数--server.port=8081;如果要在 IDE 里长期配置,可以在application.yml里改server.port。要注意的问题是前端 axios 请求的 baseURL 如果写成http://localhost:8080,后端一改端口就要联调发现跨域和连不上的问题。建议在 dev 环境直接用 8080,遇到占用就查出对应进程关掉。Windows 命令是netstat -ano | findstr 8080,再taskkill /pid 进程号 /F;这条命令应写在你的项目 README 排错里。
6. 从“能跑”到“能答辩”:加日志、做验证、给推荐一个解释
6.1 把问答和推荐的内部日志打开,让老师看到你做了什么
我在做毕设辅导时,最常听到的问题是“老师觉得我这个项目太简单”。解决办法不是加更多功能,而是把已有的功能做深。比如问答模块处理完问题后,写一条“message_log”,把用户原话、分词结果、命中意图、命中关键词、兜底是否触发、回复内容都记录下来。答辩时打开后端 log 页面,按条件查一查,现场演示“你看,这句『想租浦东大面积』被切成了什么词,又怎么匹配到了 search_house”。没有日志,你的智能逻辑全是黑匣子,有了日志,老师才会觉得你是真的把系统做通了。
6.2 用 JUnit 给推荐算法写一个回归测试
推荐算法改参数很容易把结果改疯,所以至少要写一个简单的 JUnit 测试保护核心逻辑。初始化几套 mock 数据,验证“预算越接近、得分越高”和“户型不一致时得分低于一致”这两条规则。
// 回归测试:保证价格因子和区域因子的排序方向不会错 class RecommendScorerTest { @Test void priceOverBudgetShouldScoreLowerThanInBudget() { // 给定人:预算 max=5000 UserPreference pref = new UserPreference(); pref.setBudgetMax(5000); // A 房月租4800,B 房月租5200 House h1 = new House(); h1.setRentPrice(4800); House h2 = new House(); h2.setRentPrice(5200); double score1 = RecommendScorer.score(pref, h1); double score2 = RecommendScorer.score(pref, h2); // 注意这里不能只断言score1>0,要断言score1>score2,这才是价格因子的作用 assertTrue(score1 > score2); } }这类测试跑起来很快,也是告诉面试官“我做推荐时不只是调参,还有回归观念”的直观证据。写断言时尽量用“比较方向”而不是“等于某个值”,因为权重一改,具体数值就变了,但大小关系不应该变。
6.3 给推荐结果配一句“推荐理由”,让你从技术实现者变成产品思考者
最后是我的压箱底技巧:推荐列表里返回房源列表的同时,返回一个recommendReason字符串。它由得分最高的因子组合而成,比如“该房源靠近地铁站,且价格低于您设定的预算 10%”,“该房源属于您常浏览的浦东板块,户型也匹配您的选择”。代码里是这样生成的:
// 根据因子明细生成面向用户的一句话推荐理由,用于前端展示 public String buildRecommendReason(ScoredHouse scored, UserPreference pref) { House house = scored.getHouse(); List<String> reasons = new ArrayList<>(); if (house.getRentPrice() <= pref.getBudgetMax()) { reasons.add("价格在您的预算范围内"); } if (house.getSubwayDistance() <= pref.getMaxSubwayMinutes()) { reasons.add("距地铁步行" + house.getSubwayDistance() + "分钟"); } if (house.getArea().equals(pref.getPreferredArea())) { reasons.add("位于您常看的" + house.getArea() + "板块"); } // 最多取两个原因,避免话术太长 return reasons.size() >= 2 ? String.join(",", reasons.subList(0, 2)) + ",推荐指数较高" : String.join(",", reasons) + ",综合排序靠前"; }这样做至少是在向“推荐解释”这个工业级方向思考。答辩老师问“你和别人有什么区别”,你就能说“别人只返回一套结果,我能告诉用户为什么是这个结果,也能让用户根据规则反馈调整推荐”。我自己以前带过的毕设小组,靠再加上这一个功能,系统在展示效果上拔高了一个档次;而且它几乎没有增加多少工作量。
说到底,毕设项目源码下载下来不代表你完成了,真正值钱的是你把其中几个模块拆开、改坏、再修好的过程。把问答的日志留好,把推荐的分数打出来,把启动排错写进 README,答辩时你会比我那时候稳得多。希望帮到你。
本文还有配套的精品资源,点击获取