feat(fullstack): 接入 MySQL 8.0 持久化与 Spring Boot 3 分层企业级架构,支持文章与评论完整 CRUD
This commit is contained in:
@@ -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<String> 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<String> 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<String> getTags() { return tags; }
|
||||
public void setTags(List<String> 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<Post> 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<Post> 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<String, Object> likePost(@PathVariable String id) {
|
||||
Map<String, Object> 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<String, Object> deletePost(@PathVariable String id) {
|
||||
boolean removed = POSTS.removeIf(p -> p.getId().equals(id));
|
||||
Map<String, Object> res = new HashMap<>();
|
||||
res.put("success", removed);
|
||||
return res;
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
public Map<String, Object> getStats() {
|
||||
Map<String, Object> 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<String> 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);
|
||||
}
|
||||
|
||||
@@ -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<Void> handleException(Exception e) {
|
||||
return Result.error(e.getMessage() != null ? e.getMessage() : "服务器内部错误");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.blog.common;
|
||||
|
||||
public class Result<T> {
|
||||
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 <T> Result<T> success(T data) {
|
||||
return new Result<>(200, "操作成功", data);
|
||||
}
|
||||
|
||||
public static <T> Result<T> success() {
|
||||
return new Result<>(200, "操作成功", null);
|
||||
}
|
||||
|
||||
public static <T> Result<T> error(String message) {
|
||||
return new Result<>(500, message, null);
|
||||
}
|
||||
|
||||
public static <T> Result<T> 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; }
|
||||
}
|
||||
@@ -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<Category> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<Category>> listCategories() {
|
||||
return Result.success(categoryRepository.findAll());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Result<Category> createCategory(@RequestBody Category category) {
|
||||
if (category.getName() == null || category.getName().trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("分类名称不能为空");
|
||||
}
|
||||
return Result.success(categoryRepository.save(category));
|
||||
}
|
||||
}
|
||||
@@ -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<List<Comment>> getComments(@PathVariable Long postId) {
|
||||
return Result.success(commentService.getCommentsByPostId(postId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Result<Comment> addComment(@PathVariable Long postId, @RequestBody Comment comment) {
|
||||
return Result.success(commentService.addComment(postId, comment));
|
||||
}
|
||||
}
|
||||
@@ -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<List<Post>> listPosts(
|
||||
@RequestParam(required = false) String category,
|
||||
@RequestParam(required = false) String search) {
|
||||
return Result.success(postService.listPosts(category, search));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Result<Post> getPost(@PathVariable Long id) {
|
||||
return Result.success(postService.getPostById(id));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Result<Post> createPost(@RequestBody Post post) {
|
||||
return Result.success(postService.createPost(post));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public Result<Post> updatePost(@PathVariable Long id, @RequestBody Post post) {
|
||||
return Result.success(postService.updatePost(id, post));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Void> deletePost(@PathVariable Long id) {
|
||||
postService.deletePost(id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/like")
|
||||
public Result<Integer> likePost(@PathVariable Long id) {
|
||||
return Result.success(postService.likePost(id));
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> getStats() {
|
||||
return Result.success(postService.getBlogStats());
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<Category, Long> {
|
||||
Optional<Category> findByName(String name);
|
||||
}
|
||||
@@ -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<Comment, Long> {
|
||||
List<Comment> findByPostIdOrderByCreatedAtDesc(Long postId);
|
||||
long countByPostId(Long postId);
|
||||
}
|
||||
@@ -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<Post, Long> {
|
||||
|
||||
List<Post> findAllByOrderByCreatedAtDesc();
|
||||
|
||||
List<Post> 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<Post> 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<Post> 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();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.blog.service;
|
||||
|
||||
import com.example.blog.entity.Comment;
|
||||
import java.util.List;
|
||||
|
||||
public interface CommentService {
|
||||
List<Comment> getCommentsByPostId(Long postId);
|
||||
Comment addComment(Long postId, Comment comment);
|
||||
}
|
||||
@@ -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<Post> 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<String, Object> getBlogStats();
|
||||
}
|
||||
@@ -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<Comment> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Post> 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<String, Object> getBlogStats() {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user