Phase 1: 基础框架搭建 🏗️
学习目标: 掌握模块结构、Flyway数据库迁移、BaseServiceImpl继承、MapStruct对象转换
📚 学习要点
通过这个阶段,你将学会:
- ✅ Flyway数据库版本管理 - 如何编写SQL迁移脚本
- ✅ MyBatis-Plus实体映射 - Entity注解的正确使用
- ✅ MapStruct对象转换 - 零反射的编译期代码生成
- ✅ BaseServiceImpl继承 - 复用基础CRUD能力
- ✅ 统一响应格式 -
Result<T>的标准用法 - ✅ 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
测试以下接口:
- POST /api/v1/comments - 创建评论
- GET /api/v1/comments/
{id}- 查询评论 - PUT /api/v1/comments - 更新评论
- 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 | 逻辑删除 | DELETE 变 UPDATE is_deleted=1 |
Identifiable<T> | ID接口 | BaseServiceImpl依赖,强制实现 |
| BaseServiceImpl | CRUD基类 | 复用80%代码,仅写业务逻辑 |
| MapStruct | 编译期代码生成 | 零反射,性能优于BeanUtils |
| @Valid | JSR-303校验 | 自动校验@NotNull/@NotBlank |
Result<T> | 统一响应 | 标准化API返回格式 |
🎯 下一步
Phase 1完成后,你已经掌握了:
- ✅ 从0到1搭建完整模块
- ✅ 理解项目的分层架构
- ✅ 熟悉MyBatis-Plus和MapStruct
准备进入 Phase 2: 树形结构实现 🌲,学习:
- Materialized Path 算法
- 递归查询优化
- 树形数据组装