diff --git a/.idea/.cache/.Apifox_Helper/.toolWindow.db b/.idea/.cache/.Apifox_Helper/.toolWindow.db index 1bfb44b..7b57fac 100644 Binary files a/.idea/.cache/.Apifox_Helper/.toolWindow.db and b/.idea/.cache/.Apifox_Helper/.toolWindow.db differ diff --git a/video-recommend/pom.xml b/video-recommend/pom.xml index 3e8e77f..365b81e 100644 --- a/video-recommend/pom.xml +++ b/video-recommend/pom.xml @@ -12,24 +12,123 @@ video-recommend + com.nuaa.video video-common ${project.version} - + - org.apache.spark - spark-mllib_2.12 - 3.4.1 + org.springframework.boot + spring-boot-starter-web - + + + org.springframework.boot + spring-boot-starter-security + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + + + com.mysql + mysql-connector-j + runtime + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.11 + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.apache.commons commons-math3 3.6.1 + + + + org.projectlombok + lombok + true + + + + + cn.hutool + hutool-all + 5.8.26 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/RecommendApplication.java b/video-recommend/src/main/java/com/nuaa/video/recommend/RecommendApplication.java index 0e12137..c021228 100644 --- a/video-recommend/src/main/java/com/nuaa/video/recommend/RecommendApplication.java +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/RecommendApplication.java @@ -1,11 +1,20 @@ 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); diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/config/SecurityConfig.java b/video-recommend/src/main/java/com/nuaa/video/recommend/config/SecurityConfig.java new file mode 100644 index 0000000..20b5e78 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/config/SecurityConfig.java @@ -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(); + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/controller/RecommendController.java b/video-recommend/src/main/java/com/nuaa/video/recommend/controller/RecommendController.java new file mode 100644 index 0000000..fea78f6 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/controller/RecommendController.java @@ -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> 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> 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> getSimilarVideos( + @PathVariable Long videoId, + @RequestParam(value = "size", defaultValue = "10") Integer size) { + return recommendService.getSimilarVideos(videoId, size); + } + + /** + * 获取标签视频推荐 + */ + @GetMapping("/tag/{tagId}") + public Result> getTagVideos( + @PathVariable Long tagId, + @RequestParam(value = "size", defaultValue = "20") Integer size) { + return recommendService.getTagVideos(tagId, size); + } + + /** + * 记录用户行为 + */ + @PostMapping("/behavior") + public Result 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 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; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/dto/UserBehaviorDTO.java b/video-recommend/src/main/java/com/nuaa/video/recommend/dto/UserBehaviorDTO.java new file mode 100644 index 0000000..b446b1e --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/dto/UserBehaviorDTO.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendHotVideo.java b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendHotVideo.java new file mode 100644 index 0000000..bda4df1 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendHotVideo.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserBehavior.java b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserBehavior.java new file mode 100644 index 0000000..4b9f47a --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserBehavior.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserInterest.java b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserInterest.java new file mode 100644 index 0000000..b0341fd --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserInterest.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserRecommendation.java b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserRecommendation.java new file mode 100644 index 0000000..fc56649 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserRecommendation.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendVideoSimilarity.java b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendVideoSimilarity.java new file mode 100644 index 0000000..e7e5735 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendVideoSimilarity.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClient.java b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClient.java new file mode 100644 index 0000000..7c4d5b7 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClient.java @@ -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 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 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 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 getTagList(); + + /** + * 获取分类列表 + * + * @return 分类列表 + */ + @GetMapping("/content/category/list") + Result getCategoryList(); + + /** + * 获取视频标签 + * + * @param videoId 视频ID + * @return 标签列表 + */ + @GetMapping("/content/tag/video/{videoId}") + Result getVideoTags(@PathVariable("videoId") Long videoId); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClientFallbackFactory.java b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClientFallbackFactory.java new file mode 100644 index 0000000..b1b68fe --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClientFallbackFactory.java @@ -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 { + + @Override + public ContentFeignClient create(Throwable cause) { + log.error("内容服务调用失败:", cause); + return new ContentFeignClient() { + @Override + public Result getVideoInfo(Long id) { + log.error("获取视频信息失败,视频ID:{}", id); + return Result.error("获取视频信息失败,请稍后重试"); + } + + @Override + public Result getVideoList(Integer pageNum, Integer pageSize, Long categoryId, + List tagIds, String orderBy, String orderDirection, Integer isRecommend) { + log.error("获取视频列表失败"); + return Result.error("获取视频列表失败,请稍后重试"); + } + + @Override + public Result getTagList() { + log.error("获取标签列表失败"); + return Result.error("获取标签列表失败,请稍后重试"); + } + + @Override + public Result getCategoryList() { + log.error("获取分类列表失败"); + return Result.error("获取分类列表失败,请稍后重试"); + } + + @Override + public Result getVideoTags(Long videoId) { + log.error("获取视频标签失败,视频ID:{}", videoId); + return Result.error("获取视频标签失败,请稍后重试"); + } + }; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClient.java b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClient.java new file mode 100644 index 0000000..ae98bfd --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClient.java @@ -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 getUserProfile(@PathVariable("userId") Long userId); + + /** + * 获取当前用户信息 + * + * @param token 认证令牌 + * @return 用户信息 + */ + @GetMapping("/user/profile/current") + Result getCurrentUserProfile(@RequestHeader("Authorization") String token); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClientFallbackFactory.java b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClientFallbackFactory.java new file mode 100644 index 0000000..fdebba0 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClientFallbackFactory.java @@ -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 { + + @Override + public UserFeignClient create(Throwable cause) { + log.error("用户服务调用失败:", cause); + return new UserFeignClient() { + @Override + public Result getUserProfile(Long userId) { + log.error("获取用户信息失败,用户ID:{}", userId); + return Result.error("获取用户信息失败,请稍后重试"); + } + + @Override + public Result getCurrentUserProfile(String token) { + log.error("获取当前用户信息失败"); + return Result.error("获取当前用户信息失败,请稍后重试"); + } + }; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/filter/JwtAuthenticationFilter.java b/video-recommend/src/main/java/com/nuaa/video/recommend/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..16dc9d8 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/filter/JwtAuthenticationFilter.java @@ -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 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); + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendHotVideoMapper.java b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendHotVideoMapper.java new file mode 100644 index 0000000..1e769e4 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendHotVideoMapper.java @@ -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 { + + /** + * 获取热门视频ID列表 + * + * @param periodType 周期类型 + * @param limit 限制数量 + * @return 视频ID列表 + */ + List selectHotVideoIds( + @Param("periodType") Integer periodType, + @Param("limit") Integer limit); + + /** + * 批量插入热门视频 + * + * @param hotVideos 热门视频列表 + * @return 影响行数 + */ + int batchInsert(@Param("hotVideos") List hotVideos); + + /** + * 删除指定周期的热门视频 + * + * @param periodType 周期类型 + * @return 影响行数 + */ + int deleteByPeriodType(@Param("periodType") Integer periodType); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserBehaviorMapper.java b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserBehaviorMapper.java new file mode 100644 index 0000000..7ea9885 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserBehaviorMapper.java @@ -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 { + + /** + * 获取用户行为记录的视频ID列表 + * + * @param userId 用户ID + * @param behaviorTypes 行为类型列表 + * @param limit 限制数量 + * @return 视频ID列表 + */ + List selectUserBehaviorVideoIds( + @Param("userId") Long userId, + @Param("behaviorTypes") List behaviorTypes, + @Param("limit") Integer limit); + + /** + * 统计用户分类行为数量 + * + * @param userId 用户ID + * @param startTime 开始时间 + * @return 分类行为统计(categoryId -> count) + */ + List> countUserCategoryBehaviors( + @Param("userId") Long userId, + @Param("startTime") LocalDateTime startTime); + + /** + * 统计用户标签行为数量 + * + * @param userId 用户ID + * @param startTime 开始时间 + * @return 标签行为统计(tagId -> count) + */ + List> countUserTagBehaviors( + @Param("userId") Long userId, + @Param("startTime") LocalDateTime startTime); + + /** + * 获取热门视频 + * + * @param startTime 开始时间 + * @param behaviorTypes 行为类型列表 + * @param weights 行为权重Map(behaviorType -> weight) + * @param limit 限制数量 + * @return 热门视频ID及分数 + */ + List> selectHotVideos( + @Param("startTime") LocalDateTime startTime, + @Param("behaviorTypes") List behaviorTypes, + @Param("weights") Map weights, + @Param("limit") Integer limit); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserInterestMapper.java b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserInterestMapper.java new file mode 100644 index 0000000..2f10f54 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserInterestMapper.java @@ -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 { + + /** + * 获取用户标签权重前N的标签ID + * + * @param userId 用户ID + * @param limit 限制数量 + * @return 标签ID列表 + */ + List 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 tagWeights); + + /** + * 获取用户所有标签权重 + * + * @param userId 用户ID + * @return 标签权重Map(tagId -> weight) + */ + List> selectUserTagWeights(@Param("userId") Long userId); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserRecommendationMapper.java b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserRecommendationMapper.java new file mode 100644 index 0000000..642c573 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserRecommendationMapper.java @@ -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 { + + /** + * 获取用户推荐视频ID列表 + * + * @param userId 用户ID + * @param recommendType 推荐类型 + * @param limit 限制数量 + * @return 视频ID列表 + */ + List selectUserRecommendVideoIds( + @Param("userId") Long userId, + @Param("recommendType") Integer recommendType, + @Param("limit") Integer limit); + + /** + * 获取用户所有推荐视频ID + * + * @param userId 用户ID + * @return 视频ID列表 + */ + List selectAllUserRecommendVideoIds(@Param("userId") Long userId); + + /** + * 批量插入用户推荐记录 + * + * @param recommendations 推荐记录列表 + * @return 影响行数 + */ + int batchInsert(@Param("recommendations") List recommendations); + + /** + * 删除用户指定类型的推荐记录 + * + * @param userId 用户ID + * @param recommendType 推荐类型 + * @return 影响行数 + */ + int deleteUserRecommendations( + @Param("userId") Long userId, + @Param("recommendType") Integer recommendType); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendVideoSimilarityMapper.java b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendVideoSimilarityMapper.java new file mode 100644 index 0000000..79b1460 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendVideoSimilarityMapper.java @@ -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 { + + /** + * 获取相似视频列表 + * + * @param videoId 视频ID + * @param threshold 相似度阈值 + * @param limit 限制数量 + * @return 相似视频ID列表 + */ + List selectSimilarVideoIds( + @Param("videoId") Long videoId, + @Param("threshold") Float threshold, + @Param("limit") Integer limit); + + /** + * 批量插入或更新视频相似度 + * + * @param similarities 视频相似度列表 + * @return 影响行数 + */ + int batchInsertOrUpdate(@Param("similarities") List similarities); + + /** + * 获取视频相似度TOP N + * + * @param videoId 视频ID + * @param limit 限制数量 + * @return 相似视频及分数 + */ + List> selectTopSimilarVideos( + @Param("videoId") Long videoId, + @Param("limit") Integer limit); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/service/RecommendService.java b/video-recommend/src/main/java/com/nuaa/video/recommend/service/RecommendService.java new file mode 100644 index 0000000..ea14e58 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/service/RecommendService.java @@ -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> getHomeRecommend(Long userId, Integer page, Integer size); + + /** + * 获取热门推荐 + * + * @param periodType 周期类型(1:天,2:周,3:月) + * @param size 数量 + * @return 热门视频列表 + */ + Result> getHotRecommend(Integer periodType, Integer size); + + /** + * 获取相似视频推荐 + * + * @param videoId 视频ID + * @param size 数量 + * @return 相似视频列表 + */ + Result> getSimilarVideos(Long videoId, Integer size); + + /** + * 获取标签视频推荐 + * + * @param tagId 标签ID + * @param size 数量 + * @return 标签相关视频列表 + */ + Result> getTagVideos(Long tagId, Integer size); + + /** + * 记录用户行为 + * + * @param userId 用户ID + * @param behaviorDTO 行为数据 + * @return 操作结果 + */ + Result recordUserBehavior(Long userId, UserBehaviorDTO behaviorDTO); + + /** + * 更新用户兴趣 + * + * @param userId 用户ID + * @return 操作结果 + */ + Result updateUserInterests(Long userId); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/service/impl/RecommendServiceImpl.java b/video-recommend/src/main/java/com/nuaa/video/recommend/service/impl/RecommendServiceImpl.java new file mode 100644 index 0000000..7687dcd --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/service/impl/RecommendServiceImpl.java @@ -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> getHomeRecommend(Long userId, Integer page, Integer size) { + if (page == null || page < 1) { + page = 1; + } + if (size == null || size < 1) { + size = 20; + } + + List 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 hotRecommends = hotStrategy.recommend(userId, size - recommendList.size()); + recommendList.addAll(hotRecommends); + } + } else { + // 页码模3余2,混合推荐 + RecommendStrategy contentStrategy = strategyFactory.getStrategy(1); + List contentRecommends = contentStrategy.recommend(userId, size / 2); + + RecommendStrategy hotStrategy = strategyFactory.getStrategy(3); + List 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> getHotRecommend(Integer periodType, Integer size) { + if (periodType == null || periodType < 1 || periodType > 3) { + periodType = 1; // 默认使用日榜 + } + if (size == null || size < 1) { + size = 20; + } + + List 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> getSimilarVideos(Long videoId, Integer size) { + if (videoId == null) { + return Result.error("视频ID不能为空"); + } + if (size == null || size < 1) { + size = 10; + } + + List recommendList = new ArrayList<>(); + + try { + // 从数据库获取相似视频 + List> similarVideos = videoSimilarityMapper.selectTopSimilarVideos(videoId, size); + + if (similarVideos.isEmpty()) { + // 如果没有相似视频记录,直接通过内容服务获取相同分类的视频 + Result videoResult = contentFeignClient.getVideoInfo(videoId); + if (videoResult.getCode() == 200 && videoResult.getData() != null) { + @SuppressWarnings("unchecked") + Map videoData = (Map) videoResult.getData(); + + Long categoryId = null; + if (videoData.containsKey("categoryId")) { + categoryId = Long.valueOf(videoData.get("categoryId").toString()); + } + + if (categoryId != null) { + Result result = contentFeignClient.getVideoList(1, size, categoryId, null, + "view_count", "desc", null); + + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map pageData = (Map) result.getData(); + @SuppressWarnings("unchecked") + List> records = (List>) pageData.get("records"); + + if (records != null) { + for (Map 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 similar : similarVideos) { + Long similarVideoId = Long.valueOf(similar.get("videoId").toString()); + Result result = contentFeignClient.getVideoInfo(similarVideoId); + + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map videoData = (Map) 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> getTagVideos(Long tagId, Integer size) { + if (tagId == null) { + return Result.error("标签ID不能为空"); + } + if (size == null || size < 1) { + size = 20; + } + + List recommendList = new ArrayList<>(); + + try { + // 通过内容服务获取指定标签的视频 + List tagIds = Collections.singletonList(tagId); + Result result = contentFeignClient.getVideoList(1, size, null, tagIds, + "view_count", "desc", null); + + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map pageData = (Map) result.getData(); + @SuppressWarnings("unchecked") + List> records = (List>) pageData.get("records"); + + if (records != null) { + for (Map 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 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 updateUserInterests(Long userId) { + if (userId == null) { + return Result.error("用户ID不能为空"); + } + + try { + // 获取用户最近30天的行为 + LocalDateTime startTime = LocalDateTime.now().minusDays(30); + + // 1. 统计标签行为数量 + List> tagBehaviors = userBehaviorMapper.countUserTagBehaviors(userId, startTime); + + if (!tagBehaviors.isEmpty()) { + // 计算标签总权重 + double totalWeight = tagBehaviors.stream() + .mapToDouble(tag -> Double.parseDouble(tag.get("count").toString())) + .sum(); + + // 计算标签权重 + Map tagWeights = new HashMap<>(); + for (Map 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 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 tagNames = (List) videoData.get("tagNames"); + vo.setTagNames(tagNames); + } + + // 设置发布时间 + if (videoData.containsKey("publishTime")) { + String publishTimeStr = videoData.get("publishTime").toString(); + vo.setPublishTime(LocalDateTime.parse(publishTimeStr)); + } + + return vo; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/ContentBasedRecommendStrategy.java b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/ContentBasedRecommendStrategy.java new file mode 100644 index 0000000..15487a3 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/ContentBasedRecommendStrategy.java @@ -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 recommend(Long userId, Integer size) { + if (size == null || size <= 0) { + size = 20; + } + + // 根据用户兴趣推荐 + List recommendList = new ArrayList<>(); + + try { + // 1. 获取用户的兴趣标签 + List> userTagWeights = userInterestMapper.selectUserTagWeights(userId); + + if (userTagWeights == null || userTagWeights.isEmpty()) { + // 如果用户没有兴趣标签,则获取用户最近的行为 + List behaviorTypes = Arrays.asList(2, 3, 4, 5, 6); // 点赞、收藏、评论、分享、完整观看 + List recentVideoIds = userBehaviorMapper.selectUserBehaviorVideoIds(userId, behaviorTypes, 10); + + if (recentVideoIds.isEmpty()) { + // 如果用户没有行为记录,则返回空列表,后续会由热门推荐补充 + return recommendList; + } + + // 基于用户最近行为的视频,找相似视频 + Map candidateVideos = new HashMap<>(); + + for (Long videoId : recentVideoIds) { + List> similarVideos = videoSimilarityMapper.selectTopSimilarVideos(videoId, size / 2); + for (Map 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> sortedCandidates = new ArrayList<>(candidateVideos.entrySet()); + sortedCandidates.sort((e1, e2) -> e2.getValue().compareTo(e1.getValue())); + + // 取前N个 + List recommendIds = sortedCandidates.stream() + .limit(size) + .map(Map.Entry::getKey) + .collect(Collectors.toList()); + + // 获取视频详情 + for (Long videoId : recommendIds) { + Result result = contentFeignClient.getVideoInfo(videoId); + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map videoData = (Map) result.getData(); + + VideoRecommendVO vo = mapToRecommendVO(videoData); + vo.setRecommendReason("根据您的观看历史推荐"); + recommendList.add(vo); + } + } + + return recommendList; + } + + // 2. 用户有兴趣标签,基于标签推荐 + Map tagWeightMap = new HashMap<>(); + for (Map weight : userTagWeights) { + tagWeightMap.put(Long.valueOf(weight.get("tag_id").toString()), + Float.valueOf(weight.get("weight").toString())); + } + + // 3. 获取带权重的推荐标签ID + List weightedTagIds = new ArrayList<>(); + for (Map.Entry 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 recommendedVideoIds = new HashSet<>(); + for (Long tagId : weightedTagIds) { + if (recommendList.size() >= size) break; + + // 根据标签ID获取相关视频 + List tagIds = Collections.singletonList(tagId); + Result result = contentFeignClient.getVideoList(1, 5, null, tagIds, + "view_count", "desc", null); + + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map pageData = (Map) result.getData(); + @SuppressWarnings("unchecked") + List> records = (List>) pageData.get("records"); + + if (records != null) { + for (Map 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) video.get("tagNames")).size() > 0) { + tagName = ((List) 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 recommendForGuest(Integer size) { + // 游客没有兴趣标签,返回空列表,后续会由热门推荐补充 + return new ArrayList<>(); + } + + @Override + public String getName() { + return "基于内容的推荐"; + } + + /** + * 将视频数据映射为推荐VO + */ + private VideoRecommendVO mapToRecommendVO(Map 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 tagNames = (List) videoData.get("tagNames"); + vo.setTagNames(tagNames); + } + + return vo; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/HotRecommendStrategy.java b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/HotRecommendStrategy.java new file mode 100644 index 0000000..095470d --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/HotRecommendStrategy.java @@ -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 recommend(Long userId, Integer size) { + // 对已登录用户的热门推荐与游客相同,但可以过滤掉已经看过的视频 + return recommendForGuest(size); + } + + @Override + public List recommendForGuest(Integer size) { + if (size == null || size <= 0) { + size = defaultSize; + } + + // 获取每日热门视频 + List hotVideoIds = hotVideoMapper.selectHotVideoIds(1, size); + + // 如果每日热门不足,则获取每周热门补充 + if (hotVideoIds.size() < size) { + List weeklyHotVideoIds = hotVideoMapper.selectHotVideoIds(2, size - hotVideoIds.size()); + hotVideoIds.addAll(weeklyHotVideoIds); + } + + // 如果仍然不足,则获取每月热门补充 + if (hotVideoIds.size() < size) { + List monthlyHotVideoIds = hotVideoMapper.selectHotVideoIds(3, size - hotVideoIds.size()); + hotVideoIds.addAll(monthlyHotVideoIds); + } + + // 调用内容服务获取视频详情 + List recommendList = new ArrayList<>(); + for (Long videoId : hotVideoIds) { + try { + Result result = contentFeignClient.getVideoInfo(videoId); + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map videoData = (Map) 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 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 tagNames = (List) videoData.get("tagNames"); + vo.setTagNames(tagNames); + } + + return vo; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategy.java b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategy.java new file mode 100644 index 0000000..9ce96ef --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategy.java @@ -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 recommend(Long userId, Integer size); + + /** + * 为游客推荐视频 + * + * @param size 推荐数量 + * @return 推荐视频列表 + */ + List recommendForGuest(Integer size); + + /** + * 获取策略名称 + * + * @return 策略名称 + */ + String getName(); +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategyFactory.java b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategyFactory.java new file mode 100644 index 0000000..4010afa --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategyFactory.java @@ -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 strategies; + + private final Map 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 getAllStrategies() { + return strategyMap; + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/task/HotVideoUpdateTask.java b/video-recommend/src/main/java/com/nuaa/video/recommend/task/HotVideoUpdateTask.java new file mode 100644 index 0000000..4184ca1 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/task/HotVideoUpdateTask.java @@ -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 behaviorTypes = Arrays.asList(1, 2, 3, 4, 5, 6); // 浏览、点赞、收藏、评论、分享、完整观看 + Map 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> hotVideos = userBehaviorMapper.selectHotVideos( + startTime, behaviorTypes, weights, limit); + + if (!hotVideos.isEmpty()) { + // 先删除旧数据 + hotVideoMapper.deleteByPeriodType(periodType); + + // 构建热门视频实体列表 + List hotVideoEntities = new ArrayList<>(); + for (Map 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); + } + } + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/task/VideoSimilarityCalculateTask.java b/video-recommend/src/main/java/com/nuaa/video/recommend/task/VideoSimilarityCalculateTask.java new file mode 100644 index 0000000..7d7556f --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/task/VideoSimilarityCalculateTask.java @@ -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 result = contentFeignClient.getVideoList(1, 1000, null, null, + "create_time", "desc", null); + + if (result.getCode() == 200 && result.getData() != null) { + @SuppressWarnings("unchecked") + Map pageData = (Map) result.getData(); + @SuppressWarnings("unchecked") + List> records = (List>) pageData.get("records"); + + if (records != null && !records.isEmpty()) { + // 获取所有标签 + Map> videoTagsMap = new HashMap<>(); + Map videoCategoryMap = new HashMap<>(); + + // 提取每个视频的标签和分类 + for (Map video : records) { + Long videoId = Long.valueOf(video.get("id").toString()); + Long categoryId = Long.valueOf(video.get("categoryId").toString()); + videoCategoryMap.put(videoId, categoryId); + + // 获取视频标签 + Result tagResult = contentFeignClient.getVideoTags(videoId); + if (tagResult.getCode() == 200 && tagResult.getData() != null) { + @SuppressWarnings("unchecked") + List> tags = (List>) tagResult.getData(); + + Set tagIds = new HashSet<>(); + for (Map tag : tags) { + tagIds.add(Long.valueOf(tag.get("id").toString())); + } + + videoTagsMap.put(videoId, tagIds); + } + } + + // 计算视频间的相似度 + List similarities = new ArrayList<>(); + + for (int i = 0; i < records.size(); i++) { + Long videoId1 = Long.valueOf(records.get(i).get("id").toString()); + Set 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 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 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 tags1, Set tags2) { + if (tags1.isEmpty() && tags2.isEmpty()) { + return 0.0f; + } + + // 计算交集大小 + Set intersection = new HashSet<>(tags1); + intersection.retainAll(tags2); + + // 计算并集大小 + Set union = new HashSet<>(tags1); + union.addAll(tags2); + + // Jaccard相似度 = 交集大小 / 并集大小 + return (float) intersection.size() / union.size(); + } +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/vo/CategoryInterestVO.java b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/CategoryInterestVO.java new file mode 100644 index 0000000..75531c2 --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/CategoryInterestVO.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/vo/TagInterestVO.java b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/TagInterestVO.java new file mode 100644 index 0000000..ae0eacd --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/TagInterestVO.java @@ -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; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/vo/UserInterestVO.java b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/UserInterestVO.java new file mode 100644 index 0000000..ebfb9fc --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/UserInterestVO.java @@ -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 interests; + + /** + * 分类偏好列表 + */ + private List categoryPreferences; +} \ No newline at end of file diff --git a/video-recommend/src/main/java/com/nuaa/video/recommend/vo/VideoRecommendVO.java b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/VideoRecommendVO.java new file mode 100644 index 0000000..1d8b5fa --- /dev/null +++ b/video-recommend/src/main/java/com/nuaa/video/recommend/vo/VideoRecommendVO.java @@ -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 tagNames; + + /** + * 推荐原因 + */ + private String recommendReason; + + /** + * 发布时间 + */ + private LocalDateTime publishTime; +} \ No newline at end of file diff --git a/video-recommend/src/main/resources/application-dev.yml b/video-recommend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..76b0565 --- /dev/null +++ b/video-recommend/src/main/resources/application-dev.yml @@ -0,0 +1,32 @@ +# application-dev.yml +spring: + datasource: + dynamic: + primary: master + strict: false + datasource: + master: + url: jdbc:mysql://5.180.253.115:3306/video_recommend?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: sty@G458221 + driver-class-name: com.mysql.cj.jdbc.Driver + + data: + redis: + database: 4 + host: 5.180.253.115 + port: 6379 + password: redis_EsJ8FX + timeout: 10000 + +# 推荐系统配置 +recommend: + # 热门视频刷新间隔(分钟)- 开发环境设置更短以便测试 + hot: + refresh-interval: 10 + +# 开发环境日志级别 +logging: + level: + com.nuaa.video: debug + org.springframework.security: debug \ No newline at end of file diff --git a/video-recommend/src/main/resources/application.yml b/video-recommend/src/main/resources/application.yml index e958505..6bbea9c 100644 --- a/video-recommend/src/main/resources/application.yml +++ b/video-recommend/src/main/resources/application.yml @@ -4,30 +4,141 @@ server: spring: application: name: video-recommend + profiles: + active: dev + config: + import: + - optional:nacos:${spring.application.name}.yaml cloud: nacos: discovery: - server-addr: 5.180.253.115:8848 - namespace: public - group: DEFAULT_GROUP + server-addr: ${NACOS_HOST:5.180.253.115}:${NACOS_PORT:8848} + namespace: ${NACOS_NAMESPACE:public} + group: ${NACOS_GROUP:DEFAULT_GROUP} + config: + server-addr: ${spring.cloud.nacos.discovery.server-addr} + namespace: ${spring.cloud.nacos.discovery.namespace} + group: ${spring.cloud.nacos.discovery.group} + file-extension: yaml sentinel: transport: - dashboard: 5.180.253.115:8080 + dashboard: ${SENTINEL_DASHBOARD_HOST:5.180.253.115}:${SENTINEL_DASHBOARD_PORT:8080} port: 8724 eager: true + datasource: - url: jdbc:mysql://5.180.253.115:3306/video_recommend?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai - username: root - password: sty@G458221 driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${MYSQL_HOST:5.180.253.115}:${MYSQL_PORT:3306}/video_recommend?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: ${MYSQL_USERNAME:root} + password: ${MYSQL_PASSWORD:sty@G458221} + hikari: + minimum-idle: 5 + maximum-pool-size: 20 + idle-timeout: 30000 + max-lifetime: 1200000 + connection-timeout: 30000 + connection-test-query: SELECT 1 + data: redis: - host: 5.180.253.115 - port: 6379 - password: redis_EsJ8FX + host: ${REDIS_HOST:5.180.253.115} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:redis_EsJ8FX} database: 4 + 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: - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl \ No newline at end of file + 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 + +# 推荐系统配置 +recommend: + # 热门视频推荐配置 + hot: + # 默认获取数量 + default-size: 20 + # 刷新周期(分钟) + refresh-interval: 60 + # 热度计算权重 + weights: + view: 1 + like: 3 + favorite: 5 + coin: 5 + comment: 2 + share: 3 + + # 相似度计算配置 + similarity: + # 标签相似度权重 + tag-weight: 0.5 + # 分类相似度权重 + category-weight: 0.3 + # 用户行为相似度权重 + behavior-weight: 0.2 + + # 个性化推荐配置 + personalized: + # 默认获取数量 + default-size: 30 + # 行为权重 + behavior-weights: + view: 1 + like: 3 + favorite: 5 + coin: 5 + comment: 2 + share: 3 + complete-watch: 4 + +# JWT配置与auth模块保持一致 +jwt: + secret: video-platform-secret-key-2024-nuaa + expiration: 86400 + token-header: Authorization + token-prefix: "Bearer " + +auth: + ignore-urls: + - /recommend/hot/** + - /recommend/tag/** + - /actuator/** + +management: + endpoints: + web: + exposure: + include: '*' + endpoint: + health: + show-details: always + +feign: + client: + config: + default: + connectTimeout: 5000 + readTimeout: 5000 + circuitbreaker: + enabled: true + +logging: + level: + com.nuaa.video: info + org.springframework.security: info \ No newline at end of file diff --git a/video-recommend/src/main/resources/mapper/RecommendHotVideoMapper.xml b/video-recommend/src/main/resources/mapper/RecommendHotVideoMapper.xml new file mode 100644 index 0000000..61248e6 --- /dev/null +++ b/video-recommend/src/main/resources/mapper/RecommendHotVideoMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + + + INSERT INTO recommend_hot_video ( + video_id, hot_score, period_type, + created_by, updated_by, create_time, update_time + ) + VALUES + + ( + #{item.videoId}, #{item.hotScore}, #{item.periodType}, + #{item.createdBy}, #{item.updatedBy}, NOW(), NOW() + ) + + + + + + DELETE FROM recommend_hot_video + WHERE period_type = #{periodType} + + \ No newline at end of file diff --git a/video-recommend/src/main/resources/mapper/RecommendUserBehaviorMapper.xml b/video-recommend/src/main/resources/mapper/RecommendUserBehaviorMapper.xml new file mode 100644 index 0000000..b17f2b7 --- /dev/null +++ b/video-recommend/src/main/resources/mapper/RecommendUserBehaviorMapper.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/video-recommend/src/main/resources/mapper/RecommendUserInterestMapper.xml b/video-recommend/src/main/resources/mapper/RecommendUserInterestMapper.xml new file mode 100644 index 0000000..255722b --- /dev/null +++ b/video-recommend/src/main/resources/mapper/RecommendUserInterestMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + 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) + + + + + + \ No newline at end of file diff --git a/video-recommend/src/main/resources/mapper/RecommendUserRecommendationMapper.xml b/video-recommend/src/main/resources/mapper/RecommendUserRecommendationMapper.xml new file mode 100644 index 0000000..5de7938 --- /dev/null +++ b/video-recommend/src/main/resources/mapper/RecommendUserRecommendationMapper.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + INSERT INTO recommend_user_recommendation ( + user_id, video_id, recommend_type, score, + created_by, updated_by, create_time, update_time + ) + VALUES + + ( + #{item.userId}, #{item.videoId}, #{item.recommendType}, #{item.score}, + #{item.createdBy}, #{item.updatedBy}, NOW(), NOW() + ) + + + + + + DELETE FROM recommend_user_recommendation + WHERE user_id = #{userId} + + AND recommend_type = #{recommendType} + + + \ No newline at end of file diff --git a/video-recommend/src/main/resources/mapper/RecommendVideoSimilarityMapper.xml b/video-recommend/src/main/resources/mapper/RecommendVideoSimilarityMapper.xml new file mode 100644 index 0000000..408d3f7 --- /dev/null +++ b/video-recommend/src/main/resources/mapper/RecommendVideoSimilarityMapper.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + 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) + + + + + + \ No newline at end of file