1. 项目定位与核心价值拆解
先说结论:这个标题翻译成人话就是——用 Java 系最经典的 SSM 组合拳(Spring + SpringMVC + MyBatis),从零搭一个带“个性化推荐”能力的旅游网站,并且把用户从注册、逛景点、看攻略、下单买票、收藏点评到后台管理这一整条链路全部打通。
乍一看,“个性化旅游景点推荐网站”这个描述很容易让人误以为核心工作只在“推荐算法”上,实际上,毕设题目里那半句“旅游全流程管理系统”才是真正的重量级角色。我帮不少同学梳理过类似选题,很多人前期把精力全砸在算法调参上,结果后台管理模块潦草到只有一张孤零零的景点表,答辩时被问一句“用户下单之后订单状态怎么流转”就卡住了。这个标题最大的价值就在于它把“个性化”和“全流程”绑在了一起——既要有推荐这个亮点撑起技术含金量,又要有完整的管理闭环证明你的工程能力。
另一个容易被忽略的信息点是“SSM 框架”这个限定。在 2025 年这个节点,Spring Boot 几乎成了 Java 后端的事实标准,为什么毕业设计还会大量出现 SSM?我个人的理解是:SSM 是理解 Spring 生态底层机制的“解剖课”,SpringBoot 是“速效救心丸”,两者各有价值。对于毕设而言,SSM 反而更容易在答辩时展示你对配置细节、组件协作关系的掌控——比如你能说清楚 Spring 容器里 Bean 的生命周期,能讲明白 SpringMVC 的 DispatcherServlet 是怎么把请求一步步送进 Controller 的,这种东西在 Spring Boot 自动配置的包裹下反而不容易讲出深度。
从需求侧来看,这个题目覆盖的用户角色非常典型:普通游客,可以浏览、搜索、收藏景点;注册用户,可以下单、支付(模拟)、写评论;系统管理员,负责景点信息管理、分类维护、用户管理、订单处理。这三类角色就构成了“全流程”的骨架,也决定了后端必须要设计出足够清晰的权限体系和状态机模型。
再到“推荐”这个关键词,必须提前泼一盆冷水:毕设场景里的个性化推荐,核心目标不是做出一个能跟大厂推荐系统打擂台的算法,而是做出一个“有逻辑、可解释、能演示、能答辩”的推荐闭环。你不需要跑深度学习模型,也不需要搞大规模分布式计算,你需要的是一个合理的特征建模方案 + 一个能跑通的推荐逻辑 + 一个能直观呈现推荐理由的界面。这一点想清楚了,后面选型才不会跑偏。
2. 技术选型与架构设计思路
2.1 为什么选择 SSM 而不是 Spring Boot + 云原生全家桶
先聊框架选型。SSM 虽然是“老技术”,但对毕业设计来说,它依然是一个容错率很高的选择,原因有四条:
- 学习资料极厚,遇到奇葩问题的解决方案在网上随便一搜就有。
- 配置项透明,每一个组件的接入都看得见摸得着,答辩时可以从数据源配置一路讲到事务管理器,全程没有黑盒。
- 轻量级部署简单,一个 Tomcat + MySQL 就能跑,不需要搞 Docker、K8s 这种超纲的东西给自己挖坑。
- 导师认可度高,大部分的软件工程类毕设题目里仍然保留着 SSM 的影子,说明这个框架组合在高校的“信任基础”还在。
如果你非要用 Spring Boot 做,我也不会拦你,但有一个现实问题你要先想清楚:Spring Boot 的自动装配确实舒服,但是到了论文的“系统实现”这一章,你要写清楚每一个核心机制时,就会发现很多细节被“自动”掉了,写起来反而没有 SSM 那种层层递进的感觉。简单说,SSM 是“每条路都标了路牌的老城区”,Spring Boot 是“一脚油门就到的高速路”,对需要展示过程性的毕设来说,前者更容易写出东西。
2.2 分层架构:从 JSP 到 Controller 到底要分几层
这个项目我推荐采用经典 JavaWeb 项目五层结构,从展示层往下数:
- 视图层:JSP + JSTL + BootStrap / layui 这类轻量前端框架,页面直接放在 webapp 目录,顺带练熟 EL 表达式和 JSTL 核心标签的用法。
- 控制层:SpringMVC 的 Controller,只做参数接收、调用 service、返回视图或 JSON,不写任何业务逻辑。
- 业务层:Service 接口 + 实现类,承载订单状态流转、推荐计算调度、用户注册校验等核心业务,并在此层声明事务边界(@Transactional)。
- 数据访问层:MyBatis 的 Mapper 接口 + XML 映射文件,封装所有 SQL 操作。
- 持久层:使用 MySQL 数据库,配套连接池采用 Druid,方便监控 SQL 连接和性能。
很多同学在做这类项目时容易犯一个典型错误:在 Controller 里直接拼 SQL、算价格、判断登录状态,全搅成一锅粥。短平快确实爽,但后续新增一个功能从 A 端调用迁到 B 端调用时,你会发现所有代码都得重写。架构分层不是图好看,是为了隔离变化。举个真实例子:如果当初把推荐计算逻辑写死在 Controller,后面我想把推荐策略从“标签匹配”升级成“协同过滤 + 标签匹配混合”,就得把所有 controller 翻一遍;而分层之后,只需要替换 Service 内部实现,对外接口完全不变。
2.3 数据库设计:一张规范的景点表长什么样
数据库设计是这类系统的地基,我给出的基础表结构是这样的:
- user 表:用户ID、用户名、密码(MD5加盐)、昵称、邮箱、手机号、头像路径、注册时间、状态。
- scenic 表:景点ID、景点名称、所属城市、景区类型(自然/人文/主题乐园等)、门票价格、开放时间、建议游玩时长、评分、封面图、详细介绍、创建时间、(可选)经度纬度。
- favorite 表:用户ID、景点ID、收藏时间,做成联合唯一索引,防止重复收藏。
- comment 表:评论ID、用户ID、景点ID、评分星级、评价内容、评论时间、状态字段,用于管理员审核删除。
- orders 表:订单ID、订单编号、用户ID、景点ID、门票数量、单价、总金额、下单时间、支付状态、使用状态。
- category / tags / scenic_tag 表:做标签体系,用于个性化推荐的底层特征建模。
就这一套表设计,工作量适中,但已经足够撑起“全流程管理”这个定位。尤其提醒一点,orders 表的“支付状态”和“使用状态”分开设计,是答辩时的加分项,因为状态机清晰说明你思考过实际业务中“已支付未使用”和“已支付已使用”等真实场景,而不是做了一个玩具。
3. 个性化推荐系统的核心实现
3.1 推荐链路:从用户行为到推荐结果的完整闭环
个性化推荐不能只做“猜你喜欢”这四个字,要有完整的链路闭环。在这个系统里,我设计的核心流程是:
- 用户注册时勾选感兴趣的城市和景区类型,作为初始偏好,缓解决策树冷启动问题。
- 用户在浏览景点时可点击“喜欢”或直接收藏、下单。
- 每次收藏、评论、下单都会累计用户对景点“标签”的偏好权重分数。
- 每次用户进入首页或景点列表页,调用推荐引擎生成候选集合。
- 推荐引擎综合“标签画像匹配 + 协同过滤找相似用户 + 热门兜底”三种策略,生成 TopK 推荐列表。
- 页面展示结果,同时展示“喜欢某景点的人也喜欢XX”这类人性化文案,让推荐原因不再是黑盒。
这里最关键的思路转变就是:推荐结果不是算出来一次就固定了,而是随着用户行为动态更新。用户在系统中收藏一个“杭州西湖”,之后他再进首页,推荐列表就应该把杭州区域的“西溪湿地”这种同类景点往前排。这套机制的实现难度不大,但很出效果,答辩时把 Redis 缓存清掉,现场演示“收藏一个景点后首页推荐变化”这个操作,杀伤力极强。
3.2 推荐算法:为什么选择“标签权重 + 协同过滤”的混合策略
我最终在这个系统里落地的推荐逻辑分两路:
第一路是“基于标签的内容推荐”。每个景点入库时关联若干标签,如#自然风光、#历史古迹、#亲子游、#极限挑战。用户对某个带有特定标签的景点的行为(收藏记5分、下单记10分、评论记8分、浏览记2分)都会累积到该标签的偏好分数上。在产出推荐结果时,按照各标签加权归一化后去匹配候选景点,算出一个“内容相似分”。
第二路是“基于物品的协同过滤”简化版。这里的逻辑是:先找到与当前用户收藏记录最相似的其他用户,再把这些相似用户喜欢而当前用户还没见过的景点选出来。用户之间的相似度用余弦相似度计算。实际实现中,为了避免全表扫描,可以限制只取用户收藏记录的交集来计算,再配合订单表的“景点购买共现”来做协同过滤的辅助信号,不需要写得特别重。
两条路的推荐分数做线性融合,比如 contentScore 占 0.6,collaborativeScore 占 0.4,最后按综合分排序取 Top N。上面这套逻辑,写成一个 RecommendService,里面配一个策略接口,以后想换权重系数只需要改 Service 层配置,完全不动 Controller 和 JSP。这是向“可扩展设计”靠拢的做法,导师一看就知道你不是那种把算法硬塞进页面里的莽夫。
3.3 推荐核心代码片段讲解
拿一段核心实现来展示推荐逻辑是怎么落地的:
public List<Scenic> recommendForUser(Integer userId, int limit) { // 1. 获取用户标签偏好画像,例如 {"自然风光": 4.2, "历史古迹": 3.8} Map<String, Double> userProfile = userPreferenceService.getUserTagProfile(userId); // 2. 基于画像召回到候选景点列表 List<Scenic> tagCandidateScenics = scenicService.listScenicsByTagProfile(userProfile); // 3. 基于协同过滤找到相似用户收藏过的景点 List<Scenic> collabCandidateScenics = recommendCollaborativeService.recommendBySimilarUsers(userId); // 4. 线性融合两个候选集 Map<Integer, Double> scoreMap = new HashMap<>(); tagCandidateScenics.forEach(s -> scoreMap.put(s.getId(), contentScore(s, userProfile) * 0.6)); collabCandidateScenics.forEach(s -> scoreMap.merge(s.getId(), collaborativeScore(s, userId) * 0.4, Double::sum)); // 5. 按综合分数降序,排除已购买/已收藏景点,截断前 limit 条 return scoreMap.entrySet().stream() .sorted(Map.Entry.<Integer, Double>comparingByValue().reversed()) .limit(limit) .map(entry -> scenicService.getById(entry.getKey())) .filter(s -> !orderService.hasUserBought(userId, s.getId())) .collect(Collectors.toList()); }这段代码的核心思想可以总结成三条:候选集生成、多路召回、融合排序。需要注意的点是:排序后的过滤一定要滞后,不能先过滤再去重,否则排序就失去了意义。另外,相似度计算里常常出现某些景点只被很少用户收藏过、分值虚高的情况,所以我会在协同过滤打分时乘以一个基于行为数量的惩罚因子,类似 TF-IDF 里 IDF 的思想,避免“冷门小景点的推荐分异常高”这种诡异结果。
3.4 推荐系统的性能与缓存策略
如果用户量和数据量都很少,推荐接口直接实时计算也能跑。但是为了演示系统健壮性,以及为答辩时“为什么用 Redis”这个问题做铺垫,我建议在流程里插入缓存层:推荐结果设置 10 分钟有效期,同一个用户 10 分钟内重复请求推荐接口直接打缓存,不重新计算。因为推荐结果与用户近期行为强相关,10 分钟这个窗口期既保证了实时性,又降低了并发压力。
“热门景点”兜底列表的缓存设置长一点,比如 30 分钟。因为在没有足够行为数据的冷启动阶段,你总得先给用户推点有一定知名度的东西,不然全是新入库的无人问津的景点,用户一上来就会觉得这个推荐不靠谱。实测下来,这种“冷门偏好 + 热门兜底”的混合策略,在很大程度上规避了冷启动带来的体验崩塌。
4. 旅游全流程管理模块的实操拆解
4.1 系统功能模块全景地图
把标题拆解开,就能看到“全流程管理系统”要求的是模块之间的互相咬合。我落地划分如下:
- 用户端:注册登录(带验证码)、个人信息维护、密码修改、头像上传。
- 景点浏览端:首页推荐流、景点分类树浏览、关键词模糊搜索、景点详情展示(图片轮播、门票信息、游客评论、类似景点推荐)。
- 用户行为端:收藏景点、点评景点、模拟下单支付、查看我的订单、取消未支付订单、删除订单。
- 后台管理端:管理员登录、景点信息 CRUD、景点类型与标签维护、用户管理(禁用/启用)、评论审核管理、订单管理(查看、核销、统计)、基础数据看板。
如果你的毕设结题还差一点页数,可以把统计报表也加上,比如“热门景点 Top10”、“城市旅游人数对比”、“月度订单量趋势”。用 ECharts 画几个图表挂到管理首页,不管是论文截图还是现场演示,观感提升都非常明显。
4.2 用户订单状态机的流转设计
订单状态是这个“全流程”里最容易翻车的环节,很多人只用了“已支付/未支付”两个状态字段,导致无法区分“已取消”和“已完成”,逻辑漏洞会在论文的用例图阶段就被导师挑战。
我实践的方案是四状态模型:待支付 → 已支付 → 已使用/已取消(分支流转)。在数据库里用 orders 表的 status 字段加一个 int 类型常量值映射:
| status 值 | 含义 | 说明 |
|---|---|---|
| 0 | 待支付 | 下单后默认状态,可以取消 |
| 1 | 已支付(待使用) | 模拟支付成功后进入,不能直接取消 |
| 2 | 已使用 | 线下入园核销后置为已使用 |
| 3 | 已取消 | 用户手动取消或超时未支付 |
“超时未支付自动取消”这个功能可以不用定时任务,简单做法是每次查询订单列表时,在 Service 层判断当前时间与创建时间是否超过 15 分钟且状态还是待支付,如果是,则顺手把状态更新为已取消。这种方式在低并发毕设系统里完全够用,还省掉了引入 XXL-Job 这类额外中间件的复杂度。
4.3 核心业务代码实现细节
以“下单”这个操作为例,一个完整的 Service 方法长这样:
@Transactional public Order createOrder(Integer userId, Integer scenicId, Integer count) { // 1. 参数校验:景点是否存在、购买数量是否为正数 Scenic scenic = scenicMapper.selectById(scenicId); if (scenic == null || count <= 0) { throw new BusinessException("景点不存在或购买数量非法"); } // 2. 幂等校验:同一个用户同一景点未支付订单不能重复下单 Order pendingOrder = orderMapper.selectByUserAndScenicAndStatus(userId, scenicId, 0); if (pendingOrder != null) { throw new BusinessException("您还有该景点的待支付订单,请先完成支付"); } // 3. 生成订单号,并计算金额 String orderNo = UUID.randomUUID().toString().replace("-", "").substring(0, 16); double totalAmount = scenic.getPrice() * count; // 4. 插入订单 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setScenicId(scenicId); order.setCount(count); order.setTotalAmount(totalAmount); order.setStatus(0); order.setCreateTime(new Date()); orderMapper.insert(order); // 5. 注意:这里不能调点赞、收藏等加分逻辑,订单加分在“支付成功”回调里做 return order; }注意第 5 步注释,这一步是关键细节:用户行为分数的累计必须放在“支付成功”这个节点,而不是“创建订单”的节点。原因很简单,用户在待支付状态下取消订单,不应该算作一次有效购买偏好。这种对业务阶段精细划分的思考,答辩时非常加分,能直接体现你对业务建模的成熟度。
支付这块,毕设系统不需要对接真实第三方支付,只需要做一个模拟支付页面,用户点击“模拟支付成功”后,把订单状态从 0 更新为 1,同时调一下 UserPreferenceService.updatePreference(userId, scenicId),把该景点的标签偏好累加到用户画像里。这个设计既安全又完整。
4.4 后台管理:通用 CRUD 的边界与权限控制
后台管理最容易写成“增删改查四件套”轰炸机,这是很多人项目评分不高的原因。我给你一个更高的要求:后台管理模块要给出“为什么这么设计”的合理解释。
- 景点管理的“删除”不应该物理删除,而是设计一个 status 字段置为下架(相当于逻辑删除)。理由:景点可能已被历史订单引用,物理删除会让订单详情查不到景点名称,出现数据完整性问题。这是实际生产系统中涩涩的真实痛点。
- 评论管理要对敏感词做过滤,最简单的方式是引入一个敏感词前缀树(Trie 结构)做关键词命中检测。手动实现不复杂,而且这个细节写进论文里能表现的实践深度非常可观。
- 用户管理“禁用”操作要影响登录拦截器,属于会话层面的踢出效果。实现方式是在拦截器里实时查询用户状态,而不仅靠 Session 里保存的登录标记来做判断。
后台管理的权限控制用 SpringMVC 拦截器按 URL 模式拦截即可,管理员登录后写入 Session,标记一个 isAdmin 标识,访问 /admin/** 前缀的请求时判断该标记是否存在。不需要引入 Shiro / Spring Security 这种重型安全框架,否则又得写一堆配置,与这个项目的核心亮点没多大关系。如果你非要写,至少得把“为什么不用 Shiro”这个理由准备充分,不然答辩时容易被追问到卡壳。
4.5 搜索与列表:MyBatis 动态 SQL 的典型应用
景点列表页和模糊搜索是用户端的核心入口。MyBatis 的<where>和<if>标签在这里堪称救星。一个典型的分页条件查询长这样:
<select id="pageQuery" resultType="com.example.entity.Scenic"> SELECT * FROM scenic <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR city LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if> </where> ORDER BY <choose> <when test="sortType == 1">score DESC</when> <when test="sortType == 2">price ASC</when> <otherwise>create_time DESC</otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>分页这块强烈建议自己封装一个简单的 PageResult 工具类,而不是一上来就引入 PageHelper。因为 PageHelper 的线程本地变量在异常场景下有概率出现分页串扰问题,处理起来要额外小心,而你手动封装一个分页类还能在论文里写清楚 LIMIT 和 OFFSET 是什么,504个字都省了。
5. 系统搭建与部署过程实录
5.1 从零搭建 SSM 工程的目录结构
工程骨架我这里直接给目录方案,你照着建就不会乱:
travel-recommend/ ├── pom.xml ├── src/main/java/com/example/ │ ├── controller/ // 用户端 Controller │ ├── controller/admin/ // 后台管理 Controller │ ├── service/ // Service 接口 │ ├── service/impl/ // Service 实现 │ ├── dao/ // MyBatis Mapper 接口 │ ├── entity/ // 实体类 │ ├── dto/ // 参数传输对象 │ ├── vo/ // 视图对象 │ ├── common/ // 通用工具类、常量定义 │ ├── config/ // Spring 配置、MyBatis 配置 │ └── interceptor/ // 登录拦截器、管理员拦截器 ├── src/main/resources/ │ ├── mapper/ // MyBatis XML 文件 │ ├── jdbc.properties │ ├── spring-context.xml │ ├── spring-mvc.xml │ └── mybatis-config.xml └── src/main/webapp/ ├── static/ // css/js/images ├── WEB-INF/views/ // JSP 页面 └── WEB-INF/web.xmlSpring 配置文件要分成 spring-context.xml(IOC + Service 层 + 数据源 + 事务)和 spring-mvc.xml(组件扫描 controller、注解驱动、视图解析器)。这个拆分是从维护角度出发的:一个管后端容器,一个管 WEB 层,各司其职,后面扩展也不容易手忙脚乱。千万不要把所有 Bean 都塞进一个配置文件里,亲测项目规模一大,那个文件能膨胀到你根本不想打开。
5.2 开发环境与版本选择建议
- JDK:1.8 即可,不要故意追新用 JDK 17,许多老框架配置和第三方包在 JDK 8 上最稳定。
- Maven:3.6+ ,用于依赖管理,优先配置阿里云镜像仓库。
- Tomcat:8.5 版本,与 JDK8 完美配对,若你用的 JDK 版本更高,一定要检查兼容性。
- MySQL:5.7 或 8.0 均可,注意 mysql-connector-java 的版本要和数据库版本匹配(5.7 用 5.1.x,8.0 用 8.0.x,否则驱动类名和时区配置都会出问题)。
- IDE:任意版本稳定的 IntelliJ IDEA,社区版完全够跑。
5.3 部署环节的必坑清单
部署到 Tomcat 后经常出现“访问页面 404”的问题。我的排查经验是按这个顺序走:
- 先确认 Project Structure 里的 Artifacts 是否选择了“Web Application: Exploded”,并把 WebINF-INF 下的 classes 目录正确标记为编译输出目录。
- 再确认
web.xml中<servlet-mapping>的 URL 路径,SpringMVC 的 DispatcherServlet 通常映射/,如果你之前照抄过某个 SSM 教程配置成*.do,页面访问路径也要带.do,两处不一致就变 404。 - 查看 Tomcat 的 localhost 日志,重点看有没有 BeanCreationException,这类异常通常意味着某个 Mapper 扫描路径写错了。
另一个“老熟人”问题是数据库中文乱码。除了数据库表设置 utf8 字符集之外,还需要在 jdbc.properties 的 JDBC URL 上强制三件套:
jdbc.url=jdbc:mysql://localhost:3306/travel_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai缺了 characterEncoding,哪怕表结构全 utf8,页面传参的中文照样在入库时变成问号。这种问题出现后,很多人第一反应是去检查 JSP 页面编码,那个方向属于花式绕路,正确顺序永远是:页面编码 → 请求编码 → JDBC 编码 → 数据库编码,逐层往下查,才能根治。
6. 常见问题排查与优化技巧
6.1 推荐模块冷启动与数据稀疏问题
新用户没有任何行为数据时,推荐模块拿到的标签画像是一张白纸,协同过滤找不到相似用户。我的兜底方案是三段式推荐:用户首次登录首页,展示当前热门景点 Top10;用户完成注册,勾选兴趣标签后,按兴趣标签推送对应分类的景点;用户产生第一条收藏记录后,立即触发一次用户画像的重新计算,再把个性化推荐的权重拉满。这个节奏在演示时最好让时间线足够紧凑——从注册到看到“推荐变化”的过程不超过 3 次点击,观感最直接。
如果协同过滤计算出来的相似用户集太小,另一个有效招式是“热门加权”。把景点按照收藏量排序,在协同过滤候选里,确保有一定比例的沉淀热门景点被随机选出,保持推荐列表丰富度。这么做本质上是做了探索(exploration)与利用(exploitation)的简单权衡,你在论文里完全可以把它写成算法亮点。
6.2 MyBatis 中新增字段导致实体类与表不同步
开发过程中,如果你给数据库表新增了一个字段,但忘了同步实体类,最常见的异常是“SQLSyntaxErrorException: Unknown column”。排查方式很简单:打开 MyBatis 映射文件,检查<resultMap>的定义是否把表里的每一种字段都映射清楚了。实践里我用过最顺手的方法是保持 MyBatis 的mapUnderscoreToCamelCase为 true,并且尽量不手写<resultMap>,而是让实体类驼峰属性与下划线列名自动映射,减少一把一把的配置量。
6.3 JSP 与 Servlet 版本间的 EL 表达式失效问题
这是一个不起眼但很折磨人的坑。Tomcat 8.5 默认 JSP 版本是 2.3,EL 表达式默认开启;但一旦你在 web.xml 里声明了老版本的 servlet 头,或者 webapp 的 web.xml 版本声明低于 3.0,EL 会被静默禁用,页面输出一堆${xxx}明文。解决方法是把 web.xml 换成 JavaWeb 4.0 版本头(对应 Servlet 4.0),并且 JSP 页面第一行用<%@ page isELIgnored="false" %>强制开启。这两步双保险能避免绝大多数浏览器端看到一坨表达式原样的尴尬。
6.4 并发扣库存(门票余量)演示场景的处理
门票数量这个字段如果做“立即扣减”很容易超卖,如果你把门票库存数字写死在页面上,多人同时下单时会出现余票为负。虽然毕设项目没有高并发压力,但对“加锁逻辑”的理解还是要体现一下。这里我推荐一种简单办法:在订单表里增加一个 version 字段或直接使用数据库乐观锁机制。
UPDATE scenic SET stock = stock - #{count}, version = version + 1 WHERE id = #{scenicId} AND version = #{oldVersion} AND stock >= #{count}如果 update 影响的行数为 0,说明库存被改过了,抛出业务异常“手慢了,余票不足”,然后引导用户刷新重选。这是一个比 select-then-update 更健壮的库存扣减方式,在答辩时被问“如何避免超卖”就能拿出这个有据可循的答案。
6.5 图片上传与回显的路径问题
景点封面图和用户头像上传后,如果 Tomcat 部署路径是临时目录,重启后图片就丢失。推荐做法是在服务器本地指定一个固定磁盘目录(比如D:/travel-upload或 Linux 下的/usr/local/travel-upload),并在 SpringMVC 配置里注册静态资源映射:
<mvc:resources mapping="/upload/**" location="file:D:/travel-upload/" />然后数据库里只存相对路径如/upload/xxx.jpg,页面回显直接用${ctx}/upload/xxx.jpg。这样重启服务图片依旧在,部署时也方便迁移。别看这个点小,踩过的人都知道,这在演示当天出现“图片全部裂开”事故时会非常致命。
7. 答辩与技术写作中的增色要点
7.1 目录结构、模块图与数据库 ER 图的准备
很多同学把大量时间花在写代码上,最后两天才突击画图,质量自然堪忧。我的建议是在开发过程中养成“边开发边截图”的好习惯:每完成一个模块的可用版本,就顺手截一张运行效果图,写论文时直接取材。ER 图不需要用很专业的工具,用一个在线绘图工具或者 IDEA 的数据库工具导出表结构,再手动整一整,就能得到一份清爽的实体关系图。注意把表里边的主外键关联标清楚,这是评阅老师最先看的图之一。
7.2 推荐模块的“技术亮点”怎么包装
写论文时,推荐模块不要只写“我用了协同过滤”六个字。可以拓宽一下:描述系统如何从 0 开始构建用户画像;说明推荐结果里“内容相似分”和“行为协同分”的融合策略与权重调节逻辑;补充冷启动阶段的“热门兜底”策略;以及数据稀疏时使用“标签压缩”减少维度爆炸的技巧。每一个策略无论大小,都要配一段“为什么这样做”的论证,这些论证是论文真正加分的来源。
7.3 演示时最容易翻车的三个盲区
第一个是数据库账号密码权限,答辩现场网络环境一变,第一次连接可能就报错,建议准备一键初始化 SQL 脚本,确保现场重建数据库不超过两分钟。第二个是浏览器清除缓存后 Token / Cookie 丢失,导致登录态失效,演示时要提前把登录态的保持时间调长一点,又或者在现场只做一个连贯的 Demo 故事线,不要中途随意刷新页面。第三个是中文乱码,现场环境可能与本地不一致,重现概率高,最好的预防就是把项目里所有的资源文件、JSP 页面、数据库连接串统一设置为 UTF-8,并做成脚本一键验证。
7.4 从毕设到简历项目:这个系统能怎么延伸
这套系统完成以后,简历项目描述可以比口语化表达略精细一些:从“景点、用户、订单、收藏点评”等核心模块的数据库建模与接口设计;到“标签偏好画像 + 协同过滤混合推荐”的核心逻辑实现;再到“后台管理 + 订单状态机 + 权限拦截”的完整闭环;最后还能写“使用 Redis 缓存推荐结果与热门榜单,降低热点接口响应时间”。每一步都有真东西,面试官沿着任何一条追问,你都能从代码里的具体实现往深讲。
我个人在整理这类项目材料时还有一个心得:给系统录一个五分钟的短视频,把“注册 → 选兴趣 → 浏览景点 → 收藏 → 看推荐变化 → 下单支付 → 后台核销”整条链路一气呵成走一遍。答辩前发给导师看一下,导师大概率会对你的完成度和逻辑性心里有数,现场发挥的压力会小很多。最后再补一句,如果你时间紧张,优先把“推荐结果随行为变化”这个演示做好,它才是这个题目中最亮眼的那颗螺丝钉。