Building a Tech Blog from Scratch: Implementing a Comment System and Full‑Text Search

This article walks through the design and implementation of a nested comment system with likes, @‑mentions, and sensitive‑word filtering, plus full‑text search using Elasticsearch, covering database schema changes, backend services, Vue components, and recommendation logic for a technical blog platform.

Coder Trainee
Coder Trainee
Coder Trainee
Building a Tech Blog from Scratch: Implementing a Comment System and Full‑Text Search

Comment System Design

Features: posting comments (login required), nested replies with @user, single‑click like (one per user), delete (author or admin), sorting by time or like count, backend sensitive‑word filtering.

Database Schema

-- comment table (additional columns)
ALTER TABLE `comment`
  ADD COLUMN `floor` int DEFAULT 0 COMMENT '楼层号',
  ADD COLUMN `like_count` int DEFAULT 0,
  ADD COLUMN `ip_address` varchar(45) COMMENT '评论者IP',
  ADD COLUMN `user_agent` varchar(200);

-- comment_like table
CREATE TABLE `comment_like` (
  `id` bigint PRIMARY KEY AUTO_INCREMENT,
  `comment_id` bigint NOT NULL,
  `user_id` bigint NOT NULL,
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY `uk_comment_user` (`comment_id`,`user_id`)
);

-- sensitive_word table
CREATE TABLE `sensitive_word` (
  `id` int PRIMARY KEY AUTO_INCREMENT,
  `word` varchar(50) NOT NULL,
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY `uk_word` (`word`)
);

Backend Implementation

Comment VO

@Data
public class CommentVO {
    private Long id;
    private Long articleId;
    private Long userId;
    private String userNickname;
    private String userAvatar;
    private Long parentId;
    private Long replyToUserId;
    private String replyToNickname;
    private String content;
    private Integer likeCount;
    private Integer floor;
    private Boolean liked;
    private Date createTime;
    private List<CommentVO> children;
}

Comment Service

@Service
@Transactional
public class CommentServiceImpl implements CommentService {
    @Autowired private CommentMapper commentMapper;
    @Autowired private CommentLikeMapper commentLikeMapper;
    @Autowired private SensitiveWordService sensitiveWordService;
    @Autowired private ArticleMapper articleMapper;
    @Autowired private UserMapper userMapper;

    // Add comment
    public Long addComment(CommentAddRequest request, Long userId, String ip, String userAgent) {
        Article article = articleMapper.selectById(request.getArticleId());
        if (article == null || article.getStatus() != 1) {
            throw new BusinessException("文章不存在或未发布");
        }
        String filteredContent = sensitiveWordService.filter(request.getContent());
        if (StringUtils.isBlank(filteredContent)) {
            throw new BusinessException("评论内容包含敏感词");
        }
        String replyToNickname = null;
        if (request.getParentId() != null && request.getParentId() > 0) {
            Comment parent = commentMapper.selectById(request.getParentId());
            if (parent == null) {
                throw new BusinessException("回复的评论不存在");
            }
            replyToNickname = parent.getUserNickname();
        }
        Integer maxFloor = commentMapper.selectMaxFloor(request.getArticleId());
        int floor = (maxFloor == null ? 0 : maxFloor) + 1;
        Comment comment = new Comment();
        comment.setArticleId(request.getArticleId());
        comment.setUserId(userId);
        comment.setParentId(request.getParentId());
        comment.setReplyToUserId(request.getReplyToUserId());
        comment.setContent(filteredContent);
        comment.setFloor(floor);
        comment.setIpAddress(ip);
        comment.setUserAgent(userAgent);
        commentMapper.insert(comment);
        articleMapper.incrementCommentCount(request.getArticleId());
        return comment.getId();
    }

    // Get comment tree
    public List<CommentVO> getCommentTree(Long articleId, Long currentUserId, String orderBy) {
        List<Comment> topComments = commentMapper.selectByArticleAndParent(articleId, 0L, orderBy);
        List<Comment> allReplies = commentMapper.selectRepliesByArticle(articleId);
        Map<Long, List<Comment>> replyMap = allReplies.stream()
                .collect(Collectors.groupingBy(Comment::getParentId));
        List<CommentVO> result = new ArrayList<>();
        for (Comment comment : topComments) {
            CommentVO vo = convertToVO(comment, currentUserId);
            vo.setChildren(buildChildrenTree(vo.getId(), replyMap, currentUserId));
            result.add(vo);
        }
        return result;
    }

