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-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/OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java similarity index 100% rename from OpenBlog-business/src/main/java/com/yqz/openblog/common/ApiResponse.java rename to OpenBlog-common/src/main/java/com/yqz/openblog/common/ApiResponse.java diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java similarity index 100% rename from OpenBlog-business/src/main/java/com/yqz/openblog/common/BizException.java rename to OpenBlog-common/src/main/java/com/yqz/openblog/common/BizException.java diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java similarity index 78% rename from OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java rename to OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java index 1a6df42..fab6368 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java +++ b/OpenBlog-common/src/main/java/com/yqz/openblog/common/GlobalExceptionHandler.java @@ -9,11 +9,9 @@ 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)。 + * 统一异常处理(OpenBlog-common)。通过 CommonAutoConfiguration 自动装配注册。 */ @RestControllerAdvice public class GlobalExceptionHandler { @@ -38,15 +36,6 @@ public ResponseEntity> onValidation(MethodArgumentNotValidEx 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 读写、文件读写等)直接返回原始错误信息,便于排查。 */ @@ -64,4 +53,3 @@ public ResponseEntity> onOther(Exception ex) { 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-common/src/main/java/com/yqz/openblog/common/PageResult.java similarity index 100% rename from OpenBlog-business/src/main/java/com/yqz/openblog/common/PageResult.java rename to OpenBlog-common/src/main/java/com/yqz/openblog/common/PageResult.java 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-business/src/main/java/com/yqz/openblog/common/TraceId.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java similarity index 100% rename from OpenBlog-business/src/main/java/com/yqz/openblog/common/TraceId.java rename to OpenBlog-common/src/main/java/com/yqz/openblog/common/TraceId.java diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java b/OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java similarity index 100% rename from OpenBlog-business/src/main/java/com/yqz/openblog/common/TreeUtils.java rename to OpenBlog-common/src/main/java/com/yqz/openblog/common/TreeUtils.java 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 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); } } 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 聚合模块 | 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 | 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..8c2569b --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-openblog-common-design.md @@ -0,0 +1,136 @@ +# 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 "无权限" +- `CommonAutoConfiguration` 的 `@Bean` 方法级注解 `@ConditionalOnClass(name = "org.springframework.security.access.AccessDeniedException")`(字符串形式,基于 ASM 检查 classpath,无 security 时不加载该类) +- 依赖 `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 +@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 { ... } +``` + +注意: +- 显式 `@ComponentScan` 不会自动带上 `@SpringBootApplication` 默认的两个过滤,必须**手动补回** `TypeExcludeFilter` / `AutoConfigurationExcludeFilter`,否则 `com.yqz.openblog` 下 framework 模块的 `@AutoConfiguration` 类会被组件扫描当作普通 `@Configuration` 重复注册。 +- POJO 类(`ApiResponse` 等)不是 Spring bean,不受排除影响,包名不变、business 的 import 依旧零改动。 +- email 不扫描 `com.yqz.openblog` 包,无需排除,自动装配直接生效。 +- `CommonAutoConfiguration` 类级单个 `@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 编译通过,新响应格式生效。 +- 前端无需改动。 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