跳到主要内容

Phase 1: 基础框架搭建 🏗️

学习目标: 掌握模块结构、Flyway数据库迁移、BaseServiceImpl继承、MapStruct对象转换


📚 学习要点

通过这个阶段,你将学会:

  1. Flyway数据库版本管理 - 如何编写SQL迁移脚本
  2. MyBatis-Plus实体映射 - Entity注解的正确使用
  3. MapStruct对象转换 - 零反射的编译期代码生成
  4. BaseServiceImpl继承 - 复用基础CRUD能力
  5. 统一响应格式 - Result<T>的标准用法
  6. Swagger API文档 - @Schema注解的规范

🔧 前置准备

检查模块POM配置

确认 blog-modules/blog-module-comment/pom.xml 包含:

<modules>
<module>blog-comment-api</module>
<module>blog-comment-service</module>
</modules>

确认 blog-comment-api/pom.xml 依赖:

<dependencies>
<dependency>
<groupId>com.blog</groupId>
<artifactId>blog-common</artifactId>
</dependency>
</dependencies>

确认 blog-comment-service/pom.xml 依赖:

<dependencies>
<dependency>
<groupId>com.blog</groupId>
<artifactId>blog-comment-api</artifactId>
</dependency>
<dependency>
<groupId>com.blog</groupId>
<artifactId>blog-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
</dependencies>

确认 blog-application/pom.xml 添加:

<dependency>
<groupId>com.blog</groupId>
<artifactId>blog-comment-service</artifactId>
</dependency>

📊 Step 1: 数据库设计(简化版)

创建文件: blog-application/src/main/resources/db/V1.2.0__init_comment_tables.sql

-- ========================================================
-- 文件名: V1.2.0__init_comment_tables.sql
-- 描述: 创建评论表 (Phase 1 简化版)
-- 作者: liusxml
-- 日期: 2025-12-15
-- 版本: 1.2.0
-- ========================================================

USE blog_db;

