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、点赞、回复、缓存、安全、分页等核心功能,可根据实际业务需求逐步扩展。
取消评论你是访客,请填写下个人信息吧