refactor(project): Reorganize frontend and backend directories

This commit is contained in:
2026-03-18 10:45:15 +08:00
parent cc9eaf4cdf
commit 92a139e470
513 changed files with 28251 additions and 287 deletions
@@ -0,0 +1,22 @@
package com.nuaa.video.recommend;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 推荐服务启动类
*/
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@EnableScheduling
@MapperScan("com.nuaa.video.recommend.mapper")
public class RecommendApplication {
public static void main(String[] args) {
SpringApplication.run(RecommendApplication.class, args);
}
}
@@ -0,0 +1,42 @@
package com.nuaa.video.recommend.config;
import com.nuaa.video.recommend.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* 安全配置类
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/recommend/hot/**",
"/recommend/tag/**",
"/actuator/**",
"/error"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@@ -0,0 +1,104 @@
package com.nuaa.video.recommend.controller;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.recommend.dto.UserBehaviorDTO;
import com.nuaa.video.recommend.service.RecommendService;
import com.nuaa.video.recommend.vo.VideoRecommendVO;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 推荐控制器
*/
@Slf4j
@RestController
@RequestMapping("/recommend")
@RequiredArgsConstructor
public class RecommendController {
private final RecommendService recommendService;
/**
* 获取首页推荐
*/
@GetMapping("/home")
public Result<List<VideoRecommendVO>> getHomeRecommend(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "20") Integer size,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
return recommendService.getHomeRecommend(userId, page, size);
}
/**
* 获取热门推荐
*/
@GetMapping("/hot")
public Result<List<VideoRecommendVO>> getHotRecommend(
@RequestParam(value = "periodType", defaultValue = "1") Integer periodType,
@RequestParam(value = "size", defaultValue = "20") Integer size) {
return recommendService.getHotRecommend(periodType, size);
}
/**
* 获取相似视频推荐
*/
@GetMapping("/similar/{videoId}")
public Result<List<VideoRecommendVO>> getSimilarVideos(
@PathVariable Long videoId,
@RequestParam(value = "size", defaultValue = "10") Integer size) {
return recommendService.getSimilarVideos(videoId, size);
}
/**
* 获取标签视频推荐
*/
@GetMapping("/tag/{tagId}")
public Result<List<VideoRecommendVO>> getTagVideos(
@PathVariable Long tagId,
@RequestParam(value = "size", defaultValue = "20") Integer size) {
return recommendService.getTagVideos(tagId, size);
}
/**
* 记录用户行为
*/
@PostMapping("/behavior")
public Result<Void> recordUserBehavior(
@RequestBody @Valid UserBehaviorDTO behaviorDTO,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return recommendService.recordUserBehavior(userId, behaviorDTO);
}
/**
* 更新用户兴趣
*/
@PostMapping("/interest/update")
public Result<Void> updateUserInterests(HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return recommendService.updateUserInterests(userId);
}
/**
* 从请求中获取用户ID
*/
private Long getUserIdFromRequest(HttpServletRequest request) {
Object userIdObj = request.getAttribute("userId");
if (userIdObj instanceof Long) {
return (Long) userIdObj;
}
return null;
}
}
@@ -0,0 +1,26 @@
package com.nuaa.video.recommend.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
/**
* 用户行为记录DTO
*/
@Data
public class UserBehaviorDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
@NotNull(message = "视频ID不能为空")
private Long videoId;
/**
* 行为类型(1:浏览,2:点赞,3:收藏,4:评论,5:分享,6:完整观看)
*/
@NotNull(message = "行为类型不能为空")
private Integer behaviorType;
}
@@ -0,0 +1,59 @@
package com.nuaa.video.recommend.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 热门视频实体
*/
@Data
@TableName("recommend_hot_video")
public class RecommendHotVideo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 视频ID
*/
private Long videoId;
/**
* 热度分数
*/
private Float hotScore;
/**
* 周期类型(1:天,2:周,3:月)
*/
private Integer periodType;
/**
* 创建人
*/
private String createdBy;
/**
* 更新人
*/
private String updatedBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,54 @@
package com.nuaa.video.recommend.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户行为记录实体
*/
@Data
@TableName("recommend_user_behavior")
public class RecommendUserBehavior implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 视频ID
*/
private Long videoId;
/**
* 行为类型(1:浏览,2:点赞,3:收藏,4:评论,5:分享,6:完整观看)
*/
private Integer behaviorType;
/**
* 行为权重
*/
private Float behaviorWeight;
/**
* 创建人
*/
private String createdBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,59 @@
package com.nuaa.video.recommend.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户兴趣标签实体
*/
@Data
@TableName("recommend_user_interest")
public class RecommendUserInterest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 标签ID
*/
private Long tagId;
/**
* 权重
*/
private Float weight;
/**
* 创建人
*/
private String createdBy;
/**
* 更新人
*/
private String updatedBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,64 @@
package com.nuaa.video.recommend.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户推荐实体
*/
@Data
@TableName("recommend_user_recommendation")
public class RecommendUserRecommendation implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 视频ID
*/
private Long videoId;
/**
* 推荐类型(1:基于内容,2:协同过滤,3:热门推荐,4:新视频推荐)
*/
private Integer recommendType;
/**
* 推荐分数
*/
private Float score;
/**
* 创建人
*/
private String createdBy;
/**
* 更新人
*/
private String updatedBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,59 @@
package com.nuaa.video.recommend.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 视频相似度实体
*/
@Data
@TableName("recommend_video_similarity")
public class RecommendVideoSimilarity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 视频ID
*/
private Long videoId;
/**
* 相似视频ID
*/
private Long similarVideoId;
/**
* 相似度
*/
private Float similarity;
/**
* 创建人
*/
private String createdBy;
/**
* 更新人
*/
private String updatedBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
@@ -0,0 +1,72 @@
package com.nuaa.video.recommend.feign;
import com.nuaa.video.common.model.vo.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* 内容服务Feign客户端
*/
@FeignClient(name = "video-content", fallbackFactory = ContentFeignClientFallbackFactory.class)
public interface ContentFeignClient {
/**
* 获取视频详情
*
* @param id 视频ID
* @return 视频信息
*/
@GetMapping("/content/video/{id}")
Result<Object> getVideoInfo(@PathVariable("id") Long id);
/**
* 获取视频列表
*
* @param pageNum 页码
* @param pageSize 每页数量
* @param categoryId 分类ID
* @param tagIds 标签ID列表
* @param orderBy 排序字段
* @param orderDirection 排序方向
* @param isRecommend 是否推荐
* @return 视频列表
*/
@GetMapping("/content/video/list")
Result<Object> getVideoList(
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "orderBy", required = false) String orderBy,
@RequestParam(value = "orderDirection", required = false) String orderDirection,
@RequestParam(value = "isRecommend", required = false) Integer isRecommend);
/**
* 获取标签列表
*
* @return 标签列表
*/
@GetMapping("/content/tag/list")
Result<Object> getTagList();
/**
* 获取分类列表
*
* @return 分类列表
*/
@GetMapping("/content/category/list")
Result<Object> getCategoryList();
/**
* 获取视频标签
*
* @param videoId 视频ID
* @return 标签列表
*/
@GetMapping("/content/tag/video/{videoId}")
Result<Object> getVideoTags(@PathVariable("videoId") Long videoId);
}
@@ -0,0 +1,53 @@
package com.nuaa.video.recommend.feign;
import com.nuaa.video.common.model.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 内容服务Feign客户端降级工厂
*/
@Slf4j
@Component
public class ContentFeignClientFallbackFactory implements FallbackFactory<ContentFeignClient> {
@Override
public ContentFeignClient create(Throwable cause) {
log.error("内容服务调用失败:", cause);
return new ContentFeignClient() {
@Override
public Result<Object> getVideoInfo(Long id) {
log.error("获取视频信息失败,视频ID:{}", id);
return Result.error("获取视频信息失败,请稍后重试");
}
@Override
public Result<Object> getVideoList(Integer pageNum, Integer pageSize, Long categoryId,
List<Long> tagIds, String orderBy, String orderDirection, Integer isRecommend) {
log.error("获取视频列表失败");
return Result.error("获取视频列表失败,请稍后重试");
}
@Override
public Result<Object> getTagList() {
log.error("获取标签列表失败");
return Result.error("获取标签列表失败,请稍后重试");
}
@Override
public Result<Object> getCategoryList() {
log.error("获取分类列表失败");
return Result.error("获取分类列表失败,请稍后重试");
}
@Override
public Result<Object> getVideoTags(Long videoId) {
log.error("获取视频标签失败,视频ID:{}", videoId);
return Result.error("获取视频标签失败,请稍后重试");
}
};
}
}
@@ -0,0 +1,32 @@
package com.nuaa.video.recommend.feign;
import com.nuaa.video.common.model.vo.Result;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* 用户服务Feign客户端
*/
@FeignClient(name = "video-user", fallbackFactory = UserFeignClientFallbackFactory.class)
public interface UserFeignClient {
/**
* 获取用户信息
*
* @param userId 用户ID
* @return 用户信息
*/
@GetMapping("/user/profile/public/{userId}")
Result<Object> getUserProfile(@PathVariable("userId") Long userId);
/**
* 获取当前用户信息
*
* @param token 认证令牌
* @return 用户信息
*/
@GetMapping("/user/profile/current")
Result<Object> getCurrentUserProfile(@RequestHeader("Authorization") String token);
}
@@ -0,0 +1,32 @@
package com.nuaa.video.recommend.feign;
import com.nuaa.video.common.model.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* 用户服务Feign客户端降级工厂
*/
@Slf4j
@Component
public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeignClient> {
@Override
public UserFeignClient create(Throwable cause) {
log.error("用户服务调用失败:", cause);
return new UserFeignClient() {
@Override
public Result<Object> getUserProfile(Long userId) {
log.error("获取用户信息失败,用户ID:{}", userId);
return Result.error("获取用户信息失败,请稍后重试");
}
@Override
public Result<Object> getCurrentUserProfile(String token) {
log.error("获取当前用户信息失败");
return Result.error("获取当前用户信息失败,请稍后重试");
}
};
}
}
@@ -0,0 +1,102 @@
package com.nuaa.video.recommend.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* JWT认证过滤器
*/
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}")
private String secret;
private static final String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 从请求头中获取JWT令牌
String token = getJwtFromRequest(request);
// 如果令牌存在,则本地验证
if (StringUtils.hasText(token)) {
try {
// 验证令牌
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
// 从令牌中提取用户ID和用户名
Long userId = Long.parseLong(claims.getSubject());
String username = claims.get("username", String.class);
// 设置用户权限
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
// 创建认证对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
// 设置额外信息
authentication.setDetails(userId);
// 设置认证信息到安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
// 在请求属性中存储用户ID
request.setAttribute("userId", userId);
request.setAttribute("username", username);
log.debug("认证成功: 用户ID={}, 用户名={}", userId, username);
} catch (Exception e) {
log.error("JWT验证失败: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
/**
* 从请求头中提取JWT令牌
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
/**
* 获取签名密钥
*/
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
@@ -0,0 +1,42 @@
package com.nuaa.video.recommend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.recommend.entity.RecommendHotVideo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 热门视频Mapper
*/
@Mapper
public interface RecommendHotVideoMapper extends BaseMapper<RecommendHotVideo> {
/**
* 获取热门视频ID列表
*
* @param periodType 周期类型
* @param limit 限制数量
* @return 视频ID列表
*/
List<Long> selectHotVideoIds(
@Param("periodType") Integer periodType,
@Param("limit") Integer limit);
/**
* 批量插入热门视频
*
* @param hotVideos 热门视频列表
* @return 影响行数
*/
int batchInsert(@Param("hotVideos") List<RecommendHotVideo> hotVideos);
/**
* 删除指定周期的热门视频
*
* @param periodType 周期类型
* @return 影响行数
*/
int deleteByPeriodType(@Param("periodType") Integer periodType);
}
@@ -0,0 +1,67 @@
package com.nuaa.video.recommend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.recommend.entity.RecommendUserBehavior;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 用户行为记录Mapper
*/
@Mapper
public interface RecommendUserBehaviorMapper extends BaseMapper<RecommendUserBehavior> {
/**
* 获取用户行为记录的视频ID列表
*
* @param userId 用户ID
* @param behaviorTypes 行为类型列表
* @param limit 限制数量
* @return 视频ID列表
*/
List<Long> selectUserBehaviorVideoIds(
@Param("userId") Long userId,
@Param("behaviorTypes") List<Integer> behaviorTypes,
@Param("limit") Integer limit);
/**
* 统计用户分类行为数量
*
* @param userId 用户ID
* @param startTime 开始时间
* @return 分类行为统计(categoryId -> count)
*/
List<Map<String, Object>> countUserCategoryBehaviors(
@Param("userId") Long userId,
@Param("startTime") LocalDateTime startTime);
/**
* 统计用户标签行为数量
*
* @param userId 用户ID
* @param startTime 开始时间
* @return 标签行为统计(tagId -> count)
*/
List<Map<String, Object>> countUserTagBehaviors(
@Param("userId") Long userId,
@Param("startTime") LocalDateTime startTime);
/**
* 获取热门视频
*
* @param startTime 开始时间
* @param behaviorTypes 行为类型列表
* @param weights 行为权重Map(behaviorType -> weight)
* @param limit 限制数量
* @return 热门视频ID及分数
*/
List<Map<String, Object>> selectHotVideos(
@Param("startTime") LocalDateTime startTime,
@Param("behaviorTypes") List<Integer> behaviorTypes,
@Param("weights") Map<Integer, Float> weights,
@Param("limit") Integer limit);
}
@@ -0,0 +1,42 @@
package com.nuaa.video.recommend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.recommend.entity.RecommendUserInterest;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 用户兴趣标签Mapper
*/
@Mapper
public interface RecommendUserInterestMapper extends BaseMapper<RecommendUserInterest> {
/**
* 获取用户标签权重前N的标签ID
*
* @param userId 用户ID
* @param limit 限制数量
* @return 标签ID列表
*/
List<Long> selectTopTagIds(@Param("userId") Long userId, @Param("limit") Integer limit);
/**
* 批量更新用户标签权重
*
* @param userId 用户ID
* @param tagWeights 标签权重Map(tagId -> weight)
* @return 影响行数
*/
int batchUpdateWeights(@Param("userId") Long userId, @Param("tagWeights") Map<Long, Float> tagWeights);
/**
* 获取用户所有标签权重
*
* @param userId 用户ID
* @return 标签权重Map(tagId -> weight)
*/
List<Map<String, Object>> selectUserTagWeights(@Param("userId") Long userId);
}
@@ -0,0 +1,56 @@
package com.nuaa.video.recommend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.recommend.entity.RecommendUserRecommendation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 用户推荐Mapper
*/
@Mapper
public interface RecommendUserRecommendationMapper extends BaseMapper<RecommendUserRecommendation> {
/**
* 获取用户推荐视频ID列表
*
* @param userId 用户ID
* @param recommendType 推荐类型
* @param limit 限制数量
* @return 视频ID列表
*/
List<Long> selectUserRecommendVideoIds(
@Param("userId") Long userId,
@Param("recommendType") Integer recommendType,
@Param("limit") Integer limit);
/**
* 获取用户所有推荐视频ID
*
* @param userId 用户ID
* @return 视频ID列表
*/
List<Long> selectAllUserRecommendVideoIds(@Param("userId") Long userId);
/**
* 批量插入用户推荐记录
*
* @param recommendations 推荐记录列表
* @return 影响行数
*/
int batchInsert(@Param("recommendations") List<RecommendUserRecommendation> recommendations);
/**
* 删除用户指定类型的推荐记录
*
* @param userId 用户ID
* @param recommendType 推荐类型
* @return 影响行数
*/
int deleteUserRecommendations(
@Param("userId") Long userId,
@Param("recommendType") Integer recommendType);
}
@@ -0,0 +1,48 @@
package com.nuaa.video.recommend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.recommend.entity.RecommendVideoSimilarity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 视频相似度Mapper
*/
@Mapper
public interface RecommendVideoSimilarityMapper extends BaseMapper<RecommendVideoSimilarity> {
/**
* 获取相似视频列表
*
* @param videoId 视频ID
* @param threshold 相似度阈值
* @param limit 限制数量
* @return 相似视频ID列表
*/
List<Long> selectSimilarVideoIds(
@Param("videoId") Long videoId,
@Param("threshold") Float threshold,
@Param("limit") Integer limit);
/**
* 批量插入或更新视频相似度
*
* @param similarities 视频相似度列表
* @return 影响行数
*/
int batchInsertOrUpdate(@Param("similarities") List<RecommendVideoSimilarity> similarities);
/**
* 获取视频相似度TOP N
*
* @param videoId 视频ID
* @param limit 限制数量
* @return 相似视频及分数
*/
List<Map<String, Object>> selectTopSimilarVideos(
@Param("videoId") Long videoId,
@Param("limit") Integer limit);
}
@@ -0,0 +1,67 @@
package com.nuaa.video.recommend.service;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.recommend.dto.UserBehaviorDTO;
import com.nuaa.video.recommend.vo.VideoRecommendVO;
import java.util.List;
/**
* 推荐服务接口
*/
public interface RecommendService {
/**
* 获取首页推荐
*
* @param userId 用户ID
* @param page 页码
* @param size 每页数量
* @return 推荐视频列表
*/
Result<List<VideoRecommendVO>> getHomeRecommend(Long userId, Integer page, Integer size);
/**
* 获取热门推荐
*
* @param periodType 周期类型(1:天,2:周,3:月)
* @param size 数量
* @return 热门视频列表
*/
Result<List<VideoRecommendVO>> getHotRecommend(Integer periodType, Integer size);
/**
* 获取相似视频推荐
*
* @param videoId 视频ID
* @param size 数量
* @return 相似视频列表
*/
Result<List<VideoRecommendVO>> getSimilarVideos(Long videoId, Integer size);
/**
* 获取标签视频推荐
*
* @param tagId 标签ID
* @param size 数量
* @return 标签相关视频列表
*/
Result<List<VideoRecommendVO>> getTagVideos(Long tagId, Integer size);
/**
* 记录用户行为
*
* @param userId 用户ID
* @param behaviorDTO 行为数据
* @return 操作结果
*/
Result<Void> recordUserBehavior(Long userId, UserBehaviorDTO behaviorDTO);
/**
* 更新用户兴趣
*
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> updateUserInterests(Long userId);
}
@@ -0,0 +1,381 @@
package com.nuaa.video.recommend.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.recommend.dto.UserBehaviorDTO;
import com.nuaa.video.recommend.entity.RecommendUserBehavior;
import com.nuaa.video.recommend.entity.RecommendUserInterest;
import com.nuaa.video.recommend.entity.RecommendUserRecommendation;
import com.nuaa.video.recommend.entity.RecommendVideoSimilarity;
import com.nuaa.video.recommend.feign.ContentFeignClient;
import com.nuaa.video.recommend.mapper.RecommendUserBehaviorMapper;
import com.nuaa.video.recommend.mapper.RecommendUserInterestMapper;
import com.nuaa.video.recommend.mapper.RecommendUserRecommendationMapper;
import com.nuaa.video.recommend.mapper.RecommendVideoSimilarityMapper;
import com.nuaa.video.recommend.service.RecommendService;
import com.nuaa.video.recommend.strategy.RecommendStrategy;
import com.nuaa.video.recommend.strategy.RecommendStrategyFactory;
import com.nuaa.video.recommend.vo.VideoRecommendVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 推荐服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RecommendServiceImpl implements RecommendService {
private final RecommendUserBehaviorMapper userBehaviorMapper;
private final RecommendUserInterestMapper userInterestMapper;
private final RecommendUserRecommendationMapper userRecommendationMapper;
private final RecommendVideoSimilarityMapper videoSimilarityMapper;
private final ContentFeignClient contentFeignClient;
private final RecommendStrategyFactory strategyFactory;
@Override
public Result<List<VideoRecommendVO>> getHomeRecommend(Long userId, Integer page, Integer size) {
if (page == null || page < 1) {
page = 1;
}
if (size == null || size < 1) {
size = 20;
}
List<VideoRecommendVO> recommendList = new ArrayList<>();
try {
// 不同页使用不同的推荐策略,提供多样性
if (userId == null) {
// 游客只使用热门推荐
RecommendStrategy hotStrategy = strategyFactory.getStrategy(3);
recommendList = hotStrategy.recommendForGuest(size);
} else {
int pageType = page % 3; // 分成3种页面类型
if (pageType == 0) {
// 页码被3整除,使用热门推荐
RecommendStrategy hotStrategy = strategyFactory.getStrategy(3);
recommendList = hotStrategy.recommend(userId, size);
} else if (pageType == 1) {
// 页码模3余1,使用基于内容的推荐
RecommendStrategy contentStrategy = strategyFactory.getStrategy(1);
recommendList = contentStrategy.recommend(userId, size);
// 如果基于内容的推荐不足,使用热门推荐补充
if (recommendList.size() < size) {
RecommendStrategy hotStrategy = strategyFactory.getStrategy(3);
List<VideoRecommendVO> hotRecommends = hotStrategy.recommend(userId, size - recommendList.size());
recommendList.addAll(hotRecommends);
}
} else {
// 页码模3余2,混合推荐
RecommendStrategy contentStrategy = strategyFactory.getStrategy(1);
List<VideoRecommendVO> contentRecommends = contentStrategy.recommend(userId, size / 2);
RecommendStrategy hotStrategy = strategyFactory.getStrategy(3);
List<VideoRecommendVO> hotRecommends = hotStrategy.recommend(userId, size - contentRecommends.size());
recommendList.addAll(contentRecommends);
recommendList.addAll(hotRecommends);
}
}
} catch (Exception e) {
log.error("获取首页推荐异常, userId={}, page={}, size={}", userId, page, size, e);
}
return Result.success(recommendList);
}
@Override
public Result<List<VideoRecommendVO>> getHotRecommend(Integer periodType, Integer size) {
if (periodType == null || periodType < 1 || periodType > 3) {
periodType = 1; // 默认使用日榜
}
if (size == null || size < 1) {
size = 20;
}
List<VideoRecommendVO> recommendList = new ArrayList<>();
try {
RecommendStrategy hotStrategy = strategyFactory.getStrategy(3);
recommendList = hotStrategy.recommendForGuest(size);
} catch (Exception e) {
log.error("获取热门推荐异常, periodType={}, size={}", periodType, size, e);
}
return Result.success(recommendList);
}
@Override
public Result<List<VideoRecommendVO>> getSimilarVideos(Long videoId, Integer size) {
if (videoId == null) {
return Result.error("视频ID不能为空");
}
if (size == null || size < 1) {
size = 10;
}
List<VideoRecommendVO> recommendList = new ArrayList<>();
try {
// 从数据库获取相似视频
List<Map<String, Object>> similarVideos = videoSimilarityMapper.selectTopSimilarVideos(videoId, size);
if (similarVideos.isEmpty()) {
// 如果没有相似视频记录,直接通过内容服务获取相同分类的视频
Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId);
if (videoResult.getCode() == 200 && videoResult.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoResult.getData();
Long categoryId = null;
if (videoData.containsKey("categoryId")) {
categoryId = Long.valueOf(videoData.get("categoryId").toString());
}
if (categoryId != null) {
Result<Object> result = contentFeignClient.getVideoList(1, size, categoryId, null,
"view_count", "desc", null);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> pageData = (Map<String, Object>) result.getData();
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) pageData.get("records");
if (records != null) {
for (Map<String, Object> video : records) {
Long id = Long.valueOf(video.get("id").toString());
if (!id.equals(videoId)) { // 过滤掉当前视频
VideoRecommendVO vo = mapToRecommendVO(video);
vo.setRecommendReason("相关推荐");
recommendList.add(vo);
}
}
}
}
}
}
} else {
// 有相似视频记录,获取视频详情
for (Map<String, Object> similar : similarVideos) {
Long similarVideoId = Long.valueOf(similar.get("videoId").toString());
Result<Object> result = contentFeignClient.getVideoInfo(similarVideoId);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) result.getData();
VideoRecommendVO vo = mapToRecommendVO(videoData);
// 设置推荐原因
String reason = "相关推荐";
if (vo.getTagNames() != null && !vo.getTagNames().isEmpty()) {
reason = "与本视频相关: " + vo.getTagNames().get(0);
}
vo.setRecommendReason(reason);
recommendList.add(vo);
}
}
}
} catch (Exception e) {
log.error("获取相似视频推荐异常, videoId={}, size={}", videoId, size, e);
}
return Result.success(recommendList);
}
@Override
public Result<List<VideoRecommendVO>> getTagVideos(Long tagId, Integer size) {
if (tagId == null) {
return Result.error("标签ID不能为空");
}
if (size == null || size < 1) {
size = 20;
}
List<VideoRecommendVO> recommendList = new ArrayList<>();
try {
// 通过内容服务获取指定标签的视频
List<Long> tagIds = Collections.singletonList(tagId);
Result<Object> result = contentFeignClient.getVideoList(1, size, null, tagIds,
"view_count", "desc", null);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> pageData = (Map<String, Object>) result.getData();
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) pageData.get("records");
if (records != null) {
for (Map<String, Object> video : records) {
VideoRecommendVO vo = mapToRecommendVO(video);
// 获取标签名称
String tagName = "相关内容";
if (vo.getTagNames() != null && !vo.getTagNames().isEmpty()) {
for (String name : vo.getTagNames()) {
tagName = name;
break;
}
}
vo.setRecommendReason("相关标签: " + tagName);
recommendList.add(vo);
}
}
}
} catch (Exception e) {
log.error("获取标签视频推荐异常, tagId={}, size={}", tagId, size, e);
}
return Result.success(recommendList);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> recordUserBehavior(Long userId, UserBehaviorDTO behaviorDTO) {
if (userId == null) {
return Result.error("用户ID不能为空");
}
if (behaviorDTO == null || behaviorDTO.getVideoId() == null || behaviorDTO.getBehaviorType() == null) {
return Result.error("行为数据不完整");
}
try {
// 根据行为类型设置权重
float weight = 1.0f;
switch (behaviorDTO.getBehaviorType()) {
case 1: // 浏览
weight = 1.0f;
break;
case 2: // 点赞
weight = 3.0f;
break;
case 3: // 收藏
weight = 5.0f;
break;
case 4: // 评论
weight = 2.0f;
break;
case 5: // 分享
weight = 3.0f;
break;
case 6: // 完整观看
weight = 4.0f;
break;
default:
weight = 1.0f;
}
// 记录用户行为
RecommendUserBehavior behavior = new RecommendUserBehavior();
behavior.setUserId(userId);
behavior.setVideoId(behaviorDTO.getVideoId());
behavior.setBehaviorType(behaviorDTO.getBehaviorType());
behavior.setBehaviorWeight(weight);
behavior.setCreatedBy(userId.toString());
behavior.setCreateTime(LocalDateTime.now());
userBehaviorMapper.insert(behavior);
// 异步更新用户兴趣(实际项目中可以使用消息队列或线程池)
updateUserInterests(userId);
return Result.success();
} catch (Exception e) {
log.error("记录用户行为异常, userId={}, behaviorDTO={}", userId, behaviorDTO, e);
return Result.error("记录用户行为失败");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> updateUserInterests(Long userId) {
if (userId == null) {
return Result.error("用户ID不能为空");
}
try {
// 获取用户最近30天的行为
LocalDateTime startTime = LocalDateTime.now().minusDays(30);
// 1. 统计标签行为数量
List<Map<String, Object>> tagBehaviors = userBehaviorMapper.countUserTagBehaviors(userId, startTime);
if (!tagBehaviors.isEmpty()) {
// 计算标签总权重
double totalWeight = tagBehaviors.stream()
.mapToDouble(tag -> Double.parseDouble(tag.get("count").toString()))
.sum();
// 计算标签权重
Map<Long, Float> tagWeights = new HashMap<>();
for (Map<String, Object> tag : tagBehaviors) {
Long tagId = Long.valueOf(tag.get("tagId").toString());
double count = Double.parseDouble(tag.get("count").toString());
float weight = (float) (count / totalWeight);
tagWeights.put(tagId, weight);
}
// 更新用户标签兴趣
userInterestMapper.batchUpdateWeights(userId, tagWeights);
}
// 2. 统计分类行为数量(如果需要可以添加分类兴趣统计)
return Result.success();
} catch (Exception e) {
log.error("更新用户兴趣异常, userId={}", userId, e);
return Result.error("更新用户兴趣失败");
}
}
/**
* 将视频数据映射为推荐VO
*/
private VideoRecommendVO mapToRecommendVO(Map<String, Object> videoData) {
VideoRecommendVO vo = new VideoRecommendVO();
// 设置基本信息
if (videoData.containsKey("id")) vo.setId(Long.valueOf(videoData.get("id").toString()));
if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title"));
if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description"));
if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl"));
if (videoData.containsKey("duration")) vo.setDuration(Integer.valueOf(videoData.get("duration").toString()));
if (videoData.containsKey("userId")) vo.setUserId(Long.valueOf(videoData.get("userId").toString()));
if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname"));
if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar"));
if (videoData.containsKey("categoryId")) vo.setCategoryId(Long.valueOf(videoData.get("categoryId").toString()));
if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName"));
if (videoData.containsKey("likeCount")) vo.setLikeCount(Integer.valueOf(videoData.get("likeCount").toString()));
if (videoData.containsKey("commentCount")) vo.setCommentCount(Integer.valueOf(videoData.get("commentCount").toString()));
if (videoData.containsKey("viewCount")) vo.setViewCount(Long.valueOf(videoData.get("viewCount").toString()));
// 设置标签
if (videoData.containsKey("tagNames")) {
@SuppressWarnings("unchecked")
List<String> tagNames = (List<String>) videoData.get("tagNames");
vo.setTagNames(tagNames);
}
// 设置发布时间
if (videoData.containsKey("publishTime")) {
String publishTimeStr = videoData.get("publishTime").toString();
vo.setPublishTime(LocalDateTime.parse(publishTimeStr));
}
return vo;
}
}
@@ -0,0 +1,217 @@
package com.nuaa.video.recommend.strategy;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.recommend.entity.RecommendUserInterest;
import com.nuaa.video.recommend.entity.RecommendVideoSimilarity;
import com.nuaa.video.recommend.feign.ContentFeignClient;
import com.nuaa.video.recommend.mapper.RecommendUserBehaviorMapper;
import com.nuaa.video.recommend.mapper.RecommendUserInterestMapper;
import com.nuaa.video.recommend.mapper.RecommendVideoSimilarityMapper;
import com.nuaa.video.recommend.vo.VideoRecommendVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 基于内容的推荐策略
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ContentBasedRecommendStrategy implements RecommendStrategy {
private final RecommendUserInterestMapper userInterestMapper;
private final RecommendUserBehaviorMapper userBehaviorMapper;
private final RecommendVideoSimilarityMapper videoSimilarityMapper;
private final ContentFeignClient contentFeignClient;
/**
* 基于内容的推荐类型: 1
*/
private static final Integer CONTENT_BASED_RECOMMEND_TYPE = 1;
@Override
public Integer getType() {
return CONTENT_BASED_RECOMMEND_TYPE;
}
@Override
public List<VideoRecommendVO> recommend(Long userId, Integer size) {
if (size == null || size <= 0) {
size = 20;
}
// 根据用户兴趣推荐
List<VideoRecommendVO> recommendList = new ArrayList<>();
try {
// 1. 获取用户的兴趣标签
List<Map<String, Object>> userTagWeights = userInterestMapper.selectUserTagWeights(userId);
if (userTagWeights == null || userTagWeights.isEmpty()) {
// 如果用户没有兴趣标签,则获取用户最近的行为
List<Integer> behaviorTypes = Arrays.asList(2, 3, 4, 5, 6); // 点赞、收藏、评论、分享、完整观看
List<Long> recentVideoIds = userBehaviorMapper.selectUserBehaviorVideoIds(userId, behaviorTypes, 10);
if (recentVideoIds.isEmpty()) {
// 如果用户没有行为记录,则返回空列表,后续会由热门推荐补充
return recommendList;
}
// 基于用户最近行为的视频,找相似视频
Map<Long, Double> candidateVideos = new HashMap<>();
for (Long videoId : recentVideoIds) {
List<Map<String, Object>> similarVideos = videoSimilarityMapper.selectTopSimilarVideos(videoId, size / 2);
for (Map<String, Object> video : similarVideos) {
Long similarVideoId = Long.valueOf(video.get("videoId").toString());
Double similarity = Double.valueOf(video.get("score").toString());
// 过滤掉用户已经看过的视频
if (!recentVideoIds.contains(similarVideoId)) {
candidateVideos.put(similarVideoId,
candidateVideos.getOrDefault(similarVideoId, 0.0) + similarity);
}
}
}
// 按相似度降序排序
List<Map.Entry<Long, Double>> sortedCandidates = new ArrayList<>(candidateVideos.entrySet());
sortedCandidates.sort((e1, e2) -> e2.getValue().compareTo(e1.getValue()));
// 取前N个
List<Long> recommendIds = sortedCandidates.stream()
.limit(size)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
// 获取视频详情
for (Long videoId : recommendIds) {
Result<Object> result = contentFeignClient.getVideoInfo(videoId);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) result.getData();
VideoRecommendVO vo = mapToRecommendVO(videoData);
vo.setRecommendReason("根据您的观看历史推荐");
recommendList.add(vo);
}
}
return recommendList;
}
// 2. 用户有兴趣标签,基于标签推荐
Map<Long, Float> tagWeightMap = new HashMap<>();
for (Map<String, Object> weight : userTagWeights) {
tagWeightMap.put(Long.valueOf(weight.get("tag_id").toString()),
Float.valueOf(weight.get("weight").toString()));
}
// 3. 获取带权重的推荐标签ID
List<Long> weightedTagIds = new ArrayList<>();
for (Map.Entry<Long, Float> entry : tagWeightMap.entrySet()) {
int count = Math.round(entry.getValue() * 10); // 标签权重决定在推荐池中的数量
for (int i = 0; i < count; i++) {
weightedTagIds.add(entry.getKey());
}
}
// 随机选择标签,避免推荐结果过于单一
Collections.shuffle(weightedTagIds);
// 4. 根据标签获取视频列表
Set<Long> recommendedVideoIds = new HashSet<>();
for (Long tagId : weightedTagIds) {
if (recommendList.size() >= size) break;
// 根据标签ID获取相关视频
List<Long> tagIds = Collections.singletonList(tagId);
Result<Object> result = contentFeignClient.getVideoList(1, 5, null, tagIds,
"view_count", "desc", null);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> pageData = (Map<String, Object>) result.getData();
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) pageData.get("records");
if (records != null) {
for (Map<String, Object> video : records) {
Long videoId = Long.valueOf(video.get("id").toString());
// 过滤掉已经推荐的视频
if (!recommendedVideoIds.contains(videoId)) {
recommendedVideoIds.add(videoId);
VideoRecommendVO vo = mapToRecommendVO(video);
// 设置推荐原因
String tagName = "您感兴趣的内容";
if (video.containsKey("tagNames") && ((List<String>) video.get("tagNames")).size() > 0) {
tagName = ((List<String>) video.get("tagNames")).get(0);
}
vo.setRecommendReason("根据您对" + tagName + "的兴趣推荐");
recommendList.add(vo);
if (recommendList.size() >= size) break;
}
}
}
}
}
} catch (Exception e) {
log.error("基于内容的推荐异常, userId={}", userId, e);
}
return recommendList;
}
@Override
public List<VideoRecommendVO> recommendForGuest(Integer size) {
// 游客没有兴趣标签,返回空列表,后续会由热门推荐补充
return new ArrayList<>();
}
@Override
public String getName() {
return "基于内容的推荐";
}
/**
* 将视频数据映射为推荐VO
*/
private VideoRecommendVO mapToRecommendVO(Map<String, Object> videoData) {
VideoRecommendVO vo = new VideoRecommendVO();
// 设置基本信息
if (videoData.containsKey("id")) vo.setId(Long.valueOf(videoData.get("id").toString()));
if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title"));
if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description"));
if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl"));
if (videoData.containsKey("duration")) vo.setDuration(Integer.valueOf(videoData.get("duration").toString()));
if (videoData.containsKey("userId")) vo.setUserId(Long.valueOf(videoData.get("userId").toString()));
if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname"));
if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar"));
if (videoData.containsKey("categoryId")) vo.setCategoryId(Long.valueOf(videoData.get("categoryId").toString()));
if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName"));
if (videoData.containsKey("likeCount")) vo.setLikeCount(Integer.valueOf(videoData.get("likeCount").toString()));
if (videoData.containsKey("commentCount")) vo.setCommentCount(Integer.valueOf(videoData.get("commentCount").toString()));
if (videoData.containsKey("viewCount")) vo.setViewCount(Long.valueOf(videoData.get("viewCount").toString()));
// 设置标签
if (videoData.containsKey("tagNames")) {
@SuppressWarnings("unchecked")
List<String> tagNames = (List<String>) videoData.get("tagNames");
vo.setTagNames(tagNames);
}
return vo;
}
}
@@ -0,0 +1,123 @@
package com.nuaa.video.recommend.strategy;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.recommend.feign.ContentFeignClient;
import com.nuaa.video.recommend.mapper.RecommendHotVideoMapper;
import com.nuaa.video.recommend.vo.VideoRecommendVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 热门推荐策略
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotRecommendStrategy implements RecommendStrategy {
private final RecommendHotVideoMapper hotVideoMapper;
private final ContentFeignClient contentFeignClient;
@Value("${recommend.hot.default-size:20}")
private Integer defaultSize;
/**
* 热门推荐类型: 3
*/
private static final Integer HOT_RECOMMEND_TYPE = 3;
@Override
public Integer getType() {
return HOT_RECOMMEND_TYPE;
}
@Override
public List<VideoRecommendVO> recommend(Long userId, Integer size) {
// 对已登录用户的热门推荐与游客相同,但可以过滤掉已经看过的视频
return recommendForGuest(size);
}
@Override
public List<VideoRecommendVO> recommendForGuest(Integer size) {
if (size == null || size <= 0) {
size = defaultSize;
}
// 获取每日热门视频
List<Long> hotVideoIds = hotVideoMapper.selectHotVideoIds(1, size);
// 如果每日热门不足,则获取每周热门补充
if (hotVideoIds.size() < size) {
List<Long> weeklyHotVideoIds = hotVideoMapper.selectHotVideoIds(2, size - hotVideoIds.size());
hotVideoIds.addAll(weeklyHotVideoIds);
}
// 如果仍然不足,则获取每月热门补充
if (hotVideoIds.size() < size) {
List<Long> monthlyHotVideoIds = hotVideoMapper.selectHotVideoIds(3, size - hotVideoIds.size());
hotVideoIds.addAll(monthlyHotVideoIds);
}
// 调用内容服务获取视频详情
List<VideoRecommendVO> recommendList = new ArrayList<>();
for (Long videoId : hotVideoIds) {
try {
Result<Object> result = contentFeignClient.getVideoInfo(videoId);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) result.getData();
VideoRecommendVO vo = mapToRecommendVO(videoData);
vo.setRecommendReason("热门推荐");
recommendList.add(vo);
}
} catch (Exception e) {
log.error("获取视频信息失败, videoId={}", videoId, e);
}
}
return recommendList;
}
@Override
public String getName() {
return "热门推荐";
}
/**
* 将视频数据映射为推荐VO
*/
private VideoRecommendVO mapToRecommendVO(Map<String, Object> videoData) {
VideoRecommendVO vo = new VideoRecommendVO();
// 设置基本信息
if (videoData.containsKey("id")) vo.setId(Long.valueOf(videoData.get("id").toString()));
if (videoData.containsKey("title")) vo.setTitle((String) videoData.get("title"));
if (videoData.containsKey("description")) vo.setDescription((String) videoData.get("description"));
if (videoData.containsKey("coverUrl")) vo.setCoverUrl((String) videoData.get("coverUrl"));
if (videoData.containsKey("duration")) vo.setDuration(Integer.valueOf(videoData.get("duration").toString()));
if (videoData.containsKey("userId")) vo.setUserId(Long.valueOf(videoData.get("userId").toString()));
if (videoData.containsKey("userNickname")) vo.setUserNickname((String) videoData.get("userNickname"));
if (videoData.containsKey("userAvatar")) vo.setUserAvatar((String) videoData.get("userAvatar"));
if (videoData.containsKey("categoryId")) vo.setCategoryId(Long.valueOf(videoData.get("categoryId").toString()));
if (videoData.containsKey("categoryName")) vo.setCategoryName((String) videoData.get("categoryName"));
if (videoData.containsKey("likeCount")) vo.setLikeCount(Integer.valueOf(videoData.get("likeCount").toString()));
if (videoData.containsKey("commentCount")) vo.setCommentCount(Integer.valueOf(videoData.get("commentCount").toString()));
if (videoData.containsKey("viewCount")) vo.setViewCount(Long.valueOf(videoData.get("viewCount").toString()));
// 设置标签
if (videoData.containsKey("tagNames")) {
@SuppressWarnings("unchecked")
List<String> tagNames = (List<String>) videoData.get("tagNames");
vo.setTagNames(tagNames);
}
return vo;
}
}
@@ -0,0 +1,42 @@
package com.nuaa.video.recommend.strategy;
import com.nuaa.video.recommend.vo.VideoRecommendVO;
import java.util.List;
/**
* 推荐策略接口
*/
public interface RecommendStrategy {
/**
* 获取推荐策略类型
*
* @return 推荐类型
*/
Integer getType();
/**
* 为用户推荐视频
*
* @param userId 用户ID
* @param size 推荐数量
* @return 推荐视频列表
*/
List<VideoRecommendVO> recommend(Long userId, Integer size);
/**
* 为游客推荐视频
*
* @param size 推荐数量
* @return 推荐视频列表
*/
List<VideoRecommendVO> recommendForGuest(Integer size);
/**
* 获取策略名称
*
* @return 策略名称
*/
String getName();
}
@@ -0,0 +1,55 @@
package com.nuaa.video.recommend.strategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 推荐策略工厂
*/
@Slf4j
@Component
public class RecommendStrategyFactory {
@Autowired
private List<RecommendStrategy> strategies;
private final Map<Integer, RecommendStrategy> strategyMap = new HashMap<>();
@PostConstruct
public void init() {
strategies.forEach(strategy -> {
strategyMap.put(strategy.getType(), strategy);
log.info("注册推荐策略: type={}, name={}", strategy.getType(), strategy.getName());
});
}
/**
* 获取推荐策略
*
* @param type 推荐类型
* @return 推荐策略
*/
public RecommendStrategy getStrategy(Integer type) {
RecommendStrategy strategy = strategyMap.get(type);
if (strategy == null) {
log.warn("未找到推荐策略: type={}, 使用热门推荐策略", type);
strategy = strategyMap.get(3); // 默认使用热门推荐
}
return strategy;
}
/**
* 获取所有推荐策略
*
* @return 推荐策略Map
*/
public Map<Integer, RecommendStrategy> getAllStrategies() {
return strategyMap;
}
}
@@ -0,0 +1,143 @@
package com.nuaa.video.recommend.task;
import com.nuaa.video.recommend.entity.RecommendHotVideo;
import com.nuaa.video.recommend.mapper.RecommendHotVideoMapper;
import com.nuaa.video.recommend.mapper.RecommendUserBehaviorMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
/**
* 热门视频更新定时任务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class HotVideoUpdateTask {
private final RecommendUserBehaviorMapper userBehaviorMapper;
private final RecommendHotVideoMapper hotVideoMapper;
@Value("${recommend.hot.weights.view:1}")
private float viewWeight;
@Value("${recommend.hot.weights.like:3}")
private float likeWeight;
@Value("${recommend.hot.weights.favorite:5}")
private float favoriteWeight;
@Value("${recommend.hot.weights.coin:5}")
private float coinWeight;
@Value("${recommend.hot.weights.comment:2}")
private float commentWeight;
@Value("${recommend.hot.weights.share:3}")
private float shareWeight;
/**
* 每小时更新日榜热门视频
*/
@Scheduled(cron = "0 0 * * * ?")
@Transactional(rollbackFor = Exception.class)
public void updateDailyHotVideos() {
log.info("开始更新日榜热门视频");
try {
LocalDateTime startTime = LocalDateTime.now().minusDays(1);
updateHotVideos(startTime, 1, 100);
log.info("日榜热门视频更新完成");
} catch (Exception e) {
log.error("更新日榜热门视频失败", e);
}
}
/**
* 每天凌晨2点更新周榜热门视频
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional(rollbackFor = Exception.class)
public void updateWeeklyHotVideos() {
log.info("开始更新周榜热门视频");
try {
LocalDateTime startTime = LocalDateTime.now().minusDays(7);
updateHotVideos(startTime, 2, 100);
log.info("周榜热门视频更新完成");
} catch (Exception e) {
log.error("更新周榜热门视频失败", e);
}
}
/**
* 每周一凌晨3点更新月榜热门视频
*/
@Scheduled(cron = "0 0 3 ? * MON")
@Transactional(rollbackFor = Exception.class)
public void updateMonthlyHotVideos() {
log.info("开始更新月榜热门视频");
try {
LocalDateTime startTime = LocalDateTime.now().minusDays(30);
updateHotVideos(startTime, 3, 100);
log.info("月榜热门视频更新完成");
} catch (Exception e) {
log.error("更新月榜热门视频失败", e);
}
}
/**
* 更新热门视频
*
* @param startTime 开始时间
* @param periodType 周期类型(1:天,2:周,3:月)
* @param limit 数量限制
*/
private void updateHotVideos(LocalDateTime startTime, int periodType, int limit) {
// 设置行为类型和权重
List<Integer> behaviorTypes = Arrays.asList(1, 2, 3, 4, 5, 6); // 浏览、点赞、收藏、评论、分享、完整观看
Map<Integer, Float> weights = new HashMap<>();
weights.put(1, viewWeight);
weights.put(2, likeWeight);
weights.put(3, favoriteWeight);
weights.put(4, commentWeight);
weights.put(5, shareWeight);
weights.put(6, viewWeight * 2); // 完整观看权重翻倍
// 获取热门视频
List<Map<String, Object>> hotVideos = userBehaviorMapper.selectHotVideos(
startTime, behaviorTypes, weights, limit);
if (!hotVideos.isEmpty()) {
// 先删除旧数据
hotVideoMapper.deleteByPeriodType(periodType);
// 构建热门视频实体列表
List<RecommendHotVideo> hotVideoEntities = new ArrayList<>();
for (Map<String, Object> video : hotVideos) {
Long videoId = Long.valueOf(video.get("videoId").toString());
float score = Float.parseFloat(video.get("score").toString());
RecommendHotVideo hotVideo = new RecommendHotVideo();
hotVideo.setVideoId(videoId);
hotVideo.setHotScore(score);
hotVideo.setPeriodType(periodType);
hotVideo.setCreatedBy("system");
hotVideo.setUpdatedBy("system");
hotVideo.setCreateTime(LocalDateTime.now());
hotVideo.setUpdateTime(LocalDateTime.now());
hotVideoEntities.add(hotVideo);
}
// 批量插入
if (!hotVideoEntities.isEmpty()) {
hotVideoMapper.batchInsert(hotVideoEntities);
}
}
}
}
@@ -0,0 +1,161 @@
package com.nuaa.video.recommend.task;
import com.nuaa.video.common.model.vo.Result;
import com.nuaa.video.recommend.entity.RecommendVideoSimilarity;
import com.nuaa.video.recommend.feign.ContentFeignClient;
import com.nuaa.video.recommend.mapper.RecommendVideoSimilarityMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.math3.linear.ArrayRealVector;
import org.apache.commons.math3.linear.RealVector;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 视频相似度计算定时任务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class VideoSimilarityCalculateTask {
private final ContentFeignClient contentFeignClient;
private final RecommendVideoSimilarityMapper videoSimilarityMapper;
/**
* 每天凌晨4点计算视频相似度
*/
@Scheduled(cron = "0 0 4 * * ?")
@Transactional(rollbackFor = Exception.class)
public void calculateVideoSimilarity() {
log.info("开始计算视频相似度");
try {
// 获取视频列表
Result<Object> result = contentFeignClient.getVideoList(1, 1000, null, null,
"create_time", "desc", null);
if (result.getCode() == 200 && result.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> pageData = (Map<String, Object>) result.getData();
@SuppressWarnings("unchecked")
List<Map<String, Object>> records = (List<Map<String, Object>>) pageData.get("records");
if (records != null && !records.isEmpty()) {
// 获取所有标签
Map<Long, Set<Long>> videoTagsMap = new HashMap<>();
Map<Long, Long> videoCategoryMap = new HashMap<>();
// 提取每个视频的标签和分类
for (Map<String, Object> video : records) {
Long videoId = Long.valueOf(video.get("id").toString());
Long categoryId = Long.valueOf(video.get("categoryId").toString());
videoCategoryMap.put(videoId, categoryId);
// 获取视频标签
Result<Object> tagResult = contentFeignClient.getVideoTags(videoId);
if (tagResult.getCode() == 200 && tagResult.getData() != null) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> tags = (List<Map<String, Object>>) tagResult.getData();
Set<Long> tagIds = new HashSet<>();
for (Map<String, Object> tag : tags) {
tagIds.add(Long.valueOf(tag.get("id").toString()));
}
videoTagsMap.put(videoId, tagIds);
}
}
// 计算视频间的相似度
List<RecommendVideoSimilarity> similarities = new ArrayList<>();
for (int i = 0; i < records.size(); i++) {
Long videoId1 = Long.valueOf(records.get(i).get("id").toString());
Set<Long> tags1 = videoTagsMap.getOrDefault(videoId1, new HashSet<>());
Long category1 = videoCategoryMap.get(videoId1);
for (int j = i + 1; j < records.size(); j++) {
Long videoId2 = Long.valueOf(records.get(j).get("id").toString());
Set<Long> tags2 = videoTagsMap.getOrDefault(videoId2, new HashSet<>());
Long category2 = videoCategoryMap.get(videoId2);
// 计算标签相似度
float tagSimilarity = calculateTagSimilarity(tags1, tags2);
// 计算分类相似度
float categorySimilarity = category1.equals(category2) ? 1.0f : 0.0f;
// 综合相似度 (标签权重0.7,分类权重0.3)
float similarity = tagSimilarity * 0.7f + categorySimilarity * 0.3f;
// 只保存相似度大于0.3的记录
if (similarity > 0.3f) {
// 创建相似度记录
RecommendVideoSimilarity sim1 = new RecommendVideoSimilarity();
sim1.setVideoId(videoId1);
sim1.setSimilarVideoId(videoId2);
sim1.setSimilarity(similarity);
sim1.setCreatedBy("system");
sim1.setUpdatedBy("system");
sim1.setCreateTime(LocalDateTime.now());
sim1.setUpdateTime(LocalDateTime.now());
similarities.add(sim1);
// 相似度是双向的
RecommendVideoSimilarity sim2 = new RecommendVideoSimilarity();
sim2.setVideoId(videoId2);
sim2.setSimilarVideoId(videoId1);
sim2.setSimilarity(similarity);
sim2.setCreatedBy("system");
sim2.setUpdatedBy("system");
sim2.setCreateTime(LocalDateTime.now());
sim2.setUpdateTime(LocalDateTime.now());
similarities.add(sim2);
}
}
}
// 批量更新视频相似度
if (!similarities.isEmpty()) {
// 每批处理100条
int batchSize = 100;
for (int i = 0; i < similarities.size(); i += batchSize) {
int endIndex = Math.min(i + batchSize, similarities.size());
List<RecommendVideoSimilarity> batch = similarities.subList(i, endIndex);
videoSimilarityMapper.batchInsertOrUpdate(batch);
}
}
log.info("视频相似度计算完成,共处理{}个视频,生成{}条相似度记录", records.size(), similarities.size());
}
}
} catch (Exception e) {
log.error("计算视频相似度失败", e);
}
}
/**
* 计算标签相似度 (Jaccard相似度)
*/
private float calculateTagSimilarity(Set<Long> tags1, Set<Long> tags2) {
if (tags1.isEmpty() && tags2.isEmpty()) {
return 0.0f;
}
// 计算交集大小
Set<Long> intersection = new HashSet<>(tags1);
intersection.retainAll(tags2);
// 计算并集大小
Set<Long> union = new HashSet<>(tags1);
union.addAll(tags2);
// Jaccard相似度 = 交集大小 / 并集大小
return (float) intersection.size() / union.size();
}
}
@@ -0,0 +1,28 @@
package com.nuaa.video.recommend.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 分类兴趣VO
*/
@Data
public class CategoryInterestVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 分类ID
*/
private Long categoryId;
/**
* 分类名称
*/
private String categoryName;
/**
* 偏好权重
*/
private Float weight;
}
@@ -0,0 +1,28 @@
package com.nuaa.video.recommend.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 标签兴趣VO
*/
@Data
public class TagInterestVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 标签ID
*/
private Long tagId;
/**
* 标签名称
*/
private String tagName;
/**
* 兴趣权重
*/
private Float weight;
}
@@ -0,0 +1,29 @@
package com.nuaa.video.recommend.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 用户兴趣VO
*/
@Data
public class UserInterestVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long userId;
/**
* 用户标签列表
*/
private List<TagInterestVO> interests;
/**
* 分类偏好列表
*/
private List<CategoryInterestVO> categoryPreferences;
}
@@ -0,0 +1,95 @@
package com.nuaa.video.recommend.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 视频推荐VO
*/
@Data
public class VideoRecommendVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
private Long id;
/**
* 视频标题
*/
private String title;
/**
* 视频描述
*/
private String description;
/**
* 封面URL
*/
private String coverUrl;
/**
* 视频时长(秒)
*/
private Integer duration;
/**
* 用户ID
*/
private Long userId;
/**
* 用户昵称
*/
private String userNickname;
/**
* 用户头像
*/
private String userAvatar;
/**
* 分类ID
*/
private Long categoryId;
/**
* 分类名称
*/
private String categoryName;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 评论数
*/
private Integer commentCount;
/**
* 播放数
*/
private Long viewCount;
/**
* 标签名称列表
*/
private List<String> tagNames;
/**
* 推荐原因
*/
private String recommendReason;
/**
* 发布时间
*/
private LocalDateTime publishTime;
}
@@ -0,0 +1,49 @@
server:
port: 9006
spring:
application:
name: video-recommend
cloud:
nacos:
discovery:
server-addr: ${NACOS_HOST:nacos}:${NACOS_PORT:8848}
namespace: public
group: DEFAULT_GROUP
sentinel:
transport:
dashboard: ${SENTINEL_HOST:sentinel}:${SENTINEL_PORT:8080}
port: 8726
eager: true
datasource:
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/video_recommend?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379}
password: redis_EsJ8FX
database: 5
timeout: 10000
lettuce:
pool:
max-active: 200
max-wait: -1
max-idle: 10
min-idle: 0
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.nuaa.video.recommend.entity
configuration:
map-underscore-to-camel-case: true
cache-enabled: false
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: is_deleted
logic-delete-value: 1
logic-not-delete-value: 0
@@ -0,0 +1,38 @@
server:
port: 9005
spring:
application:
name: video-recommend
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
config:
import:
- optional:nacos:common.yaml?refreshEnabled=true
- optional:nacos:common-redis-${spring.profiles.active}.yaml?refreshEnabled=true
- optional:nacos:${spring.application.name}.yaml?refreshEnabled=true
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:${NACOS_HOST:127.0.0.1}:${NACOS_PORT:8848}}
namespace: ${NACOS_NAMESPACE:}
group: ${NACOS_GROUP:DEFAULT_GROUP}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
ip: ${SPRING_CLOUD_NACOS_DISCOVERY_IP:}
config:
server-addr: ${spring.cloud.nacos.discovery.server-addr}
namespace: ${spring.cloud.nacos.discovery.namespace}
group: ${spring.cloud.nacos.discovery.group}
file-extension: yaml
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
management:
endpoints:
web:
exposure:
include: '*'
endpoint:
health:
show-details: always
@@ -0,0 +1,23 @@
spring:
application:
name: video-recommend
cloud:
nacos:
discovery:
server-addr: ${NACOS_HOST:nacos}:${NACOS_PORT:8848}
namespace: public
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
ephemeral: true
ip: ${RECOMMEND_IP:}
port: ${server.port:9006}
fail-fast: true
register-enabled: true
config:
server-addr: ${NACOS_HOST:nacos}:${NACOS_PORT:8848}
namespace: public
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
file-extension: yaml
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.recommend.mapper.RecommendHotVideoMapper">
<!-- 获取热门视频ID列表 -->
<select id="selectHotVideoIds" resultType="java.lang.Long">
SELECT video_id
FROM recommend_hot_video
WHERE period_type = #{periodType}
ORDER BY hot_score DESC
LIMIT #{limit}
</select>
<!-- 批量插入热门视频 -->
<insert id="batchInsert">
INSERT INTO recommend_hot_video (
video_id, hot_score, period_type,
created_by, updated_by, create_time, update_time
)
VALUES
<foreach collection="hotVideos" item="item" separator=",">
(
#{item.videoId}, #{item.hotScore}, #{item.periodType},
#{item.createdBy}, #{item.updatedBy}, NOW(), NOW()
)
</foreach>
</insert>
<!-- 删除指定周期的热门视频 -->
<delete id="deleteByPeriodType">
DELETE FROM recommend_hot_video
WHERE period_type = #{periodType}
</delete>
</mapper>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.recommend.mapper.RecommendUserBehaviorMapper">
<!-- 获取用户行为记录的视频ID列表 -->
<select id="selectUserBehaviorVideoIds" resultType="java.lang.Long">
SELECT DISTINCT video_id
FROM recommend_user_behavior
WHERE user_id = #{userId}
<if test="behaviorTypes != null and behaviorTypes.size > 0">
AND behavior_type IN
<foreach collection="behaviorTypes" item="type" open="(" separator="," close=")">
#{type}
</foreach>
</if>
ORDER BY create_time DESC
LIMIT #{limit}
</select>
<!-- 统计用户分类行为数量 -->
<select id="countUserCategoryBehaviors" resultType="java.util.Map">
SELECT c.category_id as categoryId, COUNT(1) as count
FROM recommend_user_behavior b
JOIN video_content.content_video v ON b.video_id = v.id
JOIN video_content.content_category c ON v.category_id = c.id
WHERE b.user_id = #{userId}
<if test="startTime != null">
AND b.create_time >= #{startTime}
</if>
GROUP BY c.category_id
</select>
<!-- 统计用户标签行为数量 -->
<select id="countUserTagBehaviors" resultType="java.util.Map">
SELECT vt.tag_id as tagId, COUNT(1) as count
FROM recommend_user_behavior b
JOIN video_content.content_video_tag vt ON b.video_id = vt.video_id
WHERE b.user_id = #{userId}
<if test="startTime != null">
AND b.create_time >= #{startTime}
</if>
GROUP BY vt.tag_id
</select>
<!-- 获取热门视频 -->
<select id="selectHotVideos" resultType="java.util.Map">
SELECT
video_id as videoId,
SUM(
CASE
<foreach collection="weights.entrySet()" item="entry">
WHEN behavior_type = #{entry.key} THEN behavior_weight * #{entry.value}
</foreach>
ELSE behavior_weight
END
) as score
FROM recommend_user_behavior
WHERE
<if test="startTime != null">
create_time >= #{startTime}
</if>
<if test="behaviorTypes != null and behaviorTypes.size > 0">
AND behavior_type IN
<foreach collection="behaviorTypes" item="type" open="(" separator="," close=")">
#{type}
</foreach>
</if>
GROUP BY video_id
ORDER BY score DESC
LIMIT #{limit}
</select>
</mapper>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.recommend.mapper.RecommendUserInterestMapper">
<!-- 获取用户标签权重前N的标签ID -->
<select id="selectTopTagIds" resultType="java.lang.Long">
SELECT tag_id
FROM recommend_user_interest
WHERE user_id = #{userId}
ORDER BY weight DESC
LIMIT #{limit}
</select>
<!-- 批量更新用户标签权重 -->
<update id="batchUpdateWeights">
<foreach collection="tagWeights.entrySet()" item="entry" separator=";">
INSERT INTO recommend_user_interest (user_id, tag_id, weight, created_by, updated_by, create_time, update_time)
VALUES (#{userId}, #{entry.key}, #{entry.value}, 'system', 'system', NOW(), NOW())
ON DUPLICATE KEY UPDATE
weight = VALUES(weight),
updated_by = VALUES(updated_by),
update_time = VALUES(update_time)
</foreach>
</update>
<!-- 获取用户所有标签权重 -->
<select id="selectUserTagWeights" resultType="java.util.Map">
SELECT tag_id, weight
FROM recommend_user_interest
WHERE user_id = #{userId}
</select>
</mapper>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.recommend.mapper.RecommendUserRecommendationMapper">
<!-- 获取用户推荐视频ID列表 -->
<select id="selectUserRecommendVideoIds" resultType="java.lang.Long">
SELECT video_id
FROM recommend_user_recommendation
WHERE user_id = #{userId}
<if test="recommendType != null">
AND recommend_type = #{recommendType}
</if>
ORDER BY score DESC
LIMIT #{limit}
</select>
<!-- 获取用户所有推荐视频ID -->
<select id="selectAllUserRecommendVideoIds" resultType="java.lang.Long">
SELECT video_id
FROM recommend_user_recommendation
WHERE user_id = #{userId}
</select>
<!-- 批量插入用户推荐记录 -->
<insert id="batchInsert">
INSERT INTO recommend_user_recommendation (
user_id, video_id, recommend_type, score,
created_by, updated_by, create_time, update_time
)
VALUES
<foreach collection="recommendations" item="item" separator=",">
(
#{item.userId}, #{item.videoId}, #{item.recommendType}, #{item.score},
#{item.createdBy}, #{item.updatedBy}, NOW(), NOW()
)
</foreach>
</insert>
<!-- 删除用户指定类型的推荐记录 -->
<delete id="deleteUserRecommendations">
DELETE FROM recommend_user_recommendation
WHERE user_id = #{userId}
<if test="recommendType != null">
AND recommend_type = #{recommendType}
</if>
</delete>
</mapper>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.recommend.mapper.RecommendVideoSimilarityMapper">
<!-- 获取相似视频列表 -->
<select id="selectSimilarVideoIds" resultType="java.lang.Long">
SELECT similar_video_id
FROM recommend_video_similarity
WHERE video_id = #{videoId}
AND similarity >= #{threshold}
ORDER BY similarity DESC
LIMIT #{limit}
</select>
<!-- 批量插入或更新视频相似度 -->
<insert id="batchInsertOrUpdate">
<foreach collection="similarities" item="item" separator=";">
INSERT INTO recommend_video_similarity (
video_id, similar_video_id, similarity,
created_by, updated_by, create_time, update_time
)
VALUES (
#{item.videoId}, #{item.similarVideoId}, #{item.similarity},
#{item.createdBy}, #{item.updatedBy}, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
similarity = VALUES(similarity),
updated_by = VALUES(updated_by),
update_time = VALUES(update_time)
</foreach>
</insert>
<!-- 获取视频相似度TOP N -->
<select id="selectTopSimilarVideos" resultType="java.util.Map">
SELECT similar_video_id as videoId, similarity as score
FROM recommend_video_similarity
WHERE video_id = #{videoId}
ORDER BY similarity DESC
LIMIT #{limit}
</select>
</mapper>