作为一个过来人,我要先把话撂在前面:毕设题目里带“智慧”、“推荐”、“定制”这些词,并不代表一定要上多高深的算法。我的项目全称是 SpringBoot古镇旅游路线规划网站,说白了就是一个基于 SpringBoot 的历史文化名镇智能行程定制平台,也就是一个小型的 SpringBoot 驱动的古村落智慧游览路径推荐系统。它解决的问题很实在:游客在古镇里经常乱转,时间不够用,有的景点打卡完就得赶车,结果漏掉了真正顺路、值得看的点。网站会根据你输入的可玩时长、预算和对哪些文化类型感兴趣,自动生成一条不走回头路、时间合适的游览路线,并在地图上可视化展示出来。
这个选题适合正在准备毕设的同学,尤其是想做一个“业务逻辑 + 算法亮点”都齐全的系统的人。整个项目从需求梳理到部署上线,我前后花了大约两个月。这篇文章会把我从技术选型、数据库设计、推荐算法实现到常见踩坑的完整过程写下来,希望能帮你少走弯路。
1. 项目选题与需求拆解
1.1 为什么做古镇旅游路线规划网站
先说说选题灵感。我有一次去一个以明清建筑闻名的古镇,出发前看了好几篇攻略,手机上划掉一个又一个“必打卡点”,结果到了地方还是懵了:镇子看起来不大,但景点分布很散,有的在老街东头,有的在河对岸。我按攻略推荐的顺序走,一会儿往东一会儿往西,全程走了将近三小时,实际游玩时间不到一个半小时。回来之后我就在想,这种情况太常见了,缺的不是景点信息,而是把景点按照时间和位置串成一条最优路径的服务。
把这个痛点落到毕设里,就有了三个层面的价值。第一是业务完整度高,游客端和管理员端都有明确的用例,能做成一个“像样”的网站。第二是算法有亮点,路线推荐不是简单查表,而是基于偏好、时间、预算建立优化模型求解,答辩的时候有东西可以深挖。第三是扩展空间好,今天是古镇,明天换成语境一样的城市漫游、博物馆参观,核心算法可以复用。所以这个题目不是赶时髦的“标题党”,它确实能撑起一个完整的设计。
1.2 核心需求与功能边界
我在写需求文档时,没有把系统往“大而全”的方向设计,而是守住三条边界:面向游客、面向管理员、不碰复杂的电商交易。最终的功能清单是这样的:
游客端:
- 注册、登录、个人信息管理
- 按分类和关键词浏览景点,查看景点详情、开放时间、票价、位置
- 查看管理员配置好的预置路线
- 提交“定制行程”:选择游玩总时长、预算、兴趣标签
- 查看系统生成的路线图和时间轴,保存/收藏行程
- 查看历史行程记录
管理员端:
- 景点信息维护,包括经纬度、营业时段、建议停留时长、图片
- 预置路线维护:选择一个景点序列,自动计算总距离
- 用户列表与禁用管理
- 热门景点数据统计
我没有做在线支付、用户GPS实时定位、导游语音讲解这些偏离主题的功能。原因很简单:毕业设计的时间是有限的,功能越多就越难把每个环节做扎实。把推荐算法和路线可视化的体验做好,远比堆功能列表更有说服力。
2. 技术选型与整体架构
2.1 技术栈的选择与取舍
技术选型上,我一开始想过用现在流行的“SpringBoot + Vue 前后端分离”,但最终放弃了。不是不会 Vue,而是这个项目需要处理的页面有十来个,前后端分离意味着要维护两个工程、处理跨域、处理接口文档,这些都会吞掉大量时间。所以我最后选择的是 SpringBoot + Thymeleaf + Bootstrap + jQuery 的组合,后端一套代码搞定页面渲染和API返回,部署时就是一个 jar 包。
后端核心依赖用了 SpringBoot 2.7 + MyBatis-Plus + MySQL 8 + Redis。选 MyBatis-Plus 而不是原生 MyBatis,是因为它自带的分页插件、条件构造器和逻辑删除能力能帮我省出很多重复代码,而且遇到复杂SQL仍然可以手写,不会卡住。Redis 主要用来做两件事:一是缓存热门景点列表,降低数据库压力;二是缓存推荐结果,避免同一个用户反复提交相同条件时每次都重新计算路线。
地图部分用了 Leaflet 这个轻量开源组件,配合公开的瓦片数据。它的 API 简单,支持把路线画成折线,对毕设来说完全够。之所以不用更重的可视化库,是因为我们只需要点位和线,不需要热力、不需要3D,复杂度越少越稳。
2.2 系统分层与核心数据流
整个后端按经典的分层结构组织:Controller 接收请求参数,Service 做业务编排和算法调用,Mapper 负责数据库操作。算法模块被单独抽成了RouteRecommendService,不直接依赖任何页面数据,只接收景点列表、用户偏好和时间预算,返回一个有序路线。这样设计的理由是方便单元测试,也为了日后如果要把推荐换成别的算法,不用动接口。
从用户点击“生成路线”的前后顺序看,数据流是这样的:前端把游玩总时长、预算、兴趣标签等参数提交到/plan/customize接口;Controller 先做参数校验,再调用 Service;Service 先查 Redis 里是否有相同参数的缓存;如果没有,就加载候选景点,计算景点间的通行时间矩阵,然后跑动态规划求解;得到路线后,把总耗时、总门票、每个景点的先后顺序和预计时间写入数据库,同时写一份缓存;最后返回给前端,前端用 Leaflet 在地图上把路线画出来。
这套逻辑本质上是个“读-算-写-画”的闭环。整体架构不复杂,但边界清晰。写代码的时候,我要求自己不在 Controller 里写任何业务判断,所有判断都下沉到 Service,这样后面做单元测试时会轻松很多。
3. 数据库设计与核心表结构
3.1 实体关系梳理
数据库设计我花了不少时间,因为路线推荐算法依赖的数据质量,很大程度上决定了推荐结果靠不靠谱。核心实体有六个:用户、景点、预置路线、定制行程、行程明细、收藏。
用户和行程是一对多关系,一个用户可以有多个历史行程;每个行程包含多个行程明细,每一条明细指向一个景点,并保存该景点的游览顺序和预计开始时间。预置路线和定制行程是两套逻辑:预置路线是管理员手工编排的模板,所有人可以直接查看;定制行程是算法算出来的个性化结果,属于某个用户。收藏是用户和景点之间的一张关联表,用来记录“我想去”。
3.2 关键表字段与索引设计
这里给出几张最核心的表的字段设计。景点表是最关键的,推荐算法读得最多,所以要特别注意字段类型。
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| name | VARCHAR(64) | 景点名称 |
| category | VARCHAR(16) | 分类:古建/美食/民俗/自然等 |
| description | TEXT | 景点介绍 |
| longitude | DECIMAL(10, 6) | 经度 |
| latitude | DECIMAL(10, 6) | 纬度 |
| ticket_price | DECIMAL(8, 2) | 门票价格 |
| stay_duration | INT | 建议停留分钟数 |
| open_time | VARCHAR(32) | 开放时段,如"08:00-17:30" |
| popularity | INT | 热度分 1-100 |
| cover_url | VARCHAR(255) | 封面图地址 |
| deleted | TINYINT | 逻辑删除标记 |
经纬度用 DECIMAL(10,6) 而不是 FLOAT,这个细节我特别要强调。我最早偷懒用了 FLOAT,结果地图上标记位置偏移了几百米,后来排查出来是精度丢失导致的。纬度范围 -90 到 90,经度 -180 到 180,DECIMAL(10,6) 足够保存到米级精度,而且还能避免浮点运算误差。
行程表和行程明细表如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| user_id | BIGINT | 用户ID |
| plan_name | VARCHAR(64) | 行程名称 |
| total_time | INT | 总时长(分钟) |
| total_budget | DECIMAL(8, 2) | 总预算 |
| total_distance | DECIMAL(8, 2) | 预估总距离(公里) |
| create_time | DATETIME | 创建时间 |
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| plan_id | BIGINT | 行程ID |
| scenic_id | BIGINT | 景点ID |
| order_no | INT | 游览顺序 |
| start_time | VARCHAR(16) | 预计开始时间,如"09:20" |
| duration | INT | 计划游览分钟数 |
| suggested_wait | INT | 前往下一景点的通行分钟数 |
索引方面,给scenic_spot表的category加普通索引,因为用户筛选偏好的时候会频繁按分类查;给trip_plan表的user_id加普通索引,因为“我的历史行程”也要按用户查。不要过度加索引,景点表最多几百条数据,性能问题不是瓶颈。
我最初尝试不用“行程明细”这种标准关系模型,而是把景点ID用逗号拼接成一个字符串存到trip_plan.scenic_ids里,反正生成路线也不需要二次修改。后来发现这个设计会在两个地方吃亏:一是显示历史行程时,Java 端要把字符串拆开再批量查景点,很别扭;二是如果以后想对“一个路线被多少人使用过”做统计分析,这种冗余结构会很痛苦。所以老老实实拆成了两张表,代码并没有因此复杂多少,反而更清晰。
4. 路线推荐算法设计与实现
4.1 推荐逻辑拆解与约束建模
推荐算法是整个项目里最值得讲的部分。我没有直接套用现成的“最短路径”,因为游客要的不是“距离最短”,而是在有限时间内“玩得最值”。所以我把问题建模成:给定一个景点候选集合,在总时间不超过用户可用时间、总门票不超过预算、每个景点只游览一次的前提下,选出一个有序子集,使得“满意度”最大。
满意度不能拍脑袋定,我把它拆成两部分来打分。第一部分是偏好匹配度,用户在定制行程时会勾选兴趣标签:古建民居、地方美食、民俗体验、自然风光、博物馆。每个景点在后台可以挂多个标签,例如“某老宅”同时有“古建民居”和“民俗体验”两个标签。偏好匹配度就是用户标签向量和景点标签向量的余弦相似度,范围在 0 到 1 之间。第二部分是景点热度,直接用后台维护的popularity字段归一化到 0-1。
最终得分公式为:
finalScore = 0.6 * preferenceScore + 0.4 * popularityScore为什么偏好权重占 0.6 而不是更多?因为如果偏好权重过高,冷门但非常符合用户偏好的点就会被无脑选进来;如果过低,推荐结果又会变成所有人都是同一个热门路线。0.6 是我拿十几组测试数据调出来的经验值,基本能让“想去体验民俗”的人优先看到民俗点,同时热门点也不会完全被忽略。
除了打分,还要计算两个景点之间的通行时间。古镇里的道路不是直线,所以我不直接使用直线距离作为时间依据,而是用 Haversine 公式算出球面直线距离,再乘一个路网系数 1.4 来模拟曲折的道路,然后用成人步行平均速度 4.5 公里/小时估算时间。
Haversine 公式是:
distance = 2 * R * asin( sqrt( sin^2((lat2 - lat1) / 2) + cos(lat1) * cos(lat2) * sin^2((lon2 - lon1) / 2) ) )其中 R 取地球平均半径 6371 公里。举个例子,两个景点坐标分别是 (31.123456, 121.654321) 和 (31.124567, 121.657891),算出直线距离大约 0.36 公里,乘 1.4 后是 0.5 公里,按 4.5km/h 的速度,通行时间约 6.7 分钟。再加上景点停留时间 45 分钟,那么从这家出来到下一家开始游览,总共要预留 52 分钟。这个数字看起来很琐碎,但它直接影响算法结果是否可行,所以一定不能省。
4.2 基于动态规划的时间预算路线求解
当候选景点数量小于等于 15 时,我用状态压缩动态规划来求最优解。这个问题可以理解成“在时间约束下找满意度最高的游览顺序”,和经典的“背包 + 旅行商”问题很接近,但因为景点数量少,完全可以用集合状态枚举。
定义dp[mask][i]表示已经游览过的景点集合为mask,且最后游览的是第i个景点时,能得到的最大满意度;同时用time[mask][i]保存对应累计游览和通行总时间。状态转移时,从mask中去掉第i个景点,得到上一个集合prevMask = mask ^ (1 << i),枚举上一个停留景点j,如果j在prevMask中,尝试更新:
newScore = dp[prevMask][j] + finalScore[i] newTime = time[prevMask][j] + travelTime[j][i] + stayDuration[i]如果newTime <= availableTotalTime,并且newScore比原来的dp[mask][i]更大,就更新状态,同时记录前驱preState[mask][i]用于回溯路线。最终从所有访问了任意景点集合的状态里,选出满意度最大的一个,再根据前驱数组还原出景点访问顺序。
复杂度是 O(2^n * n^2)。n = 15 时,2 的 15 次方是 32768,再乘 225,大约 700 万次操作,用 Java 跑一遍不到 100 毫秒。这个性能放在本地开发环境里完全没问题。如果以后景点数量到了 40 个以上,2^n 会爆炸,所以我还留了一个降级方案:当候选景点超过 15 个时,先用“单位时间满意度”排序取前 15 个,再跑动态规划;或者换成贪心 + 局部搜索,效果会打折,但能保证接口不超时。
这里有个很关键的点:DP 求解前一定要做剪枝。我写第一版算法时没有剪枝,结果生成一次推荐要等两秒,很尴尬。后来加了三道剪枝:第一道,门票价格超过剩余预算的景点直接不进入候选集合;第二道,景点建议停留时间超过用户总时长 1/3 的直接不进入候选,因为单个景点占比太大,路线会非常单调;第三道,在 DP 内部,如果当前累计时间已经超过剩余可用时间,就直接跳过后继。这样剪完,单机跑 15 个景点的 DP,耗时基本在 30 到 60 毫秒。
4.3 开放时间与景点分组处理
光有时间和预算还不行,游览还有一个硬约束是开放时间。古镇里的封闭景点通常不是 24 小时开放的,比如祠堂上午八点开,博物馆下午四点半闭馆。如果推荐路线把某个庙排在 17:40 到达,那游客只能吃闭门羹。我的解决办法是在生成路线之后,从第一站开始模拟时间轴:如果某站的到达时间早于开放时间,就把当前时间修正为开放时间再继续;如果到达时间晚于关闭时间减建议停留时长,就认为这个景点不可安排,需要从状态集合里踢掉后重新计算。
这个逻辑听起来简单,但处理起来很容易遗漏边界。我的坑是只检查了“关闭时间晚于到达时间”,忘了一天的游览可能跨过中午,而部分景点中午有休息时间。后来在景点表里加了open_time存的是时间段文本,解析成多个区间后,每次模拟时都要判断当前时间落在哪个区间里。后来为了减少开发量,我暂时约定所有景点的开放时段最多两段,比如上午 08:00-12:00、下午 13:00-17:30,代码里做一个简单的TimeSlot类来处理。
另一个提升可玩性的设计是景点分组。我统计算过,一个典型古镇的重要景点数量大约在 20 个左右,如果全部参与推荐,DP 性能会开始吃紧。从业务角度讲,游客也不可能全逛完。所以我按位置把景点分成几个“游览簇”:两两距离小于 200 米的景点视为同一个簇,推荐时先从每个簇中挑出最值得去的代表,再把这些代表作为 DP 候选。这不仅降低复杂度,也避免了推荐路线里出现“同一个区域内来回折返”的新手错误。
5. 核心模块开发实录
5.1 行程定制接口的完整实现
前后端设计好了之后,真正写代码是从行程定制接口开始的。Controller 层很简单,我只负责接收参数和返回结果:
@PostMapping("/plan/customize") @ResponseBody public Result<PlanVO> customize(@RequestBody CustomizeRequest request) { if (request.getAvailableMinutes() == null || request.getAvailableMinutes() <= 0) { return Result.error("可用时间不能为空"); } PlanVO plan = planService.customizePlan( currentUserId(), request.getAvailableMinutes(), request.getBudget(), request.getInterestTags() ); return Result.success(plan); }Service 里的流程才是重点。我在PlanServiceImpl里维护了一个RecommendEngine对象,专门负责算法计算。customizePlan方法会依次执行四步:组装候选景点列表,调用引擎计算推荐路线,把结果转换成行程明细并批量插入,最后存入 Redis 缓存。这里我特意把算法和数据库操作分开,RecommendEngine不关心数据库长什么样,只接收内存里的List<ScenicSpotVO>。这样我可以很方便地写一个 JUnit 测试,直接构造几十个景点对象验证算法正确性,而不用连数据库。
关于事务,我的做法是:算法计算完成后,把“行程主表 + 明细表”保存在一个事务里;推荐过程和事务没有关系。为什么这么设计?因为算法计算可能要 200 毫秒,如果整个算法过程都被事务包裹,数据库连接会被占住很久,并发高一点就容易连接池耗尽。行程写库本身只是插入几条记录,事务范围越小越好。
接口返回给前端的数据结构我设计成三层:第一层是routePath,一个有序的景点 ID 列表;第二层是timeAxis,每个景点对应的预计开始时间、停留时长、到下一站的通行时间;第三层是汇总信息,比如总距离、总消费、总时间。前端拿到这三层数据后,可以分别画地图、画时间轴、显示统计摘要,职责清晰。
5.2 地图路径可视化与坐标处理
地图部分,我选的是轻量的开源库 Leaflet。页面初始化时,先用后台传来的路线中心点坐标把地图定位到古镇:
var map = L.map('map').setView([centerLat, centerLng], 14); L.tileLayer('瓦片服务地址').addTo(map);生成路线成功后,我先清除旧图层,再把每个景点的坐标点用L.marker逐个加到地图上,最后把有序坐标数组传给L.polyline,画出一条红色折线。如果需要展示顺序,我还在每个锚点上绑了一个数字序号,方便用户一眼看出从哪个点走到哪个点。
这里有一个绕不开的坐标偏转问题。国内常用的在线地图用的是加密后的坐标系,而我在数据库里存的是 WGS84 坐标,两者不统一会导致标记偏离几百米。毕设项目里,我选择了不接商业地图平台,而是用不受偏转影响的公共瓦片源,这样数据库坐标、算法距离计算、地图展示就都能统一在 WGS84 下,省去了坐标转换的烦恼。如果以后一定要接国内地图平台,得在后台录入坐标时同步做偏转处理,或者在读取时动态转换,这个不能疏忽。
5.3 管理员后台与文件处理
管理员后台用 Bootstrap 做了一套简单的响应式布局,左侧是菜单,右侧是内容区。景点管理页面就是一个表格加弹窗表单,表单里包含景点的基本字段和经纬度。经纬度录入我做过一个小优化:旁边放一个“在地图上选点”按钮,打开地图,点击某个位置后自动填充经纬度输入框。这个功能看起来不起眼,但避免了手工查经纬度再复制粘贴的麻烦,实际使用中很受欢迎。
图片上传是容易踩坑的地方。我把图片保存在本地的/upload目录,并使用 UUID 作为文件名,避免中文文件名导致乱码和特殊字符问题。然后通过 Spring 的WebMvcConfigurer添加虚拟目录映射,这样图片 URL 是/upload/xxx.jpg,不用再担心部署目录变化。这里有个经验:不要用真实文件名直接落盘,即使是“某祖宅.jpg”这种名字,经过 URL 编码后会出现各种问题。用 UUID 重新命名,原始文件名可以存到数据库的original_name字段里备用。
6. 常见问题与排查指南
6.1 我踩过的几个典型坑
这个项目踩的坑,很多是新手大概率会遇到的。我把最典型的几个列出来,你可能也会用得上。
第一个坑是 MyBatis-Plus 分页查询总数一直为 0。问题原因是分页插件没有配置。只引入依赖还不够,必须建一个MybatisPlusConfig,加上PaginationInnerInterceptor:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }如果不加这个配置,IPage.getTotal()返回 0,页面底部会显示“共 0 条”,但实际数据是有值的。这个坑排查了我一下午。
第二个坑是经纬度用 FLOAT 保存后精度丢失。MySQL 里 FLOAT 是单精度浮点数,存 31.123456 经过计算可能变成 31.123456954,地图上偏移非常明显。改成 DECIMAL(10,6) 后再没出现过。
第三个坑是 LocalDateTime 字段在 JSON 序列化后变成了一长串数字。原因是 Jackson 默认没有注册 JavaTimeModule。解决方法是引入jackson-datatype-jsr310,并在配置类里注册JavaTimeModule,同时设置时间格式。
第四个坑是推荐结果出现“回头路”。从 A 走到 B,又从 B 走回 A,这种路线在 DP 里不应该出现,因为每个景点只能访问一次,但有两处会产生重复:一是如果缓存了旧的推荐结果,数据库里没有及时更新;二是景点分簇计算时,如果两个簇的距离特别近,算法可能先走簇1,再走簇2,最后为了回入口再把簇1里漏掉的点塞进去。我的最终解决办法是:生成路线后加一个检查,如果路线中连续三段在空间上形成明显的往返,就尝试交换中间两段的顺序;如果交换后总时间更短,就采纳交换结果。这本质上是一个小的 2-opt 局部搜索,代码量不大,但对结果改善很明显。
6.2 性能优化与部署经验
性能优化我主要处理了两块。第一块是数据库查询,景点列表接口和分类筛选接口频繁访问,我直接用 Redis 做了缓存,键是“景点分类加页码”,比如scenic:category:ancient:page:1。因为景点数据变化频率很低,管理员修改景点后只要在 Service 里主动删除对应缓存即可,不需要设置很短的过期时间。第二块是推荐计算的缓存,键设计为plan:recommend:{userId}:{time}:{budget}:{tagsHash},因为用户参数组合很多,所以不需要担心缓存雪崩;过期时间设成 6 小时,保证短时间内重复定制能立刻返回结果。
部署上,我用 Maven 打包成可执行 jar。在application-prod.yml里指定生产库连接、Redis 地址和文件上传路径,然后用 systemd 写一个服务脚本让进程常驻。需要注意的坑是:本地 Windows 路径和生产 Linux 路径分隔符不同,不要写死路径拼接,用File.separator或者 Java 的Paths工具类。另外,打包时要把前端静态资源放进src/main/resources/static,否则 jar 包里没有页面。这个问题我在第一次打包时就遇到了,页面 404,查出来原因是资源目录路径放错了。
数据库脚本我用了spring.sql.init.mode=always来初始化表结构和基础数据,但生产环境后来改成了手动执行,避免每次启动都重复执行导致数据重置。如果你准备用 Docker 部署,建议把 MySQL 和 Redis 都放进 docker-compose,然后应用容器单独运行。不过毕设答辩环境往往没有 Docker,所以简单的一个java -jar方案反而最可靠。
最后的几点心得
我个人在整个项目中最深的一点体会是:这类“智能推荐”系统,难点从来不只是把算法跑通,而是让算法结果看起来像人排出来的路线。我经历过一次现场演示,推荐结果把两个相隔很远、但评分都很高的景点排在一起,导致游客要在古镇里狂奔半小时。从算法分数看,它没有错;但从用户直觉看,这个结果就是不合理。
为此我在后面加了一个人工规则:两个景点之间通行时间超过 20 分钟时,直接惩罚这个相邻关系,降低整条路线的评分。这种“算法为主、规则兜底”的混合策略,比纯粹追求数学最优值更实用。如果你也在做类似的项目,建议早点把你认为合理的“常识”写成约束加进去,而不是等答辩前才开始补。
这个小项目做下来,我对 SpringBoot 工程的组织方式、MyBatis-Plus 的使用、以及算法落地的工程化过程都有了比较完整的认识。后续如果继续扩展,可以把这个推荐模块抽出来做成一个独立服务,换上新景点数据源,就能覆盖城市漫游、公园路线规划等场景。至少在我自己看来,这不只是一份毕设作品,而是一个可以继续长大的系统底座。