浏览代码

search-service 添加 bnt 项目的 cn.reghao.bnt.web.blog 包

reghao 5 月之前
父节点
当前提交
b2cc6f7a2d
共有 43 个文件被更改,包括 2179 次插入7 次删除
  1. 22 0
      search/search-service/pom.xml
  2. 106 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/AdminArticleController.java
  3. 58 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/AdminCategoryController.java
  4. 63 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/AdminQuestionController.java
  5. 24 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/BaseController.java
  6. 145 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/ForegroundController.java
  7. 13 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/AboutViewRepository.java
  8. 18 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/ArticleRepository.java
  9. 22 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/ArticleTagRepository.java
  10. 24 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/ArticleViewRepository.java
  11. 39 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/CategoryRepository.java
  12. 27 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/QuestionRepository.java
  13. 25 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/CategoryType.java
  14. 32 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/dto/ArticleDto.java
  15. 31 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/dto/ChannelDto.java
  16. 20 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/dto/QuestionUpdateDto.java
  17. 24 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/AboutView.java
  18. 67 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/Article.java
  19. 27 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/ArticleTag.java
  20. 29 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/ArticleView.java
  21. 25 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/Category.java
  22. 58 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/Question.java
  23. 40 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/AdminArticle.java
  24. 23 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArchiveArticle.java
  25. 19 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleCount.java
  26. 31 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleLink.java
  27. 20 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleProjection.java
  28. 43 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleVO.java
  29. 11 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/BlogChannel.java
  30. 34 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/CategoryCount.java
  31. 33 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/EditArticle.java
  32. 11 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/GroupCount.java
  33. 15 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/QuestionCount.java
  34. 31 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/QuestionView.java
  35. 34 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/UserArticle.java
  36. 283 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/ArticleQuery.java
  37. 177 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/ArticleService.java
  38. 43 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/ArticleViewService.java
  39. 121 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/CategoryService.java
  40. 132 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/QuestionService.java
  41. 58 0
      search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/util/MarkdownUtil.java
  42. 6 4
      search/search-service/src/main/java/cn/reghao/tnb/search/app/hibernate/HibernateLucene.java
  43. 115 3
      search/search-service/src/main/java/cn/reghao/tnb/search/app/hibernate/HibernateQuery.java

+ 22 - 0
search/search-service/pom.xml

@@ -118,6 +118,28 @@
             <version>9.8.0</version>
         </dependency>
 
+        <!-- blog -->
+        <dependency>
+            <groupId>org.jsoup</groupId>
+            <artifactId>jsoup</artifactId>
+            <version>1.12.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.atlassian.commonmark</groupId>
+            <artifactId>commonmark</artifactId>
+            <version>0.12.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.atlassian.commonmark</groupId>
+            <artifactId>commonmark-ext-gfm-tables</artifactId>
+            <version>0.12.1</version>
+        </dependency>
+        <dependency>
+            <groupId>com.atlassian.commonmark</groupId>
+            <artifactId>commonmark-ext-yaml-front-matter</artifactId>
+            <version>0.12.1</version>
+        </dependency>
+
         <!-- elasticsearch -->
         <dependency>
             <groupId>co.elastic.clients</groupId>

+ 106 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/AdminArticleController.java