    private List<CommentVO> buildChildrenTree(Long parentId, Map<Long, List<Comment>> replyMap, Long currentUserId) {
        List<Comment> replies = replyMap.get(parentId);
        if (replies == null || replies.isEmpty()) {
            return Collections.emptyList();
        }
        List<CommentVO> result = new ArrayList<>();
        for (Comment reply : replies) {
            CommentVO vo = convertToVO(reply, currentUserId);
            vo.setChildren(buildChildrenTree(vo.getId(), replyMap, currentUserId));
            result.add(vo);
        }
        return result;
    }

    // Like / unlike comment
    public boolean likeComment(Long commentId, Long userId) {
        CommentLike existing = commentLikeMapper.selectByCommentAndUser(commentId, userId);
        if (existing != null) {
            commentLikeMapper.deleteById(existing.getId());
            commentMapper.decrementLikeCount(commentId);
            return false;
        } else {
            CommentLike like = new CommentLike();
            like.setCommentId(commentId);
            like.setUserId(userId);
            commentLikeMapper.insert(like);
            commentMapper.incrementLikeCount(commentId);
            return true;
        }
    }

    // Delete comment (soft delete)
    public void deleteComment(Long commentId, Long userId, boolean isAdmin) {
        Comment comment = commentMapper.selectById(commentId);
        if (comment == null) {
            throw new BusinessException("评论不存在");
        }
        if (!comment.getUserId().equals(userId) && !isAdmin) {
            Article article = articleMapper.selectById(comment.getArticleId());
            if (!article.getUserId().equals(userId)) {
                throw new BusinessException("无权限删除");
            }
        }
        commentMapper.softDelete(commentId);
        int count = commentMapper.countByArticle(comment.getArticleId());
        articleMapper.updateCommentCount(comment.getArticleId(), count);
    }
}

Sensitive‑Word Filtering (DFA)

@Component
public class SensitiveWordService {
    private Map<String, Object> sensitiveWordMap = new HashMap<>();

    @PostConstruct
    public void init() {
        List<String> words = sensitiveWordMapper.selectAllWords();
        sensitiveWordMap = addSensitiveWordToHashMap(words);
    }

    private Map<String, Object> addSensitiveWordToHashMap(List<String> words) {
        Map<String, Object> root = new HashMap<>();
        for (String word : words) {
            Map<String, Object> current = root;
            for (char c : word.toCharArray()) {
                Object obj = current.get(String.valueOf(c));
                if (obj == null) {
                    obj = new HashMap<String, Object>();
                    current.put(String.valueOf(c), obj);
                }
                current = (Map<String, Object>) obj;
            }
            current.put("isEnd", true);
        }
        return root;
    }

    public String filter(String text) {
        if (StringUtils.isBlank(text)) return text;
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < text.length(); i++) {
            int length = checkSensitiveWord(text, i);
            if (length > 0) {
                for (int j = 0; j < length; j++) result.append("*");
                i += length - 1;
            } else {
                result.append(text.charAt(i));
            }
        }
        return result.toString();
    }

    private int checkSensitiveWord(String text, int start) {
        Map<String, Object> current = sensitiveWordMap;
        int length = 0;
        for (int i = start; i < text.length(); i++) {
            Object obj = current.get(String.valueOf(text.charAt(i)));
            if (obj == null) break;
            length++;
            current = (Map<String, Object>) obj;
            if (Boolean.TRUE.equals(current.get("isEnd"))) {
                return length;
            }
        }
        return 0;
    }
}

Frontend Implementation

Comment Section Component (Vue)

<!-- components/CommentSection.vue -->
<template>
  <div class="comment-section">
    <h3>评论 {{ totalCount }}</h3>
    <div v-if="isLoggedIn">
      <el-avatar :src="userAvatar" />
      <el-input v-model="newComment" type="textarea" :rows="3" placeholder="写下你的评论..." />
      <el-button type="primary" @click="submitComment" :loading="submitting">发表评论</el-button>
    </div>
    <div v-else><a @click="goToLogin">登录</a> 后参与评论</div>
    <el-radio-group v-model="orderBy" @change="loadComments">
      <el-radio-button label="time">最新</el-radio-button>
      <el-radio-button label="like">最热</el-radio-button>
    </el-radio-group>
    <CommentItem v-for="c in comments" :key="c.id" :comment="c"
      @reply="handleReply" @like="handleLike" @delete="handleDelete" />
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';
import { useUserStore } from '@/stores/user';
import CommentItem from './CommentItem.vue';

const props = defineProps({ articleId: { type: Number, required: true } });
const userStore = useUserStore();
const isLoggedIn = computed(() => !!userStore.token);
const userAvatar = computed(() => userStore.userInfo?.avatar);

const comments = ref([]);
const totalCount = ref(0);
const newComment = ref('');
const submitting = ref(false);
const orderBy = ref('time');
const replyingTo = ref(null);

