Java电商系统留言功能实现指南

Java电商留言功能实现方案整体架构设计用户端(前端)→Controller→S... 显示全部

Java 电商留言功能实现方案

整体架构设计

用户端 (前端) → Controller → Service → DAO → MySQL
                                      ↓
                                   Redis (缓存)
                                      ↓
                                   Elasticsearch (搜索)

数据库设计

留言表 (comment)

CREATE TABLE `comment` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键ID',
    `user_id`     BIGINT       NOT NULL COMMENT '用户ID',
    `product_id`  BIGINT       NOT NULL COMMENT '商品ID',
    `parent_id`   BIGINT       DEFAULT 0 COMMENT '父评论ID,0表示一级评论',
    `content`     TEXT         NOT NULL COMMENT '评论内容',
    `images`      VARCHAR(2000) DEFAULT NULL COMMENT '图片URL,逗号分隔',
    `rating`      TINYINT      DEFAULT 5 COMMENT '评分1-5',
    `status`      TINYINT      DEFAULT 1 COMMENT '状态: 0-待审核, 1-正常, 2-隐藏, 3-删除',
    `is_anonymous` TINYINT     DEFAULT 0 COMMENT '是否匿名: 0-否, 1-是',
    `like_count`  INT          DEFAULT 0 COMMENT '点赞数',
    `reply_count` INT          DEFAULT 0 COMMENT '回复数',
    `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updated_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_product_id` (`product_id`),
    KEY `idx_user_id` (`user_id`),
    KEY `idx_parent_id` (`parent_id`),
    KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品评论表';

评论回复表 (comment_reply)

CREATE TABLE `comment_reply` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT,
    `comment_id`  BIGINT       NOT NULL COMMENT '所属评论ID',
    `user_id`     BIGINT       NOT NULL COMMENT '回复用户ID',
    `to_user_id`  BIGINT       DEFAULT NULL COMMENT '被回复的用户ID',
    `content`     TEXT         NOT NULL,
    `status`      TINYINT      DEFAULT 1,
    `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    KEY `idx_comment_id` (`comment_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论回复表';

评论点赞表 (comment_like)

