旅游信息推荐系统是当前互联网+旅游领域的热门应用方向。随着国内旅游市场的持续升温,游客对个性化、智能化旅游服务的需求日益增长。传统的旅游门户网站往往存在信息过载、推荐精准度不足等问题,而基于SpringBoot+Vue的技术栈恰好能够构建高性能、易扩展的推荐系统解决方案。
这个毕业设计项目的核心价值在于:
我在实际开发中发现,这类系统最难的不是基础CRUD功能的实现,而是如何设计合理的推荐策略,以及如何处理旅游数据的时空特性。下面我就从技术选型到具体实现,分享这个项目的完整开发经验。
SpringBoot 2.7.x作为后端框架的选择主要基于以下考虑:
关键依赖配置示例:
xml复制<dependencies>
<!-- Web基础 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 数据持久化 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 推荐算法支持 -->
<dependency>
<groupId>org.apache.mahout</groupId>
<artifactId>mahout-core</artifactId>
<version>0.13.0</version>
</dependency>
</dependencies>
Vue 3.x + Element Plus的组合提供了:
典型项目结构:
code复制src/
├── api/ # 接口定义
├── assets/ # 静态资源
├── components/ # 公共组件
├── router/ # 路由配置
├── stores/ # Pinia状态管理
├── utils/ # 工具函数
└── views/ # 页面组件
系统采用经典的三层架构:
code复制表现层(Vue) ←HTTP→ 业务层(SpringBoot) ←JDBC→ 数据层(MySQL)
↑ ↑
JSON 算法引擎
用户服务模块
内容管理模块
推荐引擎模块
交互分析模块
sql复制CREATE TABLE `scenic_spot` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`location` point NOT NULL SRID 4326,
`tags` json DEFAULT NULL,
`description` text,
`opening_hours` json DEFAULT NULL,
PRIMARY KEY (`id`),
SPATIAL KEY `idx_location` (`location`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_behavior` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`item_id` bigint NOT NULL,
`behavior_type` enum('VIEW','COLLECT','SHARE','BOOK') NOT NULL,
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_item` (`user_id`,`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
空间数据存储:使用MySQL的GIS功能存储景点坐标,便于后续的距离计算和地理围栏查询
标签体系设计:采用JSON类型存储动态标签,避免频繁的schema变更
行为数据优化:对高频查询建立复合索引,同时考虑分表策略应对数据增长
历史数据归档:设置合理的分区策略,将冷热数据分离存储
核心逻辑:
java复制public List<ScenicSpot> contentBasedRecommend(Long userId, int size) {
// 1. 获取用户偏好标签
Set<String> userTags = userService.getUserTags(userId);
// 2. 计算景点标签匹配度
return scenicSpotRepository.findAll().stream()
.sorted((a,b) -> {
double scoreA = calculateTagSimilarity(userTags, a.getTags());
double scoreB = calculateTagSimilarity(userTags, b.getTags());
return Double.compare(scoreB, scoreA);
})
.limit(size)
.collect(Collectors.toList());
}
private double calculateTagSimilarity(Set<String> userTags, JsonNode spotTags) {
// Jaccard相似度计算
Set<String> spotTagSet = extractTags(spotTags);
Set<String> intersection = new HashSet<>(userTags);
intersection.retainAll(spotTagSet);
Set<String> union = new HashSet<>(userTags);
union.addAll(spotTagSet);
return union.isEmpty() ? 0 : (double)intersection.size() / union.size();
}
使用Mahout库实现基于用户的协同过滤:
java复制public List<RecommendedItem> userCFRecommend(Long userId, int size) throws TasteException {
// 1. 构建数据模型
DataModel model = new MySQLJDBCDataModel(
dataSource,
"user_behavior",
"user_id",
"item_id",
"behavior_type='VIEW' OR behavior_type='COLLECT'",
"create_time");
// 2. 计算用户相似度
UserSimilarity similarity = new PearsonCorrelationSimilarity(model);
// 3. 构建推荐器
UserNeighborhood neighborhood = new NearestNUserNeighborhood(10, similarity, model);
Recommender recommender = new GenericUserBasedRecommender(
model, neighborhood, similarity);
// 4. 生成推荐结果
return recommender.recommend(userId, size);
}
实际项目中通常需要组合多种算法:
java复制public List<ScenicSpot> hybridRecommend(Long userId, int size) {
// 获取各算法推荐结果
List<ScenicSpot> cbResults = contentBasedRecommend(userId, size*2);
List<RecommendedItem> cfResults = userCFRecommend(userId, size*2);
// 结果融合与去重
Map<Long, ScenicSpot> finalResults = new LinkedHashMap<>();
// 优先保留协同过滤结果
cfResults.forEach(item -> {
ScenicSpot spot = scenicSpotRepository.findById(item.getItemID()).orElse(null);
if(spot != null) {
finalResults.putIfAbsent(spot.getId(), spot);
}
});
// 补充内容推荐结果
cbResults.forEach(spot -> {
if(finalResults.size() < size) {
finalResults.putIfAbsent(spot.getId(), spot);
}
});
return new ArrayList<>(finalResults.values());
}
使用高德地图API实现景点展示:
vue复制<template>
<div class="map-container">
<el-amap
:zoom="zoom"
:center="center"
@init="initMap">
<el-amap-marker
v-for="spot in spots"
:key="spot.id"
:position="[spot.longitude, spot.latitude]"
@click="handleMarkerClick(spot)">
</el-amap-marker>
</el-amap>
</div>
</template>
<script setup>
import { ref } from 'vue';
const zoom = ref(12);
const center = ref([116.397428, 39.90923]);
const spots = ref([]);
const initMap = (map) => {
// 加载推荐景点数据
loadRecommendSpots();
};
const handleMarkerClick = (spot) => {
// 显示景点详情弹窗
};
</script>
基于Element Plus的无限滚动组件:
vue复制<template>
<div v-infinite-scroll="loadMore" class="waterfall">
<div v-for="item in items" :key="item.id" class="card">
<el-image :src="item.cover" fit="cover" />
<div class="info">
<h3>{{ item.name }}</h3>
<el-rate v-model="item.rating" disabled />
<el-tag
v-for="tag in item.tags"
:key="tag"
size="small">
{{ tag }}
</el-tag>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { getRecommendations } from '@/api/recommend';
const items = ref([]);
const page = ref(1);
const loading = ref(false);
const loadMore = async () => {
if(loading.value) return;
loading.value = true;
try {
const res = await getRecommendations({
page: page.value,
size: 10
});
items.value.push(...res.data);
page.value++;
} finally {
loading.value = false;
}
};
</script>
典型application-prod.yml配置:
yaml复制server:
port: 8080
servlet:
context-path: /api
spring:
datasource:
url: jdbc:mysql://mysql-server:3306/tourism?useSSL=false
username: app_user
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
jpa:
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
format_sql: true
logging:
file:
name: /logs/application.log
level:
root: info
org.springframework.web: debug
vue.config.js生产环境配置:
javascript复制module.exports = {
productionSourceMap: false,
css: {
extract: true,
sourceMap: false
},
configureWebpack: {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
libs: {
name: 'chunk-libs',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial'
},
elementPlus: {
name: 'chunk-elementPlus',
test: /[\\/]node_modules[\\/]_?element-plus(.*)/,
priority: 20
}
}
}
}
},
chainWebpack: config => {
config.plugin('html').tap(args => {
args[0].title = '旅游推荐系统';
return args;
});
}
};
java复制@Cacheable(value = "recommendations", key = "#userId")
public List<ScenicSpot> getRecommendations(Long userId) {
// 原始推荐逻辑
}
java复制// 避免N+1查询问题
@EntityGraph(attributePaths = {"tags", "images"})
List<ScenicSpot> findTop10ByOrderByPopularityDesc();
java复制@Async
@EventListener
public void handleBehaviorEvent(UserBehaviorEvent event) {
// 异步记录用户行为
behaviorRepository.save(event.getBehavior());
}
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*")
.allowedHeaders("*")
.maxAge(3600);
}
}
java复制@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
return builder -> {
builder.serializers(new LocalDateTimeSerializer(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
builder.serializers(new LocalDateSerializer(
DateTimeFormatter.ofPattern("yyyy-MM-dd")));
};
}
}
javascript复制// nginx配置
location / {
try_files $uri $uri/ /index.html;
}
java复制@Test
void testHybridRecommend() {
// 给定测试用户
Long userId = 1L;
// 当调用推荐方法
List<ScenicSpot> results = recommendService.hybridRecommend(userId, 10);
// 验证结果
assertThat(results).hasSize(10);
assertThat(results)
.extracting("id")
.doesNotHaveDuplicates();
}
javascript复制// Jest测试示例
describe('推荐API测试', () => {
it('应返回10条推荐结果', async () => {
const res = await request(app)
.get('/api/recommend?userId=1')
.expect(200);
expect(res.body.data).toHaveLength(10);
expect(res.body.data[0]).toHaveProperty('id');
expect(res.body.data[0]).toHaveProperty('name');
});
});
在实际开发这类系统时,我最大的体会是:不要过度追求算法的复杂性,而应该先构建完整的数据采集和评估体系。很多时候,简单的算法配合高质量的数据,效果会优于复杂算法加噪声数据。另外,推荐系统的AB测试框架应该在项目早期就建立起来,这能帮你快速验证各种想法的实际效果。