做旅游类系统的人很多,但绝大多数都是“景点列表+后台CRUD”的模板化产品,真正把“个性化定制”做出来、让用户输入偏好就能自动生成一份可落地的行程攻略的,反而不多。这套基于Java+SSM+Django的个性化旅游攻略定制系统,核心就是用一套可运行的推荐+排程逻辑,把“查攻略”变成“生成攻略”。对于正在做旅游类课题、想把手里的CRUD项目升级成有决策引擎的系统的人,这篇东西会很有参考价值。
在动手写代码之前,我先说下这套系统的整体设计思路,以及为什么最终会采用SSM和Django共存的双后端结构。很多人在拿到类似题目时第一反应是“SSM就够了”,但实际做完你会发现,纯粹用Java写推荐计算和行程编排,代码会非常臃肿,而且后续想接入数据采集、分词、协同过滤这些Python生态的东西会很别扭。所以我把系统拆成两个服务:SSM端负责用户、攻略、后台管理等主体业务,Django端负责景点数据管理和个性化行程生成的计算服务,两边通过HTTP接口协作。这个取舍不是炫技,而是让每个技术栈干自己最擅长的事。
1. 项目定位与整体设计思路
1.1 从“搜攻略”到“生成攻略”:个性化到底解决什么问题
传统旅游平台的核心是内容库,用户自己去搜目的地、看游记、再手动拼行程,决策成本非常高。这套定制系统的核心是把决策逻辑内置化:用户只需要告诉系统“我想去哪个方向、玩几天、预算多少、和谁一起”,系统就能基于景点标签、用户偏好、预算约束和时间约束,自动生成一份按天排序的行程方案。
这里的“个性化”不是指标题上挂个“智能推荐”就完事,而是要落到三个可量化的维度上:偏好匹配度、预算可行性、时间合理性。偏好匹配度解决的是“用户喜不喜欢这个景点”的问题,预算可行性解决的是“钱够不够花”的问题,时间合理性解决的是“一天之内路线走不走得通”的问题。三者一起跑通,生成的攻略才是用户愿意保存下来照着走的攻略,而不是一个看起来热闹、实际没法执行的景点列表。
这套系统的目标用户有两类:一类是C端游客,注册后填写偏好、生成并保存自己的行程;另一类是平台运营方,负责维护景点库、审核用户发布的攻略、查看系统运行数据。C端体验决定了系统有没有用,运营端能力决定了内容质量能不能持续,两边缺一不可。
1.2 系统角色与核心业务闭环
从角色上拆,系统分游客端和管理员端。游客端的主流程是:注册登录 -> 填写偏好画像 -> 选择目的地和出行参数 -> 系统生成行程 -> 用户保存/调整/分享 -> 浏览他人攻略并收藏。管理员端的主流程是:登录后台 -> 管理景点和标签 -> 审核攻略 -> 查看统计数据。
整套业务的闭环逻辑是这样的:新游客注册后先做偏好设置,偏好数据进入画像表;游客发起定制请求时,SSM端把请求参数转发给Django端的定制服务;Django服务从景点库中筛选、打分、排程,返回一份按天分组的行程;行程状态为“草稿”时用户可以手动调整顺序或者换掉某个景点,确认后保存为“我的攻略”;如果用户选择公开,攻略进入待审核状态,管理员审核通过后展示在攻略广场,其他用户可以浏览、收藏、点赞。
核心业务闭环里最容易忽略的环节是“保存后的二次调整”。很多定制系统生成了行程就结束,导致用户一旦不满意只能重新生成,体验很差。我在设计时给行程明细单独建了表,每个景点项可以独立增删改顺序,这样“生成结果”和“最终攻略”就是可分离的两层数据,后面扩展“人工微调”功能时几乎不用改表结构。
2. 技术选型:SSM + Django 双后端的取舍逻辑
2.1 为什么不是单一框架
先说结论:单用SSM能做出来,但代码维护成本和后续扩展成本都会更高。
SSM(Spring + SpringMVC + MyBatis)在业务系统层面非常成熟,用户管理、攻略管理、后台权限、事务控制这些场景用它写起来又快又稳。但到了“个性化定制”这个核心功能上,SSM的短板就出来了:推荐计算涉及大量的集合运算、权重计算、遍历排程,Java写起来也不是不能写,但代码可读性和修改效率都比较差。比如景点标签和用户偏好标签的匹配度计算,在Java里你需要写多层for循环加Map统计,而在Python里几行列表推导式就能搞定,后续想换成向量计算、引入简单的内容推荐算法,也更方便。
所以我最终采用的结构是:SSM作为主业务服务跑在8080端口,承担用户、攻略、后台相关接口;Django作为定制服务跑在8000端口,负责景点数据的管理接口和行程生成接口。两个服务共享同一个MySQL数据库,通过HTTP + JSON进行通信。SSM端收到用户定制请求后,调用Django端的/api/travel-plan/generate接口,拿到行程结果后落库返回。
这个方案还有一个实际的好处:答辩和演示的时候,你可以理直气壮地讲“系统采用多服务协作架构,Java负责业务闭环,Python负责算法逻辑”,这个架构思路本身就是加分项。
2.2 数据库与中间件规划
数据库使用MySQL 5.7,字符集统一设为utf8mb4,因为景点描述、攻略正文可能包含特殊符号和生僻字,utf8mb4才能完整存储。连接池使用Druid,监控页面在调试阶段非常好用。Redis在这里承担两个职责:一是存储登录会话(SSM端使用Spring Session + Redis共享会话),二是缓存热门景点列表和首页攻略列表,避免每次请求都打到数据库。
这里有一个经验:不要在项目一开始就把Redis接入进来,先把业务跑通,再加缓存。刚开始就引入Redis,你会分不清报错到底来自业务逻辑还是来自缓存层,排错成本会翻倍。
2.3 为什么不引入微服务
不是所有的两个服务拆开都叫微服务。这套系统没有用Spring Cloud或者Dubbo,两个服务之间就是简单的HTTP调用,部署时可以放在同一台服务器上,服务间的通信也不需要注册中心。理由有三点:第一,项目规模摆在这里,引入注册中心、配置中心、网关这些组件,光环境搭建就够折腾半天,对实际功能毫无帮助;第二,两个服务共享同一个数据库,本身就谈不上彻底的微服务化,硬上微服务只会增加接口联调成本;第三,项目要兼顾易部署性,评委或同学拿到源码后应该能在半小时内跑起来,而不是被一堆基础组件卡住。
选择技术栈时,不要为了“架构先进”而牺牲“可运行性”,能跑起来、能讲清楚、能演示,比什么都重要。
3. 核心功能模块与数据库设计
3.1 功能模块拆解
整个系统的功能模块可以拆成六个部分:
- 用户模块:注册、登录、个人信息维护、密码修改、头像上传。
- 偏好画像模块:用户填写出行天数、预算范围、同行人类型、偏好主题(如美食/文化/自然/购物/休闲),这部分是定制服务的输入数据。
- 景点管理模块:管理员维护景点信息,包括名称、所属城市、分类、门票价格、建议游玩时长、经纬度、热度、开放时间、标签。
- 定制服务模块:接收偏好和目的地参数,调用打分和排程逻辑,按天生成行程方案。
- 攻略管理模块:用户保存生成的行程为攻略,可以公开分享;管理员审核、下架攻略。
- 前台浏览模块:首页攻略推荐、攻略详情、收藏、点赞、搜索。
模块拆分的核心原则是“高内聚、低耦合”。实操中的表现就是:每个模块的Controller、Service、Mapper分层清晰,模块之间只通过接口调用,不互相访问对方的Mapper。比如攻略模块想读取行程明细,应该调用行程模块提供的Service方法,而不是直接写一条SQL去查行程明细表,这样后续改表结构时不会牵一发动全身。
3.2 关键数据表设计
数据表是整个系统的地基,我直接把核心表结构列出来讲。
用户表(user):id、username、password(BCrypt加密存储)、nickname、avatar、phone、create_time。密码必须加密,明文存储是会被直接扣分的硬伤。
用户偏好表(user_preference):id、user_id、budget_range、travel_days、companion_type(独行/情侣/亲子/朋友)、themes(多选标签,可为美食,文化,自然这种逗号分隔的格式)、pace(紧凑/宽松)、create_time。字段themes虽然不符合严格的范式,但实际查询和传输非常方便,属于合理的反范式设计。
景点表(scenic_spot):id、name、city、category、ticket_price、recommend_hours、rating、longitude、latitude、hot_rank、open_time、description、status。其中longitude和latitude是行程排程时计算景点间距离的关键字段,很多人会忽略,但这个字段决定了后续“按天排程”功能能不能实现。
景点标签表(tag)和景点标签关联表(scenic_tag):景点和标签是多对多关系,一个景点可以有多个标签,一个标签下也可以有多个景点。标签包括“亲子友好”“夜景”“免费”“网红打卡”“历史遗迹”等。
攻略表(travel_strategy):id、user_id、title、destination、days、total_budget、summary、status(草稿/待审核/已发布/已下架)、view_count、like_count、create_time。
行程明细表(strategy_item):id、strategy_id、day_no、spot_id、seq、start_time、end_time、note。这张表是“定制结果可调整”的关键,每个景点项对应攻略中的某一天、某个顺序,支持单独增删改。
3.3 多对多关系与冗余字段的处理
设计时有一个需要反复权衡的问题:字段是存关联ID还是存冗余文本。我的处理原则是:用于统计和关联查询的字段存ID,用于展示和传输的字段允许冗余。
以攻略目的地为例,攻略表里存了destination文本,同时也存了一个冗余的destination_city字段。虽然城市信息可以通过景点表反查得到,但攻略列表页需要显示目的地,每次联表查询会多几次磁盘IO。在数据量不大的阶段,适度冗余能明显简化查询逻辑。另外一个典型处理是收藏表(favorite):id、user_id、strategy_id、create_time,唯一索引建在user_id + strategy_id上,防止重复收藏。
普通游客用户和管理员是不是要拆成两张表?我的做法是统一放在user表中,增加一个role字段区分,这样登录逻辑统一,后台权限判断也简单。用户量到了千万级再拆表不迟,现在拆只会给自己增加没必要的复杂度。
4. 个性化推荐与行程生成的核心逻辑
4.1 用户偏好画像
定制服务的起点是用户偏好画像。用户在偏好设置页填写的信息会被整理成几个关键参数:出行天数D、总预算B、同行人类型C、主题标签集合T、节奏P。
画像数据不只是一条记录,它决定了后续所有计算的输入口径。比如同行人类型为“亲子”时,系统会自动把标签“亲子友好”设为硬约束条件,也就是候选景点必须包含该标签;同行人类型为“情侣”时,“浪漫”“夜景”类标签的权重会提升。节奏P决定每天安排景点的数量:紧凑模式每天最多排4个景点,宽松模式每天最多排3个景点。
实操中我建议把偏好页面做成“滑块+多选”的交互风格,预算用区间滑块,主题用多选标签卡片,同行人用单选图标。这种交互形式会让用户觉得“这个系统确实在为我定制”,而不是在填一张枯燥的表单。UI层面获得的体验加分,往往比算法本身更多。
4.2 景点评分与筛选规则
生成行程前,先把目的地城市的景点池取出来,经过硬性条件过滤后进入打分环节。过滤规则包括:景点状态正常、满足硬性标签约束(如亲子型必须有“亲子友好”标签)、门票价格不超过单日预算的一半、开放时间覆盖主要游览时段。
过滤之后进入打分环节,每个景点计算一个综合评分:
score = 0.4 * 标签匹配度 + 0.2 * 热度归一化值 + 0.2 * 好评归一化值 + 0.1 * 价格优惠度 + 0.1 * 时间适配度
标签匹配度的算法是:用户偏好标签集合与景点标签集合的交集数量,除以用户偏好标签总数。比如用户偏好“美食、文化、自然”三个标签,景点有“美食、亲子友好”两个标签,交集是“美食”,匹配度就是1/3。这个算法简单、可解释性强,答辩时评委问起来你能清晰讲出每一步的含义。
热度归一化值用最小-最大归一化处理:将景点热度映射到0到1区间,避免不同城市景点热度数值差异过大导致评分失真。价格优惠度指的是低门票景点得分更高,但不会超过预算约束的硬限制。
4.3 按天排程的贪心算法
评分排完序后,还不能直接把高分景点丢给用户,因为“一天去5个距离很远的景点”在现实中根本执行不了。所以核心的排程算法采用“按天贪心 + 距离约束 + 时间约束 + 预算约束”的规则。
具体流程是:先把候选景点按综合评分降序排列,然后从第1天开始,依次尝试把景点放入当天。每个景点放入前检查三个条件:当天累计游玩时间不超过8小时(每个景点有建议游玩时长);当天累计门票费用加上餐费不超过日均预算(日均预算 = 总预算 / 天数);当前景点与当天上一个景点之间的距离小于30公里(用经纬度通过Haversine公式计算)。满足条件则放入当天,并记录开始和结束时间;不满足则跳过,尝试下一个景点。
如果某一天已经排满但还有剩余候选景点,就继续排下一天,直到所有天数都排完或者候选池耗尽。这里有个很容易踩的坑:排程时只约束了“时间”和“预算”,忘了约束“距离”,导致生成结果里上午还在城市东边、下午就到了城市西边。实际跑一次用户反馈就会发现,这类行程根本没人愿意按着走,所以距离约束一定要写进算法里。
Haversine公式的Java实现不复杂,可以在Service里单独抽一个工具类。在MySQL里也可以直接存储每个城市的近似市中心经纬度,用来做“当天第一站不能离酒店太远”的约束计算。
4.4 冷启动与兜底策略
冷启动是推荐系统绕不开的问题。新用户没有完整的偏好画像或者没有明确选择偏好标签时,怎么生成行程?我的处理方式是:当用户偏好标签集合为空时,启用兜底规则——按景点热度排序取当天可容纳的景点,此时标签匹配度权重置为0,热度权重提升到0.6,保证生成结果至少是“大众觉得值得去”的景点。
还有一种情况是过滤后候选池为空,比如某个冷门城市景点很少,又叠加了过于严格的标签过滤。兜底策略是逐步放宽过滤条件:先放宽硬性标签约束,再放宽价格约束,最后放宽距离约束。按这个顺序一级级放宽,直到候选池不为空。这个“松弛过滤”的逻辑非常实用,能让系统在数据不足时仍然输出结果,而不是直接返回一个“暂无可定制行程”的空页面。
生成完毕后,返回的数据结构要按天分组,并且每天包含景点列表、整体路线描述、当天预估花费。这些内容会填充到前端行程卡片里,让用户直观看到“定制”的成果。
5. 实操搭建与关键代码实现
5.1 环境准备与项目初始化
整套系统的运行环境如下:
- JDK 1.8 + Maven 3.6 + Tomcat 8.5
- MySQL 5.7 + Redis 5.x
- Python 3.8 + Django 3.2 + Django REST Framework
- 前端使用JSP + Bootstrap 4 + jQuery,后台管理端使用AdminLTE模板
拿到源码后按这个顺序跑:先创建数据库(编码utf8mb4),导入项目根目录下sql/目录的初始化脚本;然后修改SSM端的application.properties,把数据库地址、账号、密码改成自己的;再修改Django端settings.py里的数据库配置和ALLOWED_HOSTS(本地调试填localhost, 127.0.0.1即可);启动Redis;用Maven打包SSM项目生成war包,丢到Tomcat的webapps目录下启动Tomcat;最后在Django项目根目录执行python manage.py runserver 8000。
两个服务都起来后,先用浏览器访问SSM端的后台管理地址,确认能登录;再用接口测试工具请求Django端/api/scenic-spots/,能返回景点JSON就说明环境通了。
5.2 SSM端核心代码实现
SSM端最核心的接口是“发起定制”,Controller层接收前端参数后组装请求对象,转发给Django服务。这里展示一下核心实现思路。
@RestController @RequestMapping("/api/travel-plan") public class TravelPlanController { @Autowired private TravelPlanService travelPlanService; @PostMapping("/generate") public Result generate(@RequestBody PlanGenerateRequest request) { // 校验参数:目的地、天数、预算不能为空 if (StringUtils.isEmpty(request.getDestination())) { return Result.error("目的地不能为空"); } // 调用定制服务,内部会请求Django接口 TravelPlanVO plan = travelPlanService.generatePlan(request); return Result.success(plan); } }Service层里做了两件事:把请求参数整理成Django端需要的JSON结构,然后通过RestTemplate发起HTTP调用。
public TravelPlanVO generatePlan(PlanGenerateRequest request) { // 读取用户偏好 UserPreference preference = preferenceMapper.findByUserId(request.getUserId()); // 组装参数 Map<String, Object> params = new HashMap<>(); params.put("city", request.getDestination()); params.put("days", request.getDays()); params.put("budget", request.getBudget()); params.put("themes", preference.getThemes()); params.put("companion_type", preference.getCompanionType()); params.put("pace", preference.getPace()); // 请求Django定制服务 RestTemplate restTemplate = new RestTemplate(); String url = "http://127.0.0.1:8000/api/travel-plan/generate"; ResponseEntity<PlanResult> response = restTemplate.postForEntity(url, params, PlanResult.class); // 落库保存行程明细 return savePlan(request, response.getBody()); }这里要注意超时设置,RestTemplate默认没有超时时间,如果Django服务没起来,接口会一直卡住直到Tomcat线程池耗尽。一定要设置连接超时和读取超时,我一般设成连接超时3秒、读取超时5秒。
5.3 Django端接口实现
Django端是整个定制服务的计算核心。接口/api/travel-plan/generate接收SSM端传来的JSON参数,依次执行过滤、评分、排程三步逻辑。
def generate_travel_plan(request): data = json.loads(request.body) city = data.get('city') days = int(data.get('days', 3)) budget = float(data.get('budget', 1000)) themes = data.get('themes', []) companion_type = data.get('companion_type', '') pace = data.get('pace', 'normal') # 1. 候选景点过滤 candidates = filter_spots(city, companion_type, budget, days) # 2. 景点打分排序 scored = score_spots(candidates, themes, budget, days) # 3. 按天排程 plan = schedule_by_day(scored, days, budget, pace) return JsonResponse({'code': 0, 'data': plan})排程函数是重点,我按前面讲的贪心逻辑实现,每天记录总游玩时长、总花费、以及景点间的预估车程。这里给出一个简化版本供参考。
def schedule_by_day(scored_spots, days, budget, pace): daily_budget = budget / days max_spots_per_day = 3 if pace == 'relaxed' else 4 plan = {i + 1: [] for i in range(days)} used = set() for day in range(1, days + 1): day_time = 0 day_cost = 0 last_spot = None for spot in scored_spots: if spot['id'] in used: continue if len(plan[day]) >= max_spots_per_day: break # 时间约束:每天最多8小时 if day_time + spot['recommend_hours'] > 8: continue # 预算约束 if day_cost + spot['ticket_price'] > daily_budget: continue # 距离约束:和上一个景点距离小于30km if last_spot and calculate_distance(last_spot, spot) > 30: continue plan[day].append(spot) used.add(spot['id']) day_time += spot['recommend_hours'] day_cost += spot['ticket_price'] last_spot = spot return plan这个版本的代码已经能满足演示和实际使用需求。如果你想再进一步,可以在函数入口加上一个判断:如果当天候选不足,就调用relax_filters()函数放宽条件重算。
5.4 整体联调与效果验证
联调阶段我建议按这个顺序走:先用接口测试工具直接请求Django端,确认生成接口能返回正常的按天行程;再通过SSM端调用一次生成接口,确认数据能落库;最后通过前端页面完整走一遍“用户注册 -> 填偏好 -> 发起定制 -> 查看行程 -> 保存为攻略”的流程。
联调中最值得验证的一个细节是:生成的行程在保存后再次进入详情页,数据能否完整回显。很多人项目做到一半发现生成结果没问题,但保存后刷新页面,行程明细全丢了,问题大多出在strategy_item表的外键关联和日期排序上。所以在设计表时,我特意加了seq字段表示当天顺序,查询时按day_no和seq排序,避免靠id排序导致的乱序问题。
6. 常见问题排查与避坑记录
6.1 问题速查表
我在开发和调试过程中整理过一份问题排查表,很多问题都是反复出现的。
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 页面中文乱码 | 数据库连接未指定字符集 | 在JDBC连接串加上characterEncoding=utf8mb4,并确认数据库表字符集为utf8mb4 |
| 8080端口被占用 | Tomcat端口冲突 | 修改server.xml端口,或杀掉占用进程 |
| Django接口无法访问 | ALLOWED_HOSTS未配置 | 本地调试设置ALLOWED_HOSTS = ['localhost', '127.0.0.1'] |
| SSM调用Django超时 | RestTemplate未设置超时 | 使用SimpleClientHttpRequestFactory设置连接和读取超时 |
| Redis连接拒绝 | Redis未启动或密码不匹配 | 检查Redis进程、配置密码和Spring配置一致 |
| MyBatis查询返回null | 实体类属性与数据库字段映射错误 | 开启mybatis的驼峰映射,或写ResultMap显式映射 |
| 生成的行程为空 | 过滤条件过严或景点数据不足 | 检查景点表数据量,启用松弛过滤逻辑 |
| 登录会话总是失效 | Redis未配置session共享 | 检查Spring Session配置和Redis连接状态 |
| 攻略保存后行程丢失 | 行程明细外键未设置或查询排序错乱 | 检查strategy_item的strategy_id关联,按day_no和seq查询 |
6.2 我踩过的几个典型坑
第一个坑是双服务接口契约没先定好。最开始我是先写SSM端的请求代码,再写Django端的解析代码,结果两边字段命名不一致,一个用travel_days,一个用days,联调时返回的数据全是默认值。后来我改成先写一份接口文档,明确每个字段的名称、类型、格式,再分头开发,联调效率提升非常明显。这个经验对所有前后端分离、多服务协作的项目都适用。
第二个坑是行程生成算法里忘了做距离约束。第一批测试数据生成出来,用户上午安排在城市A的东边,下午就跑到西边去了,被一个朋友直接评价“这个行程不可执行”。后来我加入Haversine距离计算,并把30km设为默认阈值,重新生成的结果才真正有参考价值。这也是为什么我把“距离约束”放在排程算法的核心位置来讲。
第三个坑是初始化数据不足导致推荐效果看起来像没做。最开始景点表里一个城市只有五六条数据,生成的行程翻来覆去就那几个景点,完全体现不出“定制”的差异。后来我集中补了一批带标签、经纬度、建议游玩时间的数据,每个热门城市至少准备20个候选景点,推荐效果立刻就不一样了。如果你在做类似的系统,请务必重视初始化数据的质量,数据量不够,再好的算法也出不了效果。
第四个坑是演示时现场生成行程太慢。第一次答辩演示时,我现场触发了一次行程生成,结果因为Django端没有预热,加上景点数据查询和排序耗时,白屏了好一会儿。后来我在演示前先预生成几份不同偏好组合的攻略存在库里,现场演示时直接打开保存好的攻略,再补一次现场生成的动效展示。这个操作在正式场合帮了大忙,建议你也在演示前做同样的准备。
关于这套系统,我最后想说的是:个性化旅游攻略定制系统的价值不在增删改查,而在“定制”这个动作背后的决策逻辑。把偏好画像、评分公式、排程约束这些规则设计清楚,比单纯堆功能更能让项目出彩。如果你后面想继续扩展,两个方向值得考虑:一是接地图API,把生成的行程按天画成路线图,展示效果会上升一个档次;二是把用户对生成结果的反馈(换掉、保留)记录下来,反哺评分权重,做简单的个性化反馈闭环。先把当前的版本跑熟、跑稳,再考虑这些升级,你会走得踏实很多。