feat(comment): Implement comment module foundation

This commit is contained in:
2025-04-10 02:02:02 +08:00
parent 27dd1409f6
commit 449f5418b2
34 changed files with 2624 additions and 13 deletions
@@ -1,11 +1,18 @@
package com.nuaa.video.comment;
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;
/**
* 评论服务启动类
*/
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan("com.nuaa.video.comment.mapper")
public class CommentApplication {
public static void main(String[] args) {
SpringApplication.run(CommentApplication.class, args);
@@ -0,0 +1,41 @@
package com.nuaa.video.comment.config;
import com.nuaa.video.comment.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(
"/comment/public/**",
"/actuator/**",
"/error"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@@ -0,0 +1,141 @@
package com.nuaa.video.comment.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.comment.dto.CommentAddDTO;
import com.nuaa.video.comment.dto.CommentQueryDTO;
import com.nuaa.video.comment.service.CommentService;
import com.nuaa.video.comment.vo.CommentStatVO;
import com.nuaa.video.comment.vo.CommentVO;
import com.nuaa.video.common.model.vo.Result;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 评论控制器
*/
@Slf4j
@RestController
@RequestMapping("/comment")
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
/**
* 添加评论
*/
@PostMapping
public Result<Long> addComment(@RequestBody @Valid CommentAddDTO commentDTO, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentService.addComment(commentDTO, userId);
}
/**
* 获取评论列表(公开接口)
*/
@GetMapping("/public/list")
public Result<Page<CommentVO>> getCommentList(CommentQueryDTO queryDTO, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
return commentService.getCommentList(queryDTO, userId);
}
/**
* 获取评论详情(公开接口)
*/
@GetMapping("/public/detail/{id}")
public Result<CommentVO> getCommentDetail(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
return commentService.getCommentDetail(id, userId);
}
/**
* 删除评论
*/
@DeleteMapping("/{id}")
public Result<Void> deleteComment(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentService.deleteComment(id, userId);
}
/**
* 点赞评论
*/
@PostMapping("/like/{id}")
public Result<Void> likeComment(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentService.likeComment(id, userId);
}
/**
* 取消点赞评论
*/
@DeleteMapping("/like/{id}")
public Result<Void> unlikeComment(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentService.unlikeComment(id, userId);
}
/**
* 置顶评论
*/
@PostMapping("/top/{id}")
public Result<Void> topComment(
@PathVariable Long id,
@RequestParam Long videoId,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentService.topComment(id, userId, videoId);
}
/**
* 取消置顶评论
*/
@DeleteMapping("/top/{id}")
public Result<Void> untopComment(
@PathVariable Long id,
@RequestParam Long videoId,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentService.untopComment(id, userId, videoId);
}
/**
* 获取评论统计信息(公开接口)
*/
@GetMapping("/public/stat/{videoId}")
public Result<CommentStatVO> getCommentStat(@PathVariable Long videoId) {
return commentService.getCommentStat(videoId);
}
/**
* 从请求中获取用户ID
*/
private Long getUserIdFromRequest(HttpServletRequest request) {
Object userIdObj = request.getAttribute("userId");
if (userIdObj instanceof Long) {
return (Long) userIdObj;
}
return null;
}
}
@@ -0,0 +1,110 @@
package com.nuaa.video.comment.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.comment.dto.ReplyAddDTO;
import com.nuaa.video.comment.service.CommentReplyService;
import com.nuaa.video.comment.vo.ReplyVO;
import com.nuaa.video.common.model.vo.Result;
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("/comment/reply")
@RequiredArgsConstructor
public class ReplyController {
private final CommentReplyService commentReplyService;
/**
* 添加回复
*/
@PostMapping
public Result<Long> addReply(@RequestBody @Valid ReplyAddDTO replyDTO, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentReplyService.addReply(replyDTO, userId);
}
/**
* 获取评论的回复列表(公开接口)
*/
@GetMapping("/public/list/{commentId}")
public Result<Page<ReplyVO>> getReplyList(
@PathVariable Long commentId,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
return commentReplyService.getReplyList(commentId, page, size, userId);
}
/**
* 获取评论的热门回复(公开接口)
*/
@GetMapping("/public/hot/{commentId}")
public Result<List<ReplyVO>> getHotReplies(
@PathVariable Long commentId,
@RequestParam(value = "limit", defaultValue = "3") Integer limit,
HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
return commentReplyService.getHotReplies(commentId, limit, userId);
}
/**
* 删除回复
*/
@DeleteMapping("/{id}")
public Result<Void> deleteReply(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentReplyService.deleteReply(id, userId);
}
/**
* 点赞回复
*/
@PostMapping("/like/{id}")
public Result<Void> likeReply(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentReplyService.likeReply(id, userId);
}
/**
* 取消点赞回复
*/
@DeleteMapping("/like/{id}")
public Result<Void> unlikeReply(@PathVariable Long id, HttpServletRequest request) {
Long userId = getUserIdFromRequest(request);
if (userId == null) {
return Result.error("未登录");
}
return commentReplyService.unlikeReply(id, userId);
}
/**
* 从请求中获取用户ID
*/
private Long getUserIdFromRequest(HttpServletRequest request) {
Object userIdObj = request.getAttribute("userId");
if (userIdObj instanceof Long) {
return (Long) userIdObj;
}
return null;
}
}
@@ -0,0 +1,29 @@
package com.nuaa.video.comment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 评论添加DTO
*/
@Data
public class CommentAddDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
@NotNull(message = "视频ID不能为空")
private Long videoId;
/**
* 评论内容
*/
@NotBlank(message = "评论内容不能为空")
@Size(max = 500, message = "评论内容最多500字")
private String content;
}
@@ -0,0 +1,43 @@
package com.nuaa.video.comment.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 评论查询DTO
*/
@Data
public class CommentQueryDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
private Long videoId;
/**
* 用户ID
*/
private Long userId;
/**
* 排序字段
*/
private String orderBy;
/**
* 排序方向
*/
private String orderDirection;
/**
* 页码
*/
private Integer pageNum = 1;
/**
* 每页条数
*/
private Integer pageSize = 10;
}
@@ -0,0 +1,40 @@
package com.nuaa.video.comment.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
/**
* 回复添加DTO
*/
@Data
public class ReplyAddDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 评论ID
*/
@NotNull(message = "评论ID不能为空")
private Long commentId;
/**
* 视频ID
*/
@NotNull(message = "视频ID不能为空")
private Long videoId;
/**
* 回复目标用户ID
*/
private Long replyToId;
/**
* 回复内容
*/
@NotBlank(message = "回复内容不能为空")
@Size(max = 500, message = "回复内容最多500字")
private String content;
}
@@ -0,0 +1,98 @@
package com.nuaa.video.comment.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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("comment")
public class Comment 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 userId;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 回复数
*/
private Integer replyCount;
/**
* 置顶状态(0:普通,1:置顶)
*/
private Integer topStatus;
/**
* 状态(0:删除,1:正常)
*/
private Integer status;
/**
* 创建人
*/
private String createdBy;
/**
* 更新人
*/
private String updatedBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 用户昵称(非数据库字段)
*/
@TableField(exist = false)
private String userNickname;
/**
* 用户头像(非数据库字段)
*/
@TableField(exist = false)
private String userAvatar;
/**
* 是否已点赞(非数据库字段)
*/
@TableField(exist = false)
private Boolean isLiked;
}
@@ -0,0 +1,44 @@
package com.nuaa.video.comment.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("comment_like")
public class CommentLike implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 主键ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 评论ID
*/
private Long commentId;
/**
* 用户ID
*/
private Long userId;
/**
* 类型(1:评论,2:回复)
*/
private Integer type;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,110 @@
package com.nuaa.video.comment.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
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("comment_reply")
public class CommentReply implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 回复ID
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 评论ID
*/
private Long commentId;
/**
* 视频ID
*/
private Long videoId;
/**
* 用户ID
*/
private Long userId;
/**
* 回复目标用户ID
*/
private Long replyToId;
/**
* 回复内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 状态(0:删除,1:正常)
*/
private Integer status;
/**
* 创建人
*/
private String createdBy;
/**
* 更新人
*/
private String updatedBy;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 用户昵称(非数据库字段)
*/
@TableField(exist = false)
private String userNickname;
/**
* 用户头像(非数据库字段)
*/
@TableField(exist = false)
private String userAvatar;
/**
* 回复目标用户昵称(非数据库字段)
*/
@TableField(exist = false)
private String replyToNickname;
/**
* 回复目标用户头像(非数据库字段)
*/
@TableField(exist = false)
private String replyToAvatar;
/**
* 是否已点赞(非数据库字段)
*/
@TableField(exist = false)
private Boolean isLiked;
}
@@ -0,0 +1,37 @@
package com.nuaa.video.comment.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.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* 内容服务Feign客户端
*/
@FeignClient(name = "video-content", fallbackFactory = ContentFeignClientFallbackFactory.class)
public interface ContentFeignClient {
/**
* 获取视频信息
*
* @param id 视频ID
* @return 视频信息
*/
@GetMapping("/content/video/{id}")
Result<Object> getVideoInfo(@PathVariable("id") Long id);
/**
* 增加视频评论数
*
* @param videoId 视频ID
* @param count 增加数量
* @param token 认证令牌
* @return 结果
*/
@PostMapping("/content/video/count/comment/{videoId}/{count}")
Result<Void> increaseCommentCount(@PathVariable("videoId") Long videoId,
@PathVariable("count") Integer count,
@RequestHeader("Authorization") String token);
}
@@ -0,0 +1,32 @@
package com.nuaa.video.comment.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 ContentFeignClientFallbackFactory implements FallbackFactory<ContentFeignClient> {
@Override
public ContentFeignClient create(Throwable cause) {
log.error("内容服务调用失败:", cause);
return new ContentFeignClient() {
@Override
public Result<Object> getVideoInfo(Long id) {
log.error("获取视频信息失败,视频ID:{}", id);
return Result.error("获取视频信息失败,请稍后重试");
}
@Override
public Result<Void> increaseCommentCount(Long videoId, Integer count, String token) {
log.error("增加视频评论数量失败,视频ID:{}, 数量:{}", videoId, count);
return Result.error("更新视频评论数量失败");
}
};
}
}
@@ -0,0 +1,39 @@
package com.nuaa.video.comment.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.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* 用户服务Feign客户端
*/
@FeignClient(name = "video-user", fallbackFactory = UserFeignClientFallbackFactory.class)
public interface UserFeignClient {
/**
* 获取用户信息
*
* @param userId 用户ID
* @return 用户信息
*/
@GetMapping("/user/profile/public/{userId}")
Result<Object> getUserProfile(@PathVariable("userId") Long userId);
/**
* 发送用户通知
*
* @param userId 用户ID
* @param type 通知类型
* @param content 通知内容
* @param relatedId 相关ID
* @param token 认证令牌
* @return 结果
*/
@PostMapping("/user/notification/send/{userId}/{type}")
Result<Void> sendNotification(@PathVariable("userId") Long userId,
@PathVariable("type") Integer type,
@RequestHeader("Authorization") String token);
}
@@ -0,0 +1,32 @@
package com.nuaa.video.comment.feign;
import com.nuaa.video.common.model.vo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* 用户服务Feign客户端降级工厂
*/
@Slf4j
@Component
public class UserFeignClientFallbackFactory implements FallbackFactory<UserFeignClient> {
@Override
public UserFeignClient create(Throwable cause) {
log.error("用户服务调用失败:", cause);
return new UserFeignClient() {
@Override
public Result<Object> getUserProfile(Long userId) {
log.error("获取用户信息失败,用户ID:{}", userId);
return Result.error("获取用户信息失败,请稍后重试");
}
@Override
public Result<Void> sendNotification(Long userId, Integer type, String token) {
log.error("发送用户通知失败,用户ID:{}, 类型:{}", userId, type);
return Result.error("发送用户通知失败");
}
};
}
}
@@ -0,0 +1,102 @@
package com.nuaa.video.comment.filter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* JWT认证过滤器
*/
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret:video-platform-secret-key-2024-nuaa}")
private String secret;
private static final String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 从请求头中获取JWT令牌
String token = getJwtFromRequest(request);
// 如果令牌存在,则本地验证
if (StringUtils.hasText(token)) {
try {
// 验证令牌
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
// 从令牌中提取用户ID和用户名
Long userId = Long.parseLong(claims.getSubject());
String username = claims.get("username", String.class);
// 设置用户权限
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
// 创建认证对象
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
// 设置额外信息
authentication.setDetails(userId);
// 设置认证信息到安全上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
// 在请求属性中存储用户ID
request.setAttribute("userId", userId);
request.setAttribute("username", username);
log.debug("认证成功: 用户ID={}, 用户名={}", userId, username);
} catch (Exception e) {
log.error("JWT验证失败: {}", e.getMessage());
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
/**
* 从请求头中提取JWT令牌
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(TOKEN_PREFIX)) {
return bearerToken.substring(TOKEN_PREFIX.length());
}
return null;
}
/**
* 获取签名密钥
*/
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
@@ -0,0 +1,31 @@
package com.nuaa.video.comment.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.nuaa.video.comment.entity.CommentLike;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 评论点赞Mapper
*/
@Mapper
public interface CommentLikeMapper extends BaseMapper<CommentLike> {
/**
* 检查用户是否已点赞
*
* @param commentId 评论/回复ID
* @param userId 用户ID
* @param type 类型(1:评论,2:回复)
* @return 点赞记录数
*/
long checkUserLiked(@Param("commentId") Long commentId, @Param("userId") Long userId, @Param("type") Integer type);
/**
* 统计视频评论点赞数
*
* @param videoId 视频ID
* @return 点赞数
*/
int countVideoCommentLikes(@Param("videoId") Long videoId);
}
@@ -0,0 +1,50 @@
package com.nuaa.video.comment.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.comment.dto.CommentQueryDTO;
import com.nuaa.video.comment.entity.Comment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 评论Mapper
*/
@Mapper
public interface CommentMapper extends BaseMapper<Comment> {
/**
* 分页查询评论列表
*
* @param page 分页参数
* @param query 查询条件
* @return 评论列表
*/
Page<Comment> selectCommentList(Page<Comment> page, @Param("query") CommentQueryDTO query);
/**
* 增加点赞数
*
* @param commentId 评论ID
* @param count 增加数量
* @return 影响行数
*/
int increaseLikeCount(@Param("commentId") Long commentId, @Param("count") int count);
/**
* 增加回复数
*
* @param commentId 评论ID
* @param count 增加数量
* @return 影响行数
*/
int increaseReplyCount(@Param("commentId") Long commentId, @Param("count") int count);
/**
* 统计视频评论数
*
* @param videoId 视频ID
* @return 评论数
*/
int countVideoComments(@Param("videoId") Long videoId);
}
@@ -0,0 +1,51 @@
package com.nuaa.video.comment.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.nuaa.video.comment.entity.CommentReply;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 评论回复Mapper
*/
@Mapper
public interface CommentReplyMapper extends BaseMapper<CommentReply> {
/**
* 查询评论的回复列表
*
* @param page 分页参数
* @param commentId 评论ID
* @return 回复列表
*/
Page<CommentReply> selectCommentReplyList(Page<CommentReply> page, @Param("commentId") Long commentId);
/**
* 获取评论的热门回复(前N条)
*
* @param commentId 评论ID
* @param limit 数量限制
* @return 热门回复列表
*/
List<CommentReply> selectHotReplies(@Param("commentId") Long commentId, @Param("limit") int limit);
/**
* 增加点赞数
*
* @param replyId 回复ID
* @param count 增加数量
* @return 影响行数
*/
int increaseLikeCount(@Param("replyId") Long replyId, @Param("count") int count);
/**
* 统计视频回复数
*
* @param videoId 视频ID
* @return 回复数
*/
int countVideoReplies(@Param("videoId") Long videoId);
}
@@ -0,0 +1,73 @@
package com.nuaa.video.comment.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.nuaa.video.comment.dto.ReplyAddDTO;
import com.nuaa.video.comment.entity.CommentReply;
import com.nuaa.video.comment.vo.ReplyVO;
import com.nuaa.video.common.model.vo.Result;
import java.util.List;
/**
* 评论回复服务接口
*/
public interface CommentReplyService extends IService<CommentReply> {
/**
* 添加回复
*
* @param replyDTO 回复信息
* @param userId 用户ID
* @return 回复ID
*/
Result<Long> addReply(ReplyAddDTO replyDTO, Long userId);
/**
* 分页查询评论的回复列表
*
* @param commentId 评论ID
* @param page 页码
* @param size 每页数量
* @param userId 当前用户ID
* @return 回复列表
*/
Result<Page<ReplyVO>> getReplyList(Long commentId, Integer page, Integer size, Long userId);
/**
* 获取评论的热门回复
*
* @param commentId 评论ID
* @param limit 数量限制
* @param userId 当前用户ID
* @return 热门回复列表
*/
Result<List<ReplyVO>> getHotReplies(Long commentId, Integer limit, Long userId);
/**
* 删除回复
*
* @param id 回复ID
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> deleteReply(Long id, Long userId);
/**
* 点赞回复
*
* @param id 回复ID
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> likeReply(Long id, Long userId);
/**
* 取消点赞回复
*
* @param id 回复ID
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> unlikeReply(Long id, Long userId);
}
@@ -0,0 +1,98 @@
package com.nuaa.video.comment.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.nuaa.video.comment.dto.CommentAddDTO;
import com.nuaa.video.comment.dto.CommentQueryDTO;
import com.nuaa.video.comment.entity.Comment;
import com.nuaa.video.comment.vo.CommentStatVO;
import com.nuaa.video.comment.vo.CommentVO;
import com.nuaa.video.common.model.vo.Result;
/**
* 评论服务接口
*/
public interface CommentService extends IService<Comment> {
/**
* 添加评论
*
* @param commentDTO 评论信息
* @param userId 用户ID
* @return 评论ID
*/
Result<Long> addComment(CommentAddDTO commentDTO, Long userId);
/**
* 分页查询评论列表
*
* @param queryDTO 查询条件
* @param userId 当前用户ID
* @return 评论列表
*/
Result<Page<CommentVO>> getCommentList(CommentQueryDTO queryDTO, Long userId);
/**
* 获取评论详情
*
* @param id 评论ID
* @param userId 当前用户ID
* @return 评论详情
*/
Result<CommentVO> getCommentDetail(Long id, Long userId);
/**
* 删除评论
*
* @param id 评论ID
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> deleteComment(Long id, Long userId);
/**
* 点赞评论
*
* @param id 评论ID
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> likeComment(Long id, Long userId);
/**
* 取消点赞评论
*
* @param id 评论ID
* @param userId 用户ID
* @return 操作结果
*/
Result<Void> unlikeComment(Long id, Long userId);
/**
* 置顶评论
*
* @param id 评论ID
* @param userId 用户ID
* @param videoId 视频ID
* @return 操作结果
*/
Result<Void> topComment(Long id, Long userId, Long videoId);
/**
* 取消置顶评论
*
* @param id 评论ID
* @param userId 用户ID
* @param videoId 视频ID
* @return 操作结果
*/
Result<Void> untopComment(Long id, Long userId, Long videoId);
/**
* 获取评论统计信息
*
* @param videoId 视频ID
* @return 统计信息
*/
Result<CommentStatVO> getCommentStat(Long videoId);
}
@@ -0,0 +1,281 @@
package com.nuaa.video.comment.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nuaa.video.comment.dto.ReplyAddDTO;
import com.nuaa.video.comment.entity.Comment;
import com.nuaa.video.comment.entity.CommentLike;
import com.nuaa.video.comment.entity.CommentReply;
import com.nuaa.video.comment.feign.UserFeignClient;
import com.nuaa.video.comment.mapper.CommentLikeMapper;
import com.nuaa.video.comment.mapper.CommentMapper;
import com.nuaa.video.comment.mapper.CommentReplyMapper;
import com.nuaa.video.comment.service.CommentReplyService;
import com.nuaa.video.comment.utils.SensitiveWordFilter;
import com.nuaa.video.comment.vo.ReplyVO;
import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 评论回复服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommentReplyServiceImpl extends ServiceImpl<CommentReplyMapper, CommentReply> implements CommentReplyService {
private final CommentReplyMapper commentReplyMapper;
private final CommentMapper commentMapper;
private final CommentLikeMapper commentLikeMapper;
private final UserFeignClient userFeignClient;
private final SensitiveWordFilter sensitiveWordFilter;
@Value("${comment.sensitive-filter.enabled:true}")
private boolean sensitiveFilterEnabled;
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Long> addReply(ReplyAddDTO replyDTO, Long userId) {
// 校验评论是否存在
Comment comment = commentMapper.selectById(replyDTO.getCommentId());
if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在");
}
// 校验回复内容
String content = replyDTO.getContent();
if (sensitiveFilterEnabled && sensitiveWordFilter.containsSensitiveWord(content)) {
// 包含敏感词,进行替换
content = sensitiveWordFilter.replaceSensitiveWords(content, "*");
}
// 创建回复对象
CommentReply reply = new CommentReply();
reply.setCommentId(replyDTO.getCommentId());
reply.setVideoId(replyDTO.getVideoId());
reply.setUserId(userId);
reply.setReplyToId(replyDTO.getReplyToId());
reply.setContent(content);
reply.setLikeCount(0);
reply.setStatus(1); // 默认正常状态
reply.setCreatedBy(userId.toString());
reply.setUpdatedBy(userId.toString());
reply.setCreateTime(LocalDateTime.now());
reply.setUpdateTime(LocalDateTime.now());
// 保存回复
save(reply);
// 更新评论回复数
commentMapper.increaseReplyCount(replyDTO.getCommentId(), 1);
// 发送回复通知给评论作者
try {
// 如果回复者不是评论作者,发送通知
if (!userId.equals(comment.getUserId())) {
userFeignClient.sendNotification(comment.getUserId(), 2, "Bearer ");
}
// 如果存在回复目标用户,且回复者不是回复目标用户,发送通知
if (replyDTO.getReplyToId() != null && !userId.equals(replyDTO.getReplyToId())) {
userFeignClient.sendNotification(replyDTO.getReplyToId(), 2, "Bearer ");
}
} catch (Exception e) {
log.error("发送回复通知失败", e);
}
return Result.success(reply.getId());
}
@Override
public Result<Page<ReplyVO>> getReplyList(Long commentId, Integer page, Integer size, Long userId) {
// 参数校验
if (commentId == null) {
return Result.error("评论ID不能为空");
}
if (page == null || page < 1) {
page = 1;
}
if (size == null || size < 1) {
size = 10;
}
// 分页查询回复
Page<CommentReply> replyPage = new Page<>(page, size);
replyPage = commentReplyMapper.selectCommentReplyList(replyPage, commentId);
// 构建返回结果
Page<ReplyVO> resultPage = new Page<>();
BeanUtils.copyProperties(replyPage, resultPage, "records");
List<ReplyVO> replyVOList = new ArrayList<>();
for (CommentReply reply : replyPage.getRecords()) {
ReplyVO vo = convertToReplyVO(reply, userId);
replyVOList.add(vo);
}
resultPage.setRecords(replyVOList);
return Result.success(resultPage);
}
@Override
public Result<List<ReplyVO>> getHotReplies(Long commentId, Integer limit, Long userId) {
// 参数校验
if (commentId == null) {
return Result.error("评论ID不能为空");
}
if (limit == null || limit < 1) {
limit = 3;
}
// 查询热门回复
List<CommentReply> hotReplies = commentReplyMapper.selectHotReplies(commentId, limit);
// 构建返回结果
List<ReplyVO> replyVOList = new ArrayList<>();
for (CommentReply reply : hotReplies) {
ReplyVO vo = convertToReplyVO(reply, userId);
replyVOList.add(vo);
}
return Result.success(replyVOList);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> deleteReply(Long id, Long userId) {
// 查询回复
CommentReply reply = getById(id);
if (reply == null) {
return Result.error("回复不存在");
}
// 校验操作权限
if (!reply.getUserId().equals(userId)) {
return Result.error("无权删除该回复");
}
// 逻辑删除回复(设置状态为0)
reply.setStatus(0);
reply.setUpdatedBy(userId.toString());
reply.setUpdateTime(LocalDateTime.now());
updateById(reply);
// 更新评论回复数
commentMapper.increaseReplyCount(reply.getCommentId(), -1);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> likeReply(Long id, Long userId) {
// 查询回复
CommentReply reply = getById(id);
if (reply == null || reply.getStatus() == 0) {
return Result.error("回复不存在");
}
// 检查是否已点赞
long count = commentLikeMapper.checkUserLiked(id, userId, 2);
if (count > 0) {
return Result.error("已经点赞过该回复");
}
// 添加点赞记录
CommentLike like = new CommentLike();
like.setCommentId(id);
like.setUserId(userId);
like.setType(2); // 2-回复
like.setCreateTime(LocalDateTime.now());
commentLikeMapper.insert(like);
// 更新回复点赞数
commentReplyMapper.increaseLikeCount(id, 1);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> unlikeReply(Long id, Long userId) {
// 查询回复
CommentReply reply = getById(id);
if (reply == null || reply.getStatus() == 0) {
return Result.error("回复不存在");
}
// 检查是否已点赞
long count = commentLikeMapper.checkUserLiked(id, userId, 2);
if (count == 0) {
return Result.error("未点赞该回复");
}
// 删除点赞记录
commentLikeMapper.delete(new LambdaQueryWrapper<CommentLike>()
.eq(CommentLike::getCommentId, id)
.eq(CommentLike::getUserId, userId)
.eq(CommentLike::getType, 2));
// 更新回复点赞数
commentReplyMapper.increaseLikeCount(id, -1);
return Result.success();
}
/**
* 将回复实体转换为VO
*/
private ReplyVO convertToReplyVO(CommentReply reply, Long userId) {
ReplyVO vo = new ReplyVO();
BeanUtils.copyProperties(reply, vo);
// 获取用户信息
try {
Result<Object> userResult = userFeignClient.getUserProfile(reply.getUserId());
if (userResult.getCode() == 200 && userResult.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) userResult.getData();
vo.setUserNickname((String) userData.get("nickname"));
vo.setUserAvatar((String) userData.get("avatar"));
}
// 如果有回复目标用户,获取目标用户信息
if (reply.getReplyToId() != null) {
Result<Object> replyToResult = userFeignClient.getUserProfile(reply.getReplyToId());
if (replyToResult.getCode() == 200 && replyToResult.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> replyToData = (Map<String, Object>) replyToResult.getData();
vo.setReplyToNickname((String) replyToData.get("nickname"));
vo.setReplyToAvatar((String) replyToData.get("avatar"));
}
}
} catch (Exception e) {
log.error("获取用户信息失败, userId={}, replyToId={}", reply.getUserId(), reply.getReplyToId(), e);
}
// 获取点赞状态
if (userId != null) {
long likeCount = commentLikeMapper.checkUserLiked(reply.getId(), userId, 2);
vo.setIsLiked(likeCount > 0);
} else {
vo.setIsLiked(false);
}
return vo;
}
}
@@ -0,0 +1,396 @@
package com.nuaa.video.comment.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.nuaa.video.comment.dto.CommentAddDTO;
import com.nuaa.video.comment.dto.CommentQueryDTO;
import com.nuaa.video.comment.entity.Comment;
import com.nuaa.video.comment.entity.CommentLike;
import com.nuaa.video.comment.feign.ContentFeignClient;
import com.nuaa.video.comment.feign.UserFeignClient;
import com.nuaa.video.comment.mapper.CommentLikeMapper;
import com.nuaa.video.comment.mapper.CommentMapper;
import com.nuaa.video.comment.mapper.CommentReplyMapper;
import com.nuaa.video.comment.service.CommentReplyService;
import com.nuaa.video.comment.service.CommentService;
import com.nuaa.video.comment.utils.SensitiveWordFilter;
import com.nuaa.video.comment.vo.CommentStatVO;
import com.nuaa.video.comment.vo.CommentVO;
import com.nuaa.video.comment.vo.ReplyVO;
import com.nuaa.video.common.model.vo.Result;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 评论服务实现类
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CommentServiceImpl extends ServiceImpl<CommentMapper, Comment> implements CommentService {
private final CommentMapper commentMapper;
private final CommentReplyMapper commentReplyMapper;
private final CommentLikeMapper commentLikeMapper;
private final CommentReplyService commentReplyService;
private final ContentFeignClient contentFeignClient;
private final UserFeignClient userFeignClient;
private final SensitiveWordFilter sensitiveWordFilter;
@Value("${comment.sensitive-filter.enabled:true}")
private boolean sensitiveFilterEnabled;
@Value("${comment.review.enabled:false}")
private boolean reviewEnabled;
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Long> addComment(CommentAddDTO commentDTO, Long userId) {
// 校验视频是否存在
Result<Object> videoResult = contentFeignClient.getVideoInfo(commentDTO.getVideoId());
if (videoResult.getCode() != 200 || videoResult.getData() == null) {
return Result.error("视频不存在");
}
// 校验评论内容
String content = commentDTO.getContent();
if (sensitiveFilterEnabled && sensitiveWordFilter.containsSensitiveWord(content)) {
// 包含敏感词,进行替换
content = sensitiveWordFilter.replaceSensitiveWords(content, "*");
}
// 创建评论对象
Comment comment = new Comment();
comment.setVideoId(commentDTO.getVideoId());
comment.setUserId(userId);
comment.setContent(content);
comment.setLikeCount(0);
comment.setReplyCount(0);
comment.setTopStatus(0);
comment.setStatus(1); // 默认正常状态
comment.setCreatedBy(userId.toString());
comment.setUpdatedBy(userId.toString());
comment.setCreateTime(LocalDateTime.now());
comment.setUpdateTime(LocalDateTime.now());
// 保存评论
save(comment);
// 更新视频评论数
contentFeignClient.increaseCommentCount(commentDTO.getVideoId(), 1, "Bearer ");
// 发送评论通知给视频作者
try {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoResult.getData();
Long videoAuthorId = Long.valueOf(videoData.get("userId").toString());
// 如果评论者不是视频作者,发送通知
if (!userId.equals(videoAuthorId)) {
userFeignClient.sendNotification(videoAuthorId, 2, "Bearer ");
}
} catch (Exception e) {
log.error("发送评论通知失败", e);
}
return Result.success(comment.getId());
}
@Override
public Result<Page<CommentVO>> getCommentList(CommentQueryDTO queryDTO, Long userId) {
// 参数校验
if (queryDTO.getPageNum() == null || queryDTO.getPageNum() < 1) {
queryDTO.setPageNum(1);
}
if (queryDTO.getPageSize() == null || queryDTO.getPageSize() < 1) {
queryDTO.setPageSize(10);
}
// 分页查询评论
Page<Comment> page = new Page<>(queryDTO.getPageNum(), queryDTO.getPageSize());
page = commentMapper.selectCommentList(page, queryDTO);
// 构建返回结果
Page<CommentVO> resultPage = new Page<>();
BeanUtils.copyProperties(page, resultPage, "records");
List<CommentVO> commentVOList = new ArrayList<>();
for (Comment comment : page.getRecords()) {
CommentVO vo = convertToCommentVO(comment, userId);
// 获取热门回复
Result<List<ReplyVO>> hotRepliesResult = commentReplyService.getHotReplies(comment.getId(), 3, userId);
if (hotRepliesResult.getCode() == 200 && hotRepliesResult.getData() != null) {
vo.setHotReplies(hotRepliesResult.getData());
} else {
vo.setHotReplies(new ArrayList<>());
}
commentVOList.add(vo);
}
resultPage.setRecords(commentVOList);
return Result.success(resultPage);
}
@Override
public Result<CommentVO> getCommentDetail(Long id, Long userId) {
// 查询评论
Comment comment = getById(id);
if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在");
}
// 转换为VO
CommentVO commentVO = convertToCommentVO(comment, userId);
// 获取热门回复
Result<List<ReplyVO>> hotRepliesResult = commentReplyService.getHotReplies(comment.getId(), 3, userId);
if (hotRepliesResult.getCode() == 200 && hotRepliesResult.getData() != null) {
commentVO.setHotReplies(hotRepliesResult.getData());
} else {
commentVO.setHotReplies(new ArrayList<>());
}
return Result.success(commentVO);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> deleteComment(Long id, Long userId) {
// 查询评论
Comment comment = getById(id);
if (comment == null) {
return Result.error("评论不存在");
}
// 校验操作权限
if (!comment.getUserId().equals(userId)) {
return Result.error("无权删除该评论");
}
// 逻辑删除评论(设置状态为0)
comment.setStatus(0);
comment.setUpdatedBy(userId.toString());
comment.setUpdateTime(LocalDateTime.now());
updateById(comment);
// 更新视频评论数
contentFeignClient.increaseCommentCount(comment.getVideoId(), -1, "Bearer ");
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> likeComment(Long id, Long userId) {
// 查询评论
Comment comment = getById(id);
if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在");
}
// 检查是否已点赞
long count = commentLikeMapper.checkUserLiked(id, userId, 1);
if (count > 0) {
return Result.error("已经点赞过该评论");
}
// 添加点赞记录
CommentLike like = new CommentLike();
like.setCommentId(id);
like.setUserId(userId);
like.setType(1); // 1-评论
like.setCreateTime(LocalDateTime.now());
commentLikeMapper.insert(like);
// 更新评论点赞数
commentMapper.increaseLikeCount(id, 1);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> unlikeComment(Long id, Long userId) {
// 查询评论
Comment comment = getById(id);
if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在");
}
// 检查是否已点赞
long count = commentLikeMapper.checkUserLiked(id, userId, 1);
if (count == 0) {
return Result.error("未点赞该评论");
}
// 删除点赞记录
commentLikeMapper.delete(new LambdaQueryWrapper<CommentLike>()
.eq(CommentLike::getCommentId, id)
.eq(CommentLike::getUserId, userId)
.eq(CommentLike::getType, 1));
// 更新评论点赞数
commentMapper.increaseLikeCount(id, -1);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> topComment(Long id, Long userId, Long videoId) {
// 查询评论
Comment comment = getById(id);
if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在");
}
// 验证视频所有权(只有视频作者才能置顶评论)
Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId);
if (videoResult.getCode() != 200 || videoResult.getData() == null) {
return Result.error("视频不存在");
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoResult.getData();
Long videoAuthorId = Long.valueOf(videoData.get("userId").toString());
if (!userId.equals(videoAuthorId)) {
return Result.error("只有视频作者才能置顶评论");
}
} catch (Exception e) {
log.error("解析视频信息失败", e);
return Result.error("获取视频信息失败");
}
// 取消之前的置顶评论
LambdaQueryWrapper<Comment> wrapper = new LambdaQueryWrapper<Comment>()
.eq(Comment::getVideoId, videoId)
.eq(Comment::getTopStatus, 1);
List<Comment> topComments = list(wrapper);
for (Comment topComment : topComments) {
topComment.setTopStatus(0);
topComment.setUpdatedBy(userId.toString());
topComment.setUpdateTime(LocalDateTime.now());
updateById(topComment);
}
// 设置新的置顶评论
comment.setTopStatus(1);
comment.setUpdatedBy(userId.toString());
comment.setUpdateTime(LocalDateTime.now());
updateById(comment);
return Result.success();
}
@Override
@Transactional(rollbackFor = Exception.class)
public Result<Void> untopComment(Long id, Long userId, Long videoId) {
// 查询评论
Comment comment = getById(id);
if (comment == null || comment.getStatus() == 0) {
return Result.error("评论不存在");
}
// 验证视频所有权(只有视频作者才能取消置顶评论)
Result<Object> videoResult = contentFeignClient.getVideoInfo(videoId);
if (videoResult.getCode() != 200 || videoResult.getData() == null) {
return Result.error("视频不存在");
}
try {
@SuppressWarnings("unchecked")
Map<String, Object> videoData = (Map<String, Object>) videoResult.getData();
Long videoAuthorId = Long.valueOf(videoData.get("userId").toString());
if (!userId.equals(videoAuthorId)) {
return Result.error("只有视频作者才能取消置顶评论");
}
} catch (Exception e) {
log.error("解析视频信息失败", e);
return Result.error("获取视频信息失败");
}
// 取消置顶评论
if (comment.getTopStatus() == 1) {
comment.setTopStatus(0);
comment.setUpdatedBy(userId.toString());
comment.setUpdateTime(LocalDateTime.now());
updateById(comment);
}
return Result.success();
}
@Override
public Result<CommentStatVO> getCommentStat(Long videoId) {
if (videoId == null) {
return Result.error("视频ID不能为空");
}
// 统计评论数量
int commentCount = commentMapper.countVideoComments(videoId);
// 统计回复数量
int replyCount = commentReplyMapper.countVideoReplies(videoId);
// 统计点赞数量
int likeCount = commentLikeMapper.countVideoCommentLikes(videoId);
// 构建统计结果
CommentStatVO statVO = new CommentStatVO();
statVO.setVideoId(videoId);
statVO.setCommentCount(commentCount);
statVO.setReplyCount(replyCount);
statVO.setLikeCount(likeCount);
return Result.success(statVO);
}
/**
* 将评论实体转换为VO
*/
private CommentVO convertToCommentVO(Comment comment, Long userId) {
CommentVO vo = new CommentVO();
BeanUtils.copyProperties(comment, vo);
// 获取用户信息
try {
Result<Object> userResult = userFeignClient.getUserProfile(comment.getUserId());
if (userResult.getCode() == 200 && userResult.getData() != null) {
@SuppressWarnings("unchecked")
Map<String, Object> userData = (Map<String, Object>) userResult.getData();
vo.setUserNickname((String) userData.get("nickname"));
vo.setUserAvatar((String) userData.get("avatar"));
}
} catch (Exception e) {
log.error("获取用户信息失败, userId={}", comment.getUserId(), e);
}
// 获取点赞状态
if (userId != null) {
long likeCount = commentLikeMapper.checkUserLiked(comment.getId(), userId, 1);
vo.setIsLiked(likeCount > 0);
} else {
vo.setIsLiked(false);
}
return vo;
}
}
@@ -0,0 +1,215 @@
package com.nuaa.video.comment.utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.*;
/**
* 敏感词过滤工具类
*/
@Slf4j
@Component
public class SensitiveWordFilter {
/**
* 敏感词匹配规则
*/
private static final int MinMatchType = 1; // 最小匹配规则
private static final int MaxMatchType = 2; // 最大匹配规则
/**
* 敏感词集合
*/
private static HashMap<String, Object> sensitiveWordMap;
/**
* 初始化敏感词库
*/
@PostConstruct
public void init() {
try {
// 从资源文件夹读取敏感词列表
Set<String> sensitiveWordSet = new HashSet<>();
// 这里简化处理,实际项目中可以从数据库或配置文件读取
InputStream is = this.getClass().getResourceAsStream("/sensitive-words.txt");
if (is != null) {
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line;
while ((line = reader.readLine()) != null) {
if (line.trim().length() > 0) {
sensitiveWordSet.add(line.trim());
}
}
reader.close();
} else {
// 添加一些默认敏感词
sensitiveWordSet.add("敏感词1");
sensitiveWordSet.add("敏感词2");
sensitiveWordSet.add("敏感词3");
}
// 初始化敏感词库
sensitiveWordMap = new HashMap<>(sensitiveWordSet.size());
for (String word : sensitiveWordSet) {
if (word.trim().length() > 0) {
addSensitiveWord(word);
}
}
log.info("敏感词库初始化完成,共有敏感词:{} 个", sensitiveWordSet.size());
} catch (IOException e) {
log.error("初始化敏感词库失败", e);
sensitiveWordMap = new HashMap<>();
}
}
/**
* 添加敏感词到HashMap中
*/
private void addSensitiveWord(String word) {
// 获取敏感词
char[] chars = word.toCharArray();
// 初始化敏感词库
HashMap<String, Object> currentMap = sensitiveWordMap;
for (int i = 0; i < chars.length; i++) {
// 转换成char型
char charKey = chars[i];
// 获取
HashMap<String, Object> wordMap = (HashMap<String, Object>) currentMap.get(String.valueOf(charKey));
// 如果存在该key,直接赋值,用于下一个循环获取
if (wordMap != null) {
currentMap = wordMap;
} else {
// 不存在则,则构建一个map,同时将isEnd设置为0,因为他不是最后一个
HashMap<String, Object> newWordMap = new HashMap<>();
// 不是最后一个
newWordMap.put("isEnd", "0");
// 添加到集合
currentMap.put(String.valueOf(charKey), newWordMap);
// 重新获取
currentMap = newWordMap;
}
// 如果是最后一个,则设置为结尾字
if (i == chars.length - 1) {
currentMap.put("isEnd", "1");
}
}
}
/**
* 检查文本中是否包含敏感词
*/
public boolean containsSensitiveWord(String text) {
if (text == null || text.trim().length() == 0) {
return false;
}
return !getSensitiveWords(text, MinMatchType).isEmpty();
}
/**
* 获取文本中的敏感词
*/
public Set<String> getSensitiveWords(String text, int matchType) {
Set<String> sensitiveWords = new HashSet<>();
if (sensitiveWordMap == null || sensitiveWordMap.isEmpty() || text == null) {
return sensitiveWords;
}
char[] chars = text.toCharArray();
for (int i = 0; i < chars.length; i++) {
int length = checkSensitiveWord(chars, i, matchType);
if (length > 0) {
sensitiveWords.add(text.substring(i, i + length));
// 如果是最小匹配,直接跳过匹配的字符
if (matchType == MinMatchType) {
i += length - 1;
}
}
}
return sensitiveWords;
}
/**
* 替换敏感词
*/
public String replaceSensitiveWords(String text, String replacement) {
if (text == null || text.trim().length() == 0) {
return text;
}
Set<String> sensitiveWords = getSensitiveWords(text, MaxMatchType);
for (String word : sensitiveWords) {
String replaceString = getReplacement(replacement, word.length());
text = text.replace(word, replaceString);
}
return text;
}
/**
* 生成替换字符串
*/
private String getReplacement(String replacement, int length) {
if (replacement == null || replacement.isEmpty()) {
replacement = "*";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(replacement);
}
return sb.toString();
}
/**
* 检查敏感词
*/
private int checkSensitiveWord(char[] chars, int beginIndex, int matchType) {
boolean flag = false; // 敏感词结束标识位
int matchFlag = 0; // 匹配标识数默认为0
HashMap<String, Object> nowMap = sensitiveWordMap;
for (int i = beginIndex; i < chars.length; i++) {
String key = String.valueOf(chars[i]);
// 获取指定key
nowMap = (HashMap<String, Object>) nowMap.get(key);
// 如果存在,则判断是否为最后一个
if (nowMap != null) {
// 找到相应key,匹配标识+1
matchFlag++;
// 如果为最后一个匹配规则,结束循环,返回匹配标识数
if ("1".equals(nowMap.get("isEnd"))) {
// 结束标志位为true
flag = true;
// 最小规则,直接返回,最大规则还需继续查找
if (MinMatchType == matchType) {
break;
}
}
} else {
// 不存在,直接返回
break;
}
}
// 长度必须大于0,flag必须为true
if (matchFlag < 1 || !flag) {
matchFlag = 0;
}
return matchFlag;
}
}
@@ -0,0 +1,33 @@
package com.nuaa.video.comment.vo;
import lombok.Data;
import java.io.Serializable;
/**
* 评论统计VO
*/
@Data
public class CommentStatVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 视频ID
*/
private Long videoId;
/**
* 评论总数
*/
private Integer commentCount;
/**
* 回复总数
*/
private Integer replyCount;
/**
* 点赞总数
*/
private Integer likeCount;
}
@@ -0,0 +1,75 @@
package com.nuaa.video.comment.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 评论VO
*/
@Data
public class CommentVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 评论ID
*/
private Long id;
/**
* 视频ID
*/
private Long videoId;
/**
* 用户ID
*/
private Long userId;
/**
* 用户昵称
*/
private String userNickname;
/**
* 用户头像
*/
private String userAvatar;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 回复数
*/
private Integer replyCount;
/**
* 置顶状态(0:普通,1:置顶)
*/
private Integer topStatus;
/**
* 是否已点赞
*/
private Boolean isLiked;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 热门回复列表(前3条)
*/
private List<ReplyVO> hotReplies;
}
@@ -0,0 +1,79 @@
package com.nuaa.video.comment.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 回复VO
*/
@Data
public class ReplyVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 回复ID
*/
private Long id;
/**
* 评论ID
*/
private Long commentId;
/**
* 视频ID
*/
private Long videoId;
/**
* 用户ID
*/
private Long userId;
/**
* 用户昵称
*/
private String userNickname;
/**
* 用户头像
*/
private String userAvatar;
/**
* 回复目标用户ID
*/
private Long replyToId;
/**
* 回复目标用户昵称
*/
private String replyToNickname;
/**
* 回复目标用户头像
*/
private String replyToAvatar;
/**
* 回复内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 是否已点赞
*/
private Boolean isLiked;
/**
* 创建时间
*/
private LocalDateTime createTime;
}
@@ -0,0 +1,25 @@
spring:
datasource:
dynamic:
primary: master
strict: false
datasource:
master:
url: jdbc:mysql://5.180.253.115:3306/video_comment?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: 3
host: 5.180.253.115
port: 6379
password: redis_EsJ8FX
timeout: 10000
# 开发环境日志级别
logging:
level:
com.nuaa.video: debug
org.springframework.security: debug
@@ -4,30 +4,109 @@ server:
spring:
application:
name: video-comment
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: 8723
eager: true
datasource:
url: jdbc:mysql://5.180.253.115:3306/video_comment?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_comment?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: 3
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.comment.entity
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
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: status
logic-delete-value: 0
logic-not-delete-value: 1
# 评论服务配置
comment:
# 敏感词过滤
sensitive-filter:
enabled: true
# 评论审核
review:
enabled: false # 默认不开启自动审核,由管理员手动审核
# JWT配置与auth模块保持一致
jwt:
secret: video-platform-secret-key-2024-nuaa
expiration: 86400
token-header: Authorization
token-prefix: "Bearer "
auth:
ignore-urls:
- /comment/public/**
- /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
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.comment.mapper.CommentLikeMapper">
<!-- 检查用户是否已点赞 -->
<select id="checkUserLiked" resultType="long">
SELECT COUNT(1)
FROM comment_like
WHERE comment_id = #{commentId}
AND user_id = #{userId}
AND type = #{type}
</select>
<!-- 统计视频评论点赞数 -->
<select id="countVideoCommentLikes" resultType="int">
SELECT COUNT(1)
FROM comment_like cl
LEFT JOIN comment c ON cl.comment_id = c.id AND cl.type = 1
LEFT JOIN comment_reply cr ON cl.comment_id = cr.id AND cl.type = 2
WHERE (c.video_id = #{videoId} OR cr.video_id = #{videoId})
</select>
</mapper>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.comment.mapper.CommentMapper">
<!-- 分页查询评论列表 -->
<select id="selectCommentList" resultType="com.nuaa.video.comment.entity.Comment">
SELECT *
FROM comment
<where>
status = 1
<if test="query.videoId != null">
AND video_id = #{query.videoId}
</if>
<if test="query.userId != null">
AND user_id = #{query.userId}
</if>
</where>
<choose>
<when test="query.orderBy != null and query.orderBy != ''">
ORDER BY top_status DESC, ${query.orderBy}
<if test="query.orderDirection != null and query.orderDirection != ''">
${query.orderDirection}
</if>
</when>
<otherwise>
ORDER BY top_status DESC, create_time DESC
</otherwise>
</choose>
</select>
<!-- 增加点赞数 -->
<update id="increaseLikeCount">
UPDATE comment
SET like_count = like_count + #{count}
WHERE id = #{commentId}
</update>
<!-- 增加回复数 -->
<update id="increaseReplyCount">
UPDATE comment
SET reply_count = reply_count + #{count}
WHERE id = #{commentId}
</update>
<!-- 统计视频评论数 -->
<select id="countVideoComments" resultType="int">
SELECT COUNT(1)
FROM comment
WHERE video_id = #{videoId}
AND status = 1
</select>
</mapper>
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.nuaa.video.comment.mapper.CommentReplyMapper">
<!-- 查询评论的回复列表 -->
<select id="selectCommentReplyList" resultType="com.nuaa.video.comment.entity.CommentReply">
SELECT *
FROM comment_reply
WHERE comment_id = #{commentId}
AND status = 1
ORDER BY create_time ASC
</select>
<!-- 获取评论的热门回复 -->
<select id="selectHotReplies" resultType="com.nuaa.video.comment.entity.CommentReply">
SELECT *
FROM comment_reply
WHERE comment_id = #{commentId}
AND status = 1
ORDER BY like_count DESC, create_time ASC
LIMIT #{limit}
</select>
<!-- 增加点赞数 -->
<update id="increaseLikeCount">
UPDATE comment_reply
SET like_count = like_count + #{count}
WHERE id = #{replyId}
</update>
<!-- 统计视频回复数 -->
<select id="countVideoReplies" resultType="int">
SELECT COUNT(1)
FROM comment_reply
WHERE video_id = #{videoId}
AND status = 1
</select>
</mapper>