☰
JSP+协同过滤旅游推荐系统:从毕业论文到可运行代码的完整实现
2026/10/6 11:16:19 网站建设 项目流程

简介:面向智慧旅游与个性化推荐方向的毕业论文资源,以“智慧旅游平台的设计与实现”为题,完整呈现基于JSP与Servlet、协同过滤算法的旅游景点个性化推荐系统。系统采用B/S三层架构,客户端基于Web浏览器,服务端基于JSP与Servlet,数据源基于关系型数据库,涵盖用户注册登录、景点分类展示、推荐引擎、景点详情、首页热门轮播等模块,并支持景点搜索、个性化推荐、旅游笔记等功能,论文中对需求分析、MVC结构、服务端请求处理及数据库同步更新均有翔实展开。压缩包内含1个docx文档,约1.35MB,摘要、目录、中英文关键词、需求分析、系统设计等完整章节一并收录。文档中直接给出了协同过滤推荐算法的落地思路,也展示了旅游门户网站的功能组织与交互设计,便于作为毕业设计、课程设计或二次开发的框架底稿。目前已有99人学习下载,适合需要快速搭建旅游推荐系统整体方案的高校学生与开发者参考。

1. 当毕业论文里的旅游推荐系统可以复现时:JSP + 协同过滤的完整方案

每年毕业季都有大量 JavaWeb 方向的论文产出,大部分停留在概念层面。这份旅游景点个性化推荐系统的毕业论文却不太一样:它把协同过滤算法和 JSP/Servlet 三层架构真正放到了一套可以运行的业务里,五个功能模块——首页热门推荐、景点搜索、个性化推荐、用户管理、旅游笔记——都有对应的表结构和请求处理逻辑。你拿到的虽然是一份论文,但它的价值在于:论文里的表设计、推荐引擎描述、请求处理流程,已经足够支撑你从零搭出一个能跑通的旅游推荐系统。对于正在做毕设选题、或者想快速搭建一个带推荐逻辑的 JavaWeb 项目的从业者来说,这份论文就是一份可以照着画葫芦的施工图。

2. 系统架构与协同过滤算法:论文里没明说的两个关键设计

2.1 MVC 三层架构:职责边界决定你代码怎么放

论文开篇的技术基础部分反复出现 MVC 结构。把它落地到具体代码时,三层的划分直接影响整个工程的目录结构。Model 层由 JavaBean 承担,负责封装业务数据和数据库操作;View 层是 JSP 页面和 HTML 标签,只负责展示;Controller 层由 Servlet 实现,接收请求、调用业务逻辑、跳转页面。

常见的做法是这样一个包结构:

src/ ├── com.travel.bean/ # 实体类 User.java, Tourist.java, Prefer.java ├── com.travel.dao/ # 数据库操作类,JDBC 封装 ├── com.travel.servlet/ # Controller 层 LoginServlet.java, RegisterServlet.java, RecommendServlet.java ├── com.travel.util/ # 工具类 DBUtil.java, CosineUtil.java web/ ├── WEB-INF/ │ ├── web.xml │ └── lib/ # mysql-connector-java.jar ├── index.jsp # 首页 ├── login.jsp ├── register.jsp ├── detail.jsp # 景点详情 └── css/ js/ images/

