feat(article): 实现管理端文章模块的增删改查功能

- 新增文章表、内容表、标签关联及分类关联的数据库设计与实现
- 实现文章发布、删除、分页查询、详情查看及更新接口
- 文章发布时支持分类验证和标签新增与绑定
- 删除操作会级联删除文章关联的分类和标签关系
- 查询详情接口返回文章基本信息、正文内容、分类及标签列表
- 支持根据标签ID列表批量查询标签信息
- 管理分类接口新增根据ID查询分类详情功能
- 删除分类、标签时增加文章关联校验,防止误删
- 统一返回结构,异常时抛出业务异常,规范日志输出
- 统一使用JPA进行数据库操作,保障事务一致性
- 优化查询性能,添加必要的索引及外键约束
- 补充对应请求和响应的VO类,支持参数校验与业务传递
This commit is contained in:
2025-12-06 17:30:49 +08:00
parent 7db42c6c30
commit 6fae09f42f
32 changed files with 1168 additions and 37 deletions

View File

@@ -0,0 +1,60 @@
package com.hanserwei.common.domain.dataobject;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.hibernate.annotations.UpdateTimestamp;
import java.io.Serial;
import java.io.Serializable;
import java.time.Instant;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "t_article", indexes = {
@Index(name = "idx_article_create_time", columnList = "create_time")
})
@SQLRestriction("is_deleted = false")
@SQLDelete(sql = "update t_article set is_deleted = true where id = ?")
public class Article implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
@Builder.Default
private String cover = "";
@Column(columnDefinition = "TEXT")
@Builder.Default
private String summary = "";
@Column(name = "read_num", nullable = false)
@Builder.Default
private Integer readNum = 1;
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false)
private Instant createTime;
@UpdateTimestamp
@Column(name = "update_time", nullable = false)
private Instant updateTime;
@Column(name = "is_deleted", nullable = false)
@Builder.Default
private Boolean isDeleted = false;
}

View File

@@ -0,0 +1,39 @@
package com.hanserwei.common.domain.dataobject;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serial;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "t_article_category_rel",
indexes = {
@Index(name = "idx_rel_cat_category_id", columnList = "category_id")
})
public class ArticleCategoryRel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 文章ID (Unique)
*/
@Column(name = "article_id", nullable = false, unique = true)
private Long articleId;
/**
* 分类ID
*/
@Column(name = "category_id", nullable = false)
private Long categoryId;
}

View File

@@ -0,0 +1,40 @@
package com.hanserwei.common.domain.dataobject;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serial;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "t_article_content", indexes = {
@Index(name = "idx_article_content_article_id", columnList = "article_id")
})
public class ArticleContent implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 关联 Article 的 ID
* 这里只存储 ID不建立对象关联以实现轻量级操作
*/
@Column(name = "article_id", nullable = false)
private Long articleId;
/**
* 正文内容
* 映射为 TEXT 类型,支持大文本
*/
@Column(name = "content", columnDefinition = "TEXT")
private String content;
}

View File

@@ -0,0 +1,44 @@
package com.hanserwei.common.domain.dataobject;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serial;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "t_article_tag_rel",
indexes = {
@Index(name = "idx_rel_tag_article_id", columnList = "article_id"),
@Index(name = "idx_rel_tag_tag_id", columnList = "tag_id")
},
uniqueConstraints = {
// 对应数据库中的联合唯一约束
@UniqueConstraint(name = "uk_article_tag_rel_unique_pair", columnNames = {"article_id", "tag_id"})
})
public class ArticleTagRel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 文章ID
*/
@Column(name = "article_id", nullable = false)
private Long articleId;
/**
* 标签ID
*/
@Column(name = "tag_id", nullable = false)
private Long tagId;
}

View File

@@ -0,0 +1,20 @@
package com.hanserwei.common.domain.repository;
import com.hanserwei.common.domain.dataobject.ArticleCategoryRel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ArticleCategoryRelRepository extends JpaRepository<ArticleCategoryRel, Long> {
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from ArticleCategoryRel acr where acr.articleId = :articleId")
void deleteByArticleId(@Param("articleId") Long articleId);
ArticleCategoryRel findByArticleId(Long articleId);
List<ArticleCategoryRel> findByCategoryId(Long categoryId);
}

View File

@@ -0,0 +1,8 @@
package com.hanserwei.common.domain.repository;
import com.hanserwei.common.domain.dataobject.ArticleContent;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ArticleContentRepository extends JpaRepository<ArticleContent, Long> {
ArticleContent findByArticleId(Long articleId);
}