const loadComments = async () => {
  const res = await axios.get(`/api/comment/list/${props.articleId}`, { params: { orderBy: orderBy.value } });
  comments.value = res.data.data;
  totalCount.value = comments.value.length;
};

const submitComment = async () => {
  if (!newComment.value.trim()) return;
  submitting.value = true;
  try {
    await axios.post('/api/comment/add', {
      articleId: props.articleId,
      content: newComment.value,
      parentId: replyingTo.value?.id,
      replyToUserId: replyingTo.value?.userId
    });
    newComment.value = '';
    replyingTo.value = null;
    await loadComments();
  } finally {
    submitting.value = false;
  }
};

const handleReply = (c) => { replyingTo.value = c; };
const handleLike = async (id) => { await axios.post(`/api/comment/like/${id}`); await loadComments(); };
const handleDelete = async (id) => { await axios.delete(`/api/comment/${id}`); await loadComments(); };

onMounted(() => { loadComments(); });
</script>

Comment Item Component (Vue)

<!-- components/CommentItem.vue -->
<template>
  <div class="comment-item">
    <el-avatar :src="comment.userAvatar" :size="40" />
    <span class="nickname">{{ comment.userNickname }}</span>
    <span class="floor">#{{ comment.floor }}</span>
    <span class="time">{{ formatTime(comment.createTime) }}</span>
    <div class="body">
      <template v-if="comment.replyToNickname">
        @{{ comment.replyToNickname }}
      </template>
      {{ comment.content }}
    </div>
    <span @click="$emit('reply', comment)">回复</span>
    <span @click="$emit('like', comment.id)">点赞 {{ comment.likeCount }}</span>
    <span v-if="canDelete(comment)" @click="$emit('delete', comment.id)">删除</span>
    <CommentItem v-for="child in comment.children" :key="child.id" :comment="child"
      @reply="$emit('reply', $event)" @like="$emit('like', $event)" @delete="$emit('delete', $event)" />
  </div>
</template>

Full‑Text Search with Elasticsearch

Dependency

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>

Document Mapping

@Document(indexName = "articles")
@Data
public class ArticleDocument {
    @Id private Long id;
    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String title;
    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String summary;
    @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
    private String content;
    @Field(type = FieldType.Keyword)
    private String categoryName;
    @Field(type = FieldType.Keyword)
    private List<String> tags;
    @Field(type = FieldType.Long)
    private Long userId;
    @Field(type = FieldType.Integer)
    private Integer viewCount;
    @Field(type = FieldType.Integer)
    private Integer likeCount;
    @Field(type = FieldType.Date)
    private Date publishedTime;
}

Repository Interface

@Repository
public interface ArticleSearchRepository extends ElasticsearchRepository<ArticleDocument, Long> {
    @Query("{\"bool\": {\"should\": [" +
          "{\"match\": {\"title\": {\"query\": \"?0\", \"boost\": 3}}}," +
          "{\"match\": {\"summary\": {\"query\": \"?0\", \"boost\": 2}}}," +
          "{\"match\": {\"content\": {\"query\": \"?0\", \"boost\": 1}}}" +
          "]}}")
    Page<ArticleDocument> searchByKeyword(String keyword, Pageable pageable);
}

Search Service

@Service
public class SearchService {
    @Autowired private ArticleSearchRepository searchRepository;
    @Autowired private ArticleMapper articleMapper;

    public void syncToES(Long articleId) {
        Article article = articleMapper.selectById(articleId);
        if (article == null || article.getStatus() != 1) {
            searchRepository.deleteById(articleId);
            return;
        }
        ArticleDocument doc = new ArticleDocument();
        doc.setId(article.getId());
        doc.setTitle(article.getTitle());
        doc.setSummary(article.getSummary());
        doc.setContent(stripHtml(article.getContent()));
        doc.setCategoryName(article.getCategoryName());
        doc.setTags(article.getTagNames());
        doc.setUserId(article.getUserId());
        doc.setViewCount(article.getViewCount());
        doc.setLikeCount(article.getLikeCount());
        doc.setPublishedTime(article.getPublishedTime());
        searchRepository.save(doc);
    }

    public PageResult<ArticleSearchVO> search(String keyword, int pageNum, int pageSize) {
        Pageable pageable = PageRequest.of(pageNum - 1, pageSize);
        Page<ArticleDocument> page = searchRepository.searchByKeyword(keyword, pageable);
        List<ArticleSearchVO> list = page.getContent().stream()
                .map(this::convertToVO)
                .collect(Collectors.toList());
        return PageResult.of(list, page.getTotalElements());
    }

