做旅游类系统的人很多但绝大多数都是“景点列表后台CRUD”的模板化产品真正把“个性化定制”做出来、让用户输入偏好就能自动生成一份可落地的行程攻略的反而不多。这套基于JavaSSMDjango的个性化旅游攻略定制系统核心就是用一套可运行的推荐排程逻辑把“查攻略”变成“生成攻略”。对于正在做旅游类课题、想把手里的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能做出来但代码维护成本和后续扩展成本都会更高。SSMSpring 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 关键数据表设计数据表是整个系统的地基我直接把核心表结构列出来讲。用户表userid、username、passwordBCrypt加密存储、nickname、avatar、phone、create_time。密码必须加密明文存储是会被直接扣分的硬伤。用户偏好表user_preferenceid、user_id、budget_range、travel_days、companion_type独行/情侣/亲子/朋友、themes多选标签可为美食,文化,自然这种逗号分隔的格式、pace紧凑/宽松、create_time。字段themes虽然不符合严格的范式但实际查询和传输非常方便属于合理的反范式设计。景点表scenic_spotid、name、city、category、ticket_price、recommend_hours、rating、longitude、latitude、hot_rank、open_time、description、status。其中longitude和latitude是行程排程时计算景点间距离的关键字段很多人会忽略但这个字段决定了后续“按天排程”功能能不能实现。景点标签表tag和景点标签关联表scenic_tag景点和标签是多对多关系一个景点可以有多个标签一个标签下也可以有多个景点。标签包括“亲子友好”“夜景”“免费”“网红打卡”“历史遗迹”等。攻略表travel_strategyid、user_id、title、destination、days、total_budget、summary、status草稿/待审核/已发布/已下架、view_count、like_count、create_time。行程明细表strategy_itemid、strategy_id、day_no、spot_id、seq、start_time、end_time、note。这张表是“定制结果可调整”的关键每个景点项对应攻略中的某一天、某个顺序支持单独增删改。3.3 多对多关系与冗余字段的处理设计时有一个需要反复权衡的问题字段是存关联ID还是存冗余文本。我的处理原则是用于统计和关联查询的字段存ID用于展示和传输的字段允许冗余。以攻略目的地为例攻略表里存了destination文本同时也存了一个冗余的destination_city字段。虽然城市信息可以通过景点表反查得到但攻略列表页需要显示目的地每次联表查询会多几次磁盘IO。在数据量不大的阶段适度冗余能明显简化查询逻辑。另外一个典型处理是收藏表favoriteid、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.5MySQL 5.7 Redis 5.xPython 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()); // 组装参数 MapString, 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; ResponseEntityPlanResult 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连接串加上characterEncodingutf8mb4并确认数据库表字符集为utf8mb48080端口被占用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把生成的行程按天画成路线图展示效果会上升一个档次二是把用户对生成结果的反馈换掉、保留记录下来反哺评分权重做简单的个性化反馈闭环。先把当前的版本跑熟、跑稳再考虑这些升级你会走得踏实很多。
阅读完成 · 觉得有帮助?