From 3aba6309df14f0b3a631da66b6bb57781f87598d Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 00:59:02 +0800 Subject: [PATCH 1/9] docs(common): add OpenBlog-common module extraction design Co-Authored-By: Claude --- .../2026-08-09-openblog-common-design.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-openblog-common-design.md diff --git a/docs/superpowers/specs/2026-08-09-openblog-common-design.md b/docs/superpowers/specs/2026-08-09-openblog-common-design.md new file mode 100644 index 0000000..518caec --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-openblog-common-design.md @@ -0,0 +1,137 @@ +# OpenBlog-common 公共模块抽取设计 + +日期:2026-08-09 +状态:已批准 + +## 背景与目标 + +当前 `OpenBlog-business` 的 `com.yqz.openblog.common` 包内有一批通用 REST/基础设施代码(`ApiResponse`、`PageResult`、`BizException`、`TraceId`、`TreeUtils`、`GlobalExceptionHandler`),但它们被锁死在 business 模块内,**独立服务 `OpenBlog-email` 无法复用**。这导致: + +- email 的 HTTP 接口返回原始对象 / `Map.of(...)`,与 business 的 `ApiResponse` 响应格式不一致。 +- email 没有任何统一异常处理,出错时返回 Spring 默认错误 JSON。 + +**目标**:抽取独立 `OpenBlog-common` 模块,让 business / email 共用统一响应格式与异常处理;email 的 HTTP 接口切换为 `ApiResponse` 包装。Dubbo RPC 契约(`EmailSendResult`)保持不变。 + +## 范围 + +**做**: +- 新建 `OpenBlog-common` jar 模块,搬入 6 个公共类 + 异常处理。 +- business 移除已搬走的源文件,加 common 依赖(import 零改动,因包名不变)。 +- email 加 common 依赖,HTTP 接口切 `ApiResponse` / `PageResult`。 + +**不做**(后续单独处理): +- `MybatisPlusMetaObjectHandler`(MyBatis-Plus 配置,概念上属于 framework 模块)。 +- 重复版本号收敛到根 pom `dependencyManagement`(属于独立的"基础设施清理"事项)。 +- 单元测试补充(README 待办)。 + +## 新模块结构 + +``` +OpenBlog-common/ +├── pom.xml # jar,parent = OpenBlog +└── src/main/java/com/yqz/openblog/common/ + ├── ApiResponse.java # 原样搬入 + ├── PageResult.java # 原样搬入 + ├── BizException.java # 原样搬入 + ├── TraceId.java # 原样搬入 + ├── TreeUtils.java # 原样搬入 + ├── GlobalExceptionHandler.java # 移入,移除 security 相关方法 + ├── SecurityExceptionHandler.java # 新增,security 异常处理,条件装配 + └── config/CommonAutoConfiguration.java # 自动装配入口 +src/main/resources/META-INF/spring/ + └── org.springframework.boot.autoconfigure.AutoConfiguration.imports +``` + +### pom 依赖 + +全部版本继承父 pom 的 Spring Boot BOM,不硬编码: + +| 依赖 | scope | 说明 | +|---|---|---| +| `spring-boot-autoconfigure` | compile | `@ConditionalOnClass` + 自动装配机制 | +| `spring-web` | compile | `@RestControllerAdvice`、`ResponseEntity`、校验异常 | +| `spring-security-core` | **optional** | 仅编译 `SecurityExceptionHandler`;不传递给下游模块 | +| `slf4j-api` | compile | 异常处理日志 | + +不引入 `spring-boot-starter-web`(避免把整个 web starter 拖进消费方),只声明所需的最小依赖。 + +## 异常处理拆分 + +原 `GlobalExceptionHandler` 混合了 web 异常和 security 异常,拆成两个类: + +### GlobalExceptionHandler(仅 web 依赖) +处理: +- `BizException` → code 前三位映射 HTTP status +- `MethodArgumentNotValidException` → 4001 +- `IOException` → 5001(带日志) +- `Exception` 兜底 → 5001(带日志) + +### SecurityExceptionHandler(新增,条件装配) +- 处理 `AccessDeniedException` / `AuthorizationDeniedException` → 4030 "无权限" +- 类级注解 `@ConditionalOnClass(AccessDeniedException.class)` +- 依赖 `spring-security-core`(optional scope),business 有 security 则生效,email 无 security 自动跳过 + +## 自动装配方式 + +**采用 Spring Boot 自动装配作为唯一注册机制**,而非修改 email 启动类扫描范围: + +- `CommonAutoConfiguration`(`@AutoConfiguration`)通过 `@Bean` 注册 `GlobalExceptionHandler`、`SecurityExceptionHandler`。 +- `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 声明该配置类。 +- 与现有 `OpenBlog-framework-audit` 模块的做法一致。 +- 原因:email 启动类在 `com.yqz.openblog.email` 包,默认组件扫描不到 `com.yqz.openblog.common`;用 `scanBasePackages` 硬改扫描范围脆弱,自动装配是标准库化方案。 + +### 组件扫描冲突及处理(关键) + +`@RestControllerAdvice` 是 `@Component` 的元注解。business 启动类 `OpenBlogApplication` 位于 `com.yqz.openblog` 包,**默认组件扫描会覆盖 `com.yqz.openblog.common.**`**,导致异常处理器被组件扫描 + 自动装配各注册一次 → 同名 bean 冲突(`ConflictingBeanDefinitionException`)。 + +**处理**:自动装配是唯一注册机制,business 启动类用正则过滤将 `com.yqz.openblog.common.*` 排除出组件扫描: + +```java +@SpringBootApplication +@ComponentScan( + basePackages = "com.yqz.openblog", + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = "com\\.yqz\\.openblog\\.common\\..*")) +public class OpenBlogApplication { ... } +``` + +注意: +- POJO 类(`ApiResponse` 等)不是 Spring bean,不受排除影响,包名不变、business 的 import 依旧零改动。 +- email 不扫描 `com.yqz.openblog` 包,无需排除,自动装配直接生效。 +- 两个 `@ConditionalOnWebApplication` 守卫保证该自动装配只在 web 应用中生效。 + +## 依赖接线 + +## 依赖接线 + +1. **根 `pom.xml`**:`` 增加 `OpenBlog-common`。 +2. **`OpenBlog-business/pom.xml`**:增加 `OpenBlog-common` 依赖;删除已搬走的 6 个源文件(`ApiResponse`、`PageResult`、`BizException`、`TraceId`、`TreeUtils`、`GlobalExceptionHandler`);`OpenBlogApplication` 加 `@ComponentScan` 排除 `com.yqz.openblog.common.*`。 +3. **`OpenBlog-email/pom.xml`**:增加 `OpenBlog-common` 依赖。 + +## email 接口契约变更 + +`EmailAdminController` 两个 HTTP 端点切换包装格式: + +| 端点 | 现状 | 改为 | +|---|---|---| +| `POST /api/v1/email/test` | 返回 `EmailSendResult` | `ApiResponse` | +| `GET /api/v1/email/records` | 返回 `Map.of("items", ...)` | `ApiResponse>` | + +**不变**: +- Dubbo RPC 接口 `EmailRpcService.send()` 仍返回 `EmailSendResult`(RPC 契约不裹壳)。 +- business 的 HTTP 响应结构(`code/message/data/traceId`)不变,前端零影响。 + +**顺带收益**:email 获得统一异常响应与 `traceId` 字段。 + +## 验证 + +```bash +mvn -pl OpenBlog-common -am clean install +mvn -pl OpenBlog-business -am clean package -DskipTests +mvn -pl OpenBlog-email -am clean package -DskipTests +``` + +- business 编译通过且 import 无遗漏(包名不变,预期无需改业务代码)。 +- email 编译通过,新响应格式生效。 +- 前端无需改动。 From b27ca5ce41d20428e0d026378819d4283d79a459 Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:02:14 +0800 Subject: [PATCH 2/9] docs(common): add implementation plan for OpenBlog-common extraction Co-Authored-By: Claude --- .../plans/2026-08-09-openblog-common.md | 784 ++++++++++++++++++ 1 file changed, 784 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-openblog-common.md diff --git a/docs/superpowers/plans/2026-08-09-openblog-common.md b/docs/superpowers/plans/2026-08-09-openblog-common.md new file mode 100644 index 0000000..92dcd3c --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-openblog-common.md @@ -0,0 +1,784 @@ +# OpenBlog-common 抽取 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 抽取独立 `OpenBlog-common` 模块,让 business / email 共用统一响应格式与异常处理,email HTTP 接口切换为 `ApiResponse` 包装。 + +**Architecture:** 纯 jar 库模块,包名沿用 `com.yqz.openblog.common`(business 的 50+ 处 import 零改动)。异常处理器通过 Spring Boot 自动装配唯一注册;business 启动类用正则过滤把 common 包排除出组件扫描,避免同名 bean 冲突。Dubbo RPC 契约 `EmailSendResult` 保持不变。 + +**Tech Stack:** Maven 多模块 · Spring Boot 3.5 自动装配(`@AutoConfiguration` + `AutoConfiguration.imports`) · `@ConditionalOnClass` 条件装配 · MyBatis-Plus。 + +**设计文档:** `docs/superpowers/specs/2026-08-09-openblog-common-design.md` + +--- + +## 文件清单 + +**创建(OpenBlog-common 模块):** +- `OpenBlog-common/pom.xml` +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java`(自 business 原样搬入) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java`(原样搬入) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java`(原样搬入) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java`(原样搬入) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java`(原样搬入) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java`(移入,移除 security 方法) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/SecurityExceptionHandler.java`(新增) +- `OpenBlog-common/src/main/java/com/yqz/openblog/common/config/CommonAutoConfiguration.java`(新增) +- `OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`(新增) + +**修改:** +- `pom.xml`(根,注册模块) +- `OpenBlog-business/pom.xml`(加 common 依赖) +- `OpenBlog-email/pom.xml`(加 common 依赖) +- `OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java`(加 `@ComponentScan` 排除) +- `OpenBlog-email/src/main/java/com/yqz/openblog/email/controller/EmailAdminController.java`(切 `ApiResponse` / `PageResult`) +- `README.md`(模块表) + +**删除(business 内的旧文件):** +- `OpenBlog-business/src/main/java/com/yqz/openblog/common/{ApiResponse,PageResult,BizException,TraceId,TreeUtils,GlobalExceptionHandler}.java` + +--- + +### Task 1: 创建 OpenBlog-common 模块骨架 + +**Files:** +- Create: `OpenBlog-common/pom.xml` +- Modify: `pom.xml`(根) + +- [ ] **Step 1: 创建 `OpenBlog-common/pom.xml`** + +```xml + + + 4.0.0 + + com.yqz + OpenBlog + 1.0.0-SNAPSHOT + + + OpenBlog-common + OpenBlog-common + OpenBlog common module — unified REST response, exceptions, and utilities + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-web + + + org.springframework.security + spring-security-core + true + + + org.slf4j + slf4j-api + + + +``` + +版本全部继承父 pom 的 Spring Boot BOM(`spring-boot-dependencies`),不硬编码。 + +- [ ] **Step 2: 根 `pom.xml` 注册模块** + +在 `` 中新增(建议放首位,common 是基础): + +```xml + + OpenBlog-common + OpenBlog-framework-redis + OpenBlog-framework-elasticsearch + OpenBlog-api + OpenBlog-email + OpenBlog-business + OpenBlog-framework-audit + +``` + +- [ ] **Step 3: 验证模块被识别** + +Run: `mvn -q -pl OpenBlog-common -am clean package -DskipTests` +Expected: BUILD SUCCESS(空 jar 模块可正常构建) + +- [ ] **Step 4: Commit** + +```bash +git add pom.xml OpenBlog-common/pom.xml +git commit -m "build(common): create OpenBlog-common module skeleton" +``` + +--- + +### Task 2: 搬入 5 个 POJO 类(原样迁移) + +**Files:** +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java` +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java` +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java` +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java` +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java` + +- [ ] **Step 1: 创建 `ApiResponse.java`**(内容与 business 原文件逐字一致) + +```java +package com.yqz.openblog.common; + +/** + * 统一返回结构(MVP 先用简单 code/message/data)。 + */ +public class ApiResponse { + + private int code; + private String message; + private T data; + private String traceId; + + public static ApiResponse ok(T data) { + ApiResponse r = new ApiResponse<>(); + r.code = 0; + r.message = "success"; + r.data = data; + r.traceId = TraceId.get(); + return r; + } + + public static ApiResponse ok() { + return ok(null); + } + + public static ApiResponse fail(int code, String message) { + ApiResponse r = new ApiResponse<>(); + r.code = code; + r.message = message; + r.data = null; + r.traceId = TraceId.get(); + return r; + } + + 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; + } + + public String getTraceId() { + return traceId; + } + + public void setTraceId(String traceId) { + this.traceId = traceId; + } +} +``` + +- [ ] **Step 2: 创建 `PageResult.java`** + +```java +package com.yqz.openblog.common; + +import java.util.List; + +public class PageResult { + private List items; + private int page; + private int size; + private long total; + + public PageResult() { + } + + public PageResult(List items, int page, int size, long total) { + this.items = items; + this.page = page; + this.size = size; + this.total = total; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } +} +``` + +- [ ] **Step 3: 创建 `BizException.java`** + +```java +package com.yqz.openblog.common; + +/** + * 业务异常(统一由 GlobalExceptionHandler 捕获并转成 ApiResponse)。 + */ +public class BizException extends RuntimeException { + + private final int code; + + public BizException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} +``` + +- [ ] **Step 4: 创建 `TraceId.java`** + +```java +package com.yqz.openblog.common; + +import java.util.UUID; + +/** + * 简单 traceId 生成器(MVP)。 + * 后续可接 MDC + 日志框架对接。 + */ +public final class TraceId { + + private TraceId() { + } + + public static String get() { + return UUID.randomUUID().toString().replace("-", ""); + } +} +``` + +- [ ] **Step 5: 创建 `TreeUtils.java`** + +```java +package com.yqz.openblog.common; + +import java.util.*; +import java.util.function.Function; + +/** + * 树形结构通用工具方法。CategoryService 和 MediaFolderService 共用。 + */ +public final class TreeUtils { + + private TreeUtils() { + } + + /** + * 按 ID 建立索引 Map。 + */ + public static Map indexById(List list, Function idGetter) { + Map map = new HashMap<>(); + for (T item : list) { + map.put(idGetter.apply(item), item); + } + return map; + } + + /** + * 从指定节点向上追溯,构建路径名列表(从根到当前节点)。 + */ + public static List buildPathNames(Long nodeId, + Map byId, + Function parentIdGetter, + Function nameGetter) { + List path = new ArrayList<>(); + Set visited = new HashSet<>(); + Long current = nodeId; + while (current != null && visited.add(current)) { + T node = byId.get(current); + if (node == null) { + break; + } + path.add(0, nameGetter.apply(node)); + current = parentIdGetter.apply(node); + } + return path; + } + + /** + * 递归收集所有子孙节点 ID(含自身)。 + */ + public static void collectDescendants(Long id, Map> childrenMap, Set out) { + if (id == null || !out.add(id)) { + return; + } + for (Long childId : childrenMap.getOrDefault(id, List.of())) { + collectDescendants(childId, childrenMap, out); + } + } + + /** + * 构建 parentId → children 映射。 + */ + public static Map> buildChildrenMap(List ids, Function parentIdGetter) { + Map> map = new HashMap<>(); + for (Long id : ids) { + Long parentId = parentIdGetter.apply(id); + if (parentId != null) { + map.computeIfAbsent(parentId, k -> new ArrayList<>()).add(id); + } + } + return map; + } + + /** + * 检查 nodeId 是否为 ancestorId 的后代。 + */ + public static boolean isDescendant(Long ancestorId, Long nodeId, Map byId, Function parentIdGetter) { + Long current = nodeId; + while (current != null) { + if (current.equals(ancestorId)) { + return true; + } + T node = byId.get(current); + current = node == null ? null : parentIdGetter.apply(node); + } + return false; + } +} +``` + +- [ ] **Step 6: 验证 common 编译** + +Run: `mvn -q -pl OpenBlog-common -am clean package -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 7: Commit** + +```bash +git add OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java +git commit -m "feat(common): move common POJOs and TreeUtils into OpenBlog-common" +``` + +--- + +### Task 3: 异常处理与自动装配 + +**Files:** +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java` +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/SecurityExceptionHandler.java` +- Create: `OpenBlog-common/src/main/java/com/yqz/openblog/common/config/CommonAutoConfiguration.java` +- Create: `OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` + +- [ ] **Step 1: 创建 `GlobalExceptionHandler.java`**(自 business 移入,**移除** security 两个方法) + +```java +package com.yqz.openblog.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 统一异常处理(OpenBlog-common)。通过 CommonAutoConfiguration 自动装配注册。 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BizException.class) + public ResponseEntity> onBiz(BizException ex) { + int code = ex.getCode(); + // MVP:约定 code 的前 3 位近似映射为 HTTP status(如 4041 -> 404)。 + int httpStatus = Math.max(400, Math.min(500, code / 10)); + return ResponseEntity.status(httpStatus).body(ApiResponse.fail(code, ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> onValidation(MethodArgumentNotValidException ex) { + var fe = ex.getBindingResult().getFieldError(); + String msg = + fe != null && fe.getDefaultMessage() != null && !fe.getDefaultMessage().isBlank() + ? fe.getDefaultMessage() + : "参数校验失败"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.fail(4001, msg)); + } + + /** + * IO 异常(MinIO 读写、文件读写等)直接返回原始错误信息,便于排查。 + */ + @ExceptionHandler(IOException.class) + public ResponseEntity> onIO(IOException ex) { + log.error("IO exception", ex); + String msg = ex.getMessage() != null ? ex.getMessage() : "IO异常"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(5001, msg)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> onOther(Exception ex) { + log.error("unhandled server error", ex); + String msg = ex.getMessage() != null ? ex.getMessage() : "服务器异常"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(5001, msg)); + } +} +``` + +- [ ] **Step 2: 创建 `SecurityExceptionHandler.java`**(新增,条件装配,email 无 security 依赖时自动跳过) + +```java +package com.yqz.openblog.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 安全相关异常处理,仅在 classpath 存在 spring-security 时由 CommonAutoConfiguration 装配。 + * (email 等无 security 依赖的服务自动跳过。) + */ +@RestControllerAdvice +public class SecurityExceptionHandler { + + /** + * 方法级鉴权(@PreAuthorize)失败会抛出 AuthorizationDeniedException; + * 以前会落到兜底 Exception -> 5001,导致前端误判为“服务器异常”。 + */ + @ExceptionHandler({AuthorizationDeniedException.class, AccessDeniedException.class}) + public ResponseEntity> onAccessDenied(Exception ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail(4030, "无权限")); + } +} +``` + +- [ ] **Step 3: 创建 `CommonAutoConfiguration.java`**(自动装配唯一入口) + +```java +package com.yqz.openblog.common.config; + +import com.yqz.openblog.common.GlobalExceptionHandler; +import com.yqz.openblog.common.SecurityExceptionHandler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; + +/** + * OpenBlog-common 自动装配:注册统一异常处理器。 + * 注意:调用方组件扫描应排除 com.yqz.openblog.common.*,避免与自动装配重复注册。 + */ +@AutoConfiguration +@ConditionalOnWebApplication +public class CommonAutoConfiguration { + + @Bean + public GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Bean + @ConditionalOnClass(name = "org.springframework.security.access.AccessDeniedException") + public SecurityExceptionHandler securityExceptionHandler() { + return new SecurityExceptionHandler(); + } +} +``` + +`@ConditionalOnClass` 使用字符串形式的类名,基于 ASM 检查 classpath,**不存在 spring-security 时不会加载 `SecurityExceptionHandler` 类**,因此 email 可安全消费 common。 + +- [ ] **Step 4: 创建 `AutoConfiguration.imports`** + +文件 `OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`,内容一行: + +``` +com.yqz.openblog.common.config.CommonAutoConfiguration +``` + +- [ ] **Step 5: 验证 common 编译** + +Run: `mvn -q -pl OpenBlog-common -am clean package -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 6: Commit** + +```bash +git add OpenBlog-common/ +git commit -m "feat(common): add global exception handlers via auto-configuration" +``` + +--- + +### Task 4: business 接线——依赖、删旧文件、排除扫描 + +**Files:** +- Modify: `OpenBlog-business/pom.xml` +- Modify: `OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java` +- Delete: `OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java` +- Delete: `OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java` +- Delete: `OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java` +- Delete: `OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java` +- Delete: `OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java` +- Delete: `OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java` + +- [ ] **Step 1: `OpenBlog-business/pom.xml` 加 common 依赖** + +在 `` 中(Email RPC 接口依赖之后)新增: + +```xml + + com.yqz + OpenBlog-common + ${project.version} + +``` + +- [ ] **Step 2: `OpenBlogApplication.java` 加 `@ComponentScan` 排除** + +完整替换为: + +```java +package com.yqz.openblog; + +import com.yqz.openblog.config.AuthSecurityProperties; +import com.yqz.openblog.config.CorsProperties; +import com.yqz.openblog.config.SiteProperties; +import com.yqz.openblog.seo.SeoProperties; +import org.apache.dubbo.config.spring.context.annotation.EnableDubbo; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableDubbo +@EnableScheduling +@EnableConfigurationProperties({ + SiteProperties.class, + CorsProperties.class, + AuthSecurityProperties.class, + SeoProperties.class +}) +@ComponentScan( + basePackages = "com.yqz.openblog", + excludeFilters = @ComponentScan.Filter( + type = FilterType.REGEX, + pattern = "com\\.yqz\\.openblog\\.common\\..*")) +public class OpenBlogApplication { + + public static void main(String[] args) { + SpringApplication.run(OpenBlogApplication.class, args); + } + +} +``` + +说明:显式 `@ComponentScan` 会覆盖 `@SpringBootApplication` 的默认扫描(`basePackages` 保持一致,仅多了排除)。`com.yqz.openblog.common.*` 里的异常处理器改由自动装配注册,POJO 类不是 bean 不受影响。 + +- [ ] **Step 3: 删除 business 内的 6 个旧文件** + +```bash +git rm OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java \ + OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java \ + OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java \ + OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java \ + OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java \ + OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java +``` + +(删除后 `com/yqz/openblog/common/` 目录如变空则一并移除,git 不追踪空目录无需担心。) + +- [ ] **Step 4: 验证 business 编译(含依赖模块)** + +Run: `mvn -q -pl OpenBlog-business -am clean package -DskipTests` +Expected: BUILD SUCCESS。若出现"找不到符号 `com.yqz.openblog.common.X`",说明业务代码引用了未搬走的类——检查是否漏删/漏建。 + +- [ ] **Step 5: 核对无残留引用** + +Run: `grep -rn "com.yqz.openblog.common" OpenBlog-business/src --include="*.java" | grep -v "/common/" | grep import` +Expected: 输出为 business 代码里的 `import com.yqz.openblog.common.*`(这些现在解析到 common 模块,正确),**不应**有指向 business 本地 common 包的异常。 + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(business): depend on OpenBlog-common, exclude common package from scan" +``` + +--- + +### Task 5: email 接线与接口迁移 + +**Files:** +- Modify: `OpenBlog-email/pom.xml` +- Modify: `OpenBlog-email/src/main/java/com/yqz/openblog/email/controller/EmailAdminController.java` + +- [ ] **Step 1: `OpenBlog-email/pom.xml` 加 common 依赖** + +在 `` 中(RPC interface 依赖之后)新增: + +```xml + + com.yqz + OpenBlog-common + ${project.version} + +``` + +- [ ] **Step 2: 重写 `EmailAdminController.java`** + +完整替换为: + +```java +package com.yqz.openblog.email.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.yqz.openblog.common.ApiResponse; +import com.yqz.openblog.common.PageResult; +import com.yqz.openblog.email.api.EmailSendRequest; +import com.yqz.openblog.email.api.EmailSendResult; +import com.yqz.openblog.email.dto.EmailRecordResponse; +import com.yqz.openblog.email.service.EmailService; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/email") +@CrossOrigin(origins = "*") +public class EmailAdminController { + + private final EmailService emailService; + + public EmailAdminController(EmailService emailService) { + this.emailService = emailService; + } + + /** + * 快速测试发送邮件:POST /api/v1/email/test?recipient=xxx&subject=xxx&body=xxx + */ + @PostMapping("/test") + public ApiResponse testSend( + @RequestParam String recipient, + @RequestParam String subject, + @RequestParam String body) { + EmailSendRequest req = new EmailSendRequest(recipient, subject, body); + return ApiResponse.ok(emailService.send(req)); + } + + @GetMapping("/records") + public ApiResponse> listRecords( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String status) { + IPage p = emailService.listRecords(page, size, status); + PageResult pr = new PageResult<>(p.getRecords(), page, size, p.getTotal()); + return ApiResponse.ok(pr); + } +} +``` + +说明:`EmailService`、`EmailRpcService`、`EmailSendResult`(Dubbo RPC 契约)均**不动**。 + +- [ ] **Step 3: 验证 email 编译(含依赖模块)** + +Run: `mvn -q -pl OpenBlog-email -am clean package -DskipTests` +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "refactor(email): wrap HTTP endpoints with ApiResponse/PageResult" +``` + +--- + +### Task 6: README 更新与全量构建 + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: README 模块表加一行** + +在模块表 `OpenBlog-business` 行前新增: + +```markdown +| `OpenBlog-common` | 公共模块:统一响应 `ApiResponse`、`PageResult`、`BizException`、异常处理自动装配 | +``` + +- [ ] **Step 2: 全量构建验证** + +Run: `mvn -q clean package -DskipTests` +Expected: BUILD SUCCESS(所有模块依次构建,含 common → business / email 依赖解析) + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: add OpenBlog-common to module table" +``` + +--- + +## 验收核对(对照设计文档) + +| 设计要点 | 落地任务 | +|---|---| +| 新建 `OpenBlog-common` jar 模块 | Task 1 | +| 搬入 ApiResponse/PageResult/BizException/TraceId/TreeUtils | Task 2 | +| GlobalExceptionHandler 移除 security 方法 + 新增 SecurityExceptionHandler | Task 3 | +| 自动装配唯一注册 + business 排除 common 扫描 | Task 3 + Task 4 | +| business 加依赖、删旧文件、import 零改动 | Task 4 | +| email 加依赖、HTTP 接口切 ApiResponse/PageResult | Task 5 | +| RPC 契约 EmailSendResult 不变 | Task 5(未改 service/rpc) | +| 验证:三个模块 clean package | Task 4/5/6 | From ecf5bc34e70c22d17c5172375f9627890aa9051e Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:04:40 +0800 Subject: [PATCH 3/9] build(common): create OpenBlog-common module skeleton --- OpenBlog-common/pom.xml | 34 ++++++++++++++++++++++++++++++++++ pom.xml | 1 + 2 files changed, 35 insertions(+) create mode 100644 OpenBlog-common/pom.xml diff --git a/OpenBlog-common/pom.xml b/OpenBlog-common/pom.xml new file mode 100644 index 0000000..1078e63 --- /dev/null +++ b/OpenBlog-common/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + com.yqz + OpenBlog + 1.0.0-SNAPSHOT + + + OpenBlog-common + OpenBlog-common + OpenBlog common module — unified REST response, exceptions, and utilities + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework + spring-web + + + org.springframework.security + spring-security-core + true + + + org.slf4j + slf4j-api + + + diff --git a/pom.xml b/pom.xml index 39c75a2..137e1e8 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ OpenBlog OpenBlog + OpenBlog-common OpenBlog-framework-redis OpenBlog-framework-elasticsearch OpenBlog-api From a0ce36df2f8a9c2598b7006376cc4e6ad6fdf67f Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:08:56 +0800 Subject: [PATCH 4/9] feat(common): move common POJOs and TreeUtils into OpenBlog-common Co-Authored-By: Claude --- .../com/yqz/openblog/common/ApiResponse.java | 67 +++++++++++++++ .../com/yqz/openblog/common/BizException.java | 19 ++++ .../com/yqz/openblog/common/PageResult.java | 52 +++++++++++ .../java/com/yqz/openblog/common/TraceId.java | 18 ++++ .../com/yqz/openblog/common/TreeUtils.java | 86 +++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java new file mode 100644 index 0000000..696c5b8 --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java @@ -0,0 +1,67 @@ +package com.yqz.openblog.common; + +/** + * 统一返回结构(MVP 先用简单 code/message/data)。 + */ +public class ApiResponse { + + private int code; + private String message; + private T data; + private String traceId; + + public static ApiResponse ok(T data) { + ApiResponse r = new ApiResponse<>(); + r.code = 0; + r.message = "success"; + r.data = data; + r.traceId = TraceId.get(); + return r; + } + + public static ApiResponse ok() { + return ok(null); + } + + public static ApiResponse fail(int code, String message) { + ApiResponse r = new ApiResponse<>(); + r.code = code; + r.message = message; + r.data = null; + r.traceId = TraceId.get(); + return r; + } + + 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; + } + + public String getTraceId() { + return traceId; + } + + public void setTraceId(String traceId) { + this.traceId = traceId; + } +} + diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java new file mode 100644 index 0000000..046b9c3 --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java @@ -0,0 +1,19 @@ +package com.yqz.openblog.common; + +/** + * 业务异常(统一由 GlobalExceptionHandler 捕获并转成 ApiResponse)。 + */ +public class BizException extends RuntimeException { + + private final int code; + + public BizException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} + diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java new file mode 100644 index 0000000..cf80ad9 --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java @@ -0,0 +1,52 @@ +package com.yqz.openblog.common; + +import java.util.List; + +public class PageResult { + private List items; + private int page; + private int size; + private long total; + + public PageResult() { + } + + public PageResult(List items, int page, int size, long total) { + this.items = items; + this.page = page; + this.size = size; + this.total = total; + } + + public List getItems() { + return items; + } + + public void setItems(List items) { + this.items = items; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } +} diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java new file mode 100644 index 0000000..7f607ce --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java @@ -0,0 +1,18 @@ +package com.yqz.openblog.common; + +import java.util.UUID; + +/** + * 简单 traceId 生成器(MVP)。 + * 后续可接 MDC + 日志框架对接。 + */ +public final class TraceId { + + private TraceId() { + } + + public static String get() { + return UUID.randomUUID().toString().replace("-", ""); + } +} + diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java new file mode 100644 index 0000000..a205a4f --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java @@ -0,0 +1,86 @@ +package com.yqz.openblog.common; + +import java.util.*; +import java.util.function.Function; + +/** + * 树形结构通用工具方法。CategoryService 和 MediaFolderService 共用。 + */ +public final class TreeUtils { + + private TreeUtils() { + } + + /** + * 按 ID 建立索引 Map。 + */ + public static Map indexById(List list, Function idGetter) { + Map map = new HashMap<>(); + for (T item : list) { + map.put(idGetter.apply(item), item); + } + return map; + } + + /** + * 从指定节点向上追溯,构建路径名列表(从根到当前节点)。 + */ + public static List buildPathNames(Long nodeId, + Map byId, + Function parentIdGetter, + Function nameGetter) { + List path = new ArrayList<>(); + Set visited = new HashSet<>(); + Long current = nodeId; + while (current != null && visited.add(current)) { + T node = byId.get(current); + if (node == null) { + break; + } + path.add(0, nameGetter.apply(node)); + current = parentIdGetter.apply(node); + } + return path; + } + + /** + * 递归收集所有子孙节点 ID(含自身)。 + */ + public static void collectDescendants(Long id, Map> childrenMap, Set out) { + if (id == null || !out.add(id)) { + return; + } + for (Long childId : childrenMap.getOrDefault(id, List.of())) { + collectDescendants(childId, childrenMap, out); + } + } + + /** + * 构建 parentId → children 映射。 + */ + public static Map> buildChildrenMap(List ids, Function parentIdGetter) { + Map> map = new HashMap<>(); + for (Long id : ids) { + Long parentId = parentIdGetter.apply(id); + if (parentId != null) { + map.computeIfAbsent(parentId, k -> new ArrayList<>()).add(id); + } + } + return map; + } + + /** + * 检查 nodeId 是否为 ancestorId 的后代。 + */ + public static boolean isDescendant(Long ancestorId, Long nodeId, Map byId, Function parentIdGetter) { + Long current = nodeId; + while (current != null) { + if (current.equals(ancestorId)) { + return true; + } + T node = byId.get(current); + current = node == null ? null : parentIdGetter.apply(node); + } + return false; + } +} From eebddbb2f1fd7f6ebbc6722878f90e6647417fd2 Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:13:12 +0800 Subject: [PATCH 5/9] feat(common): add global exception handlers via auto-configuration --- .../common/GlobalExceptionHandler.java | 55 +++++++++++++++++++ .../common/SecurityExceptionHandler.java | 25 +++++++++ .../config/CommonAutoConfiguration.java | 31 +++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 4 files changed, 112 insertions(+) create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/SecurityExceptionHandler.java create mode 100644 OpenBlog-common/src/main/java/com/yqz/openblog/common/config/CommonAutoConfiguration.java create mode 100644 OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..fab6368 --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java @@ -0,0 +1,55 @@ +package com.yqz.openblog.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.IOException; + +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 统一异常处理(OpenBlog-common)。通过 CommonAutoConfiguration 自动装配注册。 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BizException.class) + public ResponseEntity> onBiz(BizException ex) { + int code = ex.getCode(); + // MVP:约定 code 的前 3 位近似映射为 HTTP status(如 4041 -> 404)。 + int httpStatus = Math.max(400, Math.min(500, code / 10)); + return ResponseEntity.status(httpStatus).body(ApiResponse.fail(code, ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> onValidation(MethodArgumentNotValidException ex) { + var fe = ex.getBindingResult().getFieldError(); + String msg = + fe != null && fe.getDefaultMessage() != null && !fe.getDefaultMessage().isBlank() + ? fe.getDefaultMessage() + : "参数校验失败"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.fail(4001, msg)); + } + + /** + * IO 异常(MinIO 读写、文件读写等)直接返回原始错误信息,便于排查。 + */ + @ExceptionHandler(IOException.class) + public ResponseEntity> onIO(IOException ex) { + log.error("IO exception", ex); + String msg = ex.getMessage() != null ? ex.getMessage() : "IO异常"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(5001, msg)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> onOther(Exception ex) { + log.error("unhandled server error", ex); + String msg = ex.getMessage() != null ? ex.getMessage() : "服务器异常"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(5001, msg)); + } +} diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/SecurityExceptionHandler.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/SecurityExceptionHandler.java new file mode 100644 index 0000000..b11597a --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/SecurityExceptionHandler.java @@ -0,0 +1,25 @@ +package com.yqz.openblog.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 安全相关异常处理,仅在 classpath 存在 spring-security 时由 CommonAutoConfiguration 装配。 + * (email 等无 security 依赖的服务自动跳过。) + */ +@RestControllerAdvice +public class SecurityExceptionHandler { + + /** + * 方法级鉴权(@PreAuthorize)失败会抛出 AuthorizationDeniedException; + * 以前会落到兜底 Exception -> 5001,导致前端误判为“服务器异常”。 + */ + @ExceptionHandler({AuthorizationDeniedException.class, AccessDeniedException.class}) + public ResponseEntity> onAccessDenied(Exception ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail(4030, "无权限")); + } +} diff --git a/OpenBlog-common/src/main/java/com/yqz/openblog/common/config/CommonAutoConfiguration.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/config/CommonAutoConfiguration.java new file mode 100644 index 0000000..5e295ba --- /dev/null +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/config/CommonAutoConfiguration.java @@ -0,0 +1,31 @@ +package com.yqz.openblog.common.config; + +import com.yqz.openblog.common.GlobalExceptionHandler; +import com.yqz.openblog.common.SecurityExceptionHandler; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; + +/** + * OpenBlog-common 自动装配:注册统一异常处理器。 + * 注意:调用方组件扫描应排除 com.yqz.openblog.common.*,避免与自动装配重复注册。 + */ +@AutoConfiguration +@ConditionalOnWebApplication +public class CommonAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(GlobalExceptionHandler.class) + public GlobalExceptionHandler globalExceptionHandler() { + return new GlobalExceptionHandler(); + } + + @Bean + @ConditionalOnClass(name = "org.springframework.security.access.AccessDeniedException") + @ConditionalOnMissingBean(SecurityExceptionHandler.class) + public SecurityExceptionHandler securityExceptionHandler() { + return new SecurityExceptionHandler(); + } +} diff --git a/OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..d0504f2 --- /dev/null +++ b/OpenBlog-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.yqz.openblog.common.config.CommonAutoConfiguration From e49fb193580f95d26ea319b889662309585e22d2 Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:21:54 +0800 Subject: [PATCH 6/9] refactor(business): depend on OpenBlog-common, exclude common package from scan Co-Authored-By: Claude --- OpenBlog-business/pom.xml | 6 ++ .../com/yqz/openblog/OpenBlogApplication.java | 19 ++++ .../com/yqz/openblog/common/ApiResponse.java | 67 --------------- .../com/yqz/openblog/common/BizException.java | 19 ---- .../common/GlobalExceptionHandler.java | 67 --------------- .../com/yqz/openblog/common/PageResult.java | 52 ----------- .../java/com/yqz/openblog/common/TraceId.java | 18 ---- .../com/yqz/openblog/common/TreeUtils.java | 86 ------------------- 8 files changed, 25 insertions(+), 309 deletions(-) delete mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java delete mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java delete mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java delete mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java delete mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java delete mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java diff --git a/OpenBlog-business/pom.xml b/OpenBlog-business/pom.xml index 702d67c..6a1f937 100644 --- a/OpenBlog-business/pom.xml +++ b/OpenBlog-business/pom.xml @@ -62,6 +62,12 @@ ${project.version} + + com.yqz + OpenBlog-common + ${project.version} + + org.apache.dubbo diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java b/OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java index d472d87..e87df18 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java @@ -6,10 +6,22 @@ import com.yqz.openblog.seo.SeoProperties; import org.apache.dubbo.config.spring.context.annotation.EnableDubbo; import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.TypeExcludeFilter; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; import org.springframework.scheduling.annotation.EnableScheduling; +/** + * 显式声明组件扫描(覆盖 @SpringBootApplication 的默认扫描): + * - basePackages 与默认一致;额外排除 com.yqz.openblog.common 包—— + * 该包的异常处理器改由 OpenBlog-common 的自动装配(CommonAutoConfiguration)注册。 + * - 显式 @ComponentScan 不会自动带上 @SpringBootApplication 默认的两个过滤, + * 因此手动补回 TypeExcludeFilter / AutoConfigurationExcludeFilter,保证 + * framework 模块下的 @AutoConfiguration 类不被组件扫描重复注册。 + */ @SpringBootApplication @EnableDubbo @EnableScheduling @@ -19,6 +31,13 @@ AuthSecurityProperties.class, SeoProperties.class }) +@ComponentScan( + basePackages = "com.yqz.openblog", + excludeFilters = { + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.yqz\\.openblog\\.common\\..*") + }) public class OpenBlogApplication { public static void main(String[] args) { diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java b/OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java deleted file mode 100644 index 696c5b8..0000000 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.yqz.openblog.common; - -/** - * 统一返回结构(MVP 先用简单 code/message/data)。 - */ -public class ApiResponse { - - private int code; - private String message; - private T data; - private String traceId; - - public static ApiResponse ok(T data) { - ApiResponse r = new ApiResponse<>(); - r.code = 0; - r.message = "success"; - r.data = data; - r.traceId = TraceId.get(); - return r; - } - - public static ApiResponse ok() { - return ok(null); - } - - public static ApiResponse fail(int code, String message) { - ApiResponse r = new ApiResponse<>(); - r.code = code; - r.message = message; - r.data = null; - r.traceId = TraceId.get(); - return r; - } - - 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; - } - - public String getTraceId() { - return traceId; - } - - public void setTraceId(String traceId) { - this.traceId = traceId; - } -} - diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java b/OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java deleted file mode 100644 index 046b9c3..0000000 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.yqz.openblog.common; - -/** - * 业务异常(统一由 GlobalExceptionHandler 捕获并转成 ApiResponse)。 - */ -public class BizException extends RuntimeException { - - private final int code; - - public BizException(int code, String message) { - super(message); - this.code = code; - } - - public int getCode() { - return code; - } -} - diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java b/OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java deleted file mode 100644 index 1a6df42..0000000 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.yqz.openblog.common; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.io.IOException; - -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.authorization.AuthorizationDeniedException; - -/** - * 统一异常处理(MVP)。 - */ -@RestControllerAdvice -public class GlobalExceptionHandler { - - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); - - @ExceptionHandler(BizException.class) - public ResponseEntity> onBiz(BizException ex) { - int code = ex.getCode(); - // MVP:约定 code 的前 3 位近似映射为 HTTP status(如 4041 -> 404)。 - int httpStatus = Math.max(400, Math.min(500, code / 10)); - return ResponseEntity.status(httpStatus).body(ApiResponse.fail(code, ex.getMessage())); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> onValidation(MethodArgumentNotValidException ex) { - var fe = ex.getBindingResult().getFieldError(); - String msg = - fe != null && fe.getDefaultMessage() != null && !fe.getDefaultMessage().isBlank() - ? fe.getDefaultMessage() - : "参数校验失败"; - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.fail(4001, msg)); - } - - /** - * 方法级鉴权(@PreAuthorize)失败会抛出 AuthorizationDeniedException; - * 以前会落到兜底 Exception -> 5001,导致前端误判为“服务器异常”。 - */ - @ExceptionHandler({AuthorizationDeniedException.class, AccessDeniedException.class}) - public ResponseEntity> onAccessDenied(Exception ex) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail(4030, "无权限")); - } - - /** - * IO 异常(MinIO 读写、文件读写等)直接返回原始错误信息,便于排查。 - */ - @ExceptionHandler(IOException.class) - public ResponseEntity> onIO(IOException ex) { - log.error("IO exception", ex); - String msg = ex.getMessage() != null ? ex.getMessage() : "IO异常"; - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(5001, msg)); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> onOther(Exception ex) { - log.error("unhandled server error", ex); - String msg = ex.getMessage() != null ? ex.getMessage() : "服务器异常"; - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.fail(5001, msg)); - } -} - diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java b/OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java deleted file mode 100644 index cf80ad9..0000000 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.yqz.openblog.common; - -import java.util.List; - -public class PageResult { - private List items; - private int page; - private int size; - private long total; - - public PageResult() { - } - - public PageResult(List items, int page, int size, long total) { - this.items = items; - this.page = page; - this.size = size; - this.total = total; - } - - public List getItems() { - return items; - } - - public void setItems(List items) { - this.items = items; - } - - public int getPage() { - return page; - } - - public void setPage(int page) { - this.page = page; - } - - public int getSize() { - return size; - } - - public void setSize(int size) { - this.size = size; - } - - public long getTotal() { - return total; - } - - public void setTotal(long total) { - this.total = total; - } -} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java b/OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java deleted file mode 100644 index 7f607ce..0000000 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.yqz.openblog.common; - -import java.util.UUID; - -/** - * 简单 traceId 生成器(MVP)。 - * 后续可接 MDC + 日志框架对接。 - */ -public final class TraceId { - - private TraceId() { - } - - public static String get() { - return UUID.randomUUID().toString().replace("-", ""); - } -} - diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java b/OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java deleted file mode 100644 index a205a4f..0000000 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.yqz.openblog.common; - -import java.util.*; -import java.util.function.Function; - -/** - * 树形结构通用工具方法。CategoryService 和 MediaFolderService 共用。 - */ -public final class TreeUtils { - - private TreeUtils() { - } - - /** - * 按 ID 建立索引 Map。 - */ - public static Map indexById(List list, Function idGetter) { - Map map = new HashMap<>(); - for (T item : list) { - map.put(idGetter.apply(item), item); - } - return map; - } - - /** - * 从指定节点向上追溯,构建路径名列表(从根到当前节点)。 - */ - public static List buildPathNames(Long nodeId, - Map byId, - Function parentIdGetter, - Function nameGetter) { - List path = new ArrayList<>(); - Set visited = new HashSet<>(); - Long current = nodeId; - while (current != null && visited.add(current)) { - T node = byId.get(current); - if (node == null) { - break; - } - path.add(0, nameGetter.apply(node)); - current = parentIdGetter.apply(node); - } - return path; - } - - /** - * 递归收集所有子孙节点 ID(含自身)。 - */ - public static void collectDescendants(Long id, Map> childrenMap, Set out) { - if (id == null || !out.add(id)) { - return; - } - for (Long childId : childrenMap.getOrDefault(id, List.of())) { - collectDescendants(childId, childrenMap, out); - } - } - - /** - * 构建 parentId → children 映射。 - */ - public static Map> buildChildrenMap(List ids, Function parentIdGetter) { - Map> map = new HashMap<>(); - for (Long id : ids) { - Long parentId = parentIdGetter.apply(id); - if (parentId != null) { - map.computeIfAbsent(parentId, k -> new ArrayList<>()).add(id); - } - } - return map; - } - - /** - * 检查 nodeId 是否为 ancestorId 的后代。 - */ - public static boolean isDescendant(Long ancestorId, Long nodeId, Map byId, Function parentIdGetter) { - Long current = nodeId; - while (current != null) { - if (current.equals(ancestorId)) { - return true; - } - T node = byId.get(current); - current = node == null ? null : parentIdGetter.apply(node); - } - return false; - } -} From 09bb62d966e1031f3ab3568bc052d72dd26ee05a Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:36:02 +0800 Subject: [PATCH 7/9] refactor(email): wrap HTTP endpoints with ApiResponse/PageResult --- OpenBlog-email/pom.xml | 7 +++++++ .../email/controller/EmailAdminController.java | 18 +++++++----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/OpenBlog-email/pom.xml b/OpenBlog-email/pom.xml index 0cff329..8383781 100644 --- a/OpenBlog-email/pom.xml +++ b/OpenBlog-email/pom.xml @@ -20,6 +20,13 @@ ${project.version} + + + com.yqz + OpenBlog-common + ${project.version} + + org.springframework.boot diff --git a/OpenBlog-email/src/main/java/com/yqz/openblog/email/controller/EmailAdminController.java b/OpenBlog-email/src/main/java/com/yqz/openblog/email/controller/EmailAdminController.java index 03e96da..571a52a 100644 --- a/OpenBlog-email/src/main/java/com/yqz/openblog/email/controller/EmailAdminController.java +++ b/OpenBlog-email/src/main/java/com/yqz/openblog/email/controller/EmailAdminController.java @@ -1,14 +1,14 @@ package com.yqz.openblog.email.controller; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.yqz.openblog.common.ApiResponse; +import com.yqz.openblog.common.PageResult; import com.yqz.openblog.email.api.EmailSendRequest; import com.yqz.openblog.email.api.EmailSendResult; import com.yqz.openblog.email.dto.EmailRecordResponse; import com.yqz.openblog.email.service.EmailService; import org.springframework.web.bind.annotation.*; -import java.util.Map; - @RestController @RequestMapping("/api/v1/email") @CrossOrigin(origins = "*") @@ -24,25 +24,21 @@ public EmailAdminController(EmailService emailService) { * 快速测试发送邮件:POST /api/v1/email/test?recipient=xxx&subject=xxx&body=xxx */ @PostMapping("/test") - public EmailSendResult testSend( + public ApiResponse testSend( @RequestParam String recipient, @RequestParam String subject, @RequestParam String body) { EmailSendRequest req = new EmailSendRequest(recipient, subject, body); - return emailService.send(req); + return ApiResponse.ok(emailService.send(req)); } @GetMapping("/records") - public Map listRecords( + public ApiResponse> listRecords( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String status) { IPage p = emailService.listRecords(page, size, status); - return Map.of( - "items", p.getRecords(), - "total", p.getTotal(), - "page", page, - "size", size - ); + PageResult pr = new PageResult<>(p.getRecords(), page, size, p.getTotal()); + return ApiResponse.ok(pr); } } From 665aac825922d9977497a85ada775e94808795e6 Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 02:00:27 +0800 Subject: [PATCH 8/9] docs: add OpenBlog-common to module table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4de1a1e..08a63e2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ | 模块 | 说明 | |------|------| +| `OpenBlog-common` | 公共模块:统一响应 `ApiResponse`、`PageResult`、`BizException`、异常处理自动装配 | | `OpenBlog-business` | 主业务服务(端口 8082):文章、评论、论坛、用户、认证、SEO、媒体管理 | | `OpenBlog-email` | 独立邮件服务(端口 8083):阿里云 DirectMail + Dubbo RPC + Nacos 注册 | | `OpenBlog-api` | 共享 API 聚合模块 | From e7cc38b8ac142d84ec0601c55c3a94da23257935 Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Sun, 9 Aug 2026 01:53:55 +0800 Subject: [PATCH 9/9] docs(spec): sync OpenBlog-common design with actual implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @ConditionalOnClass: bean method-level string form, not class-level - ComponentScan example: add back TypeExcludeFilter/AutoConfigurationExcludeFilter - ConditionalOnWebApplication: one class-level guard, not two - remove duplicate '依赖接线' heading Co-Authored-By: Claude --- .../specs/2026-08-09-openblog-common-design.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-openblog-common-design.md b/docs/superpowers/specs/2026-08-09-openblog-common-design.md index 518caec..8c2569b 100644 --- a/docs/superpowers/specs/2026-08-09-openblog-common-design.md +++ b/docs/superpowers/specs/2026-08-09-openblog-common-design.md @@ -68,7 +68,7 @@ src/main/resources/META-INF/spring/ ### SecurityExceptionHandler(新增,条件装配) - 处理 `AccessDeniedException` / `AuthorizationDeniedException` → 4030 "无权限" -- 类级注解 `@ConditionalOnClass(AccessDeniedException.class)` +- `CommonAutoConfiguration` 的 `@Bean` 方法级注解 `@ConditionalOnClass(name = "org.springframework.security.access.AccessDeniedException")`(字符串形式,基于 ASM 检查 classpath,无 security 时不加载该类) - 依赖 `spring-security-core`(optional scope),business 有 security 则生效,email 无 security 自动跳过 ## 自动装配方式 @@ -87,21 +87,20 @@ src/main/resources/META-INF/spring/ **处理**:自动装配是唯一注册机制,business 启动类用正则过滤将 `com.yqz.openblog.common.*` 排除出组件扫描: ```java -@SpringBootApplication @ComponentScan( basePackages = "com.yqz.openblog", - excludeFilters = @ComponentScan.Filter( - type = FilterType.REGEX, - pattern = "com\\.yqz\\.openblog\\.common\\..*")) + excludeFilters = { + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com\\.yqz\\.openblog\\.common\\..*") }) public class OpenBlogApplication { ... } ``` 注意: +- 显式 `@ComponentScan` 不会自动带上 `@SpringBootApplication` 默认的两个过滤,必须**手动补回** `TypeExcludeFilter` / `AutoConfigurationExcludeFilter`,否则 `com.yqz.openblog` 下 framework 模块的 `@AutoConfiguration` 类会被组件扫描当作普通 `@Configuration` 重复注册。 - POJO 类(`ApiResponse` 等)不是 Spring bean,不受排除影响,包名不变、business 的 import 依旧零改动。 - email 不扫描 `com.yqz.openblog` 包,无需排除,自动装配直接生效。 -- 两个 `@ConditionalOnWebApplication` 守卫保证该自动装配只在 web 应用中生效。 - -## 依赖接线 +- `CommonAutoConfiguration` 类级单个 `@ConditionalOnWebApplication` 守卫保证该自动装配只在 web 应用中生效。 ## 依赖接线