CREATE TABLE IF NOT EXISTS `cmt_comment` (
-- 主键
`id` BIGINT NOT NULL COMMENT '评论ID (主键, 雪花算法)',

-- 关联字段
`target_type` VARCHAR(20) NOT NULL COMMENT '评论目标类型 (ARTICLE)',
`target_id` BIGINT NOT NULL COMMENT '目标ID (文章ID)',
`parent_id` BIGINT NULL COMMENT '父评论ID (NULL表示顶级评论)',

-- 内容字段
`content` TEXT NOT NULL COMMENT '评论原始内容',

-- 状态字段 (Phase 1 简化,仅一个状态)
`status` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '状态 (1-已通过)',

-- 统计字段
`like_count` INT NOT NULL DEFAULT 0 COMMENT '点赞数',
`reply_count` INT NOT NULL DEFAULT 0 COMMENT '回复数',

-- 审计字段
`version` INT NOT NULL DEFAULT 1 COMMENT '版本号 (乐观锁)',
`create_by` BIGINT NOT NULL COMMENT '创建者ID',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` BIGINT NULL COMMENT '更新者ID',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',

-- 索引
PRIMARY KEY (`id`),
KEY `idx_target` (`target_type`, `target_id`, `status`),
KEY `idx_user` (`create_by`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';

知识点解析:

  • Flyway版本号: V1.2.0 - 主版本.次版本.补丁版本
  • 雪花算法ID: MyBatis-Plus 自动生成,无需手动指定
  • 逻辑删除: is_deleted 配合 @TableLogic 实现软删除
  • 乐观锁: version 配合 @Version 防止并发更新冲突

📦 Step 2: API模块实现

2.1 创建枚举 - CommentStatus

文件路径: blog-comment-api/src/main/java/com/blog/comment/api/enums/CommentStatus.java

package com.blog.comment.api.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;

/**
* 评论状态枚举
*
* @author liusxml
* @since 1.2.0
*/
@Getter
public enum CommentStatus {

APPROVED(1, "已通过");

@EnumValue // MyBatis-Plus: 数据库存储的值
private final int code;

@JsonValue // Jackson: JSON序列化的值
private final String description;

CommentStatus(int code, String description) {
this.code = code;
this.description = description;
}
}

知识点:

  • @EnumValue: MyBatis-Plus 枚举映射,数据库存1而非"APPROVED"
  • @JsonValue: JSON响应返回"已通过"而非枚举名

2.2 创建枚举 - CommentTargetType

package com.blog.comment.api.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;

/**
* 评论目标类型枚举
*/
@Getter
public enum CommentTargetType {

ARTICLE("ARTICLE", "文章");

@EnumValue
private final String code;

@JsonValue
private final String description;

CommentTargetType(String code, String description) {
this.code = code;
this.description = description;
}
}

2.3 创建 DTO - CommentDTO

文件路径: blog-comment-api/src/main/java/com/blog/comment/api/dto/CommentDTO.java

package com.blog.comment.api.dto;

import com.blog.comment.api.enums.CommentTargetType;
import com.blog.common.base.Identifiable;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.io.Serializable;

/**
* 评论请求DTO
*
* @author liusxml
* @since 1.2.0
*/
@Data
@Schema(description = "评论请求对象")
public class CommentDTO implements Serializable, Identifiable<Long> {

@Schema(description = "评论ID (更新时必填)")
private Long id;

@NotNull(message = "目标类型不能为空")
@Schema(description = "评论目标类型", example = "ARTICLE", requiredMode = Schema.RequiredMode.REQUIRED)
private CommentTargetType targetType;

@NotNull(message = "目标ID不能为空")
@Schema(description = "目标ID (文章ID)", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
private Long targetId;

@Schema(description = "父评论ID (回复评论时填写)", example = "123")
private Long parentId;

@NotBlank(message = "评论内容不能为空")
@Schema(description = "评论内容", example = "这篇文章写得很好!", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
}

知识点:

  • 实现 Identifiable<Long>: 强制要求,BaseServiceImpl 依赖此接口
  • @NotBlank vs @NotNull: 字符串用 @NotBlank(非空且非空白),对象用 @NotNull
  • @Schema: Swagger 文档注解,提升API可读性

2.4 创建 VO - CommentVO

文件路径: blog-comment-api/src/main/java/com/blog/comment/api/vo/CommentVO.java

package com.blog.comment.api.vo;

import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* 评论响应VO
*
* @author liusxml
* @since 1.2.0
*/
@Data
@Schema(description = "评论响应对象")
public class CommentVO implements Serializable {

@Schema(description = "评论ID")
private Long id;

@Schema(description = "目标类型")
private CommentTargetType targetType;

@Schema(description = "目标ID")
private Long targetId;

@Schema(description = "父评论ID")
private Long parentId;

@Schema(description = "评论内容")
private String content;

@Schema(description = "状态")
private CommentStatus status;

@Schema(description = "点赞数")
private Integer likeCount;

@Schema(description = "回复数")
private Integer replyCount;

@Schema(description = "评论者ID")
private Long createBy;

@Schema(description = "评论时间")
private LocalDateTime createTime;

@Schema(description = "更新时间")
private LocalDateTime updateTime;
}

2.5 创建查询DTO - CommentQueryDTO

package com.blog.comment.api.dto;

import com.blog.comment.api.enums.CommentTargetType;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.io.Serializable;

/**
* 评论查询参数
*/
@Data
@Schema(description = "评论查询参数")
public class CommentQueryDTO implements Serializable {

@Schema(description = "目标类型")
private CommentTargetType targetType;

@Schema(description = "目标ID")
private Long targetId;

@Schema(description = "评论者ID")
private Long createBy;

@Schema(description = "页码", example = "1")
private Integer pageNum = 1;

@Schema(description = "每页大小", example = "20")
private Integer pageSize = 20;
}

🏛️ Step 3: Service模块实现

3.1 创建 Entity - CommentEntity

文件路径: blog-comment-service/src/main/java/com/blog/comment/domain/entity/CommentEntity.java

package com.blog.comment.domain.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import lombok.Data;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
* 评论实体
*
* @author liusxml
* @since 1.2.0
*/
@Data
@TableName("cmt_comment")
public class CommentEntity implements Serializable {

@TableId(type = IdType.ASSIGN_ID)
private Long id;

private CommentTargetType targetType;

private Long targetId;

private Long parentId;

private String content;

private CommentStatus status;

private Integer likeCount;

private Integer replyCount;

@Version
private Integer version;

@TableField(fill = FieldFill.INSERT)
private Long createBy;

@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;

@TableLogic
private Integer isDeleted;
}

知识点:

  • @TableId(type = IdType.ASSIGN_ID): 雪花算法生成19位Long ID
  • @Version: 乐观锁版本号,更新时自动+1并校验
  • @TableField(fill = ...): 自动填充字段(MybatisPlusHandlerConfig 已配置)
  • @TableLogic: 逻辑删除标记,查询时自动添加 is_deleted = 0

3.2 创建 Mapper - CommentMapper

文件路径: blog-comment-service/src/main/java/com/blog/comment/infrastructure/mapper/CommentMapper.java

package com.blog.comment.infrastructure.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.blog.comment.domain.entity.CommentEntity;
import org.apache.ibatis.annotations.Mapper;

/**
* 评论Mapper
*
* @author liusxml
* @since 1.2.0
*/
@Mapper
public interface CommentMapper extends BaseMapper<CommentEntity> {
// Phase 1: 仅使用 BaseMapper 提供的方法
// 后续阶段会添加自定义查询
}

3.3 创建 Converter - CommentConverter

文件路径: blog-comment-service/src/main/java/com/blog/comment/infrastructure/converter/CommentConverter.java

package com.blog.comment.infrastructure.converter;

import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.domain.entity.CommentEntity;
import com.blog.common.base.BaseConverter;
import org.mapstruct.Mapper;
import org.mapstruct.NullValuePropertyMappingStrategy;

/**
* 评论转换器
*
* @author liusxml
* @since 1.2.0
*/
@Mapper(
componentModel = "spring",
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface CommentConverter extends BaseConverter<CommentDTO, CommentEntity, CommentVO> {
// MapStruct 自动生成以下方法:
// - CommentEntity toEntity(CommentDTO dto);
// - CommentVO toVo(CommentEntity entity);
// - void updateEntityFromDto(CommentDTO dto, @MappingTarget CommentEntity entity);
}

知识点:

  • componentModel = "spring": 生成Spring Bean,支持依赖注入
  • nullValuePropertyMappingStrategy.IGNORE: 更新时忽略null值,避免覆盖
  • 编译期生成: mvn compile 后在 target/generated-sources/annotations 查看生成代码

3.4 创建 Service - ICommentService

文件路径: blog-comment-service/src/main/java/com/blog/comment/service/ICommentService.java

package com.blog.comment.service;

import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.domain.entity.CommentEntity;
import com.blog.common.base.IBaseService;

/**
* 评论服务接口
*
* @author liusxml
* @since 1.2.0
*/
public interface ICommentService extends IBaseService<CommentEntity, CommentVO, CommentDTO> {
// Phase 1: 仅继承基础CRUD方法
// 后续阶段会添加业务方法:
// - List<CommentTreeVO> buildCommentTree(Long targetId);
// - void approveComment(Long commentId);
// - void likeComment(Long commentId);
}

3.5 创建 ServiceImpl - CommentServiceImpl

文件路径: blog-comment-service/src/main/java/com/blog/comment/service/impl/CommentServiceImpl.java

package com.blog.comment.service.impl;

import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.domain.entity.CommentEntity;
import com.blog.comment.infrastructure.converter.CommentConverter;
import com.blog.comment.infrastructure.mapper.CommentMapper;
import com.blog.comment.service.ICommentService;
import com.blog.common.base.BaseServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Objects;

/**
* 评论服务实现
*
* <p>
* Phase 1 实现要点:
* </p>
* <ul>
* <li>继承 BaseServiceImpl 获得 CRUD 能力</li>
* <li>使用构造注入(显式调用super)</li>
* <li>重写 preSave 钩子设置默认值</li>
* </ul>
*
* @author liusxml
* @since 1.2.0
*/
@Slf4j
@Service
public class CommentServiceImpl
extends BaseServiceImpl<CommentMapper, CommentEntity, CommentVO, CommentDTO, CommentConverter>
implements ICommentService {

/**
* 构造注入 Converter
*
* @param converter MapStruct 转换器
*/
public CommentServiceImpl(CommentConverter converter) {
super(converter);
}

/**
* 保存前钩子:设置默认值
*
* @param entity 评论实体
*/
@Override
protected void preSave(CommentEntity entity) {
// 1. 设置默认状态(Phase 1 简化,直接通过)
if (Objects.isNull(entity.getStatus())) {
entity.setStatus(CommentStatus.APPROVED);
}

// 2. 初始化统计字段
if (Objects.isNull(entity.getLikeCount())) {
entity.setLikeCount(0);
}
if (Objects.isNull(entity.getReplyCount())) {
entity.setReplyCount(0);
}

log.debug("评论保存前处理: status={}, likeCount={}", entity.getStatus(), entity.getLikeCount());
}
}

知识点:

  • 构造注入: 遵循项目规范,使用 @RequiredArgsConstructor 或显式构造
  • preSave钩子: BaseServiceImpl提供,在 save() 前自动调用
  • Objects.isNull(): Guava 或 Lang3 的空值判断工具

3.6 创建 Controller - CommentController

文件路径: blog-comment-service/src/main/java/com/blog/comment/api/controller/CommentController.java

package com.blog.comment.api.controller;

import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.dto.CommentQueryDTO;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.service.ICommentService;
import com.blog.common.exception.BusinessException;
import com.blog.common.exception.SystemErrorCode;
import com.blog.common.model.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import java.io.Serializable;

/**
* 评论管理 Controller
*
* @author liusxml
* @since 1.2.0
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/comments")
@RequiredArgsConstructor
@Tag(name = "评论管理", description = "评论CRUD接口")
public class CommentController {

private final ICommentService commentService;

/**
* 创建评论
*
* @param dto 评论DTO
* @return 评论ID
*/
@PostMapping
@Operation(summary = "创建评论")
public Result<Long> create(@Valid @RequestBody CommentDTO dto) {
Serializable id = commentService.saveByDto(dto);
return Result.success((Long) id);
}

/**
* 获取评论详情
*
* @param id 评论ID
* @return 评论VO
*/
@GetMapping("/{id}")
@Operation(summary = "获取评论详情")
public Result<CommentVO> getById(@PathVariable Long id) {
return commentService.getVoById(id)
.map(Result::success)
.orElseThrow(() -> new BusinessException(SystemErrorCode.NOT_FOUND));
}

/**
* 更新评论
*
* @param dto 评论DTO
* @return 是否成功
*/
@PutMapping
@Operation(summary = "更新评论")
public Result<Boolean> update(@Valid @RequestBody CommentDTO dto) {
return Result.success(commentService.updateByDto(dto));
}

/**
* 删除评论
*
* @param id 评论ID
* @return 是否成功
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除评论")
public Result<Boolean> delete(@PathVariable Long id) {
return Result.success(commentService.removeById(id));
}
}

知识点:

  • @Valid: 触发 DTO 的 @NotNull@NotBlank 校验
  • Optional.orElseThrow(): 优雅的空值处理
  • @Tag: Swagger分组标签
  • Result.success(): 统一响应格式

🧪 Step 4: 测试验证

4.1 单元测试 - CommentServiceImplTest

文件路径: blog-comment-service/src/test/java/com/blog/comment/service/impl/CommentServiceImplTest.java

package com.blog.comment.service.impl;

import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.enums.CommentStatus;
import com.blog.comment.api.enums.CommentTargetType;
import com.blog.comment.api.vo.CommentVO;
import com.blog.comment.domain.entity.CommentEntity;
import com.blog.comment.infrastructure.converter.CommentConverter;
import com.blog.comment.infrastructure.mapper.CommentMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

/**
* 评论服务单元测试
*/
@ExtendWith(MockitoExtension.class)
class CommentServiceImplTest {

@Mock
private CommentMapper commentMapper;

@Mock
private CommentConverter commentConverter;

@InjectMocks
private CommentServiceImpl commentService;

@Test
void should_createComment_when_validDto() {
// Given
CommentDTO dto = new CommentDTO();
dto.setTargetType(CommentTargetType.ARTICLE);
dto.setTargetId(1L);
dto.setContent("测试评论");

CommentEntity entity = new CommentEntity();
entity.setId(123L);

when(commentConverter.toEntity(any(CommentDTO.class))).thenReturn(entity);
when(commentMapper.insert(any(CommentEntity.class))).thenReturn(1);

// When
Long commentId = (Long) commentService.saveByDto(dto);

// Then
assertThat(commentId).isEqualTo(123L);
verify(commentMapper, times(1)).insert(any(CommentEntity.class));
}

@Test
void should_setDefaultValues_when_preSave() {
// Given
CommentEntity entity = new CommentEntity();
entity.setContent("测试评论");

// When
commentService.preSave(entity);

// Then
assertThat(entity.getStatus()).isEqualTo(CommentStatus.APPROVED);
assertThat(entity.getLikeCount()).isEqualTo(0);
assertThat(entity.getReplyCount()).isEqualTo(0);
}
}

4.2 集成测试 - CommentControllerTest

package com.blog.comment.api.controller;

import com.blog.comment.api.dto.CommentDTO;
import com.blog.comment.api.enums.CommentTargetType;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilitors.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
* 评论Controller集成测试
*/
@SpringBootTest
@AutoConfigureMockMvc
class CommentControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private ObjectMapper objectMapper;

@Test
void should_return201_when_createComment() throws Exception {
CommentDTO dto = new CommentDTO();
dto.setTargetType(CommentTargetType.ARTICLE);
dto.setTargetId(1L);
dto.setContent("集成测试评论");

mockMvc.perform(post("/api/v1/comments")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(dto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").isNumber());
}
}

4.3 验证步骤

编译项目

# 在项目根目录执行
mvn clean compile -pl blog-modules/blog-module-comment

运行单元测试

mvn test -pl blog-modules/blog-module-comment/blog-comment-service

启动应用

mvn spring-boot:run -pl blog-application

访问 Swagger UI

打开浏览器: http://localhost:8080/swagger-ui.html

测试以下接口:

  1. POST /api/v1/comments - 创建评论
  2. GET /api/v1/comments/{id} - 查询评论
  3. PUT /api/v1/comments - 更新评论
  4. DELETE /api/v1/comments/{id} - 删除评论

✅ Phase 1 完成检查清单

  • 数据库表创建成功(验证: DESCRIBE cmt_comment;
  • Flyway迁移记录存在(SELECT * FROM flyway_schema_history WHERE version='1.2.0';
  • 枚举类编译通过
  • DTO/VO添加了所有必需注解
  • Entity使用了正确的MyBatis-Plus注解
  • Converter生成了实现类(check target/generated-sources
  • Service继承了BaseServiceImpl
  • Controller返回Result<T>
  • 单元测试全部通过
  • Swagger UI可访问并能成功调用接口

💡 关键知识点总结

知识点核心概念实际作用
Flyway数据库版本控制自动执行SQL脚本,支持回滚
@TableId(ASSIGN_ID)雪花算法ID生成分布式唯一ID,无需数据库自增
@Version乐观锁防止并发更新冲突
@TableLogic逻辑删除DELETEUPDATE is_deleted=1
Identifiable<T>ID接口BaseServiceImpl依赖,强制实现
BaseServiceImplCRUD基类复用80%代码,仅写业务逻辑
MapStruct编译期代码生成零反射,性能优于BeanUtils
@ValidJSR-303校验自动校验@NotNull/@NotBlank
Result<T>统一响应标准化API返回格式

🎯 下一步

Phase 1完成后,你已经掌握了:

  • ✅ 从0到1搭建完整模块
  • ✅ 理解项目的分层架构
  • ✅ 熟悉MyBatis-Plus和MapStruct

准备进入 Phase 2: 树形结构实现 🌲,学习:

  • Materialized Path 算法
  • 递归查询优化
  • 树形数据组装