View File

@@ -0,0 +1,8 @@
package com.hanserwei.common.domain.repository;
import com.hanserwei.common.domain.dataobject.Article;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface ArticleRepository extends JpaRepository<Article, Long>, JpaSpecificationExecutor<Article> {
}

View File

@@ -0,0 +1,19 @@
package com.hanserwei.common.domain.repository;
import com.hanserwei.common.domain.dataobject.ArticleTagRel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ArticleTagRelRepository extends JpaRepository<ArticleTagRel, Long> {
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from ArticleTagRel atr where atr.articleId = :articleId")
void deleteByArticleId(@Param("articleId") Long articleId);
List<ArticleTagRel> findByArticleId(Long articleId);
List<ArticleTagRel> findByTagId(Long tagId);
}

View File

@@ -22,4 +22,5 @@ public interface TagRepository extends JpaRepository<Tag, Long>, JpaSpecificatio
*/
List<Tag> findByNameContaining(String name);
List<Tag> queryAllByIdIn(Collection<Long> ids);
}

View File

@@ -21,7 +21,12 @@ public enum ResponseCodeEnum implements BaseExceptionInterface {
CATEGORY_NAME_IS_EXISTED("20005", "该分类已存在,请勿重复添加!"),
TAG_NOT_EXIST("20006", "标签不存在!"),
CATEGORY_NOT_EXIST("20007", "分类不存在!"),
FILE_UPLOAD_FAILED("20008", "上传文件失败!");
FILE_UPLOAD_FAILED("20008", "上传文件失败!"),
CATEGORY_NOT_EXISTED("20009", "提交的分类不存在!"),
TAG_NOT_EXISTED("20010", "标签ID不存在或已删除"),
ARTICLE_NOT_EXIST("20011", "文章不存在!"),
TAG_HAS_ARTICLE("20012", "该标签有关联文章,无法删除!"),
CATEGORY_HAS_ARTICLE("20013","该分类下有关联文章,无法删除!" );
// 异常码
private final String errorCode;

View File

@@ -25,20 +25,22 @@ public class PageHelper {
/**
* 执行带条件的分页查询
*
* @param repository JPA Repository
* @param pageQuery 分页查询参数
* @param name 名称(用于模糊查询)
* @param startDate 开始日期
* @param endDate 结束日期
* @param converter DO 到 VO 的转换函数
* @param <T> 实体类型
* @param <R> 响应VO类型
* @param repository JPA Repository
* @param pageQuery 分页查询参数
* @param searchText 搜索文本(用于模糊查询)
* @param searchField 搜索字段名称(如 "name"、"title" 等)
* @param startDate 开始日期
* @param endDate 结束日期
* @param converter DO 到 VO 的转换函数
* @param <T> 实体类型
* @param <R> 响应VO类型
* @return 分页响应
*/
public static <T, R> PageResponse<R> findPageList(
JpaSpecificationExecutor<T> repository,
BasePageQuery pageQuery,
String name,
String searchText,
String searchField,
LocalDate startDate,
LocalDate endDate,
Function<T, R> converter) {
@@ -54,10 +56,10 @@ public class PageHelper {
Specification<T> specification = (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
// 名称模糊查询
if (StringUtils.hasText(name)) {
// 搜索文本模糊查询
if (StringUtils.hasText(searchText) && StringUtils.hasText(searchField)) {
predicates.add(
criteriaBuilder.like(root.get("name"), "%" + name.trim() + "%")
criteriaBuilder.like(root.get(searchField), "%" + searchText.trim() + "%")
);
}
@@ -88,4 +90,27 @@ public class PageHelper {
return PageResponse.success(page, vos);
}
/**
* 执行带条件的分页查询(使用默认字段名 "name"
*
* @param repository JPA Repository
* @param pageQuery 分页查询参数
* @param name 名称(用于模糊查询)
* @param startDate 开始日期
* @param endDate 结束日期
* @param converter DO 到 VO 的转换函数
* @param <T> 实体类型
* @param <R> 响应VO类型
* @return 分页响应
*/
public static <T, R> PageResponse<R> findPageList(
JpaSpecificationExecutor<T> repository,
BasePageQuery pageQuery,
String name,
LocalDate startDate,
LocalDate endDate,
Function<T, R> converter) {
return findPageList(repository, pageQuery, name, "name", startDate, endDate, converter);
}
}