CREATE TABLE `comment_like` (
    `id`          BIGINT       NOT NULL AUTO_INCREMENT,
    `comment_id`  BIGINT       NOT NULL,
    `user_id`     BIGINT       NOT NULL,
    `created_at`  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_comment_user` (`comment_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

后端实现(Spring Boot)

实体类

@Data
@TableName("comment")
public class Comment {
    private Long id;
    private Long userId;
    private Long productId;
    private Long parentId;
    private String content;
    private String images;
    private Integer rating;
    private Integer status;
    private Boolean isAnonymous;
    private Integer likeCount;
    private Integer replyCount;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    // 非数据库字段
    private String userName;      // 用户名(关联查询)
    private String userAvatar;    // 用户头像
    private Boolean liked;        // 当前用户是否已点赞
    private List<Reply> replies;  // 子回复列表
}
@Data
public class Reply {
    private Long id;
    private Long commentId;
    private Long userId;
    private Long toUserId;
    private String content;
    private String userName;
    private String userAvatar;
    private LocalDateTime createdAt;
}

DTO

@Data
public class CommentRequest {
    @NotNull(message = "商品ID不能为空")
    private Long productId;
    private Long parentId;       // 回复评论ID,为空表示一级评论
    @NotBlank(message = "评论内容不能为空")
    @Size(max = 1000, message = "评论内容不能超过1000字")
    private String content;
    private List<String> images; // 图片URL列表
    @Min(value = 1, max = 5)
    private Integer rating;
    private Boolean isAnonymous; // 是否匿名
}
@Data
public class CommentResponse {
    private Long id;
    private Long userId;
    private String userName;
    private String userAvatar;
    private Long productId;
    private Long parentId;
    private String content;
    private List<String> images;
    private Integer rating;
    private Integer likeCount;
    private Integer replyCount;
    private Boolean liked;
    private Boolean isAnonymous;
    private LocalDateTime createdAt;
    private List<ReplyResponse> replies;
    @Data
    public static class ReplyResponse {
        private Long id;
        private Long userId;
        private String userName;
        private String userAvatar;
        private Long toUserId;
        private String toUserName;
        private String content;
        private LocalDateTime createdAt;
    }
}

Mapper

@Mapper
public interface CommentMapper extends BaseMapper<Comment> {
    /**
     * 查询商品评论列表(含用户信息、回复)
     */
    @Select("<script>" +
            "SELECT c.*, u.user_name, u.avatar " +
            "FROM comment c " +
            "LEFT JOIN user u ON c.user_id = u.id " +
            "WHERE c.product_id = #{productId} " +
            "AND c.status = 1 " +
            "AND c.parent_id = 0 " +
            "<if test='rating != null'> AND c.rating = #{rating} </if>" +
            "ORDER BY c.created_at DESC " +
            "LIMIT #{offset}, #{limit}" +
            "</script>")
    List<Comment> selectCommentsByProduct(@Param("productId") Long productId,
                                          @Param("rating") Integer rating,
                                          @Param("offset") int offset,
                                          @Param("limit") int limit);
    /**
     * 查询评论的回复列表
     */
    @Select("SELECT r.*, u.user_name, u.avatar " +
            "FROM comment_reply r " +
            "LEFT JOIN user u ON r.user_id = u.id " +
            "WHERE r.comment_id = #{commentId} " +
            "AND r.status = 1 " +
            "ORDER BY r.created_at ASC")
    List<Reply> selectRepliesByCommentId(@Param("commentId") Long commentId);
    /**
     * 统计商品评论总数
     */
    @Select("SELECT COUNT(*) FROM comment WHERE product_id = #{productId} AND status = 1")
    int countByProductId(@Param("productId") Long productId);
    /**
     * 统计各评分数量
     */
    @Select("SELECT rating, COUNT(*) as count FROM comment " +
            "WHERE product_id = #{productId} AND status = 1 " +
            "GROUP BY rating")
    List<Map<String, Object>> countByRating(@Param("productId") Long productId);
}

Service

@Service
@Slf4j
public class CommentService {
    @Autowired
    private CommentMapper commentMapper;
    @Autowired
    private UserFeignClient userFeignClient;  // 用户服务Feign客户端
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private CommentLikeService commentLikeService;
    /**
     * 发布评论
     */
    @Transactional(rollbackFor = Exception.class)
    public CommentResponse createComment(Long userId, CommentRequest request) {
        // 1. 参数校验
        validateComment(request);
        // 2. 敏感词过滤
        String content = sensitiveWordFilter(request.getContent());
        // 3. 如果是回复,检查父评论是否存在
        if (request.getParentId() != null) {
            Comment parent = commentMapper.selectById(request.getParentId());
            if (parent == null || parent.getStatus() != 1) {
                throw new BusinessException("父评论不存在或已删除");
            }
            if (!parent.getProductId().equals(request.getProductId())) {
                throw new BusinessException("评论的商品不匹配");
            }
        }
        // 4. 创建评论
        Comment comment = new Comment();
        comment.setUserId(userId);
        comment.setProductId(request.getProductId());
        comment.setParentId(request.getParentId() != null ? request.getParentId() : 0L);
        comment.setContent(content);
        comment.setImages(request.getImages() != null ? String.join(",", request.getImages()) : null);
        comment.setRating(request.getRating() != null ? request.getRating() : 5);
        comment.setStatus(1); // 默认正常
        comment.setAnonymous(request.getIsAnonymous() != null ? request.getIsAnonymous() : false);
        comment.setLikeCount(0);
        comment.setReplyCount(0);
        commentMapper.insert(comment);
        // 5. 如果是回复,更新父评论的回复数
        if (request.getParentId() != null) {
            Comment parent = commentMapper.selectById(request.getParentId());
            parent.setReplyCount(parent.getReplyCount() + 1);
            commentMapper.updateById(parent);
        }
        // 6. 清除商品评论缓存
        clearCommentCache(request.getProductId());
        // 7. 构建响应
        return buildCommentResponse(comment, userId);
    }
    /**
     * 查询商品评论列表
     */
    public PageResult<CommentResponse> getComments(Long productId, Integer rating,
                                                    Integer page, Integer size) {
        // 尝试从缓存获取
        String cacheKey = "comment:product:" + productId + ":r:" + rating + ":p:" + page + ":s:" + size;
        Object cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return (PageResult<CommentResponse>) cached;
        }
        int offset = (page - 1) * size;
        List<Comment> comments = commentMapper.selectCommentsByProduct(productId, rating, offset, size);
        int total = commentMapper.countByProductId(productId);
        List<CommentResponse> responseList = new ArrayList<>();
        for (Comment comment : comments) {
            CommentResponse response = buildCommentResponse(comment, null);
            // 查询回复
            List<Reply> replies = commentMapper.selectRepliesByCommentId(comment.getId());
            List<CommentResponse.ReplyResponse> replyResponses = replies.stream()
                    .map(this::buildReplyResponse)
                    .collect(Collectors.toList());
            response.setReplies(replyResponses);
            responseList.add(response);
        }
        PageResult<CommentResponse> pageResult = new PageResult<>();
        pageResult.setList(responseList);
        pageResult.setTotal(total);
        pageResult.setPage(page);
        pageResult.setSize(size);
        // 缓存5分钟
        redisTemplate.opsForValue().set(cacheKey, pageResult, 5, TimeUnit.MINUTES);
        return pageResult;
    }
    /**
     * 点赞评论
     */
    public void likeComment(Long userId, Long commentId) {
        commentLikeService.toggleLike(userId, commentId);
        // 更新点赞数
        Comment comment = commentMapper.selectById(commentId);
        if (comment != null) {
            comment.setLikeCount(comment.getLikeCount() + 1);
            commentMapper.updateById(comment);
            clearCommentCache(comment.getProductId());
        }
    }
    /**
     * 删除评论
     */
    @Transactional
    public void deleteComment(Long userId, Long commentId) {
        Comment comment = commentMapper.selectById(commentId);
        if (comment == null) {
            throw new BusinessException("评论不存在");
        }
        // 权限校验:只能删除自己的评论
        if (!comment.getUserId().equals(userId)) {
            throw new BusinessException("无权删除该评论");
        }
        comment.setStatus(3); // 删除状态
        commentMapper.updateById(comment);
        // 如果是回复,减少父评论的回复数
        if (comment.getParentId() != null && comment.getParentId() != 0) {
            Comment parent = commentMapper.selectById(comment.getParentId());
            if (parent != null) {
                parent.setReplyCount(Math.max(0, parent.getReplyCount() - 1));
                commentMapper.updateById(parent);
            }
        }
        clearCommentCache(comment.getProductId());
    }
    // ==================== 私有方法 ====================
    private void validateComment(CommentRequest request) {
        if (request.getContent() == null || request.getContent().trim().isEmpty()) {
            throw new BusinessException("评论内容不能为空");
        }
        if (request.getContent().length() > 1000) {
            throw new BusinessException("评论内容不能超过1000字");
        }
        if (request.getImages() != null && request.getImages().size() > 9) {
            throw new BusinessException("最多上传9张图片");
        }
    }
    private String sensitiveWordFilter(String content) {
        // 集成敏感词过滤,如使用 DFA 算法或接入第三方服务
        return content; // 简化处理
    }
    private CommentResponse buildCommentResponse(Comment comment, Long currentUserId) {
        CommentResponse response = new CommentResponse();
        response.setId(comment.getId());
        response.setUserId(comment.getUserId());
        response.setProductId(comment.getProductId());
        response.setParentId(comment.getParentId());
        response.setContent(comment.getContent());
        response.setImages(comment.getImages() != null ? 
                Arrays.asList(comment.getImages().split(",")) : new ArrayList<>());
        response.setRating(comment.getRating());
        response.setLikeCount(comment.getLikeCount());
        response.setReplyCount(comment.getReplyCount());
        response.setAnonymous(comment.getIsAnonymous());
        response.setCreatedAt(comment.getCreatedAt());
        // 获取用户信息
        if (comment.getIsAnonymous()) {
            response.setUserName("匿名用户");
            response.setUserAvatar(null);
        } else {
            UserInfo userInfo = userFeignClient.getUserById(comment.getUserId());
            if (userInfo != null) {
                response.setUserName(userInfo.getUserName());
                response.setUserAvatar(userInfo.getAvatar());
            }
        }
        // 判断是否已点赞
        if (currentUserId != null) {
            response.setLiked(commentLikeService.isLiked(currentUserId, comment.getId()));
        }
        return response;
    }
    private CommentResponse.ReplyResponse buildReplyResponse(Reply reply) {
        CommentResponse.ReplyResponse response = new CommentResponse.ReplyResponse();
        response.setId(reply.getId());
        response.setUserId(reply.getUserId());
        response.setToUserId(reply.getToUserId());
        response.setContent(reply.getContent());
        response.setCreatedAt(reply.getCreatedAt());
        // 获取用户名
        if (reply.getUserId() != null) {
            UserInfo userInfo = userFeignClient.getUserById(reply.getUserId());
            if (userInfo != null) {
                response.setUserName(userInfo.getUserName());
                response.setUserAvatar(userInfo.getAvatar());
            }
        }
        if (reply.getToUserId() != null) {
            UserInfo toUser = userFeignClient.getUserById(reply.getToUserId());
            if (toUser != null) {
                response.setToUserName(toUser.getUserName());
            }
        }
        return response;
    }
    private void clearCommentCache(Long productId) {
        redisTemplate.delete("comment:product:" + productId + ":*");
    }
}

