这个基于SpringBoot+Vue3+MyBatis的电影推荐系统,本质上是一个融合了协同过滤算法与内容推荐机制的智能平台。我去年为某在线影视平台开发过类似系统,核心目标是通过用户行为数据分析,实现千人千面的电影推荐服务。
系统采用前后端分离架构,后端使用SpringBoot 2.7提供RESTful API,前端用Vue3组合式API开发管理界面,数据持久层采用MyBatis-Plus增强操作MySQL 8.0数据库。特别之处在于推荐算法模块的设计——我们既保留了传统的基于用户的协同过滤(UserCF),又引入了基于内容的推荐(Content-Based)作为冷启动解决方案。
推荐引擎是系统的核心价值所在。在数据库设计阶段,我们建立了三张关键表:
sql复制CREATE TABLE user_behavior (
user_id BIGINT,
movie_id BIGINT,
behavior_type TINYINT COMMENT '1-浏览 2-收藏 3-评分',
behavior_value FLOAT COMMENT '评分值或行为权重',
create_time DATETIME
) ENGINE=InnoDB;
CREATE TABLE movie_tags (
movie_id BIGINT,
tag_id INT,
relevance FLOAT COMMENT '标签关联度'
);
CREATE TABLE user_preferences (
user_id BIGINT,
tag_id INT,
preference_score FLOAT
);
算法实现采用混合策略:
java复制public List<Movie> contentBasedRecommend(long userId) {
List<UserTagPreference> preferences = preferenceMapper.selectByUser(userId);
if(preferences.isEmpty()) {
preferences = initDefaultPreferences(); // 初始化默认兴趣标签
}
return movieMapper.selectByTags(
preferences.stream()
.sorted(comparing(UserTagPreference::getScore).reversed())
.limit(5)
.map(UserTagPreference::getTagId)
.collect(Collectors.toList())
);
}
java复制public List<Movie> userCFRecommend(long userId) {
Map<Long, Double> similarUsers = userSimilarityService.findTopN(userId, 20);
List<UserBehavior> neighborBehaviors = behaviorMapper.selectByUsers(
similarUsers.keySet(),
LocalDateTime.now().minusMonths(3) // 三个月内的行为
);
return predictService.predictMovies(userId, neighborBehaviors, similarUsers);
}
前端采用Vue3+Pinia状态管理,通过axios封装实现了以下特色功能:
javascript复制// api/movie.js
export const getRecommendations = async (userId, type) => {
try {
const { data } = await http.post('/recommend', {
userId,
algorithmType: type || 'hybrid' // 可指定算法类型
}, {
timeout: 10000,
retry: 3
});
return data;
} catch (err) {
errorHandler(err);
return [];
}
};
// 在组件中使用
const loadRecs = async () => {
loading.value = true;
recommendations.value = await getRecommendations(
store.user.id,
algorithmType.value
);
loading.value = false;
};
接口设计遵循以下规范:
json复制{
"code": 200,
"data": [],
"message": "success",
"timestamp": 1630000000000
}
java复制@PostMapping("/recommend")
public Result<List<MovieDTO>> recommend(
@RequestBody RecommendQuery query,
@RequestHeader("X-Sign") String sign) {
if(!signService.verifySign(query, sign)) {
throw new BusinessException(ErrorCode.INVALID_SIGNATURE);
}
return Result.success(
recommendService.recommend(query.getUserId(), query.getAlgorithmType())
);
}
采用多级缓存架构提升推荐响应速度:
java复制@Configuration
public class CacheConfig {
@Bean
public Cache<Long, Map<Long, Double>> userSimilarityCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(2, TimeUnit.HOURS)
.recordStats()
.build();
}
}
java复制public List<MovieDTO> getHotRecommendations() {
String cacheKey = "hot:movies:" + LocalDate.now();
List<MovieDTO> cached = redisTemplate.opsForValue().get(cacheKey);
if(cached != null) return cached;
List<MovieDTO> realData = movieService.getHotMovies();
redisTemplate.opsForValue().set(
cacheKey,
realData,
6, TimeUnit.HOURS
);
return realData;
}
针对行为数据分析场景的特殊优化:
sql复制ALTER TABLE user_behavior ADD INDEX idx_uid_btime (user_id, create_time);
ALTER TABLE user_behavior ADD INDEX idx_mid_type (movie_id, behavior_type);
java复制public PageInfo<UserBehavior> listBehaviors(Long userId, PageParam param) {
return PageHelper.startPage(param.getPageNum(), param.getPageSize())
.doSelectPageInfo(() ->
behaviorMapper.selectByUserAfterTime(
userId,
param.getLastRecordTime() // 使用时间戳替代OFFSET
)
);
}
使用Docker Compose编排服务:
yaml复制version: '3'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/conf:/etc/mysql/conf.d
ports:
- "3306:3306"
redis:
image: redis:6-alpine
volumes:
- ./redis/data:/data
ports:
- "6379:6379"
backend:
build: ./recommend-service
ports:
- "8080:8080"
depends_on:
- mysql
- redis
通过Spring Boot Actuator暴露的指标:
properties复制# application.properties
management.endpoints.web.exposure.include=*
management.metrics.tags.application=${spring.application.name}
使用Prometheus采集的推荐相关指标:
java复制@RestController
public class RecommendController {
private final Counter recommendCounter;
public RecommendController(MeterRegistry registry) {
recommendCounter = registry.counter("recommend.requests",
"algorithmType", "hybrid");
}
@PostMapping("/recommend")
public Result recommend(...) {
recommendCounter.increment();
// ...
}
}
xml复制<!-- 错误示范 -->
<resultMap id="movieMap" type="Movie">
<collection property="tags" select="selectTagsByMovie" column="id"/>
</resultMap>
<!-- 正确做法 -->
<resultMap id="movieMap" type="Movie">
<collection property="tags" ofType="Tag">
<id column="tag_id" property="id"/>
<result column="tag_name" property="name"/>
</collection>
</resultMap>
javascript复制// composables/useRecommend.js
export function useRecommend() {
const recommendations = ref([]);
const loading = ref(false);
const load = async (userId, type) => {
loading.value = true;
try {
recommendations.value = await getRecommendations(userId, type);
} finally {
loading.value = false;
}
};
return { recommendations, loading, load };
}
// 在组件中使用
const { recommendations, loading, load } = useRecommend();
onMounted(() => load(userId.value));
java复制@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().configurationSource(corsConfigurationSource())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.and()
// ...其他配置
}
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.asList("https://domain.com"));
config.setAllowCredentials(true);
// ...其他CORS配置
}
}
java复制@KafkaListener(topics = "user_events")
public void handleBehaviorEvent(BehaviorEvent event) {
userProfileService.updateUserVector(event.getUserId());
cacheService.evictUserRecommendations(event.getUserId());
}
java复制public List<Movie> hybridRecommend(long userId) {
int behaviorCount = behaviorService.countUserBehaviors(userId);
// 根据行为数据量调整算法权重
double cfWeight = Math.min(1, behaviorCount / 50.0);
double cbWeight = 1 - cfWeight;
// 并行获取推荐结果
List<Movie> cfItems = userCFRecommend(userId);
List<Movie> cbItems = contentBasedRecommend(userId);
// 混合排序
return mergeRecommendations(cfItems, cbItems, cfWeight, cbWeight);
}
java复制public String getAlgorithmTypeForUser(long userId) {
String bucket = redisTemplate.opsForValue().get("ab:user:" + userId);
if(bucket == null) {
bucket = ThreadLocalRandom.current().nextBoolean() ? "A" : "B";
redisTemplate.opsForValue().set(
"ab:user:" + userId,
bucket,
30, TimeUnit.DAYS
);
}
return "A".equals(bucket) ? "userCF" : "itemCF";
}