@@ -0,0 +1,106 @@
+package cn.reghao.tnb.search.app.blog.controller;
+
+import cn.reghao.tnb.common.web.WebResult;
+import cn.reghao.tnb.search.app.blog.model.dto.ArticleDto;
+import cn.reghao.tnb.search.app.blog.model.vo.AdminArticle;
+import cn.reghao.tnb.search.app.blog.model.vo.EditArticle;
+import cn.reghao.tnb.search.app.blog.service.ArticleQuery;
+import cn.reghao.tnb.search.app.blog.service.ArticleService;
+import cn.reghao.jutil.jdk.web.db.PageList;
+import cn.reghao.jutil.jdk.web.result.Result;
+import cn.reghao.tnb.search.app.hibernate.HibernateLucene;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.data.domain.Page;
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-17 10:15:39
+ */
+@Tag(name = "博客文章接口")
+@RestController
+@RequestMapping("/api/blog/v2/post")
+public class AdminArticleController {
+    private final ArticleService articleService;
+    private final ArticleQuery articleQuery;
+    private final HibernateLucene hibernateLucene;
+    private int pageSize = 10;
+
+    public AdminArticleController(ArticleService articleService, ArticleQuery articleQuery,
+                                  HibernateLucene hibernateLucene) {
+        this.articleService = articleService;
+        this.articleQuery = articleQuery;
+        this.hibernateLucene = hibernateLucene;
+    }
+
+    @Operation(summary = "博客文章列表页面", description = "N")
+    @GetMapping("/list")
+    public String list(@RequestParam("pn") int pageNumber,
+                       @RequestParam(value = "categoryId", required = false) Integer categoryId,
+                       @RequestParam(value = "published", required = false) Integer published,
+                       @RequestParam(value = "title", required = false) String title) {
+        if (categoryId == null) {
+            categoryId = 0;
+        }
+
+        if (published == null) {
+            published = 0;
+        }
+
+        if (title == null) {
+            title = "";
+        }
+
+        Page<AdminArticle> page1 = articleQuery.findAdminArticleByPage(pageSize, pageNumber, categoryId, published, title);
+        PageList<AdminArticle> pageList = getPageList(page1);
+        return WebResult.success(pageList);
+    }
+
+    private PageList<AdminArticle> getPageList(Page<AdminArticle> page) {
+        int pageNumber = page.getNumber() + 1;
+        int pageSize = page.getSize();
+        long total = page.getTotalElements();
+        List<AdminArticle> list = page.getContent();
+        return PageList.pageList(pageNumber, pageSize, (int) total, list);
+    }
+
+    @Operation(summary = "编辑文章", description = "N")
+    @GetMapping("/edit")
+    public String edit(@RequestParam("articleId") String articleId) {
+        EditArticle editArticle = articleQuery.getEditArticle(articleId);
+        return WebResult.success(editArticle);
+    }
+
+    @Operation(summary = "新增/更新文章接口", description = "N")
+    @PostMapping(value = "/edit", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String editArticle(@Validated ArticleDto articleDto) {
+        String articleId = articleDto.getArticleId();
+        Result result;
+        if (articleId == null) {
+            result = articleService.addArticle(articleDto);
+        } else {
+            result = articleService.updateArticle(articleDto);
+        }
+
+        return WebResult.result(result);
+    }
+
+    @Operation(summary = "删除文章", description = "N")
+    @PostMapping(value = "/delete", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String delete(@RequestParam("articleId") String articleId) {
+        articleService.deleteArticle(articleId);
+        return WebResult.success();
+    }
+
+    @Operation(summary = "重置文章索引", description = "N")
+    @PostMapping(value = "/reset_index", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String resetIndex() {
+        hibernateLucene.resetIndex();
+        return WebResult.success();
+    }
+}

+ 58 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/AdminCategoryController.java

@@ -0,0 +1,58 @@
+package cn.reghao.tnb.search.app.blog.controller;
+
+import cn.reghao.tnb.common.web.WebResult;
+import cn.reghao.tnb.search.app.blog.model.CategoryType;
+import cn.reghao.tnb.search.app.blog.model.vo.CategoryCount;
+import cn.reghao.tnb.search.app.blog.service.CategoryService;
+import cn.reghao.jutil.jdk.web.result.Result;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-10 16:56:10
+ */
+@Tag(name = "博客文章分类接口")
+@RestController
+@RequestMapping("/api/blog/v2")
+public class AdminCategoryController {
+    private final CategoryService categoryService;
+
+    public AdminCategoryController(CategoryService categoryService) {
+        this.categoryService = categoryService;
+    }
+
+    @Operation(summary = "文章分类列表页面", description = "N")
+    @GetMapping("/category/list")
+    public String categoryList(@RequestParam("type") String type) {
+        int type1 = 0;
+        if ("category".equals(type)) {
+            type1 = CategoryType.Category.getValue();
+        } else if ("tag".equals(type)) {
+            type1 = CategoryType.Tag.getValue();
+        } else {
+            return WebResult.failWithMsg("type invalide");
+        }
+
+        List<CategoryCount> list = categoryService.findCategoryCountByPage(type1, true);
+        return WebResult.success(list);
+    }
+
+    @Operation(summary = "添加文章分类接口", description = "N")
+    @PostMapping(value = "/category/add", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String addCategory(String name) {
+        Result result = categoryService.add(name);
+        return WebResult.result(result);
+    }
+
+    @Operation(summary = "删除文章分类接口", description = "N")
+    @PostMapping(value = "/category/delete", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String delete(@RequestParam("id") int id) {
+        Result result = categoryService.deleteCategory(id);
+        return WebResult.result(result);
+    }
+}

+ 63 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/AdminQuestionController.java

@@ -0,0 +1,63 @@
+package cn.reghao.tnb.search.app.blog.controller;
+
+import cn.reghao.tnb.search.app.blog.model.vo.QuestionView;
+import cn.reghao.tnb.search.app.blog.service.QuestionService;
+import cn.reghao.jutil.jdk.web.db.PageList;
+import cn.reghao.jutil.jdk.web.result.WebResult;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.data.domain.Page;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-17 10:15:39
+ */
+@Tag(name = "面试题接口")
+@RestController
+@RequestMapping("/api/blog/v2/question")
+public class AdminQuestionController {
+    private final QuestionService questionService;
+    private int pageSize = 100;
+
+    public AdminQuestionController(QuestionService questionService) {
+        this.questionService = questionService;
+    }
+
+    @Operation(summary = "面试题列表页面", description = "N")
+    @GetMapping("/list")
+    public String list(@RequestParam("pn") int pageNumber,
+                       @RequestParam(value = "categoryId", required = false) Integer categoryId) {
+        if (categoryId == null) {
+            categoryId = 0;
+        }
+        Page<QuestionView> page0 = questionService.findQuestionByPage(pageSize, pageNumber, categoryId);
+        PageList<QuestionView> pageList = getPageList(page0);
+        return WebResult.success(pageList);
+    }
+
+    private PageList<QuestionView> getPageList(Page<QuestionView> page) {
+        int pageNumber = page.getNumber() + 1;
+        int pageSize = page.getSize();
+        long total = page.getTotalElements();
+        List<QuestionView> list = page.getContent();
+        return PageList.pageList(pageNumber, pageSize, (int) total, list);
+    }
+
+    @Operation(summary = "面试题加权重接口", description = "N")
+    @PostMapping(value = "/weight/add", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String addWeight(String questionId) {
+        questionService.addWeight(questionId);
+        return WebResult.success();
+    }
+
+    @Operation(summary = "面试题减权重接口", description = "N")
+    @PostMapping(value = "/weight/minus", produces = MediaType.APPLICATION_JSON_VALUE)
+    public String minusWeight(String questionId) {
+        questionService.minusWeight(questionId);
+        return WebResult.success();
+    }
+}

+ 24 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/BaseController.java

@@ -0,0 +1,24 @@
+package cn.reghao.tnb.search.app.blog.controller;
+
+import org.springframework.beans.propertyeditors.CustomDateEditor;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.ServletRequestDataBinder;
+import org.springframework.web.bind.annotation.InitBinder;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+/**
+ * @author reghao
+ * @date 2023-04-18 09:55:42
+ */
+//@Controller
+public class BaseController {
+    //@InitBinder
+    public void initBinder(ServletRequestDataBinder binder) {
+        // 自动转换日期类型的字段格式
+        binder.registerCustomEditor(Date.class, new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"), true));
+        // 防止XSS攻击
+        //binder.registerCustomEditor(String.class, new StringEscapeEditor(true, false));
+    }
+}

+ 145 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/controller/ForegroundController.java

@@ -0,0 +1,145 @@
+package cn.reghao.tnb.search.app.blog.controller;
+
+import cn.reghao.tnb.common.web.WebResult;
+import cn.reghao.tnb.search.app.blog.model.CategoryType;
+import cn.reghao.tnb.search.app.blog.model.po.AboutView;
+import cn.reghao.tnb.search.app.blog.model.vo.*;
+import cn.reghao.tnb.search.app.blog.service.ArticleQuery;
+import cn.reghao.tnb.search.app.blog.service.ArticleViewService;
+import cn.reghao.tnb.search.app.blog.service.CategoryService;
+import cn.reghao.jutil.jdk.web.db.PageList;
+import cn.reghao.tnb.search.app.hibernate.HibernateQuery;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.ui.ModelMap;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-16 14:27:07
+ */
+@Tag(name = "博客前台页面")
+@RestController
+@RequestMapping("/api/blog")
+public class ForegroundController /*extends BaseController */{
+    private final CategoryService categoryService;
+    private final ArticleQuery articleQuery;
+    private final ArticleViewService articleViewService;
+    private final HibernateQuery hibernateQuery;
+    private final int pageSize = 10;
+
+    public ForegroundController(CategoryService categoryService, ArticleQuery articleQuery,
+                                ArticleViewService articleViewService, HibernateQuery hibernateQuery) {
+        this.categoryService = categoryService;
+        this.articleQuery = articleQuery;
+        this.articleViewService = articleViewService;
+        this.hibernateQuery = hibernateQuery;
+    }
+
+    @Operation(summary = "前台首页", description = "N")
+    @GetMapping(value = "/post/list")
+    public String index(@RequestParam("pn") int pageNumber) {
+        Page<UserArticle> page = articleQuery.findUserArticleByPage(pageSize, pageNumber);
+        PageList<UserArticle> pageList = getPageList(page);
+        return WebResult.success(pageList);
+    }
+
+    @Operation(summary = "博客文章内容页面", description = "N")
+    @GetMapping("/post/detail")
+    public String article(@RequestParam("articleId") String articleId) {
+        ArticleVO articleVO = articleQuery.getArticle(articleId);
+        if (articleVO == null) {
+            return WebResult.failWithMsg("not found");
+        }
+
+        articleViewService.incr(articleId);
+        return WebResult.success(articleVO);
+    }
+
+    private PageList<UserArticle> getPageList(Page<UserArticle> page) {
+        int pageNumber = page.getNumber() + 1;
+        int pageSize = page.getSize();
+        long total = page.getTotalElements();
+        List<UserArticle> list = page.getContent();
+        return PageList.pageList(pageNumber, pageSize, (int) total, list);
+    }
+
+    @Operation(summary = "文章分类列表页面", description = "N")
+    @GetMapping("/category")
+    public String category() {
+        List<CategoryCount> list = categoryService.findCategoryCountByPage(CategoryType.Category.getValue(), false);
+        return WebResult.success(list);
+    }
+
+    @Operation(summary = "某个分类下的文章列表页面", description = "N")
+    @GetMapping("/category/post")
+    public String category1(@RequestParam("category") String category, @RequestParam("pn") int pageNumber) {
+        Page<UserArticle> page = articleQuery.findByCategory(pageSize, pageNumber, category, CategoryType.Category.getValue());
+        PageList<UserArticle> pageList = getPageList(page);
+        return WebResult.success(pageList);
+    }
+
+    @Operation(summary = "文章标签列表页面", description = "N")
+    @GetMapping("/tag")
+    public String tag(@RequestParam("type") String type) {
+        int typeInt = 0;
+        if (type.equals("tag")) {
+            typeInt = CategoryType.Tag.getValue();
+        } else if (type.equals("category")) {
+            typeInt = CategoryType.Category.getValue();
+        } else {
+            WebResult.failWithMsg("type invalid");
+        }
+
+        List<CategoryCount> list = categoryService.findCategoryCountByPage(typeInt, false);
+        return WebResult.success(list);
+    }
+
+    @Operation(summary = "某个标签下的文章列表页面", description = "N")
+    @GetMapping("/tag/post")
+    public String tagPost(@RequestParam("type") String type,
+                          @RequestParam("name") String name,
+                          @RequestParam("pn") int pageNumber) {
+        int typeInt = 0;
+        if (type.equals("tag")) {
+            typeInt = CategoryType.Tag.getValue();
+        } else if (type.equals("category")) {
+            typeInt = CategoryType.Category.getValue();
+        } else {
+            WebResult.failWithMsg("type invalid");
+        }
+
+        Page<UserArticle> page = articleQuery.findByCategory(pageSize, pageNumber, name, typeInt);
+        PageList<UserArticle> pageList = getPageList(page);
+        return WebResult.success(pageList);
+    }
+
+    @Operation(summary = "文章归档列表页面", description = "N")
+    @GetMapping("/archive")
+    public String archive() {
+        List<ArchiveArticle> results = articleQuery.getArchiveArticles();
+        List<ArticleLink> articleLinkList = articleQuery.getArticleLinks();
+        return WebResult.success(articleLinkList);
+    }
+
+    @Operation(summary = "文章搜索结果列表页面", description = "N")
+    @GetMapping("/search")
+    public String search(@RequestParam("kw") String kw, @RequestParam("pn") int pageNumber) {
+        PageRequest pageRequest = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.ASC, "createTime"));
+        Page<UserArticle> page = hibernateQuery.searchArticle(kw, pageRequest);
+        PageList<UserArticle> pageList = getPageList(page);
+        return WebResult.success(pageList);
+    }
+
+    @Operation(summary = "关于页面", description = "N")
+    @GetMapping("/about")
+    public String about(ModelMap model) {
+        AboutView aboutView = articleQuery.getAboutView();
+        return WebResult.success(aboutView);
+    }
+}

+ 13 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/AboutViewRepository.java

@@ -0,0 +1,13 @@
+package cn.reghao.tnb.search.app.blog.db.repository;
+
+import cn.reghao.tnb.search.app.blog.model.po.AboutView;
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+/**
+ * @author reghao
+ * @date 2025-06-14 20:06:46
+ */
+public interface AboutViewRepository extends JpaRepository<AboutView, Integer>, JpaSpecificationExecutor<Article> {
+}

+ 18 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/ArticleRepository.java

@@ -0,0 +1,18 @@
+package cn.reghao.tnb.search.app.blog.db.repository;
+
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+
+/**
+ * @author reghao
+ * @date 2024-01-28 13:17:47
+ */
+public interface ArticleRepository extends JpaRepository<Article, Integer>, JpaSpecificationExecutor<Article> {
+    int countByCategoryId(int categoryId);
+    Page<Article> findByPublishedIsTrueAndCategoryId(int categoryId, Pageable pageable);
+    Page<Article> findByPublishedIsTrue(Pageable pageable);
+    Article findByArticleId(String articleId);
+}

+ 22 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/ArticleTagRepository.java

@@ -0,0 +1,22 @@
+package cn.reghao.tnb.search.app.blog.db.repository;
+
+import cn.reghao.tnb.search.app.blog.model.po.ArticleTag;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-01-29 09:24:29
+ */
+public interface ArticleTagRepository extends JpaRepository<ArticleTag, Integer> {
+    @Transactional
+    @Modifying
+    void deleteByArticleId(String articleId);
+
+    List<ArticleTag> findByTagId(int tagId);
+    int countByTagId(int tagId);
+    List<ArticleTag> findByArticleId(String articleId);
+}

+ 24 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/ArticleViewRepository.java

@@ -0,0 +1,24 @@
+package cn.reghao.tnb.search.app.blog.db.repository;
+
+import cn.reghao.tnb.search.app.blog.model.po.ArticleView;
+import cn.reghao.tnb.search.app.blog.model.vo.ArticleCount;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-02-05 14:29:19
+ */
+public interface ArticleViewRepository extends JpaRepository<ArticleView, Integer> {
+    ArticleView findArticleViewByArticleIdAndSessionId(String articleId, String sessionId);
+    int countByArticleId(String articleId);
+
+    @Query("select new cn.reghao.tnb.search.app.blog.model.vo.ArticleCount(view.articleId, count(view))\n" +
+            "from ArticleView view\n" +
+            "group by view.articleId \n" +
+            "order by count(view) desc")
+    List<ArticleCount> findViewCountByGroup(Pageable pageable);
+}

+ 39 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/CategoryRepository.java

@@ -0,0 +1,39 @@
+package cn.reghao.tnb.search.app.blog.db.repository;
+
+import cn.reghao.tnb.search.app.blog.model.po.Category;
+import cn.reghao.tnb.search.app.blog.model.vo.GroupCount;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-01-29 09:24:29
+ */
+public interface CategoryRepository extends JpaRepository<Category, Integer> {
+    List<Category> findAllByType(int type);
+    Category findByName(String name);
+    Category findByTypeAndName(int type, String name);
+
+    @Query(value =
+            "SELECT article.category_id as id, category.name, count(article.category_id) as total\n" +
+                    "FROM blog_article as article\n" +
+                    "INNER JOIN blog_category as category\n" +
+                    "ON article.published is true and article.category_id=category.id\n" +
+                    "GROUP BY id\n" +
+                    "ORDER BY total DESC",
+            nativeQuery = true)
+    List<GroupCount> findCategoryGroup();
+
+    @Query(value =
+            "SELECT tag_id as id, category.name, count(articleTag.tag_id) as total\n" +
+                    "FROM blog_article_tag as articleTag\n" +
+                    "INNER JOIN blog_category as category\n" +
+                    "INNER JOIN blog_article as article\n" +
+                    "ON article.article_id=articleTag.article_id and article.published is true and articleTag.tag_id=category.id\n" +
+                    "GROUP BY id\n" +
+                    "ORDER BY total DESC",
+            nativeQuery = true)
+    List<GroupCount> findTagGroup();
+}

+ 27 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/db/repository/QuestionRepository.java

@@ -0,0 +1,27 @@
+package cn.reghao.tnb.search.app.blog.db.repository;
+
+import cn.reghao.tnb.search.app.blog.model.po.Question;
+import cn.reghao.tnb.search.app.blog.model.vo.QuestionCount;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-01-28 13:17:47
+ */
+public interface QuestionRepository extends JpaRepository<Question, Integer>, JpaSpecificationExecutor<Question> {
+    Page<Question> findByCategoryId(int categoryId, Pageable pageable);
+    Question findByQuestionId(String questionId);
+    int countByCategoryId(int categoryId);
+
+    @Query("select new cn.reghao.tnb.search.app.blog.model.vo.QuestionCount(question.categoryId, count(question))\n" +
+            "from Question question\n" +
+            "group by question.categoryId \n" +
+            "order by count(question) desc")
+    List<QuestionCount> findQuestionCountByGroup();
+}

+ 25 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/CategoryType.java

@@ -0,0 +1,25 @@
+package cn.reghao.tnb.search.app.blog.model;
+
+/**
+ * @author reghao
+ * @date 2024-01-30 13:26:14
+ */
+public enum CategoryType {
+    // 分类
+    Category(1),
+    // 标签
+    Tag(2);
+
+    private final int value;
+    CategoryType(int value) {
+        this.value = value;
+    }
+
+    public String getName() {
+        return this.name();
+    }
+
+    public Integer getValue() {
+        return value;
+    }
+}

+ 32 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/dto/ArticleDto.java

@@ -0,0 +1,32 @@
+package cn.reghao.tnb.search.app.blog.model.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-11 11:39:36
+ */
+@Setter
+@Getter
+public class ArticleDto {
+    private String articleId;
+    private String editor;
+    @NotBlank
+    private String title;
+    @NotBlank
+    private String content;
+    @NotNull
+    private Integer categoryId;
+    @Size(min = 1, max = 10, message = "文章必须有标签, 且最多为 10 个")
+    private List<String> tags;
+    @NotNull
+    private Boolean published;
+    @NotNull
+    private Integer type;
+}

+ 31 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/dto/ChannelDto.java

@@ -0,0 +1,31 @@
+package cn.reghao.tnb.search.app.blog.model.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-04-18 03:36:02
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class ChannelDto implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private Integer id;
+    @NotBlank
+    private String name;
+    @NotBlank
+    private String url;
+    @NotBlank
+    private String title;
+    @NotBlank
+    private String content;
+    @NotBlank
+    private String editor;
+}

+ 20 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/dto/QuestionUpdateDto.java

@@ -0,0 +1,20 @@
+package cn.reghao.tnb.search.app.blog.model.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2025-06-19 09:59:00
+ */
+@Setter
+@Getter
+public class QuestionUpdateDto {
+    @NotBlank
+    private String questionId;
+    @NotBlank
+    private String title;
+    @NotBlank
+    private String content;
+}

+ 24 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/AboutView.java

@@ -0,0 +1,24 @@
+package cn.reghao.tnb.search.app.blog.model.po;
+
+import cn.reghao.tnb.search.app.util.BaseEntity;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2023-04-18 04:38:07
+ */
+@NoArgsConstructor
+@AllArgsConstructor
+@Setter
+@Getter
+@Entity
+@Table(name = "blog_about_view")
+public class AboutView extends BaseEntity {
+    private String title;
+    private String content;
+}

+ 67 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/Article.java

@@ -0,0 +1,67 @@
+package cn.reghao.tnb.search.app.blog.model.po;
+
+import cn.reghao.tnb.common.auth.UserContext;
+import cn.reghao.tnb.search.app.blog.model.dto.ArticleDto;
+import cn.reghao.tnb.search.app.util.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.hibernate.search.engine.backend.types.Highlightable;
+import org.hibernate.search.engine.backend.types.Projectable;
+import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
+import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed;
+import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-10 17:57:36
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+@Indexed(index = "article")
+@Entity
+@Table(name = "blog_article")
+public class Article extends BaseEntity {
+    @KeywordField(projectable = Projectable.YES)
+    @Column(nullable = false, unique = true)
+    private String articleId;
+    @FullTextField(analyzer = "ikAnalyzer", highlightable = Highlightable.ANY)
+    @Column(nullable = false)
+    private String title;
+    @Column(nullable = false)
+    private String excerpt;
+    @FullTextField(analyzer = "ikAnalyzer", highlightable = Highlightable.ANY)
+    @Column(columnDefinition="text")
+    private String content;
+    @Column(nullable = false)
+    private String editor;
+    @Column(nullable = false)
+    private Integer categoryId;
+    @Column(nullable = false)
+    private Boolean published;
+    @Column(nullable = false)
+    private LocalDateTime publishAt;
+    private transient List<String> tagIds;
+    private Integer weight;
+    private Long owner;
+
+    public Article(String articleId, ArticleDto articleDto, String excerpt) {
+        this.articleId = articleId;
+        this.title = articleDto.getTitle();
+        this.excerpt = excerpt;
+        this.content = articleDto.getContent();
+        this.editor = articleDto.getEditor();
+        this.categoryId = articleDto.getCategoryId();
+        this.published = articleDto.getPublished();
+        this.publishAt = LocalDateTime.now();
+        this.weight = 0;
+        this.owner = UserContext.getUserId();
+    }
+}

+ 27 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/ArticleTag.java

@@ -0,0 +1,27 @@
+package cn.reghao.tnb.search.app.blog.model.po;
+
+import cn.reghao.tnb.search.app.util.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2023-11-03 23:28:39
+ */
+@NoArgsConstructor
+@AllArgsConstructor
+@Setter
+@Getter
+@Entity
+@Table(name = "blog_article_tag")
+public class ArticleTag extends BaseEntity {
+    @Column(nullable = false)
+    private String articleId;
+    @Column(nullable = false)
+    private Integer tagId;
+}

+ 29 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/ArticleView.java

@@ -0,0 +1,29 @@
+package cn.reghao.tnb.search.app.blog.model.po;
+
+import cn.reghao.tnb.search.app.util.BaseEntity;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2024-02-05 14:27:32
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+@Entity
+@Table(name = "blog_article_view")
+public class ArticleView extends BaseEntity {
+    private String articleId;
+    private String sessionId;
+    private String requestId;
+
+    public ArticleView(String articleId, String sessionId, String requestId) {
+        this.articleId = articleId;
+        this.sessionId = sessionId;
+        this.requestId = requestId;
+    }
+}

+ 25 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/Category.java

@@ -0,0 +1,25 @@
+package cn.reghao.tnb.search.app.blog.model.po;
+
+import cn.reghao.tnb.search.app.util.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author reghao
+ * @date 2023-04-10 17:54:17
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Getter
+@Entity
+@Table(name = "blog_category")
+public class Category extends BaseEntity {
+    @Column(nullable = false)
+    private Integer type;
+    @Column(nullable = false, unique = true)
+    private String name;
+}

+ 58 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/po/Question.java

@@ -0,0 +1,58 @@
+package cn.reghao.tnb.search.app.blog.model.po;
+
+import cn.reghao.tnb.common.auth.UserContext;
+import cn.reghao.tnb.search.app.blog.model.dto.ArticleDto;
+import cn.reghao.tnb.search.app.util.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Table;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.hibernate.search.engine.backend.types.Highlightable;
+import org.hibernate.search.engine.backend.types.Projectable;
+import org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField;
+import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed;
+import org.hibernate.search.mapper.pojo.mapping.definition.annotation.KeywordField;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author reghao
+ * @date 2023-04-10 17:57:36
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+@Indexed(index = "question")
+@Entity
+@Table(name = "blog_question")
+public class Question extends BaseEntity {
+    @KeywordField(projectable = Projectable.YES)
+    @Column(nullable = false, unique = true)
+    private String questionId;
+    @FullTextField(analyzer = "ikAnalyzer", highlightable = Highlightable.ANY)
+    @Column(nullable = false)
+    private String title;
+    @FullTextField(analyzer = "ikAnalyzer", highlightable = Highlightable.ANY)
+    @Column(columnDefinition="text")
+    private String content;
+    @Column(nullable = false)
+    private Integer categoryId;
+    private String tag;
+    @Column(nullable = false)
+    private Integer weight;
+    @Column(nullable = false)
+    private LocalDateTime publishAt;
+    private Long owner;
+
+    public Question(String questionId, ArticleDto articleDto, int categoryId) {
+        this.questionId = questionId;
+        this.title = articleDto.getTitle();
+        this.content = articleDto.getContent();
+        this.categoryId = categoryId;
+        this.weight = 0;
+        this.publishAt = LocalDateTime.now();
+        this.owner = UserContext.getUserId();
+    }
+}

+ 40 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/AdminArticle.java

@@ -0,0 +1,40 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-13 17:17:37
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class AdminArticle {
+    private String articleId;
+    private String title;
+    private String category;
+    private List<String> tags;
+    private String publishAt;
+    private boolean published;
+    private String editor;
+    private int views;
+    private String url;
+
+    public AdminArticle(Article article, int viewCount, String category, List<String> tags, String domain) {
+        this.articleId = article.getArticleId();
+        this.title = article.getTitle();
+        this.category = category;
+        this.tags = tags;
+        this.publishAt = DateTimeConverter.format(article.getUpdateTime());
+        this.published = article.getPublished();
+        this.editor = article.getEditor();
+        this.views = viewCount;
+        this.url = String.format("//%s/post/%s", domain, this.articleId);
+    }
+}

+ 23 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArchiveArticle.java

@@ -0,0 +1,23 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-14 14:13:58
+ */
+@Setter
+@Getter
+public class ArchiveArticle {
+    private String pubMonth;
+    private List<ArticleLink> list;
+
+    public ArchiveArticle(String pubMonth) {
+        this.pubMonth = pubMonth;
+        this.list = new ArrayList<>();
+    }
+}

+ 19 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleCount.java

@@ -0,0 +1,19 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2024-02-05 16:14:00
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Setter
+@Getter
+public class ArticleCount {
+    private String articleId;
+    private long total;
+}

+ 31 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleLink.java

@@ -0,0 +1,31 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+/**
+ * @author reghao
+ * @date 2023-04-13 17:25:41
+ */
+@Setter
+@Getter
+public class ArticleLink implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private String title;
+    private String pubDate;
+    private String url;
+
+    public ArticleLink() {
+        this.title = "没有了";
+        this.url = "";
+    }
+
+    public ArticleLink(String articleId, String title, String pubDate) {
+        this.title = title;
+        this.pubDate = pubDate;
+        this.url = String.format("post/%s", articleId);
+    }
+}

+ 20 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleProjection.java

@@ -0,0 +1,20 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2025-03-20 15:02:58
+ */
+@AllArgsConstructor
+@NoArgsConstructor
+@Getter
+public class ArticleProjection {
+    private String articleId;
+    private List<String> highlightTitle;
+    private List<String> highlightContent;
+}

+ 43 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/ArticleVO.java

@@ -0,0 +1,43 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-13 17:17:37
+ */
+@Setter
+@Getter
+public class ArticleVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private int id;
+    private String articleId;
+    private String title;
+    private String category;
+    private List<String> tags;
+    private String publishAt;
+    private long views;
+    private String editor;
+    private String content;
+    private ArticleLink prev;
+    private ArticleLink next;
+
+    public ArticleVO(Article article, int viewCount, String category, List<String> tags) {
+        this.id = article.getId();
+        this.articleId = article.getArticleId();
+        this.title = article.getTitle();
+        this.category = category;
+        this.tags = tags;
+        this.publishAt = DateTimeConverter.format(article.getPublishAt());
+        this.views = viewCount;
+        this.editor = article.getEditor();
+        this.content = article.getContent();
+    }
+}

+ 11 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/BlogChannel.java

@@ -0,0 +1,11 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@AllArgsConstructor
+@Getter
+public class BlogChannel {
+	private String name;
+	private String url;
+}

+ 34 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/CategoryCount.java

@@ -0,0 +1,34 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import cn.reghao.tnb.search.app.blog.model.po.Category;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2023-04-17 14:30:33
+ */
+@NoArgsConstructor
+@AllArgsConstructor
+@Setter
+@Getter
+public class CategoryCount {
+    private int index;
+    private int id;
+    private String name;
+    private int total;
+
+    public CategoryCount(GroupCount groupCount) {
+        this.id = groupCount.getId();
+        this.name = groupCount.getName();
+        this.total = groupCount.getTotal();
+    }
+
+    public CategoryCount(Category category) {
+        this.id = category.getId();
+        this.name = category.getName();
+        this.total = 0;
+    }
+}

+ 33 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/EditArticle.java

@@ -0,0 +1,33 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2023-04-13 17:17:37
+ */
+@NoArgsConstructor
+@Getter
+public class EditArticle {
+    private String articleId;
+    private String title;
+    private String content;
+    private String editor;
+    private Integer categoryId;
+    private List<String> tags;
+    private String published;
+
+    public EditArticle(Article article, List<String> tags) {
+        this.articleId = article.getArticleId();
+        this.title = article.getTitle();
+        this.content = article.getContent();
+        this.editor = article.getEditor();
+        this.categoryId = article.getCategoryId();
+        this.tags = tags;
+        this.published = article.getPublished() ? "1" : "0";
+    }
+}

+ 11 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/GroupCount.java

@@ -0,0 +1,11 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+/**
+ * @author reghao
+ * @date 2025-09-17 14:29:53
+ */
+public interface GroupCount {
+    int getId();
+    String getName();
+    int getTotal();
+}

+ 15 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/QuestionCount.java

@@ -0,0 +1,15 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * @author reghao
+ * @date 2024-07-16 20:17:58
+ */
+@AllArgsConstructor
+@Getter
+public class QuestionCount {
+    private Integer categoryId;
+    private Long total;
+}

+ 31 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/QuestionView.java

@@ -0,0 +1,31 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import cn.reghao.tnb.search.app.blog.model.po.Question;
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2024-06-13 21:05:21
+ */
+@Setter
+@Getter
+public class QuestionView {
+    private int id;
+    private String questionId;
+    private String title;
+    private String category;
+    private String tag;
+    private String content;
+    private int weight;
+
+    public QuestionView(Question question, String category, String tag) {
+        this.id = question.getId();
+        this.questionId = question.getQuestionId();
+        this.title = question.getTitle();
+        this.category = category;
+        this.tag = tag;
+        this.content = question.getContent();
+        this.weight = question.getWeight();
+    }
+}

+ 34 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/model/vo/UserArticle.java

@@ -0,0 +1,34 @@
+package cn.reghao.tnb.search.app.blog.model.vo;
+
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * @author reghao
+ * @date 2023-04-13 17:22:04
+ */
+@NoArgsConstructor
+@Setter
+@Getter
+public class UserArticle {
+    private Integer id;
+    private String articleId;
+    private String title;
+    private String category;
+    private String summary;
+    private String publishAt;
+    private int views;
+
+    public UserArticle(Article article, int viewCount, String category) {
+        this.id = article.getId();
+        this.articleId = article.getArticleId();
+        this.title = article.getTitle();
+        this.category = category;
+        this.summary = article.getExcerpt();
+        this.publishAt = DateTimeConverter.format(article.getPublishAt());
+        this.views = viewCount;
+    }
+}

+ 283 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/ArticleQuery.java

@@ -0,0 +1,283 @@
+package cn.reghao.tnb.search.app.blog.service;
+
+import cn.reghao.tnb.search.app.blog.db.repository.AboutViewRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleTagRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.CategoryRepository;
+import cn.reghao.tnb.search.app.blog.model.CategoryType;
+import cn.reghao.tnb.search.app.blog.model.po.AboutView;
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.tnb.search.app.blog.model.po.ArticleTag;
+import cn.reghao.tnb.search.app.blog.model.po.Category;
+import cn.reghao.tnb.search.app.blog.model.vo.*;
+import cn.reghao.jutil.jdk.converter.DateTimeConverter;
+import jakarta.persistence.criteria.Predicate;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.data.domain.*;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2023-04-15 03:47:50
+ */
+@Service
+public class ArticleQuery {
+    private final ArticleRepository articleRepository;
+    private final ArticleTagRepository articleTagRepository;
+    private final CategoryRepository categoryRepository;
+    private final ArticleViewService articleViewService;
+    private final AboutViewRepository aboutViewRepository;
+
+    public ArticleQuery(ArticleRepository articleRepository, ArticleTagRepository articleTagRepository,
+                        CategoryRepository categoryRepository, ArticleViewService articleViewService,
+                        AboutViewRepository aboutViewRepository) {
+        this.articleRepository = articleRepository;
+        this.articleTagRepository = articleTagRepository;
+        this.categoryRepository = categoryRepository;
+        this.articleViewService = articleViewService;
+        this.aboutViewRepository = aboutViewRepository;
+    }
+
+    public EditArticle getEditArticle(String articleId) {
+        Article article = articleRepository.findByArticleId(articleId);
+        if (article == null) {
+            return null;
+        }
+
+        List<Integer> tagIds = articleTagRepository.findByArticleId(articleId).stream()
+                .map(ArticleTag::getTagId)
+                .collect(Collectors.toList());
+        List<String> tags = categoryRepository.findAllById(tagIds).stream()
+                .map(Category::getName)
+                .collect(Collectors.toList());
+        return new EditArticle(article, tags);
+    }
+
+    @Cacheable(cacheNames = "ArticleVOs", key = "#articleId", unless = "#result == null")
+    public ArticleVO getArticle(String articleId) {
+        Article article = articleRepository.findByArticleId(articleId);
+        if (article == null || !article.getPublished()) {
+            return null;
+        }
+
+        int categoryId = article.getCategoryId();
+        Category category = categoryRepository.findById(categoryId).orElse(null);
+        String categoryName = category.getName();
+        List<ArticleTag> list = articleTagRepository.findByArticleId(articleId);
+        List<String> tags = list.stream()
+                .map(articleTag -> {
+                    int tagId = articleTag.getTagId();
+                    Category category1 = categoryRepository.findById(tagId).orElse(null);
+                    return category1 != null ? category1.getName() : null;
+                })
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        int viewCount = articleViewService.getViewCount(articleId);
+        ArticleVO articleVO = new ArticleVO(article, viewCount, categoryName, tags);
+        int id = articleVO.getId();
+        ArticleLink prev = getPrevLink(id);
+        ArticleLink next = getNextLink(id);
+        articleVO.setPrev(prev);
+        articleVO.setNext(next);
+        /*if ("markdown".equals(articleVO.getEditor())) {
+            String html = MarkdownUtil.getHtml(articleVO.getContent());
+            articleVO.setContent(html);
+        }*/
+
+        String content = articleVO.getContent();
+        //content += "<br/><p class=\"copyright\">声明:本文归作者所有,未经作者允许,不得转载</p>";
+        articleVO.setContent(content);
+        return articleVO;
+    }
+
+    public ArticleLink getPrevLink(int id) {
+        Specification<Article> specification = (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            predicates.add(cb.lt(root.get("id"), id));
+
+            return cb.and(predicates.toArray(new Predicate[0]));
+        };
+
+        List<Article> list = articleRepository.findAll(specification);
+        return list.isEmpty() ? new ArticleLink() : getLink(list.get(0));
+    }
+
+    public ArticleLink getNextLink(int id) {
+        Specification<Article> specification = (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            predicates.add(cb.gt(root.get("id"), id));
+            return cb.and(predicates.toArray(new Predicate[0]));
+        };
+
+        List<Article> list = articleRepository.findAll(specification);
+        return list.isEmpty() ? new ArticleLink() : getLink(list.get(0));
+    }
+
+    public List<ArchiveArticle> getArchiveArticles() {
+        List<ArticleLink> list = articleRepository.findAll().stream().map(this::getLink).collect(Collectors.toList());
+        Map<String, ArchiveArticle> results = new LinkedHashMap<>();
+        for (ArticleLink link : list) {
+            String pubDate = link.getPubDate();
+            String dateStr = pubDate.split(" ")[0];
+            String yearMonthStr = dateStr.substring(0, dateStr.lastIndexOf("-"));
+            ArchiveArticle article = results.computeIfAbsent(yearMonthStr, k -> new ArchiveArticle(yearMonthStr));
+            article.getList().add(link);
+        }
+
+        return new ArrayList<>(results.values());
+    }
+
+    public List<ArticleLink> getArticleLinks() {
+        return articleRepository.findAll().stream().map(this::getLink).collect(Collectors.toList());
+    }
+
+    public List<ArticleLink> getLatest(int size) {
+        Sort sort = Sort.by(Sort.Direction.DESC, "createTime");
+        Pageable pageable = PageRequest.of(0, size, sort);
+        return articleRepository.findAll(pageable).stream()
+                .map(this::getLink)
+                .collect(Collectors.toList());
+    }
+
+    public List<ArticleLink> getHottest(int size) {
+        List<ArticleCount> list = articleViewService.getArticleCount(size);
+        if (list.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        return list.stream()
+                .map(articleCount -> articleRepository.findByArticleId(articleCount.getArticleId()))
+                .filter(Objects::nonNull)
+                .map(this::getLink)
+                .collect(Collectors.toList());
+
+        /*Specification<Article> specification = (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            predicates.add(root.get("articleId").in(articleIds));
+            return cb.and(predicates.toArray(new Predicate[0]));
+        };*/
+    }
+
+    private ArticleLink getLink(Article article) {
+        String title = article.getTitle();
+        String articleId = article.getArticleId();
+        LocalDateTime pubDate = article.getPublishAt();
+        return new ArticleLink(articleId, title, DateTimeConverter.format(pubDate));
+    }
+
+    public Page<UserArticle> findUserArticleByPage(int pageSize, int pageNumber) {
+        Sort sort = Sort.by(Sort.Direction.DESC, "createTime");
+        Pageable pageable = PageRequest.of(pageNumber-1, pageSize, sort);
+        Page<Article> page = articleRepository.findByPublishedIsTrue(pageable);
+
+        List<UserArticle> list1 = page.getContent().stream().map(article -> {
+            int categoryId1 = article.getCategoryId();
+            Category category = categoryRepository.findById(categoryId1).orElse(null);
+            int viewCount = articleViewService.getViewCount(article.getArticleId());
+            String categoryName = category.getName();
+            return new UserArticle(article, viewCount, categoryName);
+        }).collect(Collectors.toList());
+        return new PageImpl<>(list1, pageable, page.getTotalElements());
+    }
+
+    public Page<UserArticle> findByCategory(int pageSize, int pageNumber, String categoryName, int type) {
+        Category category = categoryRepository.findByTypeAndName(type, categoryName);
+        if (category == null) {
+            return Page.empty();
+        }
+
+        long total;
+        List<Article> list;
+        List<UserArticle> list1;
+        Sort sort = Sort.by(Sort.Direction.DESC, "createTime");
+        Pageable pageable = PageRequest.of(pageNumber-1, pageSize, sort);
+        if (type == CategoryType.Category.getValue()) {
+            int categoryId = category.getId();
+            Page<Article> page = articleRepository.findByPublishedIsTrueAndCategoryId(categoryId, pageable);
+            total = page.getTotalElements();
+            list1 = page.getContent().stream()
+                    .map(article -> {
+                        int viewCount = articleViewService.getViewCount(article.getArticleId());
+                        return new UserArticle(article, viewCount, categoryName);
+                    })
+                    .collect(Collectors.toList());
+        } else {
+            int tagId = category.getId();
+            List<ArticleTag> articleTags = articleTagRepository.findByTagId(tagId);
+            total = articleTags.size();
+
+            list = findByArticleIds(articleTags.stream().map(ArticleTag::getArticleId).collect(Collectors.toList()));
+            list1 = list.stream()
+                    .map(article -> {
+                        int categoryId = article.getCategoryId();
+                        Category category1 = categoryRepository.findById(categoryId).orElse(null);
+                        int viewCount = articleViewService.getViewCount(article.getArticleId());
+                        return new UserArticle(article, viewCount, category1.getName());
+                    })
+                    .collect(Collectors.toList());
+        }
+
+        return new PageImpl<>(list1, pageable, total);
+    }
+
+    public Page<AdminArticle> findAdminArticleByPage(int pageSize, int pageNumber, int categoryId, int published, String title) {
+        Specification<Article> specification = (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            if (!title.isBlank()) {
+                predicates.add(cb.like(root.get("title"), "%"+title+"%"));
+            }
+
+            if (categoryId != 0) {
+                predicates.add(cb.equal(root.get("categoryId"), categoryId));
+            }
+
+            if (published == 1) {
+                predicates.add(cb.equal(root.get("published"), true));
+            } else if (published == 2) {
+                predicates.add(cb.equal(root.get("published"), false));
+            }
+
+            return cb.and(predicates.toArray(new Predicate[0]));
+        };
+
+        String domain = "";
+        Pageable pageable = PageRequest.of(pageNumber-1, pageSize, Sort.by(Sort.Direction.DESC, "createTime"));
+        Page<Article> page = articleRepository.findAll(specification, pageable);
+        List<AdminArticle> list1 = page.stream().map(article -> {
+            String articleId = article.getArticleId();
+            List<Integer> tagIds = articleTagRepository.findByArticleId(articleId).stream()
+                    .map(ArticleTag::getTagId)
+                    .collect(Collectors.toList());
+
+            List<String> tags = categoryRepository.findAllById(tagIds).stream()
+                    .map(Category::getName)
+                    .collect(Collectors.toList());
+
+            int categoryId1 = article.getCategoryId();
+            Category category = categoryRepository.findById(categoryId1).orElse(null);
+            String name = category.getName();
+            int viewCount = articleViewService.getViewCount(articleId);
+            return new AdminArticle(article, viewCount, name, tags, domain);
+        }).collect(Collectors.toList());
+        return new PageImpl<>(list1, pageable, page.getTotalElements());
+    }
+
+    public List<Article> findByArticleIds(List<String> articleIds) {
+        List<Article> list = new ArrayList<>();
+        for (String articleId : articleIds) {
+            list.add(articleRepository.findByArticleId(articleId));
+        }
+
+        return list;
+    }
+
+    public AboutView getAboutView() {
+        return aboutViewRepository.findById(1).orElse(null);
+    }
+}

+ 177 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/ArticleService.java

@@ -0,0 +1,177 @@
+package cn.reghao.tnb.search.app.blog.service;
+
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleTagRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.CategoryRepository;
+import cn.reghao.tnb.search.app.blog.model.CategoryType;
+import cn.reghao.tnb.search.app.blog.model.dto.ArticleDto;
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.tnb.search.app.blog.model.po.ArticleTag;
+import cn.reghao.tnb.search.app.blog.model.po.Category;
+import cn.reghao.jutil.jdk.string.IdGenerator;
+import cn.reghao.jutil.jdk.web.result.Result;
+import cn.reghao.tnb.search.app.blog.util.MarkdownUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.jsoup.Jsoup;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2023-04-10 18:00:50
+ */
+@Slf4j
+@Service
+public class ArticleService {
+    private final ArticleRepository articleRepository;
+    private final ArticleTagRepository articleTagRepository;
+    private final CategoryRepository categoryRepository;
+    private final IdGenerator idGenerator;
+    private final QuestionService questionService;
+
+    public ArticleService(ArticleRepository articleRepository, ArticleTagRepository articleTagRepository,
+                          CategoryRepository categoryRepository, QuestionService questionService) {
+        this.articleRepository = articleRepository;
+        this.articleTagRepository = articleTagRepository;
+        this.categoryRepository = categoryRepository;
+        this.idGenerator = new IdGenerator("article-id");
+        this.questionService = questionService;
+    }
+
+    public Result addArticle(ArticleDto articleDto) {
+        int type = articleDto.getType();
+        if (type == 2) {
+            return questionService.addQuestion(articleDto);
+        }
+
+        int categoryId = articleDto.getCategoryId();
+        Category category = categoryRepository.findById(categoryId).orElse(null);
+        if (category == null) {
+            String errMsg = String.format("category %s not exist", categoryId);
+            return Result.fail(errMsg);
+        }
+
+        if (articleDto.getEditor() == null) {
+            log.error("editor not exist");
+            articleDto.setEditor("markdown");
+        }
+
+        String articleId = idGenerator.stringId();
+        String content = articleDto.getContent();
+        String editor = articleDto.getEditor();
+        String excerpt = getExcerpt(editor, content);
+        Article article = new Article(articleId, articleDto, excerpt);
+        Set<String> tagNameSet = new HashSet<>(articleDto.getTags());
+        List<ArticleTag> articleTags = getArticleTags(articleId, tagNameSet);
+        save(article, articleTags);
+        return Result.success();
+    }
+
+    private List<ArticleTag> getArticleTags(String articleId, Set<String> tagNameSet) {
+        int type = CategoryType.Tag.getValue();
+        Set<Integer> tagIds = new HashSet<>();
+        tagNameSet.forEach(tagName -> {
+            Category tag = categoryRepository.findByTypeAndName(type, tagName);
+            if (tag != null) {
+                tagIds.add(tag.getId());
+            } else {
+                tag = new Category(type, tagName);
+                categoryRepository.save(tag);
+                tagIds.add(tag.getId());
+            }
+        });
+
+        return tagIds.stream()
+                .map(tagId -> new ArticleTag(articleId, tagId))
+                .collect(Collectors.toList());
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void save(Article article, List<ArticleTag> articleTags) {
+        articleRepository.save(article);
+        if (!articleTags.isEmpty()) {
+            // 删除当前存在的所有标签, 然后再添加更新后的标签
+            articleTagRepository.deleteByArticleId(article.getArticleId());
+            articleTagRepository.saveAll(articleTags);
+        }
+    }
+
+    @CacheEvict(cacheNames = "ArticleVOs", key = "#articleDto.getArticleId()")
+    public Result updateArticle(ArticleDto articleDto) {
+        String articleId = articleDto.getArticleId();
+        Article article = articleRepository.findByArticleId(articleId);
+        if (article == null) {
+            return Result.fail("article not exist");
+        }
+
+        int categoryId = articleDto.getCategoryId();
+        if (article.getCategoryId() != categoryId) {
+            Category category = categoryRepository.findById(categoryId).orElse(null);
+            if (category == null) {
+                String errMsg = String.format("category %s not exist", categoryId);
+                return Result.fail(errMsg);
+            }
+            article.setCategoryId(categoryId);
+        }
+
+        List<Category> currentTags = articleTagRepository.findByArticleId(articleId).stream()
+                .map(articleTag -> categoryRepository.findById(articleTag.getTagId()).orElse(null))
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+        Set<String> currentTagNames = currentTags.stream().map(Category::getName).collect(Collectors.toSet());
+        boolean notChange = currentTagNames.size() == articleDto.getTags().size();
+        if (notChange) {
+            for (String tag : articleDto.getTags()) {
+                if (!currentTagNames.contains(tag)) {
+                    notChange = false;
+                    break;
+                }
+            }
+        }
+
+        List<ArticleTag> articleTags = new ArrayList<>();
+        if (!notChange) {
+            // 如果文章标签有变化, 则重新设置
+            Set<String> tagNameSet = new HashSet<>(articleDto.getTags());
+            articleTags = getArticleTags(articleId, tagNameSet);
+        }
+
+        String editor = article.getEditor();
+        String title = articleDto.getTitle();
+        String content = articleDto.getContent();
+        String excerpt = getExcerpt(editor, content);
+        boolean published = articleDto.getPublished();
+        article.setTitle(title);
+        article.setExcerpt(excerpt);
+        article.setContent(content);
+        article.setPublished(published);
+        save(article, articleTags);
+        return Result.success();
+    }
+
+    private String getExcerpt(String editor, String content) {
+        String text = "";
+        if (editor.equals("markdown")) {
+            text = MarkdownUtil.getText(content);
+        } else if (editor.equals("tinymce")) {
+            text = Jsoup.parse(content).text();
+        }
+
+        String excerpt = text.length() < 50 ? text : text.substring(0, 50);
+        return String.format("<p>%s</p>", excerpt);
+    }
+
+    @CacheEvict(cacheNames = "ArticleVOs", key = "#articleId")
+    @Transactional(rollbackFor = Exception.class)
+    public void deleteArticle(String articleId) {
+        Article article = articleRepository.findByArticleId(articleId);
+        if (article != null) {
+            articleRepository.delete(article);
+            articleTagRepository.deleteByArticleId(articleId);
+        }
+    }
+}

+ 43 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/ArticleViewService.java

@@ -0,0 +1,43 @@
+package cn.reghao.tnb.search.app.blog.service;
+
+import cn.reghao.tnb.common.web.ServletUtil;
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleViewRepository;
+import cn.reghao.tnb.search.app.blog.model.po.ArticleView;
+import cn.reghao.tnb.search.app.blog.model.vo.ArticleCount;
+import cn.reghao.jutil.jdk.http.HeaderNames;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * @author reghao
+ * @date 2024-02-05 14:29:51
+ */
+@Service
+public class ArticleViewService {
+    private final ArticleViewRepository articleViewRepository;
+
+    public ArticleViewService(ArticleViewRepository articleViewRepository) {
+        this.articleViewRepository = articleViewRepository;
+    }
+
+    public void incr(String articleId) {
+        String sessionId = ServletUtil.getSessionId();
+        ArticleView articleVisitor = articleViewRepository.findArticleViewByArticleIdAndSessionId(articleId, sessionId);
+        if (articleVisitor == null) {
+            String requestId = (String) ServletUtil.getRequest().getAttribute(HeaderNames.XRequestId);
+            articleVisitor = new ArticleView(articleId, sessionId, requestId);
+            articleViewRepository.save(articleVisitor);
+        }
+    }
+
+    public int getViewCount(String articleId) {
+        return articleViewRepository.countByArticleId(articleId);
+    }
+
+    public List<ArticleCount> getArticleCount(int size) {
+        PageRequest pageRequest = PageRequest.of(0, size);
+        return articleViewRepository.findViewCountByGroup(pageRequest);
+    }
+}

+ 121 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/CategoryService.java

@@ -0,0 +1,121 @@
+package cn.reghao.tnb.search.app.blog.service;
+
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleTagRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.CategoryRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.QuestionRepository;
+import cn.reghao.tnb.search.app.blog.model.CategoryType;
+import cn.reghao.tnb.search.app.blog.model.po.Category;
+import cn.reghao.tnb.search.app.blog.model.vo.CategoryCount;
+import cn.reghao.tnb.search.app.blog.model.vo.GroupCount;
+import cn.reghao.jutil.jdk.web.result.Result;
+import org.springframework.stereotype.Service;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2023-04-10 17:54:39
+ */
+@Service
+public class CategoryService {
+    private final CategoryRepository categoryRepository;
+    private final ArticleTagRepository articleTagRepository;
+    private final ArticleRepository articleRepository;
+    private final QuestionRepository questionRepository;
+
+    public CategoryService(CategoryRepository categoryRepository, ArticleTagRepository articleTagRepository,
+                           ArticleRepository articleRepository, QuestionRepository questionRepository) {
+        this.categoryRepository = categoryRepository;
+        this.articleTagRepository = articleTagRepository;
+        this.articleRepository = articleRepository;
+        this.questionRepository = questionRepository;
+    }
+
+    public Result add(String name) {
+        Category category = categoryRepository.findByName(name);
+        if (category != null) {
+            return Result.fail(String.format("%s exist", name));
+        }
+
+        category = new Category(CategoryType.Category.getValue(), name);
+        categoryRepository.save(category);
+        return Result.success();
+    }
+
+    public Result deleteCategory(int id) {
+        Category category = categoryRepository.findById(id).orElse(null);
+        if (category == null) {
+            return Result.fail("not exist");
+        }
+
+        int type = category.getType();
+        if (type == CategoryType.Category.getValue()) {
+            int articleCount = articleRepository.countByCategoryId(id);
+            if (articleCount != 0) {
+                return Result.fail("分类中包含有文章");
+            }
+
+            int questionCount = questionRepository.countByCategoryId(id);
+            if (questionCount != 0) {
+                return Result.fail("Question 中含有此分类");
+            }
+        } else if (type == CategoryType.Tag.getValue()) {
+            int total = articleTagRepository.countByTagId(id);
+            if (total != 0) {
+                return Result.fail("标签中包含有文章");
+            }
+        }
+
+        categoryRepository.delete(category);
+        return Result.success();
+    }
+
+    public List<Category> findAllCategory() {
+        return categoryRepository.findAllByType(CategoryType.Category.getValue());
+    }
+
+    /**
+     * 根据分类/标签包含的 article 数量降序返回, 只有后台管理时才显示 article 数量为 0 的分类/标签
+     *
+     * @param
+     * @return
+     * @date 2025-09-17 09:35:37
+     */
+    public List<CategoryCount> findCategoryCountByPage(int type, boolean admin) {
+        List<GroupCount> groupCountList;
+        if (type == CategoryType.Category.getValue()) {
+            groupCountList = categoryRepository.findCategoryGroup();
+        } else if (type == CategoryType.Tag.getValue()) {
+            groupCountList = categoryRepository.findTagGroup();
+        } else {
+            return Collections.emptyList();
+        }
+
+        List<CategoryCount> list = groupCountList.stream()
+                .map(CategoryCount::new)
+                .collect(Collectors.toList());
+        if (admin) {
+            List<Category> categoryList = categoryRepository.findAllByType(type);
+            if (categoryList.size() > list.size()) {
+                Set<Integer> idSet = list.stream().map(CategoryCount::getId).collect(Collectors.toSet());
+                for (Category category : categoryList) {
+                    if (!idSet.contains(category.getId())) {
+                        list.add(new CategoryCount(category));
+                    }
+                }
+            }
+        }
+
+        list = list.stream()
+                .sorted((o1, o2) -> o2.getTotal()- o1.getTotal())
+                .collect(Collectors.toList());
+        for (int i = 0; i < list.size(); i++) {
+            list.get(i).setIndex(i+1);
+        }
+        return list;
+    }
+}

+ 132 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/service/QuestionService.java

@@ -0,0 +1,132 @@
+package cn.reghao.tnb.search.app.blog.service;
+
+import cn.reghao.tnb.search.app.blog.db.repository.CategoryRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.QuestionRepository;
+import cn.reghao.tnb.search.app.blog.model.dto.ArticleDto;
+import cn.reghao.tnb.search.app.blog.model.dto.QuestionUpdateDto;
+import cn.reghao.tnb.search.app.blog.model.po.Category;
+import cn.reghao.tnb.search.app.blog.model.po.Question;
+import cn.reghao.tnb.search.app.blog.model.vo.QuestionCount;
+import cn.reghao.tnb.search.app.blog.model.vo.QuestionView;
+import cn.reghao.tnb.search.app.blog.util.MarkdownUtil;
+import cn.reghao.jutil.jdk.string.IdGenerator;
+import cn.reghao.jutil.jdk.web.result.Result;
+import jakarta.persistence.criteria.Predicate;
+import org.springframework.data.domain.*;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * @author reghao
+ * @date 2024-06-12 21:15:15
+ */
+@Service
+public class QuestionService {
+    private final QuestionRepository questionRepository;
+    private final CategoryRepository categoryRepository;
+    private final IdGenerator idGenerator;
+
+    public QuestionService(QuestionRepository questionRepository, CategoryRepository categoryRepository) {
+        this.questionRepository = questionRepository;
+        this.categoryRepository = categoryRepository;
+        this.idGenerator = new IdGenerator("question-id");
+    }
+
+    public Result addQuestion(ArticleDto articleDto) {
+        int categoryId = articleDto.getCategoryId();
+        Category category = categoryRepository.findById(categoryId).orElse(null);
+        if (category == null) {
+            category = new Category();
+            categoryRepository.save(category);
+        }
+        String questionId = idGenerator.stringId();
+        Question question = new Question(questionId, articleDto, categoryId);
+        questionRepository.save(question);
+        return Result.success();
+    }
+
+    public void updateQuestion(QuestionUpdateDto questionUpdateDto) {
+        String questionId = questionUpdateDto.getQuestionId();
+        Question question = questionRepository.findByQuestionId(questionId);
+        if (question != null) {
+            question.setTitle(questionUpdateDto.getTitle());
+            question.setContent(questionUpdateDto.getContent());
+            questionRepository.save(question);
+        }
+    }
+
+    public void addWeight(String questionId) {
+        Question question = questionRepository.findByQuestionId(questionId);
+        if (question != null) {
+            int weight = question.getWeight();
+            question.setWeight(weight+1);
+            questionRepository.save(question);
+        }
+    }
+
+    public void minusWeight(String questionId) {
+        Question question = questionRepository.findByQuestionId(questionId);
+        if (question != null) {
+            int weight = question.getWeight();
+            question.setWeight(weight-1);
+            questionRepository.save(question);
+        }
+    }
+
+    public void deleteQuestions(List<String> questionIds) {
+        questionIds.forEach(questionId -> {
+            Question question = questionRepository.findByQuestionId(questionId);
+            if (question != null) {
+                questionRepository.delete(question);
+            }
+        });
+    }
+
+    public Page<QuestionView> findQuestionByPage(int pageSize, int pageNumber, int categoryId) {
+        Specification<Question> specification = (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            if (categoryId != 0) {
+                predicates.add(cb.equal(root.get("categoryId"), categoryId));
+            }
+
+            return cb.and(predicates.toArray(new Predicate[0]));
+        };
+
+        Pageable pageable = PageRequest.of(pageNumber-1, pageSize, Sort.by(Sort.Direction.DESC, "weight"));
+        Page<Question> page = questionRepository.findAll(specification, pageable);
+        List<QuestionView> list1 = page.stream().map(question -> {
+            Category category = categoryRepository.findById(question.getCategoryId()).orElse(null);
+            String categoryName = category.getName();
+            String tagName = question.getTag();
+            return new QuestionView(question, categoryName, tagName);
+        }).collect(Collectors.toList());
+
+        return new PageImpl<>(list1, pageable, page.getTotalElements());
+    }
+
+    public List<Category> getCategories() {
+        List<QuestionCount> questionCounts = questionRepository.findQuestionCountByGroup();
+        List<Integer> ids = questionCounts.stream().map(QuestionCount::getCategoryId).collect(Collectors.toList());
+        return categoryRepository.findAllById(ids);
+    }
+
+    public Question findByQuestionId(String questionId) {
+        return questionRepository.findByQuestionId(questionId);
+    }
+
+    public QuestionView getQuestionView(String questionId) {
+        Question question = questionRepository.findByQuestionId(questionId);
+        Category category = categoryRepository.findById(question.getCategoryId()).orElse(null);
+        String categoryName = category.getName();
+        String tagName = question.getTag();
+
+        QuestionView questionView = new QuestionView(question, categoryName, tagName);
+        String html = MarkdownUtil.getHtml(question.getContent());
+        questionView.setContent(html);
+        return questionView;
+    }
+}

+ 58 - 0
search/search-service/src/main/java/cn/reghao/tnb/search/app/blog/util/MarkdownUtil.java

@@ -0,0 +1,58 @@
+package cn.reghao.tnb.search.app.blog.util;
+
+import org.commonmark.Extension;
+import org.commonmark.ext.front.matter.YamlFrontMatterExtension;
+import org.commonmark.ext.gfm.tables.TableBlock;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.Node;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.AttributeProvider;
+import org.commonmark.renderer.html.HtmlRenderer;
+import org.commonmark.renderer.text.TextContentRenderer;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * @author reghao
+ * @date 2023-04-17 04:18:48
+ */
+public class MarkdownUtil {
+    public static String getText(String markdown) {
+        Parser parser = Parser.builder()
+                .extensions(EXTENSIONS)
+                .build();
+        Node node = parser.parse(markdown);
+
+        TextContentRenderer textRender = TextContentRenderer.builder().build();
+        return textRender.render(node);
+    }
+
+    public static String getHtml(String markdown) {
+        Parser parser = Parser.builder()
+                .extensions(EXTENSIONS)
+                .build();
+        Node node = parser.parse(markdown);
+
+        HtmlRenderer renderer = HtmlRenderer.builder()
+                .extensions(EXTENSIONS)
+                .attributeProviderFactory(context -> new BlogAttributeProvider())
+                .build();
+        return renderer.render(node);
+    }
+
+    private static final List<Extension> EXTENSIONS = Arrays.asList(
+            YamlFrontMatterExtension.create(),
+            TablesExtension.create()
+    );
+
+    static class BlogAttributeProvider implements AttributeProvider {
+        @Override
+        public void setAttributes(Node node, String s, Map<String, String> map) {
+            if (node instanceof TableBlock) {
+                map.put("class", "table table-bordered");
+            }
+        }
+    }
+}

+ 6 - 4
search/search-service/src/main/java/cn/reghao/tnb/search/app/hibernate/HibernateLucene.java

@@ -1,5 +1,7 @@
 package cn.reghao.tnb.search.app.hibernate;
 
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.tnb.search.app.blog.model.po.Question;
 import cn.reghao.tnb.search.app.model.po.WenshuDoc;
 import org.hibernate.search.mapper.orm.Search;
 import org.hibernate.search.mapper.orm.session.SearchSession;
@@ -20,12 +22,12 @@ public class HibernateLucene {
         this.searchSession = Search.session(entityManagerFactory.createEntityManager());
     }
 
-    public void createIndexes() {
-        List<Class<?>> classList = List.of(WenshuDoc.class);
+    public void createIndex() {
+        List<Class<?>> classList = List.of(WenshuDoc.class, Article.class, Question.class);
         searchSession.massIndexer(classList).start();
     }
 
-    public void resetIndexes() {
-        createIndexes();
+    public void resetIndex() {
+        createIndex();
     }
 }

+ 115 - 3
search/search-service/src/main/java/cn/reghao/tnb/search/app/hibernate/HibernateQuery.java

@@ -1,5 +1,10 @@
 package cn.reghao.tnb.search.app.hibernate;
 
+import cn.reghao.tnb.search.app.blog.db.repository.ArticleRepository;
+import cn.reghao.tnb.search.app.blog.db.repository.CategoryRepository;
+import cn.reghao.tnb.search.app.blog.model.po.Article;
+import cn.reghao.tnb.search.app.blog.model.vo.ArticleProjection;
+import cn.reghao.tnb.search.app.blog.model.vo.UserArticle;
 import cn.reghao.tnb.search.app.db.repository.WenshuDocRepository;
 import cn.reghao.tnb.search.app.model.po.Wenshu;
 import cn.reghao.tnb.search.app.model.po.WenshuDoc;
@@ -7,16 +12,23 @@ import cn.reghao.tnb.search.app.model.vo.WenshuDocProjection;
 import cn.reghao.tnb.search.app.model.vo.WenshuResult;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.*;
+import org.apache.lucene.search.highlight.Highlighter;
+import org.apache.lucene.search.highlight.QueryScorer;
 import org.apache.lucene.search.highlight.SimpleHTMLFormatter;
 import org.hibernate.search.engine.search.query.SearchResult;
 import org.hibernate.search.mapper.orm.Search;
 import org.hibernate.search.mapper.orm.session.SearchSession;
+import org.jsoup.Jsoup;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageImpl;
 import org.springframework.data.domain.Pageable;
 import org.springframework.stereotype.Service;
 
 import jakarta.persistence.EntityManagerFactory;
+import org.wltea.analyzer.lucene.IKAnalyzer;
+
 import java.util.List;
 import java.util.stream.Collectors;
 
@@ -28,13 +40,20 @@ import java.util.stream.Collectors;
 @Service
 public class HibernateQuery {
     private final SearchSession searchSession;
+    private final WenshuDocRepository wenshuDocRepository;
+    private final CategoryRepository categoryRepository;
+    private final ArticleRepository articleRepository;
     private final SimpleHTMLFormatter formatter;
-    private WenshuDocRepository wenshuDocRepository;
+    private final Analyzer analyzer;
 
-    public HibernateQuery(EntityManagerFactory entityManagerFactory, WenshuDocRepository wenshuDocRepository) {
+    public HibernateQuery(EntityManagerFactory entityManagerFactory, WenshuDocRepository wenshuDocRepository,
+                          CategoryRepository categoryRepository, ArticleRepository articleRepository) {
         this.searchSession = Search.session(entityManagerFactory.createEntityManager());
-        this.formatter = new SimpleHTMLFormatter("<span style='color:red;'>", "</span>");
         this.wenshuDocRepository = wenshuDocRepository;
+        this.categoryRepository = categoryRepository;
+        this.articleRepository = articleRepository;
+        this.analyzer = new IKAnalyzer();
+        this.formatter = new SimpleHTMLFormatter("<span style='color:red;'>", "</span>");
     }
 
     public Page<WenshuResult> search(String keyword, Pageable pageable) {
@@ -69,4 +88,97 @@ public class HibernateQuery {
         }).collect(Collectors.toList());
         return new PageImpl<>(wenshuResultList, pageable, totalHitCount);
     }
+
+    public Page<UserArticle> searchFromLucene(String keyword, Pageable pageable) {
+        int pn = pageable.getPageNumber();
+        int ps = pageable.getPageSize();
+        SearchResult<Article> result = searchSession.search(Article.class)
+                .where(f -> f.bool(b -> {
+                    b.must(f.matchAll())
+                            .should(f.match().field("title").matching(keyword).boost(3))
+                            .should(f.match().field("content").matching(keyword).boost(3));
+                }))
+                .fetch(pn*ps, ps);
+        long totalHitCount0 = result.total().hitCount();
+        List<Article> list0 = result.hits();
+
+        Query luceneQuery = getLuceneQuery(keyword);
+        QueryScorer scorer = new QueryScorer(luceneQuery);
+        Highlighter highlighter = new Highlighter(formatter, scorer);
+        List<UserArticle> userArticles0 = list0.stream().map(article -> {
+            int viewCount = 0;
+            int categoryId = article.getCategoryId();
+            String category = categoryRepository.findById(categoryId).orElse(null).getName();
+            UserArticle userArticle = new UserArticle(article, viewCount, category);
+            try {
+                // 处理高亮
+                String title = highlighter.getBestFragment(analyzer, "title", userArticle.getTitle());
+                if (title != null && !title.isBlank()) {
+                    userArticle.setTitle(title);
+                }
+
+                String text = Jsoup.parse(article.getContent()).text();
+                String content = highlighter.getBestFragment(analyzer, "content", text);
+                if (content != null && !content.isBlank()) {
+                    userArticle.setSummary(content);
+                }
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+            return userArticle;
+        }).collect(Collectors.toList());
+        return new PageImpl<>(userArticles0, pageable, totalHitCount0);
+    }
+
+    private Query getLuceneQuery(String keyword) {
+        Query query1 = new BoostQuery(new TermQuery(new Term("title", keyword)), 3);
+        Query query2 = new BoostQuery(new TermQuery(new Term("content", keyword)), 3);
+        BooleanQuery.Builder builder = new BooleanQuery.Builder();
+        builder.add(query1, BooleanClause.Occur.SHOULD);
+        builder.add(query2, BooleanClause.Occur.SHOULD);
+        BooleanQuery luceneQuery = builder.build();
+        return luceneQuery;
+    }
+
+    public Page<UserArticle> searchArticle(String keyword, Pageable pageable) {
+        int pn = pageable.getPageNumber();
+        int ps = pageable.getPageSize();
+        SearchResult<ArticleProjection> highlightResult = searchSession.search(Article.class)
+                .select(f -> f.composite()
+                        .from(f.field("articleId", String.class),
+                                f.highlight("title"),
+                                f.highlight("content"))
+                        .as(ArticleProjection::new))
+                .where(f -> f.bool(b -> {
+                    b.must(f.matchAll())
+                            .should(f.match().field("title").matching(keyword).boost(3))
+                            .should(f.match().field("content").matching(keyword).boost(3));
+                }))
+                .highlighter(f -> f.plain().tag("<span style='color:red;'>", "</span>"))
+                .fetch(pn*ps, ps);
+
+        long totalHitCount = highlightResult.total().hitCount();
+        List<ArticleProjection> list1 = highlightResult.hits();
+        List<UserArticle> userArticles = list1.stream().map(article -> {
+            int viewCount = 0;
+            String articleId = article.getArticleId();
+            Article article1 = articleRepository.findByArticleId(articleId);
+            int categoryId = article1.getCategoryId();
+            String category = categoryRepository.findById(categoryId).orElse(null).getName();
+            UserArticle userArticle = new UserArticle(article1, viewCount, category);
+            List<String> highlightTitle = article.getHighlightTitle();
+            if (!highlightTitle.isEmpty()) {
+                userArticle.setTitle(highlightTitle.get(0));
+            }
+
+            List<String> highlightContent = article.getHighlightContent();
+            if (!highlightContent.isEmpty()) {
+                userArticle.setSummary(highlightContent.get(0));
+            }
+
+            return userArticle;
+        }).collect(Collectors.toList());
+
+        return new PageImpl<>(userArticles, pageable, totalHitCount);
+    }
 }