Controller

@RestController
@RequestMapping("/api/comments")
@Validated
public class CommentController {
    @Autowired
    private CommentService commentService;
    @Autowired
    private SecurityContext securityContext;  // 获取当前登录用户ID
    /**
     * 发布评论
     */
    @PostMapping
    public Result<CommentResponse> createComment(@Valid @RequestBody CommentRequest request) {
        Long userId = securityContext.getCurrentUserId();
        CommentResponse response = commentService.createComment(userId, request);
        return Result.success(response);
    }
    /**
     * 查询商品评论列表
     */
    @GetMapping("/product/{productId}")
    public Result<PageResult<CommentResponse>> getComments(
            @PathVariable Long productId,
            @RequestParam(required = false) Integer rating,
            @RequestParam(defaultValue = "1") Integer page,
            @RequestParam(defaultValue = "20") Integer size) {
        Long userId = securityContext.getCurrentUserId();
        PageResult<CommentResponse> result = commentService.getComments(productId, rating, page, size);
        return Result.success(result);
    }
    /**
     * 点赞评论
     */
    @PostMapping("/{commentId}/like")
    public Result<Void> likeComment(@PathVariable Long commentId) {
        Long userId = securityContext.getCurrentUserId();
        commentService.likeComment(userId, commentId);
        return Result.success();
    }
    /**
     * 删除评论
     */
    @DeleteMapping("/{commentId}")
    public Result<Void> deleteComment(@PathVariable Long commentId) {
        Long userId = securityContext.getCurrentUserId();
        commentService.deleteComment(userId, commentId);
        return Result.success();
    }
}