这样一个拆分后,业务逻辑被集中在 Bean 和 Dao 里,JSP 只做循环输出<% for(...) { %>这种动作,Servlet 只做请求转发。后续要换数据库连接池、要改推荐算法,都只动局部模块,不会整个工程推倒重来。论文里提到了 Controller 层“控制页面跳转速度很快”,这个说法的实际含义是:Servlet 的转发比 JSP 直接嵌套 Java 代码更稳,重定向和转发的选择会影响性能,后面在踩坑章节会专门讲。

2.2 基于用户的协同过滤:核心计算链路拆解

论文花了大篇幅讲协同过滤的原理,核心思路是:如果用户 A 和用户 B 对某些景点的偏好高度一致,那么 A 喜欢的、B 没看过的景点,就有理由推荐给 B。这套逻辑看似简单,落地时需要在三个环节做具体处理:数据怎么来、相似度怎么算、TopN 怎么取。

推荐引擎内部的数据来源是景点喜好表——用户对景点的点击次数、标注行为。这里的喜好次数 count 字段就是协同过滤算法的评分矩阵元素。有了评分矩阵之后,计算两个用户相似度的标准做法是余弦相似度。论文没给出具体公式,但实现时必然要用到。用伪代码把计算链路写清楚就是这样一段逻辑:

# 用户-景点评分矩阵构建 # 横轴为用户ID,纵轴为景点ID,矩阵值为喜好次数 count def build_matrix(prefer_list): matrix = {} for record in prefer_list: uid, tid, count = record['userId'], record['tourId'], record['count'] matrix.setdefault(uid, {})[tid] = count return matrix # 余弦相似度计算 # 余弦公式 cos = sum(ab) / (sqrt(sum(a^2)) * sqrt(sum(b^2))) import math def cosine_similarity(vec_a, vec_b): common = set(vec_a.keys()) & set(vec_b.keys()) if not common: return 0.0 dot = sum(vec_a[tid] * vec_b[tid] for tid in common) norm_a = math.sqrt(sum(v ** 2 for v in vec_a.values())) norm_b = math.sqrt(sum(v ** 2 for v in vec_b.values())) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b)

这段伪代码的逻辑说明:第一步把数据库里散落的喜好记录转成稠密或稀疏的二维矩阵,dict的 key 是用户 ID,value 是景点 ID 到点击次数的映射。第二步算余弦相似度,只关注两个用户共同评价过的景点集合common,这是关键——如果两个用户没有共同浏览过的景点,余弦值直接为 0,说明他们之间没有任何可参考的行为交集。

参数说明:点击次数 count 在矩阵里就是权重值。有的实现会用 1 和 0 表示是否看过,但论文里的需求分析提到“根据用户点击次数等用户行为”,这里的 count 就能做到同一景点被点击 5 次和 1 次的用户偏好强度区分。如果只用 0/1,信息会丢失很多,推荐结果的区分度也会变差。在实际调优时,可以把 count 做一次归一化(比如除以该用户所有点击的总和),避免高频用户对相似度计算产生过大的数值主导。

2.3 推荐生成:从相似用户到候选景点集合

搞定相似度计算之后,推荐生成就是三步操作:找近邻、生成候选集、过滤已看过的景点。这里最容易被忽略的一个细节是:相似用户可能本身就对某个景点有极高的点击次数,所以推荐时要按相似度分值加权计算候选景点的得分,而不是简单地做集合合并。加权推荐的常见做法:

def recommend(user_id, similarity_map, click_matrix, top_n=5): # user_id 当前需要推荐的目标用户 # similarity_map 每个用户与其他用户的相似度映射 {uid: {other_uid: sim_score}} # click_matrix 用户-景点点击矩阵 # top_n 最终返回的推荐景点数量 sim_scores = similarity_map.get(user_id, {}) # 找出相似度最高的前 10 个用户,这一步也是可调优的参数 nearest = sorted(sim_scores.items(), key=lambda x: x[1], reverse=True)[:10] candidate_scores = {} for other_uid, sim in nearest: if other_uid == user_id: continue # 只取相似用户看过、但当前用户没看过的景点 other_items = click_matrix.get(other_uid, {}) for tid, count in other_items.items(): if tid in click_matrix.get(user_id, {}): continue candidate_scores[tid] = candidate_scores.get(tid, 0) + sim * count # 按加权得分排序,输出 TopN ranked = sorted(candidate_scores.items(), key=lambda x: x[1], reverse=True) return [tid for tid, score in ranked[:top_n]]

参数说明:这里有两个可调参数——近邻数[:10]和top_n=5。近邻数太小,相似用户群不够,推荐结果单一;近邻数太大,引入低相似度用户的噪音点击,反而不准确。一般经验是先定 10 个近邻,观察推荐列表的覆盖率再调。candidate_scores[tid] = sim * count这个写法把“相似度”和“点击次数”两个维度乘在一起,是最标准的加权推荐逻辑,优先级很高。

这个链路就是我们后面在 Servlet 里要真正调用的核心。论文里的推荐引擎模块设计图,实际对应的就是build_matrix → cosine_similarity → recommend这三段代码的组合。

3. 数据库设计与表关系拆解:三张表怎么撑起整个推荐系统

3.1 用户表 user:字段设计里隐藏的权限控制

论文给出了一张用户表,字段 7 个:序列号、用户标识、用户类型、用户姓名、密码、联系电话、电子邮件。落地建表 SQL 是这样:

CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '序列号 自增主键', `username` varchar(50) NOT NULL COMMENT '用户标识 登录名,唯一', `type` enum('super','normal') DEFAULT 'normal' COMMENT '用户类型:管理员/普通用户', `name` varchar(50) DEFAULT NULL COMMENT '用户姓名', `password` varchar(64) NOT NULL COMMENT '用户密码', `tel` varchar(20) DEFAULT NULL COMMENT '联系电话', `email` varchar(100) DEFAULT NULL COMMENT '电子邮件', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

建表 SQL 的逻辑说明:type字段用了枚举类型enum('super','normal'),这就是论文里提到的“用户类型是枚举类型(super,normal)”。它对应的是系统管理员和普通用户两种角色划分,后续做景点管理、用户管理等后台操作时,会在 Servlet 里先校验 session 中的 type 字段,只有 super 才能进管理员页面。username加了唯一索引,这是注册时“用户名是否重复”判断的数据库层保障。

参数说明:密码字段用了varchar(64),论文没有提到密码加密,实际实现时就算不加 salt 也至少做一次 MD5 或 SHA-256 摘要再入库,否则数据库一旦泄露,用户明文密码直接暴露。这个属于安全底线,不需要很复杂,DigestUtils.md5DigestAsHex()一行代码就够。

3.2 景点表 tourist:14 个字段与分类展示逻辑的对应

景点表是景点详情页和分类展示模块的数据源。论文列出的关键字段是:名称、适合季节、累计游客、推荐理由、地点、景点分类。按照论文第 3 章的分类维度,青海景点的五大分类分别是夏季旅游、文化旅游、高原精品旅游线路、亲子旅游、其他精品旅游线路。落地建表:

CREATE TABLE `tourist` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '景点ID', `name` varchar(100) NOT NULL COMMENT '线路名称', `season` varchar(20) DEFAULT NULL COMMENT '适合季节', `visitor_count` int(11) DEFAULT 0 COMMENT '累计游客数', `recommend_reason` varchar(255) DEFAULT NULL COMMENT '推荐理由', `place` varchar(100) DEFAULT NULL COMMENT '所在地点', `category` varchar(50) DEFAULT NULL COMMENT '景点分类:夏季/文化/高原/亲子/其他', `cover_image` varchar(255) DEFAULT NULL COMMENT '轮播图路径', `description` text COMMENT '景点详细介绍', `hot_score` int(11) DEFAULT 0 COMMENT '热度分值,游客流量的统计来源', PRIMARY KEY (`id`), KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:category字段加了一个普通索引,因为分类展示页面的查询条件就是WHERE category = ?,没有索引的话景点数据量到几千条以后会明显变慢。表里加了论文没明确写、但演技上必须有的cover_image和description字段——首页轮播图和景点详情页没这两个字段根本展示不了内容。

参数说明:hot_score字段对应论文提到的“对普通游客用户,按照旅游景点的热度进行推荐”。这个热度值可以直接取visitor_count,也可以在后台管理员发布景点时手动配置权重。一个取巧的做法是:hot_score = visitor_count * 0.7 + 收藏次数 * 0.3,热度排序比纯用游客数要更合理。

3.3 景点喜好表 prefer:协同过滤算法真正的数据依赖

这张表是整个推荐系统的核心数据表。论文里给出的字段是:序列号、用户 ID、景点 ID、喜好次数、预留字段。别看它结构简单,协同过滤算法的评分矩阵就是从这张表查出来的。

CREATE TABLE `prefer` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '序列号', `userId` int(11) NOT NULL COMMENT '用户ID,关联 user.id', `tourId` int(11) NOT NULL COMMENT '景点ID,关联 tourist.id', `count` int(11) DEFAULT 1 COMMENT '喜好次数,每点击一次累加1', `rsrvStr1` varchar(255) DEFAULT NULL COMMENT '预留字段', `rsrvStr2` varchar(255) DEFAULT NULL COMMENT '预留字段', `rsrvStr3` varchar(255) DEFAULT NULL COMMENT '预留字段', PRIMARY KEY (`id`), KEY `idx_user` (`userId`), KEY `idx_tour` (`tourId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逻辑说明:核心增长点是count字段。论文需求分析里提到,游客可以对喜欢的景点打标、点击浏览,每次点击行为都执行一次INSERT ... ON DUPLICATE KEY UPDATE count = count + 1,这样同一对 user-tour 的记录只会有一条,但 count 在不断累积。查询时写一句SELECT userId, tourId, count FROM prefer,直接就可以塞进协同过滤算法里构建矩阵。

参数说明:这里索引的设计值得注意,idx_user和idx_tour分别是两个单列索引。单列索引已经能覆盖两种主要查询:查询某个用户的所有喜好记录、查询某个景点被哪些用户喜欢过。如果后续要做“给相似用户找候选景点”这类多表关联查询,再考虑加联合索引(userId, tourId)。预留字段的用处论文没展开,比较合理的落法是:rsrvStr1存用户对景点的星级评价,rsrvStr2存用户来源渠道(比如注册时勾选的偏好分类),后续做推荐策略扩展时不至于改表结构。

4. 从论文到可运行的 JSP 项目:核心模块实现骨架

4.1 注册与登录:Servlet 层请求处理的具体写法

论文第 3 章把用户注册流程拆成了五步:输入合法用户名 → 判断用户名不存在 → 输入密码并确认 → 输入邮箱 → 完成注册。落到 Servlet 里,登录模块是典型的doPost处理请求模式。这里给一段可以直接用的LoginServlet实现:

@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 取前端表单参数 String username = request.getParameter("username"); String password = request.getParameter("password"); // 2. 基础校验,防止空参数直接穿透到数据库层 if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { request.setAttribute("error", "用户名和密码不能为空"); request.getRequestDispatcher("login.jsp").forward(request, response); return; } // 3. 调用 Dao 层查询用户 UserDao userDao = new UserDao(); User user = userDao.findByUsernameAndPassword(username, password); if (user != null) { // 登录成功,把用户对象存进 session HttpSession session = request.getSession(); session.setAttribute("user", user); session.setAttribute("userType", user.getType()); // 重定向到首页,避免表单重复提交 response.sendRedirect(request.getContextPath() + "/index.jsp"); } else { // 登录失败,回写到请求域 request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } } }

逻辑说明:这两行代码值得琢磨。request.getRequestDispatcher("login.jsp").forward()是转发,浏览器地址栏不变,错误提示能从 request 域里读出来;response.sendRedirect()是重定向,地址栏会变。登录成功后必须用重定向,因为如果这次还用了 forward,用户按 F5 刷新就会重新提交一次表单,导致重复登录或插入重复数据。登录失败则用 forward 把error属性带到 JSP 页面展示,地址栏不乱跳,用户体验也更顺。

参数说明:@WebServlet("/login")这个注解从 Servlet 3.0 开始支持,如果你用的 Tomcat 7 及以上版本,可以不用在web.xml里逐个注册 Servlet。如果你的环境比较老(Tomcat 6),就要老老实实地在web.xml里配置<servlet-mapping>。建议直接上 Tomcat 8.5 或 9,后续用注解写其他 Servlet 会省很多配置时间。

4.2 首页与分类展示:JSP 里的核心数据循环

首页模块包含热门景点轮播、推荐景点展示、游客入口。轮播图的数据查询逻辑比较简单——查询游客数最多的前 N 条景点记录:

// index.jsp 前段引入的 Java 代码片段,来自 HomeServlet // 热门景点查询:按累计游客数倒序取前6条 String sql = "SELECT id, name, cover_image, place FROM tourist ORDER BY visitor_count DESC LIMIT 6"; List<Tourist> hotList = touristDao.findBySql(sql); request.setAttribute("hotList", hotList); // 注册用户的个性化推荐 User user = (User) session.getAttribute("user"); List<Integer> recommendIds = new ArrayList<>(); if (user != null) { // 调用推荐引擎,基于 prefer 表计算 recommendIds = RecommendEngine.recommend(user.getId(), 5); } else { // 未登录用户直接取热门景点 recommendIds = touristDao.findHotIds(5); } request.setAttribute("recommendIds", recommendIds); request.getRequestDispatcher("index.jsp").forward(request, response);

逻辑说明:这里体现了论文需求分析里提到的双轨推荐逻辑——注册用户走协同过滤算法,普通游客按热度推荐。recommendIds存的是景点 ID 列表,JSP 页面需要再根据 ID 循环查询景点名称和图片,这种实现方式比较直观。更优的做法是一次性联表把景点数据全查回来,但在论文这个规模的项目里,先查 ID 再补全信息,代码可读性更好,也能看出推荐引擎的输出与展示逻辑是解耦的。

参数说明:LIMIT 6对应首页轮播图的位置数。轮播图展示数量不是固定的 3 张还是 5 张,用 6 张是因为青海有茶卡盐湖、青海湖、塔尔寺、门源油菜花等几个经典宣传位,6 张刚好铺满首页焦点区。你可以按实际景点库规模调整,但注意如果轮播图数量超过 10 张,页面加载图片的压力会明显增大,建议用图片懒加载。

4.3 推荐引擎的 Java 实现:把协同过滤写成可调用的类

论文花了大量篇幅描述推荐引擎,但没给出具体代码。这里给上一份可直接使用的RecommendEngine,把第 2 章算法部分落地成 Java 方法的完整流程:

public class RecommendEngine { /** * 基于用户的协同过滤推荐 * @param userId 目标用户ID * @param topN 返回推荐结果数量 * @return 推荐景点ID列表 */ public static List<Integer> recommend(int userId, int topN) { // 1. 从数据库读取所有用户的喜好记录 List<Prefer> prefers = PreferDao.findAll(); if (prefers == null || prefers.isEmpty()) { return new ArrayList<>(); } // 2. 构建用户 -> (景点 -> 次数) 的两层map Map<Integer, Map<Integer, Integer>> userClickMap = new HashMap<>(); for (Prefer p : prefers) { userClickMap .computeIfAbsent(p.getUserId(), k -> new HashMap<>()) .merge(p.getTourId(), p.getCount(), Integer::sum); } // 3. 计算当前用户与其他所有用户的余弦相似度 Map<Integer, Double> simMap = new HashMap<>(); Map<Integer, Integer> targetVector = userClickMap.get(userId); if (targetVector == null || targetVector.isEmpty()) { // 冷启动处理:用户没有任何历史行为,返回热门景点 return getHotTouristIds(topN); } for (Map.Entry<Integer, Map<Integer, Integer>> entry : userClickMap.entrySet()) { int otherUserId = entry.getKey(); if (otherUserId == userId) { continue; } double sim = cosineSimilarity(targetVector, entry.getValue()); if (sim > 0) { simMap.put(otherUserId, sim); } } // 4. 取相似度最高的前10个用户 List<Map.Entry<Integer, Double>> nearest = simMap.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(10) .collect(Collectors.toList()); // 5. 加权生成候选景点得分表 Map<Integer, Double> scoreMap = new HashMap<>(); for (Map.Entry<Integer, Double> near : nearest) { int otherUid = near.getKey(); double sim = near.getValue(); Map<Integer, Integer> otherItems = userClickMap.get(otherUid); for (Map.Entry<Integer, Integer> item : otherItems.entrySet()) { int tourId = item.getKey(); if (targetVector.containsKey(tourId)) { continue; // 已浏览过的景点不重复推荐 } double score = sim * item.getValue(); scoreMap.merge(tourId, score, Double::sum); } } // 6. 按得分排序取 TopN return scoreMap.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } /** 余弦相似度计算 */ private static double cosineSimilarity(Map<Integer, Integer> vecA, Map<Integer, Integer> vecB) { double dot = 0.0, normA = 0.0, normB = 0.0; for (Map.Entry<Integer, Integer> entryA : vecA.entrySet()) { normA += Math.pow(entryA.getValue(), 2); Integer scoreB = vecB.get(entryA.getKey()); if (scoreB != null) { dot += entryA.getValue() * scoreB; } } for (Map.Entry<Integer, Integer> entryB : vecB.entrySet()) { normB += Math.pow(entryB.getValue(), 2); } if (normA == 0 || normB == 0) { return 0.0; } return dot / (Math.sqrt(normA) * Math.sqrt(normB)); } /** 冷启动修复:返回热门景点 */ private static List<Integer> getHotTouristIds(int topN) { return TouristDao.findHotIds(topN); } }

逻辑说明:这个类与论文推荐引擎模块设计的对应关系非常直接——userClickMap对应"数据获取",cosineSimilarity对应"核心算法",scoreMap排序对应"推荐数据生成"。整个方法没有使用任何高深的数据结构,一个 HashMap 加一个双层循环就完成了全部计算。对学生来说,这种可读性优先的实现比引入 Mahout 之类的推荐框架更容易理解,也更容易写进论文的设计部分。

参数说明:两个数值参数值得留意。limit(10)是近邻数,前面算法章节已经提过;Integer::sum合并策略处理的是数据表中可能存在同一用户对同一景点多条记录的情况,做累加合并,保证向量里不会出现重复 key。冷启动处理getHotTouristIds对应论文里"普通游客按照热度推荐"的设计,也就是说新注册用户没行为数据时,系统自动降级为热门推荐,不会返回空列表。

4.4 景点详情页与喜好标注:给推荐引擎喂数据

推荐引擎依赖历史行为数据,那数据入口就在详情页。论文提到游客可以对喜欢的景点打标,实现方式是在详情页加一个"我喜欢这个景点"按钮,点击后向后端发请求:

@WebServlet("/prefer") public class PreferServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); User user = (User) session.getAttribute("user"); if (user == null) { // 未登录用户点击后跳转到登录页 response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } int tourId = Integer.parseInt(request.getParameter("tourId")); // 关键操作:若已存在偏好记录则 count+1,否则插入新记录 // INSERT INTO prefer(userId, tourId, count) VALUES (?, ?, 1) // ON DUPLICATE KEY UPDATE count = count + 1 boolean success = PreferDao.addPrefer(user.getId(), tourId); if (success) { response.sendRedirect(request.getContextPath() + "/detail.jsp?id=" + tourId); } else { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } }

逻辑说明:PreferDao.addPrefer方法内部执行的就是前面说的INSERT ... ON DUPLICATE KEY UPDATE语句,这需要prefer表有对应的唯一索引约束(userId, tourId)。如果建表时没有这个约束,每次点击都可能插入一条新记录,导致后续协同过滤矩阵里同一用户对同一景点的 count 被拆散,相似度计算会失真。这是整个项目里最容易埋坑的位置之一,建表时务必加上。

参数说明:session.getAttribute("user")在未登录状态下返回 null,这里先判空再去查数据库,避免空指针异常。整个方法保证了对偏好数据写入的幂等性——同一个用户反复点击同一个景点的"喜欢"不会产生多条记录,只是 count 递增。

5. 论文转代码避坑指南:四个高频翻车现场

5.1 乱码问题:JSP 页面中文全部变问号

现象:数据库表和数据都是 UTF-8,页面显示正常,但通过 Servlet 往数据库插入中文景点名称后,数据库里的值变成问号或乱码。

原因:Tomcat 默认的请求编码是 ISO-8859-1,不是 UTF-8。中文请求参数在 Servlet 层解析时就变成了乱码,落库自然不对。还有一层是 JDBC URL 没有显式声明编码。

解决:统一三处编码。第一,Servlet 里在取参数前强制设置:

request.setCharacterEncoding("UTF-8");

第二,JDBC 连接 URL 显式带编码参数:

String url = "jdbc:mysql://localhost:3306/travel?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";

第三,JSP 页面顶部声明:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>

三条都做到位,乱码问题才算根治。只改任何一条都会在某个环节再冒出来——这是我在复现时踩过最多次的全链路坑。

5.2 轮播图图片不显示

现象:首页轮播区域一片空白,F12 打开开发者工具看 Network,图片请求返回 404。

原因:景点表里的cover_image字段存的是相对路径,比如images/qinghaihu.jpg,但 JSP 页面部署后实际物理路径是web/images/qinghaihu.jpg。路径对比不上,大概率是项目的 contextPath 问题——用了绝对路径/images/xxx.jpg但项目部署名不是 ROOT,或用了相对路径但页面嵌套层级深。

解决:在 JSP 里统一用${pageContext.request.contextPath}拼接静态资源路径。也就是:

<img src="${pageContext.request.contextPath}/images/qinghaihu.jpg" />

另外,上传图片时确认写入的位置是web/images/(IDE 里对应的 WebContent 或 webapp 目录),而不是web/WEB-INF/images/。放在 WEB-INF 下的资源无法通过 URL 直接访问,这又是一个常见的隐藏原因。

5.3 推荐引擎计算结果为空

现象:新注册用户登录后,首页推荐区域是空的,不显示任何景点。

原因:新用户没有任何点击和标注行为,targetVector是空 Map,cosineSimilarity对所有其他用户都返回 0,simMap里一个有效相似用户都没有,推荐列表自然是空的。

解决:在算法层做冷启动降级,就是上一章代码里那段:

if (targetVector == null || targetVector.isEmpty()) { // 冷启动处理:用户没有任何历史行为,返回热门景点 return getHotTouristIds(topN); }

此外还可以在注册流程里让用户勾选感兴趣的旅游分类(夏季旅游、文化旅游等),把选中的分类存进rsrvStr1预留字段,推荐引擎启动时直接按分类找热门景点。论文的需求分析提到过注册时可以选择喜好的旅游方式,这正是冷启动问题的产品层解法,把它和算法层降级配合使用,效果比单独降级到热门要好。

5.4 推荐接口响应慢得像黑匣子

现象:推荐请求要跑接近十秒才返回,数据库 CPU 飙升,页面卡死。

原因:推荐引擎的 Java 代码在循环里发 SQL 查询。真实的低效做法是在cosineSimilarity里每次比对都实时查一次数据库,或者在第二步构建 userClickMap 时按用户逐条查询。每次 HTTP 请求触发几十次甚至上百次数据库往返,在数据量稍大的时候必然慢。

解决:一次性把偏好数据全部查出来构建内存 Map,也就是第 4.3 节代码的做法——PreferDao.findAll()一次性全量加载,后续所有相似度计算和候选集生成都走 JVM 内存操作。数据量在千级、万级时完全可行。如果之后景点数据和用户数据增长到十万级以上,再考虑做离线计算、结果缓存或引入 Redis,但论文这个项目规模,一次全量查询是最稳的。

6. 让推荐结果真正可信:小样本验证与参数调优技巧

推荐系统不像增删改查功能,写完代码就能立刻判断对不对。它的输出是一个列表,怎么判断这个列表是合理的?我的习惯是构造一组小样本数据,用手工计算验证推荐引擎的输出,然后再放到真实数据上跑。

混合我们做一个只有 4 个用户、5 个景点的极小样本测试。用户 A 的浏览记录是景点 1、2、3,用户 B 的浏览记录是景点 2、3、4,用户 C 的浏览记录是景点 1、3、5。现在给 A 做推荐。A 和 B 的共同景点是 2、3,A 和 C 的共同景点是 1、3。从直觉上来说,B 和 A 的重合度更高,B 看过的景点 4 应该优先被推荐。把这段直觉写成断言,推荐引擎计算结果必须满足两个条件:一是推荐结果包含景点 4;二是景点 4 的得分高于景点 5。这样就能验证算法的方向性是对的。

除了正确性,参数调优也有经验可循。近邻数limit(10)不是固定值,可以换成 5、15、20 分别跑同一份小样本数据,看推荐列表的变化。景点数较多的数据集,近邻数设大一些,覆盖更广;景点数少的数据集,近邻数设小一些,否则相似用户的重合度太低,算出来的相似度全是 0。相似度本身也有阈值问题,有些实现会对sim < 0.01的结果直接弃用,避免噪音用户污染推荐。

验证完算法之后,再检查业务闭环。一个完整的呼叫链路是:用户注册 → 注册时勾选旅游分类偏好 → 登录 → 浏览景点详情页 → 点击"我喜欢"按钮 →prefer表的 count 递增 → 下一次登录首页时推荐引擎输出新列表。这个闭环中任何一环断了,推荐结果都不可能真正反映用户喜好。我在复现这套系统时,给每个环节写了一遍日志输出:注册时输出用户选择的分类,浏览景点时输出"用户 X 浏览了景点 Y",推荐时输出"用户 X 的相似用户是 [B:0.87, C:0.32]"。日志一打,推荐结果对不对、数据来源对不对,一目了然。

从那以后我每次拿到这类推荐系统项目,都强制自己先搭一套 4 用户 5 景点的小样本数据跑通再上全量,别看它只有十几行数据,却能把算法的方向性、边界情况、参数敏感性全部暴露出来。希望这篇拆解能帮你把论文资源真正转成能跑的系统,少走几趟弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询