    private String highlightKeyword(String text, String keyword) {
        if (StringUtils.isBlank(text) || StringUtils.isBlank(keyword)) return text;
        return text.replaceAll("(?i)(" + Pattern.quote(keyword) + ")", "<em>$1</em>");
    }
}

Search Controller

@RestController
@RequestMapping("/api/search")
public class SearchController {
    @Autowired private SearchService searchService;

    @GetMapping("/articles")
    public Result search(@RequestParam String keyword,
                         @RequestParam(defaultValue = "1") int pageNum,
                         @RequestParam(defaultValue = "10") int pageSize) {
        saveSearchHistory(keyword);
        PageResult<ArticleSearchVO> result = searchService.search(keyword, pageNum, pageSize);
        return Result.success(result);
    }

    @GetMapping("/suggest")
    public Result suggest(@RequestParam String keyword) {
        List<String> suggestions = searchService.getSuggestions(keyword);
        return Result.success(suggestions);
    }
}

Search Frontend (Vue)

<!-- views/search/Search.vue -->
<template>
  <div class="search-page">
    <el-input v-model="keyword" placeholder="搜索文章..." size="large"
      @keyup.enter="doSearch" @input="onInput">
      <template #prefix><el-icon><Search /></el-icon></template>
      <template #append><el-button @click="doSearch">搜索</el-button></template>
    </el-input>
    <div v-if="suggestions.length && keyword">
      <div v-for="sug in suggestions" :key="sug" @click="keyword = sug; doSearch()">{{ sug }}</div>
    </div>
    <div v-if="total > 0">找到约 {{ total }} 条结果</div>
    <div v-for="item in articles" :key="item.id" class="result-item">
      <h3 @click="goToDetail(item.id)" v-html="highlight(item.title)"></h3>
      <p v-html="highlight(item.summary)"></p>
      <span>{{ item.categoryName }}</span>
      <span>{{ item.viewCount }} 阅读</span>
      <span>{{ formatDate(item.publishedTime) }}</span>
    </div>
    <el-pagination v-if="total > 0" v-model:current-page="pageNum" :total="total" @current-change="doSearch"/>
    <el-empty v-if="searched && !articles.length" description="没有找到相关文章"/>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { debounce } from 'lodash-es';

const router = useRouter();
const keyword = ref('');
const articles = ref([]);
const total = ref(0);
const pageNum = ref(1);
const searched = ref(false);
const suggestions = ref([]);

const doSearch = async () => {
  if (!keyword.value.trim()) return;
  searched.value = true;
  const res = await axios.get('/api/search/articles', { params: { keyword: keyword.value, pageNum: pageNum.value } });
  articles.value = res.data.data.records;
  total.value = res.data.data.total;
};

const highlight = (text) => {
  if (!keyword.value) return text;
  const regex = new RegExp(`(${keyword.value})`, 'gi');
  return text.replace(regex, '<em>$1</em>');
};

const onInput = debounce(async () => {
  if (keyword.value.length < 2) { suggestions.value = []; return; }
  const res = await axios.get('/api/search/suggest', { params: { keyword: keyword.value } });
  suggestions.value = res.data.data;
}, 300);

const goToDetail = (id) => { router.push(`/article/${id}`); };

watch(keyword, () => { pageNum.value = 1; });
</script>

Hot Article Recommendation

Recommendation Service

@Service
public class RecommendService {
    @Autowired private RedisTemplate redisTemplate;
    @Autowired private ArticleMapper articleMapper;

    public void recordView(Long userId, Long articleId) {
        String key = "user:view:" + userId;
        redisTemplate.opsForZSet().incrementScore(key, articleId.toString(), 1);
        redisTemplate.opsForZSet().removeRange(key, 0, -51);
    }

    public List<Article> recommendByUser(Long userId, int limit) {
        String userKey = "user:view:" + userId;
        Set<String> viewed = redisTemplate.opsForZSet().reverseRange(userKey, 0, 10);
        if (viewed == null || viewed.isEmpty()) {
            return getHotArticles(limit);
        }
        return getHotArticles(limit);
    }

    public List<Article> getHotArticles(int limit) {
        String cacheKey = "hot:articles";
        List<Article> hotList = (List<Article>) redisTemplate.opsForValue().get(cacheKey);
        if (hotList == null) {
            hotList = articleMapper.selectHotArticles(limit); // weight = view*0.3 + like*0.5 + comment*0.2
            redisTemplate.opsForValue().set(cacheKey, hotList, 1, TimeUnit.HOURS);
        }
        return hotList;
    }
}
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaElasticsearchSpringVueComment SystemFull‑Text Search
Coder Trainee
Written by

Coder Trainee

Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.