评论点赞服务

@Service
public class CommentLikeService {
    @Autowired
    private CommentLikeMapper commentLikeMapper;
    /**
     * 切换点赞状态
     */
    @Transactional
    public void toggleLike(Long userId, Long commentId) {
        CommentLike like = commentLikeMapper.selectByUserAndComment(userId, commentId);
        if (like != null) {
            // 取消点赞
            commentLikeMapper.deleteById(like.getId());
        } else {
            // 添加点赞
            CommentLike newLike = new CommentLike();
            newLike.setUserId(userId);
            newLike.setCommentId(commentId);
            commentLikeMapper.insert(newLike);
        }
    }
    /**
     * 判断用户是否已点赞
     */
    public boolean isLiked(Long userId, Long commentId) {
        CommentLike like = commentLikeMapper.selectByUserAndComment(userId, commentId);
        return like != null;
    }
}

前端对接示例(Vue3)

<template>
  <div class="comment-section">
    <!-- 评论列表 -->
    <div v-for="comment in comments" :key="comment.id" class="comment-item">
      <div class="comment-header">
        <img :src="comment.userAvatar || defaultAvatar" class="avatar" />
        <span class="username">{{ comment.userName }}</span>
        <span class="rating" v-if="comment.rating">
          {{ '★'.repeat(comment.rating) }}
        </span>
        <span class="time">{{ formatDate(comment.createdAt) }}</span>
      </div>
      <p class="content">{{ comment.content }}</p>
      <!-- 图片 -->
      <div class="images" v-if="comment.images?.length">
        <img v-for="(img, idx) in comment.images" :key="idx" :src="img" />
      </div>
      <!-- 操作栏 -->
      <div class="actions">
        <button @click="likeComment(comment.id)">
          👍 {{ comment.liked ? '已赞' : '点赞' }} ({{ comment.likeCount }})
        </button>
        <button @click="showReplyInput(comment)">回复</button>
      </div>
      <!-- 回复列表 -->
      <div class="replies" v-if="comment.replies?.length">
        <div v-for="reply in comment.replies" :key="reply.id" class="reply-item">
          <span class="username">{{ reply.userName }}</span>
          <span v-if="reply.toUserName"> 回复 {{ reply.toUserName }}:</span>
          <span>{{ reply.content }}</span>
        </div>
      </div>
    </div>
    <!-- 发表评论 -->
    <div class="write-comment">
      <textarea v-model="newContent" placeholder="写下你的评论..." maxlength="1000"></textarea>
      <div class="rating-select">
        <span v-for="star in 5" :key="star" 
              @click="rating = star"
              :class="{ active: star <= rating }">★</span>
      </div>
      <button @click="submitComment">发表评论</button>
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getComments, createComment, likeComment } from '@/api/comment'
const props = defineProps({
  productId: { type: Number, required: true }
})
const comments = ref([])
const newContent = ref('')
const rating = ref(5)
onMounted(() => {
  loadComments()
})
const loadComments = async () => {
  const res = await getComments(props.productId, 1, 20)
  comments.value = res.data.list
}
const submitComment = async () => {
  if (!newContent.value.trim()) return
  await createComment({
    productId: props.productId,
    content: newContent.value,
    rating: rating.value
  })
  newContent.value = ''
  rating.value = 5
  loadComments()
}
const likeComment = async (commentId) => {
  await likeCommentApi(commentId)
  loadComments()
}
</script>

