From 27dd1409f67436bb8dcc5db1380b3c983687b5b8 Mon Sep 17 00:00:00 2001 From: Toyas Date: Thu, 10 Apr 2025 01:12:51 +0800 Subject: [PATCH] feat(recommend): Implement recommendation module foundation --- .idea/.cache/.Apifox_Helper/.toolWindow.db | Bin 237568 -> 262144 bytes video-recommend/pom.xml | 109 ++++- .../video/recommend/RecommendApplication.java | 9 + .../recommend/config/SecurityConfig.java | 42 ++ .../controller/RecommendController.java | 104 +++++ .../video/recommend/dto/UserBehaviorDTO.java | 26 ++ .../recommend/entity/RecommendHotVideo.java | 59 +++ .../entity/RecommendUserBehavior.java | 54 +++ .../entity/RecommendUserInterest.java | 59 +++ .../entity/RecommendUserRecommendation.java | 64 +++ .../entity/RecommendVideoSimilarity.java | 59 +++ .../recommend/feign/ContentFeignClient.java | 72 ++++ .../ContentFeignClientFallbackFactory.java | 53 +++ .../recommend/feign/UserFeignClient.java | 32 ++ .../feign/UserFeignClientFallbackFactory.java | 32 ++ .../filter/JwtAuthenticationFilter.java | 102 +++++ .../mapper/RecommendHotVideoMapper.java | 42 ++ .../mapper/RecommendUserBehaviorMapper.java | 67 +++ .../mapper/RecommendUserInterestMapper.java | 42 ++ .../RecommendUserRecommendationMapper.java | 56 +++ .../RecommendVideoSimilarityMapper.java | 48 +++ .../recommend/service/RecommendService.java | 67 +++ .../service/impl/RecommendServiceImpl.java | 381 ++++++++++++++++++ .../ContentBasedRecommendStrategy.java | 217 ++++++++++ .../strategy/HotRecommendStrategy.java | 123 ++++++ .../recommend/strategy/RecommendStrategy.java | 42 ++ .../strategy/RecommendStrategyFactory.java | 55 +++ .../recommend/task/HotVideoUpdateTask.java | 143 +++++++ .../task/VideoSimilarityCalculateTask.java | 161 ++++++++ .../recommend/vo/CategoryInterestVO.java | 28 ++ .../video/recommend/vo/TagInterestVO.java | 28 ++ .../video/recommend/vo/UserInterestVO.java | 29 ++ .../video/recommend/vo/VideoRecommendVO.java | 95 +++++ .../src/main/resources/application-dev.yml | 32 ++ .../src/main/resources/application.yml | 133 +++++- .../mapper/RecommendHotVideoMapper.xml | 34 ++ .../mapper/RecommendUserBehaviorMapper.xml | 72 ++++ .../mapper/RecommendUserInterestMapper.xml | 32 ++ .../RecommendUserRecommendationMapper.xml | 47 +++ .../mapper/RecommendVideoSimilarityMapper.xml | 41 ++ 40 files changed, 2875 insertions(+), 16 deletions(-) create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/config/SecurityConfig.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/controller/RecommendController.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/dto/UserBehaviorDTO.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendHotVideo.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserBehavior.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserInterest.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendUserRecommendation.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/entity/RecommendVideoSimilarity.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClient.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/feign/ContentFeignClientFallbackFactory.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClient.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/feign/UserFeignClientFallbackFactory.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/filter/JwtAuthenticationFilter.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendHotVideoMapper.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserBehaviorMapper.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserInterestMapper.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendUserRecommendationMapper.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/mapper/RecommendVideoSimilarityMapper.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/service/RecommendService.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/service/impl/RecommendServiceImpl.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/strategy/ContentBasedRecommendStrategy.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/strategy/HotRecommendStrategy.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategy.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/strategy/RecommendStrategyFactory.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/task/HotVideoUpdateTask.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/task/VideoSimilarityCalculateTask.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/vo/CategoryInterestVO.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/vo/TagInterestVO.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/vo/UserInterestVO.java create mode 100644 video-recommend/src/main/java/com/nuaa/video/recommend/vo/VideoRecommendVO.java create mode 100644 video-recommend/src/main/resources/application-dev.yml create mode 100644 video-recommend/src/main/resources/mapper/RecommendHotVideoMapper.xml create mode 100644 video-recommend/src/main/resources/mapper/RecommendUserBehaviorMapper.xml create mode 100644 video-recommend/src/main/resources/mapper/RecommendUserInterestMapper.xml create mode 100644 video-recommend/src/main/resources/mapper/RecommendUserRecommendationMapper.xml create mode 100644 video-recommend/src/main/resources/mapper/RecommendVideoSimilarityMapper.xml diff --git a/.idea/.cache/.Apifox_Helper/.toolWindow.db b/.idea/.cache/.Apifox_Helper/.toolWindow.db index 1bfb44bd52fa71d6ef9768aa917d5ef0c8bf24a1..7b57fac4fcee7c5e50a403e23183ee98984fa1a8 100644 GIT binary patch delta 20268 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�zBc3HHqJIvy zj6_YY3}T#Z!_+{*=0z|`DJ&ye3NNz)L{p1kh+>PVT=GUOD8n~ig=^~0PG`i~*wJEP zOe0jzTD;A(KUI*&*Wwis4)J8>h(J7eVo|t%vi7GJ*5NOFqNbI*#!(}6lXeZ~89 z2-qqI^cCa$vF@aL2X$WJC?d?OD3MfN2d14*!A!})muw^kG7Lb;FjU51LFLdOv%r=> z1AzeYDEq***W=o{7}HTNiaJtvo~4d*^61W?HlBYabv*w&G>-iA25f$!)Df`~^F3S| zP>1SKN0m<3$6vs*g{W?7#SO>q0?r$SORd~B%SyK&RN z$A-@4w2Yk65}PK@dN*n>!y8D~J}?v9vj>mO@9b!5uJpgo;(Y?DvRP~0P+8a3x?vXC znuY_UJ`1dWYqRheuaTiwK+-mSII* zMoII)BNA&ZE{3GY?ep;5QV~*)M7Rv$f&+O3lA!?!u#SRx0=@yjh~$b|Y6f90|16Iq z-&s;WA8+}XyO-u?{(qu$J2qnH{?;7)O@t=8k5^+M0Znkwg&h#CQvoD616QlsU>L=BV7QdW#QnYdL*9CeHmoy_6#56lnuTc7%zbMAYW zN8N5{ur_>bZt{zyn3$Mgb0iCrI;#$cO_p#MiI|{_kA#|=d*siNyLxRaH|uvNbFaSZ zOLiWNSn?Hn2l%TKjCk=dPZJ=J9pMKB81T34{D1(CIbHls0SvEn^WkD^KG-c5+SR<8 z!e36|i&A)VDLiv25^^Zq( z2d22|LSF{IuwF{o?5($Fd+#`&hO#_2gGcR8!ol$0oLlEuA2>JIdOV-_wj>mIQo=jk z%=&9b$e(U~H{n}nlmB;izv}~cZs4M~B;4t@Ie!T~wyg{}E%$;6{sx!yS2(XEh=jfN z_F!gkh5akcr=29MRv7vNL|~n~o2S8AY01~b8l~57=Mh*f%kni?C0{1}ywaDO zctosJ_)Mpc6|$FVRZ0X7P;I%wtu2Jh6c&+%O1Z5-gQfDF0!>sXow$H>xx7cBOo^6x zG*UV+@{mPfiCkW&iN#9nETmeA!bmaA70XM78Z43zs3WR$M>^>uIlV{|g#&$)Xrw?P z%plBHSeHp{d9sJva+QdWBe78WqZ%xb*-=f*SGp-cBl8qm|4A=1SGH4ajuIWcBxcJe zH1eDbEz-m+rT0B0JyWh*q(P3{MLJvQqKhmdvK01oQpXJWlscx%#9~ckDt+xR>1lF9 zu?ACR6X_{Rx1XfJ$qI8$QAdXSg=*FgKjk26w^TB5-SS+GPC z!<8Plne;HZm$W9&kWNziqZjGvLlthSru`CSN~s1B8Kn%%mncKBg)%5VDb++kHFn&g ze?GsAE7QOyQ_D2rRr>9Cn)k?h(h0Jav|DLg9BG%lL)t0L<(hCPo#Uei+ZDc?L>)G{ zg=*G;+ISKcdA?i&v;3ZNh_qE`BEG-4=Qf=qPF7TCFjzK_ja4IGyhq>Oq_Dk@ItIxf zslzCpOEnO33Z+3VTdE1JTJCl8h@Ba@B{p>kT~90+jR~C#Mmw7e9i0as#h#3AIl)XU zj4|P_!#H3o;j1wB zOZDJe{5DkR##a7ctI<#&6YlWsaQ7W`g-4@ED0BfyLQx(}ZU@Zj;s5Cup;=!4fy;KtpI4L5=dPn+0z z0Z!a*X8#c2z@<)hS%4kK_*iBv*swZ@eJ;R?>B+2HfCc|OmURd)!PATohj(xBV*&=F-pl_eAQnp=@cRNxIPVEB1Q>*S`}kV%eich+kc1g4 z*#!YZk*#AT0Et*y&1wZi@Y5Q0Q$QFyHnVR9gm8B~OEy9(o@roz7BCKXG_s2V#$w4% zRwf_?+nbpWU<_V(ovj8Kjj6l)k2DHn_py~Gn1&k~od*%>3`;CCOh zA|qsD#(8EiLKbFTWOUvc*!LG!4lup{*ZY!yOw_KhzYc=A*mtA$`m(z($(2~#l1Qg@oVm{0Wi!imEFEm0j{;G<5 zj4%@4Si>s;M&N_Be1#E)V@VA^D_|JuqorUeGJwg&wHUQ;)z^*rZ#I!fn{7KRF6d rVFUhj43YuXvideo-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