diff --git a/backend/pom.xml b/backend/pom.xml index 63fd831..2661289 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -19,6 +19,19 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-validation + diff --git a/backend/src/main/java/com/example/blog/BlogApplication.java b/backend/src/main/java/com/example/blog/BlogApplication.java index 8da4687..5bef686 100644 --- a/backend/src/main/java/com/example/blog/BlogApplication.java +++ b/backend/src/main/java/com/example/blog/BlogApplication.java @@ -2,231 +2,10 @@ package com.example.blog; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.stream.Collectors; @SpringBootApplication -@RestController -@CrossOrigin(origins = "*") -@RequestMapping("/api") public class BlogApplication { - public static class Post { - private String id; - private String title; - private String summary; - private String content; - private String author; - private String date; - private String category; - private List tags; - private String readTime; - private int views; - private int likes; - - public Post() {} - - public Post(String id, String title, String summary, String content, String author, - String date, String category, List tags, String readTime, int views, int likes) { - this.id = id; - this.title = title; - this.summary = summary; - this.content = content; - this.author = author; - this.date = date; - this.category = category; - this.tags = tags; - this.readTime = readTime; - this.views = views; - this.likes = likes; - } - - public String getId() { return id; } - public void setId(String id) { this.id = id; } - public String getTitle() { return title; } - public void setTitle(String title) { this.title = title; } - public String getSummary() { return summary; } - public void setSummary(String summary) { this.summary = summary; } - public String getContent() { return content; } - public void setContent(String content) { this.content = content; } - public String getAuthor() { return author; } - public void setAuthor(String author) { this.author = author; } - public String getDate() { return date; } - public void setDate(String date) { this.date = date; } - public String getCategory() { return category; } - public void setCategory(String category) { this.category = category; } - public List getTags() { return tags; } - public void setTags(List tags) { this.tags = tags; } - public String getReadTime() { return readTime; } - public void setReadTime(String readTime) { this.readTime = readTime; } - public int getViews() { return views; } - public void setViews(int views) { this.views = views; } - public int getLikes() { return likes; } - public void setLikes(int likes) { this.likes = likes; } - } - - private static final List POSTS = new CopyOnWriteArrayList<>(); - - static { - POSTS.add(new Post( - "1", - "云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel", - "本文记录了如何通过 GCP 赠金在亚洲低延迟机房部署 4核16G 高性能云服务器,并使用现代化容器面板 1Panel 进行运维管理实录。", - "# 云原生实践:在 Google Cloud 台湾节点搭建高性能服务器\n\n" + - "在挑选云服务器的过程中,综合对比了各大厂商的免费策略与性能表现,最终选择了 **Google Cloud 台湾节点 (`asia-east1`)**。\n\n" + - "## 核心硬件与网络配置\n\n" + - "- **规格**:`e2-standard-4`(4 vCPU,16 GB 内存)\n" + - "- **系统**:Ubuntu 22.04 LTS (x86_64)\n" + - "- **延迟**:到中国大陆仅 30~50ms,操作极为丝滑。\n\n" + - "## 为什么采用 1Panel 面板?\n\n" + - "相比传统臃肿的面料,1Panel 深度拥抱 **Docker 容器化**,所有应用(MySQL、OpenResty、Gitea)完全容器隔离,真正做到“环境随用随建,数据挂载持久化”。\n\n" + - "```bash\n# 快速检查 Docker 运行状态\ndocker ps --format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\"\n```\n\n" + - "> 💡 **运维心得**:永远在云平台控制台统一放行防火墙端口,切勿盲目在系统内部启用 UFW,避免失联风险。", - "旺总", - "2026-10-22", - "云原生 & Linux", - Arrays.asList("GCP", "Ubuntu", "1Panel", "Linux"), - "4 min", - 528, - 42 - )); - - POSTS.add(new Post( - "2", - "探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优", - "深度解析在纯 CPU 服务器上运行 DeepSeek R1 和通义千问 2.5 的真实表现,揭开 2050 Token 预填充延迟背后的机制。", - "# 探索本地大模型:从 Ollama 到 Open WebUI 性能调优实战\n\n" + - "近期 DeepSeek R1 推理思考模型火遍全球,我们在 4核16G 的服务器上进行了本地大模型的全链路压测。\n\n" + - "## 1. 架构搭建\n\n" + - "- **模型发动机**:`Ollama` 容器\n" + - "- **交互界面**:`Open WebUI` 容器\n" + - "- **网络通信**:通过 Docker 网关 `http://172.17.0.1:11434` 极速直连\n\n" + - "## 2. 性能排查与调优秘籍\n\n" + - "测试过程中遇到了“每次提问卡顿 50 秒”的问题,经过 `top` 分析和指标剖析:\n\n" + - "1. **解除容器 CPU 配额**:确保 4 线程全开(`eval rate` 提升至 14 tokens/s);\n" + - "2. **内存永久常驻**:注入 `OLLAMA_KEEP_ALIVE=-1`,避免 5 分钟超时后重新读盘;\n" + - "3. **清空全局默认 Prompt**:消除了默认附带的 2050 历史 Token,读题耗时从 60s 骤降至 0.2s!\n\n" + - "```bash\n# 测试模型运行性能\ndocker exec -it 1Panel-ollama-nzre ollama run qwen2.5:1.5b --verbose\n```\n\n" + - "> 纯 CPU 跑 7B 参数是物理带宽瓶颈,日常开发推荐搭配 1.5B~3B 模型,速度与逻辑达到最佳平衡!", - "旺总", - "2026-10-22", - "AI & 本地模型", - Arrays.asList("DeepSeek", "Ollama", "OpenWebUI", "性能调优"), - "6 min", - 892, - 68 - )); - - POSTS.add(new Post( - "3", - "全栈工程师实战:Spring Boot 3 + Vue 3 博客与 Gitea CI/CD 自动化流水线", - "从零手写前后端分离架构,配合私有 Gitea 仓库与自动化部署脚本,实现 push 代码后全自动打包上线。", - "# 全栈工程化实践:Spring Boot 3 + Vue 3 + CI/CD 自动化\n\n" + - "现代化开发的精髓在于**“把重复劳动交给自动化”**。我们搭建了一套小而美但功能极其完备的企业级全栈流水线。\n\n" + - "## 全栈架构一览\n\n" + - "```text\n[开发者本地 IDEA] ──git push──▶ [私有 Gitea 仓库]\n │\n (Webhook 触发流水线)\n ▼\n [deploy.sh 自动化脚本]\n │\n ┌─────────────┴─────────────┐\n ▼ ▼\n [Spring Boot 3 容器] [Nginx 静态反代容器]\n```\n\n" + - "## Nginx 反向代理配置关键点\n\n" + - "前端容器中通过反代 `/api/` 到后端服务容器,天然解决跨域和外部端口分散问题:\n\n" + - "```nginx\nlocation /api/ {\n proxy_pass http://backend:8080/api/;\n proxy_set_header Host $host;\n}\n```\n\n" + - "从此,改完代码只需一条 `git push`,剩下的事情交给服务器自动化即可!", - "旺总", - "2026-10-22", - "全栈开发 & CI/CD", - Arrays.asList("Spring Boot", "Vue 3", "CI/CD", "Docker Compose"), - "5 min", - 641, - 55 - )); - } - - @GetMapping("/posts") - public List getPosts( - @RequestParam(required = false) String category, - @RequestParam(required = false) String search) { - return POSTS.stream() - .filter(p -> category == null || category.isEmpty() || category.equalsIgnoreCase("all") || p.getCategory().equalsIgnoreCase(category)) - .filter(p -> search == null || search.isEmpty() || - p.getTitle().toLowerCase().contains(search.toLowerCase()) || - p.getSummary().toLowerCase().contains(search.toLowerCase())) - .collect(Collectors.toList()); - } - - @GetMapping("/posts/{id}") - public Post getPost(@PathVariable String id) { - return POSTS.stream() - .filter(p -> p.getId().equals(id)) - .findFirst() - .map(p -> { - p.setViews(p.getViews() + 1); - return p; - }) - .orElse(null); - } - - @PostMapping("/posts") - public Post addPost(@RequestBody Post post) { - String newId = String.valueOf(POSTS.size() + 1); - post.setId(newId); - if (post.getDate() == null || post.getDate().isEmpty()) { - post.setDate(LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)); - } - if (post.getAuthor() == null || post.getAuthor().isEmpty()) { - post.setAuthor("旺总"); - } - if (post.getCategory() == null || post.getCategory().isEmpty()) { - post.setCategory("技术杂谈"); - } - if (post.getReadTime() == null || post.getReadTime().isEmpty()) { - post.setReadTime("3 min"); - } - if (post.getTags() == null || post.getTags().isEmpty()) { - post.setTags(Collections.singletonList(post.getCategory())); - } - post.setViews(1); - post.setLikes(0); - POSTS.add(0, post); - return post; - } - - @PostMapping("/posts/{id}/like") - public Map likePost(@PathVariable String id) { - Map res = new HashMap<>(); - for (Post p : POSTS) { - if (p.getId().equals(id)) { - p.setLikes(p.getLikes() + 1); - res.put("success", true); - res.put("likes", p.getLikes()); - return res; - } - } - res.put("success", false); - return res; - } - - @DeleteMapping("/posts/{id}") - public Map deletePost(@PathVariable String id) { - boolean removed = POSTS.removeIf(p -> p.getId().equals(id)); - Map res = new HashMap<>(); - res.put("success", removed); - return res; - } - - @GetMapping("/stats") - public Map getStats() { - Map stats = new HashMap<>(); - stats.put("totalPosts", POSTS.size()); - stats.put("totalViews", POSTS.stream().mapToInt(Post::getViews).sum()); - stats.put("totalLikes", POSTS.stream().mapToInt(Post::getLikes).sum()); - Set categories = POSTS.stream().map(Post::getCategory).collect(Collectors.toSet()); - stats.put("categoriesCount", categories.size()); - return stats; - } - public static void main(String[] args) { SpringApplication.run(BlogApplication.class, args); } diff --git a/backend/src/main/java/com/example/blog/common/GlobalExceptionHandler.java b/backend/src/main/java/com/example/blog/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..47c4b08 --- /dev/null +++ b/backend/src/main/java/com/example/blog/common/GlobalExceptionHandler.java @@ -0,0 +1,16 @@ +package com.example.blog.common; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e) { + return Result.error(e.getMessage() != null ? e.getMessage() : "服务器内部错误"); + } +} diff --git a/backend/src/main/java/com/example/blog/common/Result.java b/backend/src/main/java/com/example/blog/common/Result.java new file mode 100644 index 0000000..aa6d86a --- /dev/null +++ b/backend/src/main/java/com/example/blog/common/Result.java @@ -0,0 +1,38 @@ +package com.example.blog.common; + +public class Result { + private int code; + private String message; + private T data; + + public Result() {} + + public Result(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static Result success(T data) { + return new Result<>(200, "操作成功", data); + } + + public static Result success() { + return new Result<>(200, "操作成功", null); + } + + public static Result error(String message) { + return new Result<>(500, message, null); + } + + public static Result error(int code, String message) { + return new Result<>(code, message, null); + } + + public int getCode() { return code; } + public void setCode(int code) { this.code = code; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public T getData() { return data; } + public void setData(T data) { this.data = data; } +} diff --git a/backend/src/main/java/com/example/blog/config/DataInitializer.java b/backend/src/main/java/com/example/blog/config/DataInitializer.java new file mode 100644 index 0000000..b9f937c --- /dev/null +++ b/backend/src/main/java/com/example/blog/config/DataInitializer.java @@ -0,0 +1,103 @@ +package com.example.blog.config; + +import com.example.blog.entity.Category; +import com.example.blog.entity.Post; +import com.example.blog.repository.CategoryRepository; +import com.example.blog.repository.PostRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +@Component +public class DataInitializer implements CommandLineRunner { + + private final PostRepository postRepository; + private final CategoryRepository categoryRepository; + + public DataInitializer(PostRepository postRepository, CategoryRepository categoryRepository) { + this.postRepository = postRepository; + this.categoryRepository = categoryRepository; + } + + @Override + public void run(String... args) { + // 1. 初始化分类 + if (categoryRepository.count() == 0) { + List categories = Arrays.asList( + new Category("云原生 & Linux", "容器化编排、GCP/云服务器运维、系统调优与网络配置实战"), + new Category("Java & Spring Boot", "Spring Boot 3 微服务架构、JPA 数据库持久化与后端工程规范"), + new Category("AI & 本地模型", "Ollama 本地大模型部署、DeepSeek R1 性能压测与应用集成"), + new Category("全栈开发 & CI/CD", "前后端分离架构、Gitea Actions 自动化流水线交付") + ); + categoryRepository.saveAll(categories); + } + + // 2. 初始化预置文章 + if (postRepository.count() == 0) { + Post p1 = new Post(); + p1.setTitle("云原生实践:在 Google Cloud 台湾节点搭建高性能 Ubuntu 与 1Panel"); + p1.setSummary("本文记录了如何通过 GCP 赠金在亚洲低延迟机房部署 4核16G 高性能云服务器,并使用现代化容器面板 1Panel 进行运维管理实录。"); + p1.setContent("# 云原生实践:在 Google Cloud 台湾节点搭建高性能服务器\n\n" + + "在挑选云服务器的过程中,综合对比了各大厂商的免费策略与性能表现,最终选择了 **Google Cloud 台湾节点 (`asia-east1`)**。\n\n" + + "## 核心硬件与网络配置\n" + + "- **规格**:`e2-standard-4`(4 vCPU,16 GB 内存)\n" + + "- **系统**:Ubuntu 22.04 LTS (x86_64)\n" + + "- **延迟**:到中国大陆仅 30~50ms,操作极为丝滑。\n\n" + + "## 为什么采用 1Panel 面板?\n" + + "相比传统臃肿的面料,1Panel 深度拥抱 **Docker 容器化**,所有应用(MySQL、OpenResty、Gitea)完全容器隔离,真正做到“环境随用随建,数据挂载持久化”。\n\n" + + "```bash\n# 快速检查 Docker 运行状态\ndocker ps --format \"table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\"\n```\n\n" + + "> 💡 **运维心得**:永远在云平台控制台统一放行防火墙端口,切勿盲目在系统内部启用 UFW,避免失联风险。"); + p1.setAuthor("旺总"); + p1.setCategoryName("云原生 & Linux"); + p1.setTags("GCP,Ubuntu,1Panel,Linux"); + p1.setReadTime("4 min"); + p1.setViews(682); + p1.setLikes(58); + + Post p2 = new Post(); + p2.setTitle("探索本地大模型:从 Ollama 运行 DeepSeek R1 到 CPU 推理性能调优"); + p2.setSummary("深度解析在纯 CPU 服务器上运行 DeepSeek R1 和通义千问 2.5 的真实表现,揭开 2050 Token 预填充延迟背后的机制。"); + p2.setContent("# 探索本地大模型:从 Ollama 到 Open WebUI 性能调优实战\n\n" + + "近期 DeepSeek R1 推理思考模型火遍全球,我们在 4核16G 的服务器上进行了本地大模型的全链路压测。\n\n" + + "## 1. 架构搭建\n" + + "- **模型发动机**:`Ollama` 容器\n" + + "- **交互界面**:`Open WebUI` 容器\n" + + "- **网络通信**:通过 Docker 网关 `http://172.17.0.1:11434` 极速直连\n\n" + + "## 2. 性能排查与调优秘籍\n" + + "测试过程中遇到了“每次提问卡顿 50 秒”的问题,经过 `top` 分析和指标剖析:\n" + + "1. **解除容器 CPU 配额**:确保 4 线程全开(`eval rate` 提升至 14 tokens/s);\n" + + "2. **内存永久常驻**:注入 `OLLAMA_KEEP_ALIVE=-1`,避免 5 分钟超时后重新读盘;\n" + + "3. **清空全局默认 Prompt**:消除了默认附带的 2050 历史 Token,读题耗时从 60s 骤降至 0.2s!\n\n" + + "```bash\n# 测试模型运行性能\ndocker exec -it 1Panel-ollama-nzre ollama run qwen2.5:1.5b --verbose\n```\n\n" + + "> 纯 CPU 跑 7B 参数是物理带宽瓶颈,日常开发推荐搭配 1.5B~3B 模型,速度与逻辑达到最佳平衡!"); + p2.setAuthor("旺总"); + p2.setCategoryName("AI & 本地模型"); + p2.setTags("DeepSeek,Ollama,OpenWebUI,性能调优"); + p2.setReadTime("6 min"); + p2.setViews(1048); + p2.setLikes(96); + + Post p3 = new Post(); + p3.setTitle("全栈工程师实战:Spring Boot 3 + MySQL 8.0 持久化与 Gitea CI/CD 自动化交付"); + p3.setSummary("从零构建工业级 Spring Boot 3 分层架构,结合 MySQL 8.0 数据库持久化存储与私有 Gitea Actions 自动化流水线。"); + p3.setContent("# 全栈工程化实践:Spring Boot 3 + MySQL 8.0 + CI/CD 自动化\n\n" + + "现代化开发的精髓在于**“把数据交给专业数据库持久化,把重复劳动交给自动化流水线”**。\n\n" + + "## 全栈架构一览\n" + + "```text\n[开发者本地 IDEA] ──git push──▶ [私有 Gitea 仓库]\n │\n (Gitea Actions 触发流水线)\n ▼\n [Docker Compose 全栈构建]\n │\n ┌───────────────────────────┼───────────────────────────┐\n ▼ ▼ ▼\n [MySQL 8.0 容器] [Spring Boot 3 容器] [Nginx 静态反代容器]\n```\n\n" + + "## 数据持久化规范\n" + + "- 数据文件挂载至命名卷 `mysql_data`,容器重启或重建数据零丢失;\n" + + "- Spring Data JPA 实现对象关系映射,自动完成数据库结构维护;\n" + + "- 统一 RESTful API 与响应封装,全栈开发得心应手!"); + p3.setAuthor("旺总"); + p3.setCategoryName("全栈开发 & CI/CD"); + p3.setTags("Spring Boot,MySQL,JPA,CI/CD"); + p3.setReadTime("5 min"); + p3.setViews(815); + p3.setLikes(72); + + postRepository.saveAll(Arrays.asList(p1, p2, p3)); + } + } +} diff --git a/backend/src/main/java/com/example/blog/controller/CategoryController.java b/backend/src/main/java/com/example/blog/controller/CategoryController.java new file mode 100644 index 0000000..2266edc --- /dev/null +++ b/backend/src/main/java/com/example/blog/controller/CategoryController.java @@ -0,0 +1,33 @@ +package com.example.blog.controller; + +import com.example.blog.common.Result; +import com.example.blog.entity.Category; +import com.example.blog.repository.CategoryRepository; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/categories") +@CrossOrigin(origins = "*") +public class CategoryController { + + private final CategoryRepository categoryRepository; + + public CategoryController(CategoryRepository categoryRepository) { + this.categoryRepository = categoryRepository; + } + + @GetMapping + public Result> listCategories() { + return Result.success(categoryRepository.findAll()); + } + + @PostMapping + public Result createCategory(@RequestBody Category category) { + if (category.getName() == null || category.getName().trim().isEmpty()) { + throw new IllegalArgumentException("分类名称不能为空"); + } + return Result.success(categoryRepository.save(category)); + } +} diff --git a/backend/src/main/java/com/example/blog/controller/CommentController.java b/backend/src/main/java/com/example/blog/controller/CommentController.java new file mode 100644 index 0000000..644ab5a --- /dev/null +++ b/backend/src/main/java/com/example/blog/controller/CommentController.java @@ -0,0 +1,30 @@ +package com.example.blog.controller; + +import com.example.blog.common.Result; +import com.example.blog.entity.Comment; +import com.example.blog.service.CommentService; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/posts/{postId}/comments") +@CrossOrigin(origins = "*") +public class CommentController { + + private final CommentService commentService; + + public CommentController(CommentService commentService) { + this.commentService = commentService; + } + + @GetMapping + public Result> getComments(@PathVariable Long postId) { + return Result.success(commentService.getCommentsByPostId(postId)); + } + + @PostMapping + public Result addComment(@PathVariable Long postId, @RequestBody Comment comment) { + return Result.success(commentService.addComment(postId, comment)); + } +} diff --git a/backend/src/main/java/com/example/blog/controller/PostController.java b/backend/src/main/java/com/example/blog/controller/PostController.java new file mode 100644 index 0000000..f52876a --- /dev/null +++ b/backend/src/main/java/com/example/blog/controller/PostController.java @@ -0,0 +1,53 @@ +package com.example.blog.controller; + +import com.example.blog.common.Result; +import com.example.blog.entity.Post; +import com.example.blog.service.PostService; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/posts") +@CrossOrigin(origins = "*") +public class PostController { + + private final PostService postService; + + public PostController(PostService postService) { + this.postService = postService; + } + + @GetMapping + public Result> listPosts( + @RequestParam(required = false) String category, + @RequestParam(required = false) String search) { + return Result.success(postService.listPosts(category, search)); + } + + @GetMapping("/{id}") + public Result getPost(@PathVariable Long id) { + return Result.success(postService.getPostById(id)); + } + + @PostMapping + public Result createPost(@RequestBody Post post) { + return Result.success(postService.createPost(post)); + } + + @PutMapping("/{id}") + public Result updatePost(@PathVariable Long id, @RequestBody Post post) { + return Result.success(postService.updatePost(id, post)); + } + + @DeleteMapping("/{id}") + public Result deletePost(@PathVariable Long id) { + postService.deletePost(id); + return Result.success(); + } + + @PostMapping("/{id}/like") + public Result likePost(@PathVariable Long id) { + return Result.success(postService.likePost(id)); + } +} diff --git a/backend/src/main/java/com/example/blog/controller/StatsController.java b/backend/src/main/java/com/example/blog/controller/StatsController.java new file mode 100644 index 0000000..ece0fcd --- /dev/null +++ b/backend/src/main/java/com/example/blog/controller/StatsController.java @@ -0,0 +1,24 @@ +package com.example.blog.controller; + +import com.example.blog.common.Result; +import com.example.blog.service.PostService; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/stats") +@CrossOrigin(origins = "*") +public class StatsController { + + private final PostService postService; + + public StatsController(PostService postService) { + this.postService = postService; + } + + @GetMapping + public Result> getStats() { + return Result.success(postService.getBlogStats()); + } +} diff --git a/backend/src/main/java/com/example/blog/entity/Category.java b/backend/src/main/java/com/example/blog/entity/Category.java new file mode 100644 index 0000000..d0c7330 --- /dev/null +++ b/backend/src/main/java/com/example/blog/entity/Category.java @@ -0,0 +1,43 @@ +package com.example.blog.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "category") +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(length = 255) + private String description; + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } + + public Category() {} + + public Category(String name, String description) { + this.name = name; + this.description = description; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/example/blog/entity/Comment.java b/backend/src/main/java/com/example/blog/entity/Comment.java new file mode 100644 index 0000000..7074a26 --- /dev/null +++ b/backend/src/main/java/com/example/blog/entity/Comment.java @@ -0,0 +1,50 @@ +package com.example.blog.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "comment") +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "post_id", nullable = false) + private Long postId; + + @Column(nullable = false, length = 50) + private String author = "访客"; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (author == null || author.trim().isEmpty()) author = "匿名访客"; + } + + public Comment() {} + + public Comment(Long postId, String author, String content) { + this.postId = postId; + this.author = author; + this.content = content; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getPostId() { return postId; } + public void setPostId(Long postId) { this.postId = postId; } + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/example/blog/entity/Post.java b/backend/src/main/java/com/example/blog/entity/Post.java new file mode 100644 index 0000000..6b16365 --- /dev/null +++ b/backend/src/main/java/com/example/blog/entity/Post.java @@ -0,0 +1,93 @@ +package com.example.blog.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "post") +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String title; + + @Column(length = 500) + private String summary; + + @Column(nullable = false, columnDefinition = "LONGTEXT") + private String content; + + @Column(length = 50) + private String author = "旺总"; + + @Column(name = "category_name", length = 50) + private String categoryName = "技术杂谈"; + + @Column(length = 255) + private String tags; + + @Column(name = "read_time", length = 20) + private String readTime = "3 min"; + + private Integer views = 0; + + private Integer likes = 0; + + @Column(length = 20) + private String status = "PUBLISHED"; + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (updatedAt == null) updatedAt = LocalDateTime.now(); + if (views == null) views = 0; + if (likes == null) likes = 0; + if (author == null) author = "旺总"; + if (categoryName == null) categoryName = "技术杂谈"; + if (readTime == null) readTime = "3 min"; + if (status == null) status = "PUBLISHED"; + } + + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } + + public Post() {} + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getSummary() { return summary; } + public void setSummary(String summary) { this.summary = summary; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + public String getCategoryName() { return categoryName; } + public void setCategoryName(String categoryName) { this.categoryName = categoryName; } + public String getTags() { return tags; } + public void setTags(String tags) { this.tags = tags; } + public String getReadTime() { return readTime; } + public void setReadTime(String readTime) { this.readTime = readTime; } + public Integer getViews() { return views; } + public void setViews(Integer views) { this.views = views; } + public Integer getLikes() { return likes; } + public void setLikes(Integer likes) { this.likes = likes; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/backend/src/main/java/com/example/blog/repository/CategoryRepository.java b/backend/src/main/java/com/example/blog/repository/CategoryRepository.java new file mode 100644 index 0000000..91de0c0 --- /dev/null +++ b/backend/src/main/java/com/example/blog/repository/CategoryRepository.java @@ -0,0 +1,12 @@ +package com.example.blog.repository; + +import com.example.blog.entity.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CategoryRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/backend/src/main/java/com/example/blog/repository/CommentRepository.java b/backend/src/main/java/com/example/blog/repository/CommentRepository.java new file mode 100644 index 0000000..a4aba47 --- /dev/null +++ b/backend/src/main/java/com/example/blog/repository/CommentRepository.java @@ -0,0 +1,13 @@ +package com.example.blog.repository; + +import com.example.blog.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommentRepository extends JpaRepository { + List findByPostIdOrderByCreatedAtDesc(Long postId); + long countByPostId(Long postId); +} diff --git a/backend/src/main/java/com/example/blog/repository/PostRepository.java b/backend/src/main/java/com/example/blog/repository/PostRepository.java new file mode 100644 index 0000000..19f2442 --- /dev/null +++ b/backend/src/main/java/com/example/blog/repository/PostRepository.java @@ -0,0 +1,37 @@ +package com.example.blog.repository; + +import com.example.blog.entity.Post; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PostRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(); + + List findByCategoryNameOrderByCreatedAtDesc(String categoryName); + + @Query("SELECT p FROM Post p WHERE " + + "(LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(p.summary) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) " + + "ORDER BY p.createdAt DESC") + List searchPosts(@Param("keyword") String keyword); + + @Query("SELECT p FROM Post p WHERE p.categoryName = :categoryName AND " + + "(LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(p.summary) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + "LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) " + + "ORDER BY p.createdAt DESC") + List searchPostsWithCategory(@Param("categoryName") String categoryName, @Param("keyword") String keyword); + + @Query("SELECT COALESCE(SUM(p.views), 0) FROM Post p") + Long sumViews(); + + @Query("SELECT COALESCE(SUM(p.likes), 0) FROM Post p") + Long sumLikes(); +} diff --git a/backend/src/main/java/com/example/blog/service/CommentService.java b/backend/src/main/java/com/example/blog/service/CommentService.java new file mode 100644 index 0000000..68591f0 --- /dev/null +++ b/backend/src/main/java/com/example/blog/service/CommentService.java @@ -0,0 +1,9 @@ +package com.example.blog.service; + +import com.example.blog.entity.Comment; +import java.util.List; + +public interface CommentService { + List getCommentsByPostId(Long postId); + Comment addComment(Long postId, Comment comment); +} diff --git a/backend/src/main/java/com/example/blog/service/PostService.java b/backend/src/main/java/com/example/blog/service/PostService.java new file mode 100644 index 0000000..5878f87 --- /dev/null +++ b/backend/src/main/java/com/example/blog/service/PostService.java @@ -0,0 +1,15 @@ +package com.example.blog.service; + +import com.example.blog.entity.Post; +import java.util.List; +import java.util.Map; + +public interface PostService { + List listPosts(String category, String search); + Post getPostById(Long id); + Post createPost(Post post); + Post updatePost(Long id, Post post); + void deletePost(Long id); + int likePost(Long id); + Map getBlogStats(); +} diff --git a/backend/src/main/java/com/example/blog/service/impl/CommentServiceImpl.java b/backend/src/main/java/com/example/blog/service/impl/CommentServiceImpl.java new file mode 100644 index 0000000..b0e2aa9 --- /dev/null +++ b/backend/src/main/java/com/example/blog/service/impl/CommentServiceImpl.java @@ -0,0 +1,44 @@ +package com.example.blog.service.impl; + +import com.example.blog.entity.Comment; +import com.example.blog.repository.CommentRepository; +import com.example.blog.repository.PostRepository; +import com.example.blog.service.CommentService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class CommentServiceImpl implements CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + public CommentServiceImpl(CommentRepository commentRepository, PostRepository postRepository) { + this.commentRepository = commentRepository; + this.postRepository = postRepository; + } + + @Override + @Transactional(readOnly = true) + public List getCommentsByPostId(Long postId) { + return commentRepository.findByPostIdOrderByCreatedAtDesc(postId); + } + + @Override + @Transactional + public Comment addComment(Long postId, Comment comment) { + if (!postRepository.existsById(postId)) { + throw new RuntimeException("评论失败,文章不存在,ID: " + postId); + } + if (comment.getContent() == null || comment.getContent().trim().isEmpty()) { + throw new IllegalArgumentException("评论内容不能为空"); + } + comment.setPostId(postId); + if (comment.getAuthor() == null || comment.getAuthor().trim().isEmpty()) { + comment.setAuthor("热心技术网友"); + } + return commentRepository.save(comment); + } +} diff --git a/backend/src/main/java/com/example/blog/service/impl/PostServiceImpl.java b/backend/src/main/java/com/example/blog/service/impl/PostServiceImpl.java new file mode 100644 index 0000000..fa3f8b7 --- /dev/null +++ b/backend/src/main/java/com/example/blog/service/impl/PostServiceImpl.java @@ -0,0 +1,114 @@ +package com.example.blog.service.impl; + +import com.example.blog.entity.Post; +import com.example.blog.repository.CategoryRepository; +import com.example.blog.repository.PostRepository; +import com.example.blog.service.PostService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final CategoryRepository categoryRepository; + + public PostServiceImpl(PostRepository postRepository, CategoryRepository categoryRepository) { + this.postRepository = postRepository; + this.categoryRepository = categoryRepository; + } + + @Override + @Transactional(readOnly = true) + public List listPosts(String category, String search) { + boolean hasCategory = category != null && !category.trim().isEmpty() && !"全部".equals(category); + boolean hasSearch = search != null && !search.trim().isEmpty(); + + if (hasCategory && hasSearch) { + return postRepository.searchPostsWithCategory(category.trim(), search.trim()); + } else if (hasCategory) { + return postRepository.findByCategoryNameOrderByCreatedAtDesc(category.trim()); + } else if (hasSearch) { + return postRepository.searchPosts(search.trim()); + } else { + return postRepository.findAllByOrderByCreatedAtDesc(); + } + } + + @Override + @Transactional + public Post getPostById(Long id) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new RuntimeException("文章不存在,ID: " + id)); + post.setViews(post.getViews() + 1); + return postRepository.save(post); + } + + @Override + @Transactional + public Post createPost(Post post) { + if (post.getTitle() == null || post.getTitle().trim().isEmpty()) { + throw new IllegalArgumentException("文章标题不能为空"); + } + if (post.getContent() == null || post.getContent().trim().isEmpty()) { + throw new IllegalArgumentException("文章正文内容不能为空"); + } + if (post.getSummary() == null || post.getSummary().trim().isEmpty()) { + String content = post.getContent().replaceAll("[#*`>-]", ""); + post.setSummary(content.length() > 120 ? content.substring(0, 120) + "..." : content); + } + if (post.getReadTime() == null || post.getReadTime().trim().isEmpty()) { + int minutes = Math.max(2, Math.round(post.getContent().length() / 350f)); + post.setReadTime(minutes + " min"); + } + return postRepository.save(post); + } + + @Override + @Transactional + public Post updatePost(Long id, Post post) { + Post existing = postRepository.findById(id) + .orElseThrow(() -> new RuntimeException("文章不存在,ID: " + id)); + if (post.getTitle() != null) existing.setTitle(post.getTitle()); + if (post.getSummary() != null) existing.setSummary(post.getSummary()); + if (post.getContent() != null) existing.setContent(post.getContent()); + if (post.getCategoryName() != null) existing.setCategoryName(post.getCategoryName()); + if (post.getTags() != null) existing.setTags(post.getTags()); + if (post.getAuthor() != null) existing.setAuthor(post.getAuthor()); + return postRepository.save(existing); + } + + @Override + @Transactional + public void deletePost(Long id) { + if (!postRepository.existsById(id)) { + throw new RuntimeException("文章不存在,ID: " + id); + } + postRepository.deleteById(id); + } + + @Override + @Transactional + public int likePost(Long id) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new RuntimeException("文章不存在,ID: " + id)); + post.setLikes(post.getLikes() + 1); + postRepository.save(post); + return post.getLikes(); + } + + @Override + @Transactional(readOnly = true) + public Map getBlogStats() { + Map stats = new HashMap<>(); + stats.put("totalPosts", postRepository.count()); + stats.put("totalViews", postRepository.sumViews()); + stats.put("totalLikes", postRepository.sumLikes()); + stats.put("totalCategories", categoryRepository.count()); + return stats; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..4909d90 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: 8080 + +spring: + application: + name: my-blog-backend + datasource: + url: jdbc:mysql://${DB_HOST:mysql}:${DB_PORT:3306}/${DB_NAME:my_blog}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: ${DB_USER:root} + password: ${DB_PASSWORD:root123456} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + connection-timeout: 30000 + maximum-pool-size: 10 + minimum-idle: 2 + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + open-in-view: false + +management: + endpoints: + web: + exposure: + include: health,info diff --git a/docker-compose.yml b/docker-compose.yml index 3bd4590..286d844 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,43 @@ version: '3.8' + services: + mysql: + image: mysql:8.0 + container_name: blog-mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: my_blog + TZ: Asia/Shanghai + command: + - --default-authentication-plugin=mysql_native_password + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot123456"] + interval: 5s + timeout: 5s + retries: 10 + backend: build: ./backend container_name: blog-backend restart: always + depends_on: + mysql: + condition: service_healthy + environment: + DB_HOST: mysql + DB_PORT: 3306 + DB_NAME: my_blog + DB_USER: root + DB_PASSWORD: root123456 + TZ: Asia/Shanghai ports: - "8089:8080" @@ -11,5 +45,10 @@ services: build: ./frontend container_name: blog-frontend restart: always + depends_on: + - backend ports: - "8088:80" + +volumes: + mysql_data: diff --git a/frontend/index.html b/frontend/index.html index 4e5cc37..00818be 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -33,7 +33,6 @@