关键优化点

性能优化

优化项方案
缓存Redis 缓存评论列表,5分钟过期
分页游标分页或 limit+offset,避免深分页
懒加载回复列表按需加载(点击展开)
CDN图片走 CDN 加速
异步敏感词过滤、消息通知使用 MQ 异步处理

安全考虑

✅ 敏感词过滤(DFA算法 / 接入第三方API)
✅ XSS 过滤(内容转义)
✅ 权限校验(只能删自己的评论)
✅ 防刷机制(同一用户短时间限制评论次数)
✅ 图片上传校验(格式、大小、病毒扫描)
✅ 登录态校验(JWT Token)

扩展功能

⭐ 评论审核机制(敏感词自动拦截 + 人工审核队列)
⭐ 评论置顶/加精
⭐ 评论举报功能
⭐ 评论搜索(Elasticsearch)
⭐ 评论通知(WebSocket / 消息队列)
⭐ 匿名评论
⭐ 视频评论
⭐ 追评功能

项目结构参考

ecommerce-comment-service/
├── src/main/java/com/ecommerce/comment/
│   ├── controller/
│   │   └── CommentController.java
│   ├── service/
│   │   ├── CommentService.java
│   │   └── CommentLikeService.java
│   ├── mapper/
│   │   ├── CommentMapper.java
│   │   └── CommentLikeMapper.java
│   ├── entity/
│   │   ├── Comment.java
│   │   ├── CommentReply.java
│   │   └── CommentLike.java
│   ├── dto/
│   │   ├── CommentRequest.java
│   │   └── CommentResponse.java
│   ├── config/
│   │   ├── RedisConfig.java
│   │   └── SensitiveWordConfig.java
│   └── util/
│       ├── SensitiveWordFilter.java
│       └── SecurityContext.java
└── pom.xml

这个方案涵盖了CRUD、点赞、回复、缓存、安全、分页等核心功能,可根据实际业务需求逐步扩展。

回答数 0浏览数 4