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 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 应用中生效。 ## 依赖接线 From e4a569c93e6872778c9645e94cf86c2dc11be32a Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Tue, 18 Aug 2026 02:33:05 +0800 Subject: [PATCH 10/12] docs: add project roadmap for email-verify registration, homepage, docker deploy Co-Authored-By: Claude --- docs/ROADMAP.md | 124 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..e202ee6 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,124 @@ +# OpenBlog 项目规划 + +> 最近更新:2026-08-18 +> 目的:梳理近期开发任务优先级与范围,作为后续 spec/plan 的输入。 +> 每个任务开工前,建议用 brainstorming 细化设计 → writing-plans 出实现计划 → subagent-driven 执行(与 OpenBlog-common 抽取同一流程)。 + +--- + +## 任务一览(按优先级) + +| # | 任务 | 状态 | 目标 | +|---|------|------|------| +| 1 | 完善 email 服务,真正实现邮箱注册验证 | 🔲 待排期 | 注册流程真实发送验证邮件 | +| 2 | 首页前端动态化 | 🔲 待排期 | 设计动态首页 | +| 3 | 部署 Docker 化 + 启动加速 | 🔲 待排期 | 后端 docker 启动,加快启动 | + +--- + +## 任务 1:邮箱验证注册(最高优先级) + +### 现状(已核实) + +- **注册流程不发邮件**:`AuthService.register()`(`OpenBlog-business/.../user/service/AuthService.java:69`)流程为 + 滑块验证 → 邮箱格式白名单(`AllowedMailbox`:QQ/网易/谷歌系)→ 用户名/邮箱唯一性 → **直接创建 ACTIVE READER → 返回 token**(注册即登录)。 +- **email 基础设施已就绪,只差接线**: + - RPC 契约 `EmailRpcService.send(EmailSendRequest)`(`OpenBlog-api/OpenBlog-email-api/`) + - Provider:`EmailRpcServiceImpl` `@DubboService`(email 模块),委托 `EmailService.send()`,经阿里云 DirectMail 真实发送,落 `email_record` 表 + - **business 侧目前没有 `@DubboReference` 消费调用**(全库检索确认)——链路已通但未接入 +- Redis 封装可用(`OpenBlog-framework-redis`,`RedisOps`),适合存验证码。 + +### 建议范围 + +1. **business 接入 Dubbo 消费**:新增 `@DubboReference` 注入 `EmailRpcService`(consumer 配置 `application.yaml` 已就绪)。 +2. **验证码发送接口**:`POST /api/v1/auth/email-code`(邮箱 + 滑块 challenge) + - 生成 6 位数字验证码;Redis 存储(`email:code:{email}`),TTL 5 分钟;发送冷却 60 秒。 + - 调 `EmailRpcService.send()` 发"OpenBlog 注册验证码"模板邮件。 +3. **注册接口校验验证码**:`RegisterRequest` 增加 `code` 字段;校验通过才创建用户。 +4. **email 模块**:新增注册验证码邮件模板(HTML),可配置站点名/验证码。 +5. **前端注册页**(`vue/src/views/`,注册组件):邮箱输入 → "获取验证码"按钮(60s 倒计时)→ 提交注册。 + +### 推荐决策:验证码前置 + +先验证邮箱(发码 → 校验)再建号,而非"建号后激活"。理由:避免邮箱占用、符合"注册即登录"现状,改动集中在 auth 流程。 + +### 验收标准 + +- [ ] 注册流程真实发出验证邮件(本地可观察 email_record 记录) +- [ ] 验证码错误 / 过期 / 重发冷却均有明确提示 +- [ ] business → email 的 Dubbo 调用打通(日志可见) + +### 开放问题(开工前确认) + +- 验证码**前置** vs **建号后激活**(现有 `PendingUser` 后台审核是管理员审核概念,是否要与此结合?) +- email 模块现有 HTTP 管理接口(`/api/v1/email/*`)是否保留? +- 验证码邮件是否需要图形验证码之外的防刷(滑块已有,是否足够)? + +--- + +## 任务 2:首页前端动态化 + +### 现状(已核实) + +- 首页组件:`vue/src/views/HomeView.vue`。 +- 此前相关设计:`2026-07-21-home-hero-image`(Hero 图)、`2026-07-22-project-recommendation`(项目推荐)、`2026-06-23` 系列飞书主题重构。 +- business 已提供文章/论坛/项目/互动/站点配置等 API,首页可接真实数据。 + +### 建议范围 + +1. **信息架构**:Hero(动态化)→ 最新文章流 → 推荐项目墙 → 站点数据(文章数/评论数/访问) → 最新动态/评论。 +2. **动态效果**:滚动渐入、鼠标视差、动态渐变背景、骨架屏;Live2D 看板娘已有,保留。 +3. **真实数据接入**:文章列表、项目推荐、站点配置(`/api/v1/...`)替代静态占位。 +4. **响应式 + 性能**:懒加载、图片优化、首屏指标。 + +### 验收标准 + +- [ ] 首页为动态渲染(数据来自接口,非写死) +- [ ] 动效流畅,无首屏卡顿 +- [ ] 桌面/移动端响应式可用 + +### 开放问题(开工前确认) + +- "动态前端页面"侧重**视觉动效**还是**内容动态加载**,还是两者? +- 在现有飞书主题上迭代,还是重新设计风格? + +--- + +## 任务 3:部署 Docker 化 + 启动加速 + +### 现状(已核实) + +- **前端**:已 Docker 化 —— Nginx 容器(`vue/Dockerfile`、`vue/docker.sh`),宿主端口 `18088 → 80`,CI 在 Runner 构建镜像 → 上传 → `docker.sh` 加载更新。 +- **后端**:宿主机 **systemd** `java -jar`(`OpenBlog-business-1.0.0-SNAPSHOT.jar`,`-Xmx1024M -Xms256M`),由 self-hosted Runner(`openblog-backend`)部署(`.github/workflows/ci.yml`)。 +- **基础设施**:MySQL / Redis / MinIO / Nacos 位于 `10.21.76.221`(局域网,宿主或独立机器)。 + +### 建议范围 + +1. **后端 Dockerfile**(`OpenBlog-business`):基于 JRE 17 镜像;分层构建(依赖层缓存);非 root 用户;healthcheck。 +2. **docker-compose 编排**:business + email 服务;MySQL/Redis/MinIO/Nacos 作为外部依赖通过环境变量注入,保持容器外。 +3. **启动加速**(Spring Boot + JVM 层面): + - `spring.main.lazy-initialization=true`(或按 bean 选择性懒加载) + - JVM 调优:`-XX:+UseG1GC`(或 ZGC)、合理 `-Xms/-Xmx`、显式 dump 参数 + - 可选:AppCDS / 镜像分层缓存加速部署 + - 依赖瘦身:排查未用 starter +4. **CI 改造**:构建后端镜像 → 上传服务器 → `docker compose up -d`(替换 systemd);保留回滚(镜像 tag)。 + +### 验收标准 + +- [ ] `docker compose up -d` 一键启动后端服务 +- [ ] 记录优化前后启动耗时对比 +- [ ] CI 部署走 Docker,且失败可回滚 + +### 开放问题(开工前确认) + +- 是否也容器化 **email** 服务(建议一起,同机编排)? +- MySQL/Redis/MinIO/Nacos 留在容器外还是纳入编排? +- "加快启动速度"目标值(如:冷启动 < 15s / 部署总耗时减半)? + +--- + +## 通用待办(非当前优先级) + +- 单元测试补充(README 待办项)。 +- 版本号收敛到根 pom `dependencyManagement`。 +- `MybatisPlusMetaObjectHandler` 迁移至 framework 模块(common 抽取时遗留)。 From 82eacc29d3cbdcfcf5fa68e72c052331c66c19eb Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Fri, 28 Aug 2026 01:35:20 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat(email):=20=E9=82=AE=E7=AE=B1?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E9=AA=8C=E8=AF=81=E7=A0=81=20+=20Dubbo=20?= =?UTF-8?q?=E5=B9=82=E7=AD=89=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - business 新增 EmailCodeService:生成/发送/校验验证码(Redis 存码、60s 冷却、5 分钟 TTL、错误次数限制) - 注册接口校验验证码,通过才创建用户;EmailValidator 修复 '+' 别名检查越界 - email 模块幂等发送:idempotencyKey + email_records 唯一索引,防止 Dubbo 默认重试重复发信 - 前端注册页验证码输入 + 60s 倒计时 - 记录 Dubbo 重放问题与幂等设计经验(docs/dev-experiences.md) Co-Authored-By: Claude --- .../openblog/email/api/EmailSendRequest.java | 10 ++ .../config/AuthSecurityProperties.java | 48 +++++++ .../user/controller/AuthController.java | 16 ++- .../openblog/user/dto/EmailCodeRequest.java | 27 ++++ .../openblog/user/dto/EmailCodeResponse.java | 20 +++ .../openblog/user/dto/RegisterRequest.java | 13 ++ .../openblog/user/service/AuthService.java | 8 +- .../user/validator/EmailValidator.java | 3 +- .../openblog/email/entity/EmailRecord.java | 7 ++ .../openblog/email/service/EmailService.java | 55 ++++++-- .../yqz/openblog/redis/core/RedisKeys.java | 15 +++ docs/dev-experiences.md | 51 ++++++++ sql/email-records.sql | 4 +- sql/migrate-email-records-idempotency-key.sql | 9 ++ vue/src/api/admin.js | 12 +- vue/src/views/SiteAuthView.vue | 117 +++++++++++++++++- 16 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeRequest.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeResponse.java create mode 100644 docs/dev-experiences.md create mode 100644 sql/migrate-email-records-idempotency-key.sql diff --git a/OpenBlog-api/OpenBlog-email-api/src/main/java/com/yqz/openblog/email/api/EmailSendRequest.java b/OpenBlog-api/OpenBlog-email-api/src/main/java/com/yqz/openblog/email/api/EmailSendRequest.java index 0f86c1a..7b25d35 100644 --- a/OpenBlog-api/OpenBlog-email-api/src/main/java/com/yqz/openblog/email/api/EmailSendRequest.java +++ b/OpenBlog-api/OpenBlog-email-api/src/main/java/com/yqz/openblog/email/api/EmailSendRequest.java @@ -10,6 +10,13 @@ public class EmailSendRequest implements Serializable { private String subject; private String body; + /** + * 幂等键(业务方生成,如 UUID)。provider 据此去重: + * 同一幂等键的请求无论被重试/重放多少次,只发送一次。 + * 可空 —— 兼容旧调用方(无幂等键时按原逻辑直接发送)。 + */ + private String idempotencyKey; + public EmailSendRequest() {} public EmailSendRequest(String recipient, String subject, String body) { @@ -26,4 +33,7 @@ public EmailSendRequest(String recipient, String subject, String body) { public String getBody() { return body; } public void setBody(String body) { this.body = body; } + + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } } diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/config/AuthSecurityProperties.java b/OpenBlog-business/src/main/java/com/yqz/openblog/config/AuthSecurityProperties.java index 16dd451..8968de0 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/config/AuthSecurityProperties.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/config/AuthSecurityProperties.java @@ -10,6 +10,7 @@ public class AuthSecurityProperties { private Slider slider = new Slider(); private LoginLockout loginLockout = new LoginLockout(); + private EmailCode emailCode = new EmailCode(); public Slider getSlider() { return slider; @@ -27,6 +28,14 @@ public void setLoginLockout(LoginLockout loginLockout) { this.loginLockout = loginLockout; } + public EmailCode getEmailCode() { + return emailCode; + } + + public void setEmailCode(EmailCode emailCode) { + this.emailCode = emailCode; + } + public static class Slider { /** * 是否要求登录/注册前先完成「滑到尽头」验证(Redis 记录一次性凭证)。 @@ -101,4 +110,43 @@ public void setLockoutSeconds(int lockoutSeconds) { this.lockoutSeconds = lockoutSeconds; } } + + public static class EmailCode { + /** + * 验证码有效时间(秒),默认 5 分钟。 + */ + private int codeTtlSeconds = 300; + /** + * 重新发送冷却时间(秒),默认 60 秒。 + */ + private int resendCooldownSeconds = 60; + /** + * 校验失败允许的最大次数,超过后验证码作废,默认 5 次。 + */ + private int maxVerifyAttempts = 5; + + public int getCodeTtlSeconds() { + return codeTtlSeconds; + } + + public void setCodeTtlSeconds(int codeTtlSeconds) { + this.codeTtlSeconds = codeTtlSeconds; + } + + public int getResendCooldownSeconds() { + return resendCooldownSeconds; + } + + public void setResendCooldownSeconds(int resendCooldownSeconds) { + this.resendCooldownSeconds = resendCooldownSeconds; + } + + public int getMaxVerifyAttempts() { + return maxVerifyAttempts; + } + + public void setMaxVerifyAttempts(int maxVerifyAttempts) { + this.maxVerifyAttempts = maxVerifyAttempts; + } + } } diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/controller/AuthController.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/controller/AuthController.java index 83434f7..389e276 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/user/controller/AuthController.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/controller/AuthController.java @@ -3,6 +3,8 @@ import com.yqz.openblog.common.ApiResponse; import com.yqz.openblog.user.dto.AuthResponse; import com.yqz.openblog.user.dto.ChangePasswordRequest; +import com.yqz.openblog.user.dto.EmailCodeRequest; +import com.yqz.openblog.user.dto.EmailCodeResponse; import com.yqz.openblog.user.dto.LoginRequest; import com.yqz.openblog.user.dto.SliderChallengeResponse; import com.yqz.openblog.user.dto.SliderCompleteRequest; @@ -11,6 +13,7 @@ import com.yqz.openblog.user.dto.RegisterRequest; import com.yqz.openblog.user.dto.UserUpdateRequest; import com.yqz.openblog.user.service.AuthService; +import com.yqz.openblog.user.service.EmailCodeService; import com.yqz.openblog.user.service.SliderVerificationService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -23,10 +26,14 @@ public class AuthController { private final AuthService authService; private final SliderVerificationService sliderVerificationService; + private final EmailCodeService emailCodeService; - public AuthController(AuthService authService, SliderVerificationService sliderVerificationService) { + public AuthController(AuthService authService, + SliderVerificationService sliderVerificationService, + EmailCodeService emailCodeService) { this.authService = authService; this.sliderVerificationService = sliderVerificationService; + this.emailCodeService = emailCodeService; } @GetMapping("/auth/slider-challenge") @@ -42,6 +49,13 @@ public ApiResponse sliderComplete( return ApiResponse.ok(); } + @PostMapping("/auth/email-code") + public ApiResponse emailCode(@RequestBody @Valid EmailCodeRequest req) { + EmailCodeResponse resp = new EmailCodeResponse(); + resp.setCooldownSeconds(emailCodeService.sendCode(req.getEmail())); + return ApiResponse.ok(resp); + } + @PostMapping("/auth/register") public ApiResponse register(@RequestBody @Valid RegisterRequest req) { return ApiResponse.ok(authService.register(req)); diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeRequest.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeRequest.java new file mode 100644 index 0000000..684cd76 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeRequest.java @@ -0,0 +1,27 @@ +package com.yqz.openblog.user.dto; + +import com.yqz.openblog.user.validation.AllowedMailbox; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 发送邮箱注册验证码请求。 + */ +public class EmailCodeRequest { + + @NotBlank + @Email + @Size(max = 64) + @Pattern(regexp = AllowedMailbox.REGEXP, message = AllowedMailbox.MESSAGE) + private String email; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeResponse.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeResponse.java new file mode 100644 index 0000000..c109215 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/EmailCodeResponse.java @@ -0,0 +1,20 @@ +package com.yqz.openblog.user.dto; + +/** + * 发送邮箱验证码响应。 + */ +public class EmailCodeResponse { + + /** + * 本次发送后的冷却秒数,前端用它启动倒计时。 + */ + private int cooldownSeconds; + + public int getCooldownSeconds() { + return cooldownSeconds; + } + + public void setCooldownSeconds(int cooldownSeconds) { + this.cooldownSeconds = cooldownSeconds; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/RegisterRequest.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/RegisterRequest.java index b06775a..7c5b196 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/RegisterRequest.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/dto/RegisterRequest.java @@ -22,6 +22,11 @@ public class RegisterRequest { @Size(min = 6, max = 72) private String password; + /** 邮箱注册验证码(6 位数字)。 */ + @NotBlank + @Pattern(regexp = "^\\d{6}$", message = "验证码格式不正确") + private String code; + private String sliderChallengeId; public String getUsername() { @@ -48,6 +53,14 @@ public void setPassword(String password) { this.password = password; } + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + public String getSliderChallengeId() { return sliderChallengeId; } diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/AuthService.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/AuthService.java index 4094b9d..5d03e5a 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/AuthService.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/AuthService.java @@ -43,6 +43,7 @@ public class AuthService { private final LoginLockoutService loginLockoutService; private final MediaService mediaService; private final EmailValidator emailValidator; + private final EmailCodeService emailCodeService; public AuthService(UserMapper userMapper, RefreshTokenMapper refreshTokenMapper, @@ -53,7 +54,8 @@ public AuthService(UserMapper userMapper, SliderVerificationService sliderVerificationService, LoginLockoutService loginLockoutService, MediaService mediaService, - EmailValidator emailValidator) { + EmailValidator emailValidator, + EmailCodeService emailCodeService) { this.userMapper = userMapper; this.refreshTokenMapper = refreshTokenMapper; this.passwordEncoder = passwordEncoder; @@ -64,6 +66,7 @@ public AuthService(UserMapper userMapper, this.loginLockoutService = loginLockoutService; this.mediaService = mediaService; this.emailValidator = emailValidator; + this.emailCodeService = emailCodeService; } public AuthResponse register(RegisterRequest req) { @@ -75,6 +78,9 @@ public AuthResponse register(RegisterRequest req) { throw new BizException(clientErrorCode(), emailError); } + // 校验邮箱验证码(code-first:先验证验证码,再建号) + emailCodeService.verifyAndConsume(req.getEmail(), req.getCode()); + if (userMapper.selectCount(Wrappers.lambdaQuery(User.class) .eq(User::getUsername, req.getUsername())) > 0) { throw new BizException(clientErrorCode(), "用户名已存在"); diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/validator/EmailValidator.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/validator/EmailValidator.java index ae020a1..755b77a 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/user/validator/EmailValidator.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/validator/EmailValidator.java @@ -108,7 +108,8 @@ public String validate(String email) { } // 4. 拒绝 + 别名(常见攻击手段:同一邮箱无限注册) - if (lower.indexOf('+', 0) < atIndex && lower.charAt(lower.indexOf('+')) == '+') { + int plusIndex = lower.indexOf('+'); + if (plusIndex >= 0 && plusIndex < atIndex) { return "不支持带 + 别名的邮箱地址"; } diff --git a/OpenBlog-email/src/main/java/com/yqz/openblog/email/entity/EmailRecord.java b/OpenBlog-email/src/main/java/com/yqz/openblog/email/entity/EmailRecord.java index f23b361..d665c0a 100644 --- a/OpenBlog-email/src/main/java/com/yqz/openblog/email/entity/EmailRecord.java +++ b/OpenBlog-email/src/main/java/com/yqz/openblog/email/entity/EmailRecord.java @@ -27,6 +27,10 @@ public class EmailRecord { @TableField("request_id") private String requestId; + /** 幂等键(业务方生成)。email_records.idempotency_key 唯一索引兜底防重发。 */ + @TableField("idempotency_key") + private String idempotencyKey; + @TableField("sent_at") private Instant sentAt; @@ -56,6 +60,9 @@ public EmailRecord() {} public String getRequestId() { return requestId; } public void setRequestId(String requestId) { this.requestId = requestId; } + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + public Instant getSentAt() { return sentAt; } public void setSentAt(Instant sentAt) { this.sentAt = sentAt; } diff --git a/OpenBlog-email/src/main/java/com/yqz/openblog/email/service/EmailService.java b/OpenBlog-email/src/main/java/com/yqz/openblog/email/service/EmailService.java index 847cc9b..91d4e49 100644 --- a/OpenBlog-email/src/main/java/com/yqz/openblog/email/service/EmailService.java +++ b/OpenBlog-email/src/main/java/com/yqz/openblog/email/service/EmailService.java @@ -14,6 +14,7 @@ import com.yqz.openblog.email.sender.DirectMailSender; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import java.time.Instant; @@ -35,15 +36,47 @@ public EmailService(EmailRecordMapper emailRecordMapper, DirectMailSender direct /** * 发送邮件并记录到数据库。 + *

+ * 幂等设计:调用方可携带 idempotencyKey(如 UUID)。同一幂等键的请求—— + * 无论被 Dubbo 消费端重试、网络重放还是并发重提——都只实际发送一次: + * 命中已有记录时直接复用返回,不再调用发信通道。唯一索引 uk_idempotency_key + * 是硬兜底,保证并发双插时也只有一个能真正发送。 */ public EmailSendResult send(EmailSendRequest req) { + String key = req.getIdempotencyKey(); + if (key != null && !key.isBlank()) { + EmailRecord existing = emailRecordMapper.selectOne( + Wrappers.lambdaQuery(EmailRecord.class).eq(EmailRecord::getIdempotencyKey, key)); + if (existing != null) { + log.info("Idempotent replay: reuse recordId={}, key={}, status={}", + existing.getId(), key, existing.getStatus()); + return toResult(existing); + } + } + return doSend(req, key); + } + + private EmailSendResult doSend(EmailSendRequest req, String idempotencyKey) { // 1. 先写 PENDING 记录 EmailRecord record = new EmailRecord(); record.setRecipient(req.getRecipient()); record.setSubject(req.getSubject()); record.setBody(req.getBody()); record.setStatus(EmailStatus.PENDING); - emailRecordMapper.insert(record); + record.setIdempotencyKey(idempotencyKey); + try { + emailRecordMapper.insert(record); + } catch (DuplicateKeyException e) { + // 并发双插撞唯一索引:另一执行者已写入。查回其记录返回,不重复发送。 + EmailRecord existing = emailRecordMapper.selectOne( + Wrappers.lambdaQuery(EmailRecord.class).eq(EmailRecord::getIdempotencyKey, idempotencyKey)); + if (existing != null) { + log.info("Idempotent race: reuse recordId={}, key={}, status={}", + existing.getId(), idempotencyKey, existing.getStatus()); + return toResult(existing); + } + throw e; + } // 2. 发送邮件 try { @@ -58,15 +91,19 @@ public EmailSendResult send(EmailSendRequest req) { } emailRecordMapper.updateById(record); + return toResult(record); + } + + private EmailSendResult toResult(EmailRecord r) { return new EmailSendResult( - record.getId(), - record.getRecipient(), - record.getSubject(), - record.getStatus().name(), - record.getErrorMsg(), - record.getRequestId(), - record.getSentAt(), - record.getCreatedAt() + r.getId(), + r.getRecipient(), + r.getSubject(), + r.getStatus().name(), + r.getErrorMsg(), + r.getRequestId(), + r.getSentAt(), + r.getCreatedAt() ); } diff --git a/OpenBlog-framework-redis/src/main/java/com/yqz/openblog/redis/core/RedisKeys.java b/OpenBlog-framework-redis/src/main/java/com/yqz/openblog/redis/core/RedisKeys.java index a96487a..a65b1f4 100644 --- a/OpenBlog-framework-redis/src/main/java/com/yqz/openblog/redis/core/RedisKeys.java +++ b/OpenBlog-framework-redis/src/main/java/com/yqz/openblog/redis/core/RedisKeys.java @@ -34,6 +34,9 @@ private RedisKeys() { private static final String SECURITY_SLIDER_PENDING = "openblog:security:slider:pending"; private static final String SECURITY_SLIDER_OK = "openblog:security:slider:ok"; public static final String SECURITY_SLIDER_HEALTHCHECK = "openblog:security:slider:healthcheck"; + private static final String SECURITY_EMAIL_CODE = "openblog:security:email:code"; + private static final String SECURITY_EMAIL_COOLDOWN = "openblog:security:email:cooldown"; + private static final String SECURITY_EMAIL_ATTEMPT = "openblog:security:email:attempt"; // ==================== ratelimit ==================== @@ -74,6 +77,18 @@ public static String sliderOk(String id) { return SECURITY_SLIDER_OK + ":" + id; } + public static String emailCode(String email) { + return SECURITY_EMAIL_CODE + ":" + email; + } + + public static String emailCooldown(String email) { + return SECURITY_EMAIL_COOLDOWN + ":" + email; + } + + public static String emailAttempt(String email) { + return SECURITY_EMAIL_ATTEMPT + ":" + email; + } + public static String feedbackIpDay(String ipKey, Object day) { return RATELIMIT_FEEDBACK + ":" + ipKey + ":" + day; } diff --git a/docs/dev-experiences.md b/docs/dev-experiences.md new file mode 100644 index 0000000..34f9664 --- /dev/null +++ b/docs/dev-experiences.md @@ -0,0 +1,51 @@ +# 开发经验记录 + +> 沉淀踩过的坑与解法。新经验追加在末尾,按日期 + 标题格式。 + +--- + +## 2026-08-27:Dubbo 默认重试导致邮件重复发送 —— 非幂等 RPC 的幂等设计 + +### 现象 + +注册"获取验证码"一次点击,收到 **3 封完全相同的验证码邮件**(验证码和正文一样)。 + +### 根因 + +- Dubbo 消费端**默认 `retries=2`**(首次调用超时/失败后自动重试 2 次,共 3 次调用)、**默认 `timeout=1000ms`**。 +- 阿里云真实发信耗时经常 >1s → 消费端超时 → Dubbo 在网络层**把同一个请求重新发给 provider** → email 服务的 `EmailService.send()` 被执行 3 遍 → 3 封邮件。 +- 发邮件是**非幂等副作用**:重复执行会重复投递。 + +### 关键排查线索 + +**3 封邮件验证码完全相同** → 排除"点了 3 次获取"(每次点击会生成不同的随机验证码)。同码 + 同内容 = **同一次业务逻辑被重复执行**。由此定位到是 Dubbo 重试,而不是前端重复请求。 + +### 修复:三层防线 + +| 层 | 方案 | 代码位置 | +|----|------|---------| +| 1 | 消费端关闭默认重试:`@DubboReference(retries=0, timeout=5000)` | `EmailCodeService.java` | +| 2 | **provider 幂等去重**:按 `idempotencyKey` 查 `email_records`,命中直接返回已有记录、不重发 | `EmailService.send()` | +| 3 | `email_records.idempotency_key` **唯一索引**硬兜底:并发双插 / 进程崩溃 / 时间重放都不重发 | `sql/migrate-email-records-idempotency-key.sql` | + +- 调用方(business):一次 `sendCode` 生成一个 **UUID 幂等键**随 RPC 传入;复用 Redis 里未过期的验证码,避免重发作废旧邮件。 +- 部署顺序:先执行迁移 SQL 加唯一索引 → 再部署 email 服务 → 最后部署 business。 + +### 认知教训:为什么"分布式锁"解决不了这个问题 + +踩坑时想过用分布式锁,**它是错误工具**: + +- **Dubbo 重试发生在消费端代理层**:`sendCode()` 业务代码只执行一次,重试是网络层把同一请求重新投递给 provider。业务侧加锁根本拦不住 provider 被重复执行。 +- 锁解决的是**并发互斥**,这里是**同一请求被重复投递、副作用重复执行**,需要的是**幂等**(让重复投递无害),两者不是一回事。 +- 锁有 TTL 过期、进程崩溃丢锁、"几小时后重放"时锁早已不存在的缺陷。 +- **DB 唯一索引本身就是一种内置的、race-safe 的分布式互斥**:它同时做到"并发只有一个成功"和"重放返回已有结果",跨进程崩溃、跨时间均有效,是这类问题的最强解。 + +### 通用经验 + +1. **Dubbo/微服务调用任何非幂等副作用**(发邮件、发短信、扣款、生成订单)都必须:要么显式 `retries=0`,要么做幂等。二者都做最稳。 +2. **幂等设计三件套**: + - 调用方生成**幂等键**(UUID 或 `recipient+业务标识` 派生),一次逻辑操作一个键; + - 接收方按幂等键**去重**(查已有记录 → 复用返回); + - 存储层**唯一索引**兜底并发竞态。 +3. **排查"重复副作用"**:先看重复对象里是否有业务标识(验证码/订单号/流水号)。标识相同 = 同一逻辑被重放;标识不同 = 被触发多次。这能快速二分定位是"重试/重放"还是"重复请求"。 +4. 阿里云等真实外部调用耗时长,Dubbo 默认 `timeout=1000ms` 过紧,需要按实际放大(本项目 5000ms)。 diff --git a/sql/email-records.sql b/sql/email-records.sql index ede5e6a..c779db9 100644 --- a/sql/email-records.sql +++ b/sql/email-records.sql @@ -7,8 +7,10 @@ CREATE TABLE IF NOT EXISTS email_records ( status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING / SENT / FAILED', error_msg VARCHAR(512) COMMENT '失败原因', request_id VARCHAR(64) COMMENT '阿里云 DirectMail 请求 ID', + idempotency_key VARCHAR(64) COMMENT '幂等键(业务方生成,如 UUID)。provider 据此去重,防止同一逻辑请求被重试/重放时重复发送', sent_at DATETIME COMMENT '发送时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_status_created (status, created_at DESC), - INDEX idx_recipient (recipient) + INDEX idx_recipient (recipient), + UNIQUE KEY uk_idempotency_key (idempotency_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/migrate-email-records-idempotency-key.sql b/sql/migrate-email-records-idempotency-key.sql new file mode 100644 index 0000000..2c0aefb --- /dev/null +++ b/sql/migrate-email-records-idempotency-key.sql @@ -0,0 +1,9 @@ +-- 迁移:email_records 增加幂等键列 + 唯一索引(防 Dubbo 重试/重放导致重复发信) +-- 生产库执行一次即可;幂等键为业务方生成的 UUID,历史记录留空不影响查询/展示。 +-- 注意:MySQL 中 UNIQUE 索引对 NULL 值不去重(NULL 可重复),历史无幂等键的行不受影响。 + +ALTER TABLE email_records + ADD COLUMN idempotency_key VARCHAR(64) NULL COMMENT '幂等键(业务方生成,如 UUID)。provider 据此去重,防止同一逻辑请求被重试/重放时重复发送' AFTER request_id; + +ALTER TABLE email_records + ADD UNIQUE INDEX uk_idempotency_key (idempotency_key); diff --git a/vue/src/api/admin.js b/vue/src/api/admin.js index 1cbfd8a..e0f657f 100644 --- a/vue/src/api/admin.js +++ b/vue/src/api/admin.js @@ -10,10 +10,18 @@ export function login(account, password) { /** 前台账号注册(与控制台管理员登录入口分离) */ export function register(payload) { - const { username, email, password } = payload + const { username, email, password, code } = payload return request('/api/v1/auth/register', { method: 'POST', - body: JSON.stringify({ username, email, password }) + body: JSON.stringify({ username, email, password, code }) + }) +} + +/** 发送邮箱注册验证码 */ +export function sendEmailCode(email) { + return request('/api/v1/auth/email-code', { + method: 'POST', + body: JSON.stringify({ email }) }) } diff --git a/vue/src/views/SiteAuthView.vue b/vue/src/views/SiteAuthView.vue index e21c187..2397387 100644 --- a/vue/src/views/SiteAuthView.vue +++ b/vue/src/views/SiteAuthView.vue @@ -88,6 +88,30 @@ />

{{ ALLOWED_EMAIL_MESSAGE }}
+
+
邮箱验证码
+
+ + +
+
验证码将发送至你的邮箱,5 分钟内有效
+
密码
+ + From a392214c58addf13e2710b9a802aa9f1e721f9d1 Mon Sep 17 00:00:00 2001 From: yqz <2678785492@qq.com> Date: Fri, 28 Aug 2026 01:35:26 +0800 Subject: [PATCH 12/12] =?UTF-8?q?feat(notification):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=8A=BD=E8=B1=A1=E5=B1=82=20+=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=B6=88=E6=81=AF=E8=A1=A8=20MQ=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E6=8A=95=E9=80=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知抽象层:NotificationChannel 策略 + AbstractNotificationChannel 模板方法 + 模板渲染 + ChannelRegistry 路由 + NotificationService 门面,当前 EMAIL 直发 Dubbo - P1 异步管道:notification_outbox + RocketMQ Relay/Consumer,幂等复用 messageId (outbox 与业务同事务,Relay 至少一次发布,email 模块唯一索引兜底去重) - EmailCodeService 改经 NotificationService 发信,同步验证码行为与幂等保障不变 - 单测:ChannelRegistry 路由 / 模板渲染 / outbox 映射 / Relay 发布 / submitAsync 幂等键 - RocketMQ docker-compose 部署脚本 + outbox 建表 SQL Co-Authored-By: Claude --- OpenBlog-business/pom.xml | 7 + .../com/yqz/openblog/OpenBlogApplication.java | 4 +- .../AbstractNotificationChannel.java | 34 +++ .../notification/ChannelRegistry.java | 41 +++ .../notification/NotificationChannel.java | 16 + .../notification/NotificationChannelType.java | 17 ++ .../notification/NotificationMessage.java | 70 +++++ .../notification/NotificationProperties.java | 100 +++++++ .../notification/NotificationService.java | 85 ++++++ .../NotificationTemplateService.java | 56 ++++ .../channel/EmailNotificationChannel.java | 73 +++++ .../mq/NotificationMqConsumer.java | 48 +++ .../mq/NotificationMqProducer.java | 24 ++ .../notification/mq/NotificationTopics.java | 17 ++ .../outbox/NotificationOutbox.java | 94 ++++++ .../outbox/NotificationOutboxMapper.java | 21 ++ .../notification/outbox/OutboxRelay.java | 106 +++++++ .../user/service/EmailCodeService.java | 156 ++++++++++ .../resources/application-local.example.yaml | 20 ++ .../src/main/resources/application.yaml | 22 ++ .../notification/ChannelRegistryTest.java | 58 ++++ .../notification/NotificationServiceTest.java | 75 +++++ .../NotificationTemplateServiceTest.java | 44 +++ .../outbox/NotificationOutboxTest.java | 34 +++ .../notification/outbox/OutboxRelayTest.java | 92 ++++++ docker/rocketmq/broker.conf | 10 + docker/rocketmq/docker-compose.yml | 43 +++ docs/ROADMAP.md | 9 +- docs/designs/notification-mq-async.md | 278 ++++++++++++++++++ sql/notification-outbox.sql | 23 ++ 30 files changed, 1675 insertions(+), 2 deletions(-) create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/AbstractNotificationChannel.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/ChannelRegistry.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannel.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannelType.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationMessage.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationProperties.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationService.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationTemplateService.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/channel/EmailNotificationChannel.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqConsumer.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqProducer.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationTopics.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutbox.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutboxMapper.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/OutboxRelay.java create mode 100644 OpenBlog-business/src/main/java/com/yqz/openblog/user/service/EmailCodeService.java create mode 100644 OpenBlog-business/src/test/java/com/yqz/openblog/notification/ChannelRegistryTest.java create mode 100644 OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationServiceTest.java create mode 100644 OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationTemplateServiceTest.java create mode 100644 OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/NotificationOutboxTest.java create mode 100644 OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/OutboxRelayTest.java create mode 100644 docker/rocketmq/broker.conf create mode 100644 docker/rocketmq/docker-compose.yml create mode 100644 docs/designs/notification-mq-async.md create mode 100644 sql/notification-outbox.sql diff --git a/OpenBlog-business/pom.xml b/OpenBlog-business/pom.xml index 6a1f937..68dc1ee 100644 --- a/OpenBlog-business/pom.xml +++ b/OpenBlog-business/pom.xml @@ -85,6 +85,13 @@ 2.4.3 + + + org.apache.rocketmq + rocketmq-spring-boot-starter + 2.3.2 + + com.yqz OpenBlog-framework-audit 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 e87df18..412958d 100644 --- a/OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/OpenBlogApplication.java @@ -3,6 +3,7 @@ import com.yqz.openblog.config.AuthSecurityProperties; import com.yqz.openblog.config.CorsProperties; import com.yqz.openblog.config.SiteProperties; +import com.yqz.openblog.notification.NotificationProperties; import com.yqz.openblog.seo.SeoProperties; import org.apache.dubbo.config.spring.context.annotation.EnableDubbo; import org.springframework.boot.SpringApplication; @@ -29,7 +30,8 @@ SiteProperties.class, CorsProperties.class, AuthSecurityProperties.class, - SeoProperties.class + SeoProperties.class, + NotificationProperties.class }) @ComponentScan( basePackages = "com.yqz.openblog", diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/AbstractNotificationChannel.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/AbstractNotificationChannel.java new file mode 100644 index 0000000..c3ea145 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/AbstractNotificationChannel.java @@ -0,0 +1,34 @@ +package com.yqz.openblog.notification; + +/** + * 通知渠道模板方法基类。 + *

+ * 固定投递骨架:模板渲染 → doSend(子类实现渠道差异)。子类只需实现 {@link #type()} 与 + * {@link #doSend(NotificationMessage, String)},统一处理模板渲染,降低渠道实现与调用方的耦合。 + * 新增渠道(SMS / 飞书)只需继承本类,主链路零改动。 + */ +public abstract class AbstractNotificationChannel implements NotificationChannel { + + private final NotificationTemplateService templateService; + + protected AbstractNotificationChannel(NotificationTemplateService templateService) { + this.templateService = templateService; + } + + /** + * 模板方法(final,子类不可覆写骨架):渲染内容后交给子类真正投递。 + * 渲染失败抛 {@link com.yqz.openblog.common.BizException}(4000),投递失败由 doSend 抛(5002 等)。 + */ + @Override + public final void send(NotificationMessage message) { + String content = templateService.render(message.getTemplateCode(), message.getParams()); + doSend(message, content); + } + + /** + * 子类实现:真正投递。入参为完整消息(含 {@code messageId} 幂等键)与已渲染内容。 + * Email → Dubbo RPC 调 email 模块;未来 SMS → 阿里云短信;Feishu → webhook。 + * 失败必须抛 {@link com.yqz.openblog.common.BizException},由调用方决定是否回滚。 + */ + protected abstract void doSend(NotificationMessage message, String content); +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/ChannelRegistry.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/ChannelRegistry.java new file mode 100644 index 0000000..d8e17d5 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/ChannelRegistry.java @@ -0,0 +1,41 @@ +package com.yqz.openblog.notification; + +import com.yqz.openblog.common.BizException; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * 渠道路由表:把容器里所有启用的 {@link NotificationChannel} 按 {@code type()} 收敛成 Map, + * {@link NotificationService} 据此分发。新增渠道 = 新增实现类(自动被 Spring 收集),主链路零改动。 + */ +@Component +public class ChannelRegistry { + + private final Map channels = + new EnumMap<>(NotificationChannelType.class); + + public ChannelRegistry(List channelList) { + for (NotificationChannel channel : channelList) { + NotificationChannelType type = channel.type(); + if (channels.put(type, channel) != null) { + throw new IllegalStateException("通知渠道重复注册: " + type); + } + } + } + + /** 按类型取渠道;未配置/被禁用时抛 4000(fail-closed)。 */ + public NotificationChannel resolve(NotificationChannelType type) { + NotificationChannel channel = channels.get(type); + if (channel == null) { + throw new BizException(4000, "未配置的通知渠道: " + type); + } + return channel; + } + + public Map all() { + return channels; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannel.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannel.java new file mode 100644 index 0000000..eda3492 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannel.java @@ -0,0 +1,16 @@ +package com.yqz.openblog.notification; + +/** + * 通知渠道策略接口。 + *

+ * 每个渠道一个实现(当前仅 {@code EmailNotificationChannel};未来 SMS / 飞书各加一个实现), + * 由 {@link ChannelRegistry} 按 {@link #type()} 路由。新增渠道只需新增实现类,主链路零改动。 + */ +public interface NotificationChannel { + + /** 本渠道对应的类型(用于路由)。 */ + NotificationChannelType type(); + + /** 投递一条通知。失败抛 {@link com.yqz.openblog.common.BizException}(由调用方决定是否回滚)。 */ + void send(NotificationMessage message); +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannelType.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannelType.java new file mode 100644 index 0000000..cca8cbb --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationChannelType.java @@ -0,0 +1,17 @@ +package com.yqz.openblog.notification; + +/** + * 通知渠道类型。 + *

+ * 当前只实现 EMAIL(经 Dubbo 调 email 模块直发)。SMS / FEISHU 为未来扩展位, + * 接入时新增枚举值 + 对应 {@link NotificationChannel} 策略 + 配置即可,主链路零改动。 + */ +public enum NotificationChannelType { + + /** 邮件 */ + EMAIL; + + // 未来扩展位: + // SMS —— 阿里云短信(新增 SmsNotificationChannel) + // FEISHU —— 飞书机器人 webhook(新增 FeishuNotificationChannel) +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationMessage.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationMessage.java new file mode 100644 index 0000000..03636a8 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationMessage.java @@ -0,0 +1,70 @@ +package com.yqz.openblog.notification; + +import java.util.Map; + +/** + * 统一通知消息模型。 + *

+ * 渠道无关:channel 决定由哪个策略投递,recipient/subject 为投递目标,templateCode + params + * 经 {@link NotificationTemplateService} 渲染成各渠道内容。未来接 SMS/飞书时扩展 templateCode + * 与模板即可,调用方无需感知渠道实现差异。 + */ +public class NotificationMessage { + + /** + * 全局幂等键(业务方生成,如 UUID):贯穿 outbox / MQ / 消费端投递。 + * 异步链路下由 submitAsync 生成;通道据此传给下游(如 EmailSendRequest.idempotencyKey), + * 保证同一条消息被 MQ 重投 N 次也只真正投递一次。同步链路可空(通道自行生成)。 + */ + private String messageId; + private NotificationChannelType channel; + private String recipient; + private String subject; + private String templateCode; + private Map params; + + public NotificationMessage() {} + + public NotificationMessage(String messageId, NotificationChannelType channel, String recipient, String subject, + String templateCode, Map params) { + this.messageId = messageId; + this.channel = channel; + this.recipient = recipient; + this.subject = subject; + this.templateCode = templateCode; + this.params = params; + } + + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } + + public NotificationChannelType getChannel() { return channel; } + public void setChannel(NotificationChannelType channel) { this.channel = channel; } + + public String getRecipient() { return recipient; } + public void setRecipient(String recipient) { this.recipient = recipient; } + + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + + public String getTemplateCode() { return templateCode; } + public void setTemplateCode(String templateCode) { this.templateCode = templateCode; } + + public Map getParams() { return params; } + public void setParams(Map params) { this.params = params; } + + public static Builder builder() { return new Builder(); } + + public static class Builder { + private final NotificationMessage msg = new NotificationMessage(); + + public Builder messageId(String messageId) { msg.messageId = messageId; return this; } + public Builder channel(NotificationChannelType channel) { msg.channel = channel; return this; } + public Builder recipient(String recipient) { msg.recipient = recipient; return this; } + public Builder subject(String subject) { msg.subject = subject; return this; } + public Builder templateCode(String templateCode) { msg.templateCode = templateCode; return this; } + public Builder params(Map params) { msg.params = params; return this; } + + public NotificationMessage build() { return msg; } + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationProperties.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationProperties.java new file mode 100644 index 0000000..e5028e5 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationProperties.java @@ -0,0 +1,100 @@ +package com.yqz.openblog.notification; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 通知渠道配置(prefix: openblog.notification),对齐 AuthSecurityProperties 的嵌套类风格。 + *

+ * 当前只 {@code email} 生效;SMS / 飞书为未来扩展位(新增嵌套类 + 配置注释即可), + * 未配置的渠道由 {@link ChannelRegistry} fail-closed 拒绝。 + */ +@ConfigurationProperties(prefix = "openblog.notification") +public class NotificationProperties { + + private Email email = new Email(); + private Outbox outbox = new Outbox(); + + public Email getEmail() { + return email; + } + + public void setEmail(Email email) { + this.email = email; + } + + public Outbox getOutbox() { + return outbox; + } + + public void setOutbox(Outbox outbox) { + this.outbox = outbox; + } + + /** 邮件渠道配置。 */ + public static class Email { + /** 是否启用邮件渠道(false 时 EmailNotificationChannel 不注册)。 */ + private boolean enabled = true; + /** 调用方未指定 subject 时的默认主题。 */ + private String defaultSubject = "OpenBlog"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDefaultSubject() { + return defaultSubject; + } + + public void setDefaultSubject(String defaultSubject) { + this.defaultSubject = defaultSubject; + } + } + + /** 异步通知本地消息表(outbox)配置。 */ + public static class Outbox { + /** 是否启用异步通知(false 时 submitAsync 直接抛 4000)。 */ + private boolean enabled = true; + /** Relay 扫描间隔(毫秒),@Scheduled fixedDelay。 */ + private long relayIntervalMs = 5000; + /** 跳过刚提交的记录窗口(毫秒),给业务事务提交留出余量,避免读到未提交的脏窗口。 */ + private long publishWindowMs = 1000; + /** 投递失败最大重试次数,超过后进死信(P2 延时重试使用,本期先暴露配置位)。 */ + private int maxRetry = 8; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public long getRelayIntervalMs() { + return relayIntervalMs; + } + + public void setRelayIntervalMs(long relayIntervalMs) { + this.relayIntervalMs = relayIntervalMs; + } + + public long getPublishWindowMs() { + return publishWindowMs; + } + + public void setPublishWindowMs(long publishWindowMs) { + this.publishWindowMs = publishWindowMs; + } + + public int getMaxRetry() { + return maxRetry; + } + + public void setMaxRetry(int maxRetry) { + this.maxRetry = maxRetry; + } + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationService.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationService.java new file mode 100644 index 0000000..977aa4f --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationService.java @@ -0,0 +1,85 @@ +package com.yqz.openblog.notification; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yqz.openblog.common.BizException; +import com.yqz.openblog.notification.outbox.NotificationOutbox; +import com.yqz.openblog.notification.outbox.NotificationOutboxMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +/** + * 通知门面:调用方只描述「发什么」(channel / recipient / subject / templateCode / params), + * 由本类按渠道路由分发,渠道差异对调用方完全透明。 + *

+ * 双路径: + * - {@link #submit(NotificationMessage)}:同步投递(强反馈场景,如注册验证码,Dubbo 直发)。 + * - {@link #submitAsync(NotificationMessage)}:异步投递(本地消息表 outbox → MQ → Consumer 投递)。 + * 两条路径共用同一套校验 / Channel 策略 / 幂等键,只是触发方式不同。 + */ +@Service +public class NotificationService { + + private final ChannelRegistry registry; + private final NotificationProperties properties; + private final NotificationOutboxMapper outboxMapper; + private final ObjectMapper objectMapper; + + public NotificationService(ChannelRegistry registry, + NotificationProperties properties, + NotificationOutboxMapper outboxMapper, + ObjectMapper objectMapper) { + this.registry = registry; + this.properties = properties; + this.outboxMapper = outboxMapper; + this.objectMapper = objectMapper; + } + + /** 同步提交:校验 → 补默认主题 → 按渠道分发。失败抛 BizException(由调用方决定是否回滚)。 */ + public void submit(NotificationMessage message) { + prepare(message); + registry.resolve(message.getChannel()).send(message); + } + + /** + * 异步提交:与业务动作同事务写入 outbox(PENDING),由 Relay 发布 MQ、Consumer 投递。 + * 保证「业务提交」与「通知入队」原子,消息不丢失;messageId 贯穿 MQ 重投,不重复。 + */ + @Transactional + public void submitAsync(NotificationMessage message) { + if (!properties.getOutbox().isEnabled()) { + throw new BizException(4000, "异步通知未启用"); + } + prepare(message); + + // 一次 submitAsync = 一个 messageId:贯穿 outbox / MQ / 消费端幂等。 + if (message.getMessageId() == null || message.getMessageId().isBlank()) { + message.setMessageId(UUID.randomUUID().toString()); + } + + try { + String paramsJson = objectMapper.writeValueAsString(message.getParams()); + outboxMapper.insert(NotificationOutbox.from(message, paramsJson)); + } catch (JsonProcessingException e) { + throw new BizException(4000, "通知参数序列化失败"); + } + } + + /** 公共前置:非空校验 + 补默认主题。 */ + private void prepare(NotificationMessage message) { + if (message == null) { + throw new BizException(4000, "通知消息不能为空"); + } + if (message.getChannel() == null) { + throw new BizException(4000, "通知渠道不能为空"); + } + if (message.getRecipient() == null || message.getRecipient().isBlank()) { + throw new BizException(4000, "通知接收方不能为空"); + } + if (message.getSubject() == null || message.getSubject().isBlank()) { + message.setSubject(properties.getEmail().getDefaultSubject()); + } + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationTemplateService.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationTemplateService.java new file mode 100644 index 0000000..6c78c74 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/NotificationTemplateService.java @@ -0,0 +1,56 @@ +package com.yqz.openblog.notification; + +import com.yqz.openblog.common.BizException; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 通知模板渲染:templateCode + params → 各渠道内容(邮件 HTML / 短信文本 / 飞书 JSON)。 + *

+ * 本期内置「注册验证码」邮件模板,占位符 {{key}} 替换;未来可扩展为配置化 / DB 多模板, + * 调用方无感。这是通知抽象层「开放性」的落点之一。 + */ +@Service +public class NotificationTemplateService { + + /** 注册验证码模板 code。 */ + public static final String REGISTER_VERIFICATION_CODE = "register-verification-code"; + + private static final Map TEMPLATES = Map.of( + REGISTER_VERIFICATION_CODE, + "

" + + "

OpenBlog 注册验证码

" + + "

你正在注册 OpenBlog 账号,以下是你的验证码:

" + + "

{{code}}

" + + "

验证码 5 分钟内有效,请勿泄露给他人。" + + "若非本人操作请忽略本邮件。

" + + "
" + ); + + /** + * 渲染通知内容:将模板中的 {{key}} 占位符替换为 params 中的值。 + * 未知模板抛 BizException(4000),fail-closed。 + */ + public String render(String templateCode, Map params) { + if (templateCode == null || templateCode.isBlank()) { + throw new BizException(4000, "通知模板不能为空"); + } + String template = TEMPLATES.get(templateCode); + if (template == null) { + throw new BizException(4000, "未知的通知模板: " + templateCode); + } + + String content = template; + if (params != null) { + for (Map.Entry e : params.entrySet()) { + if (e.getValue() == null) { + continue; + } + content = content.replace("{{" + e.getKey() + "}}", e.getValue().toString()); + } + } + return content; + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/channel/EmailNotificationChannel.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/channel/EmailNotificationChannel.java new file mode 100644 index 0000000..4eed7b7 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/channel/EmailNotificationChannel.java @@ -0,0 +1,73 @@ +package com.yqz.openblog.notification.channel; + +import com.yqz.openblog.common.BizException; +import com.yqz.openblog.email.api.EmailRpcService; +import com.yqz.openblog.email.api.EmailSendRequest; +import com.yqz.openblog.email.api.EmailSendResult; +import com.yqz.openblog.notification.AbstractNotificationChannel; +import com.yqz.openblog.notification.NotificationChannelType; +import com.yqz.openblog.notification.NotificationMessage; +import com.yqz.openblog.notification.NotificationTemplateService; +import org.apache.dubbo.config.annotation.DubboReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * 邮件通知渠道(本期唯一策略)。继承模板方法基类,只实现投递差异: + * 经 Dubbo 调 email 模块直发,复用已上线的幂等保障(retries=0 + 幂等键 + DB 唯一索引)。 + *

+ * 新增渠道(SMS / 飞书)参照本类再写一个 {@code extends AbstractNotificationChannel} 即可。 + */ +@Component +@ConditionalOnProperty(prefix = "openblog.notification.email", name = "enabled", havingValue = "true", matchIfMissing = true) +public class EmailNotificationChannel extends AbstractNotificationChannel { + + private static final Logger log = LoggerFactory.getLogger(EmailNotificationChannel.class); + + /** + * Dubbo 消费 email 服务。@DubboReference 需字段注入(Dubbo Spring Boot 3.x 对构造器注入支持不稳), + * 沿用项目约定。retries=0:发送邮件是【非幂等】操作,必须关闭 Dubbo 默认重试,否则调用超时时 + * 同一封邮件会被发多次;timeout=5000ms 放宽默认的 1000ms(阿里云真实发送通常超过 1s)。 + */ + @DubboReference(retries = 0, timeout = 5000) + private EmailRpcService emailRpcService; + + public EmailNotificationChannel(NotificationTemplateService templateService) { + super(templateService); + } + + @Override + public NotificationChannelType type() { + return NotificationChannelType.EMAIL; + } + + @Override + protected void doSend(NotificationMessage message, String content) { + // 幂等键:异步链路优先用 messageId(MQ 重投同一消息时,email 服务命中已有记录直接返回, + // 不再重复发送);同步链路 messageId 为空则生成 UUID。见 EmailService.send 幂等逻辑。 + String idempotencyKey = (message.getMessageId() == null || message.getMessageId().isBlank()) + ? UUID.randomUUID().toString() + : message.getMessageId(); + EmailSendRequest request = new EmailSendRequest(message.getRecipient(), message.getSubject(), content); + request.setIdempotencyKey(idempotencyKey); + + EmailSendResult result; + try { + result = emailRpcService.send(request); + } catch (Exception e) { + // No provider / RPC 异常 → 统一抛 5002,由调用方(EmailCodeService)决定是否回滚 Redis。 + log.warn("邮件通知发送失败(Dubbo 调用异常)。recipient={}", message.getRecipient(), e); + throw new BizException(5002, "邮件服务暂不可用,请稍后再试"); + } + + if (!"SENT".equals(result.getStatus())) { + log.warn("邮件通知发送失败(status={}, errorMsg={})。recipient={}", + result.getStatus(), result.getErrorMsg(), message.getRecipient()); + throw new BizException(5002, "邮件发送失败,请稍后再试"); + } + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqConsumer.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqConsumer.java new file mode 100644 index 0000000..7d504b0 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqConsumer.java @@ -0,0 +1,48 @@ +package com.yqz.openblog.notification.mq; + +import com.yqz.openblog.notification.NotificationMessage; +import com.yqz.openblog.notification.NotificationService; +import com.yqz.openblog.notification.outbox.NotificationOutboxMapper; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 通知消息消费者:收到 MQ 消息 → 复用同步门面投递(渲染 + 路由 + Channel 策略)→ 成功置 outbox SENT。 + *

+ * 失败抛出:交由 RocketMQ 按消费组退避重投,重投仍携带同一 messageId → 通道幂等,不重复投递; + * 超过重试上限进 RocketMQ DLQ(死信)留待人工 / 告警(P2 再细化 FAILED/DEAD 状态机)。 + * 重投期间 outbox 保持 PUBLISHED,与「已发布未送达」的实际状态一致。 + */ +@Component +@RocketMQMessageListener( + topic = NotificationTopics.TOPIC, + consumerGroup = NotificationTopics.CONSUMER_GROUP) +public class NotificationMqConsumer implements RocketMQListener { + + private static final Logger log = LoggerFactory.getLogger(NotificationMqConsumer.class); + + private final NotificationService notificationService; + private final NotificationOutboxMapper outboxMapper; + + public NotificationMqConsumer(NotificationService notificationService, + NotificationOutboxMapper outboxMapper) { + this.notificationService = notificationService; + this.outboxMapper = outboxMapper; + } + + @Override + public void onMessage(NotificationMessage message) { + try { + notificationService.submit(message); + outboxMapper.markSent(message.getMessageId()); + } catch (Exception e) { + // 永久性失败(如模板未知、渠道未配置)也会重投;超上限进 DLQ 后再人工处理。 + log.warn("MQ 通知投递失败 messageId={} channel={} recipient={}", + message.getMessageId(), message.getChannel(), message.getRecipient(), e); + throw e; + } + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqProducer.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqProducer.java new file mode 100644 index 0000000..41b03ea --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationMqProducer.java @@ -0,0 +1,24 @@ +package com.yqz.openblog.notification.mq; + +import com.yqz.openblog.notification.NotificationMessage; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.springframework.stereotype.Component; + +/** + * 通知消息发布。由 {@code OutboxRelay} 扫描 outbox 后调用;发送成功才由 Relay 推进状态。 + * 发送失败抛异常,outbox 保持 PENDING,下轮重扫(至少一次)。 + */ +@Component +public class NotificationMqProducer { + + private final RocketMQTemplate rocketMQTemplate; + + public NotificationMqProducer(RocketMQTemplate rocketMQTemplate) { + this.rocketMQTemplate = rocketMQTemplate; + } + + /** 发布一条通知消息(JSON 序列化)。失败抛异常,由调用方决定是否重扫。 */ + public void publish(NotificationMessage message) { + rocketMQTemplate.convertAndSend(NotificationTopics.TOPIC, message); + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationTopics.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationTopics.java new file mode 100644 index 0000000..354737c --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/mq/NotificationTopics.java @@ -0,0 +1,17 @@ +package com.yqz.openblog.notification.mq; + +/** + * RocketMQ topic / 消费组常量(单一来源)。 + *

+ * {@code @RocketMQMessageListener} 注解属性要求编译期常量,故不放配置中心; + * 生产/消费端统一引用,避免两处配置漂移。topic 变更需在 RocketMQ 控制台创建同名 topic。 + */ +public final class NotificationTopics { + + public static final String TOPIC = "openblog_notification"; + public static final String CONSUMER_GROUP = "notification-consumer"; + public static final String PRODUCER_GROUP = "openblog-notification-producer"; + + private NotificationTopics() { + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutbox.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutbox.java new file mode 100644 index 0000000..0446ac6 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutbox.java @@ -0,0 +1,94 @@ +package com.yqz.openblog.notification.outbox; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.yqz.openblog.notification.NotificationMessage; + +import java.time.LocalDateTime; + +/** + * 通知本地消息表(Transactional Outbox)实体。 + *

+ * 待投递任务:submitAsync 与业务动作同事务写入 PENDING → Relay 发布 MQ 置 PUBLISHED → + * Consumer 投递成功置 SENT。message_id 唯一,贯穿 outbox / MQ / 消费端幂等。 + */ +@TableName("notification_outbox") +public class NotificationOutbox { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_PUBLISHED = "PUBLISHED"; + public static final String STATUS_SENT = "SENT"; + + @TableId(type = IdType.AUTO) + private Long id; + private String messageId; + private String channel; + private String recipient; + private String subject; + private String templateCode; + private String paramsJson; + private String status; + private Integer retryCount; + private LocalDateTime nextRetryAt; + private String lastError; + private LocalDateTime sentAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + /** 由 NotificationMessage 构造待投递记录(status=PENDING, retryCount=0)。 */ + public static NotificationOutbox from(NotificationMessage m, String paramsJson) { + NotificationOutbox o = new NotificationOutbox(); + o.setMessageId(m.getMessageId()); + o.setChannel(m.getChannel().name()); + o.setRecipient(m.getRecipient()); + o.setSubject(m.getSubject()); + o.setTemplateCode(m.getTemplateCode()); + o.setParamsJson(paramsJson); + o.setStatus(STATUS_PENDING); + o.setRetryCount(0); + return o; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getMessageId() { return messageId; } + public void setMessageId(String messageId) { this.messageId = messageId; } + + public String getChannel() { return channel; } + public void setChannel(String channel) { this.channel = channel; } + + public String getRecipient() { return recipient; } + public void setRecipient(String recipient) { this.recipient = recipient; } + + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + + public String getTemplateCode() { return templateCode; } + public void setTemplateCode(String templateCode) { this.templateCode = templateCode; } + + public String getParamsJson() { return paramsJson; } + public void setParamsJson(String paramsJson) { this.paramsJson = paramsJson; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Integer getRetryCount() { return retryCount; } + public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; } + + public LocalDateTime getNextRetryAt() { return nextRetryAt; } + public void setNextRetryAt(LocalDateTime nextRetryAt) { this.nextRetryAt = nextRetryAt; } + + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + + public LocalDateTime getSentAt() { return sentAt; } + public void setSentAt(LocalDateTime sentAt) { this.sentAt = sentAt; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutboxMapper.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutboxMapper.java new file mode 100644 index 0000000..f20f1f5 --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/NotificationOutboxMapper.java @@ -0,0 +1,21 @@ +package com.yqz.openblog.notification.outbox; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Mapper; + +/** + * notification_outbox Mapper。状态流转用带条件的 UPDATE(乐观:只推进,不倒退)。 + */ +@Mapper +public interface NotificationOutboxMapper extends BaseMapper { + + /** 发布成功:PENDING → PUBLISHED。仅在仍为 PENDING 时推进,防止并发/重复发布竞态。 */ + @Update("UPDATE notification_outbox SET status = 'PUBLISHED' WHERE id = #{id} AND status = 'PENDING'") + int markPublished(@Param("id") Long id); + + /** 投递成功:→ SENT。仅推进一次(同 messageId 重投时幂等 no-op)。 */ + @Update("UPDATE notification_outbox SET status = 'SENT', sent_at = NOW() WHERE message_id = #{messageId} AND status <> 'SENT'") + int markSent(@Param("messageId") String messageId); +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/OutboxRelay.java b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/OutboxRelay.java new file mode 100644 index 0000000..fa975ae --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/notification/outbox/OutboxRelay.java @@ -0,0 +1,106 @@ +package com.yqz.openblog.notification.outbox; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yqz.openblog.notification.NotificationChannelType; +import com.yqz.openblog.notification.NotificationMessage; +import com.yqz.openblog.notification.NotificationProperties; +import com.yqz.openblog.notification.mq.NotificationMqProducer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * outbox 中继:定时扫描 PENDING 记录 → 发布到 MQ → 置 PUBLISHED。 + *

+ * 至少一次发布:发布成功才推进状态;崩溃发生在「已发布未置状态」窗口时,重扫会再次发布, + * 重复消息由消费端按 message_id 幂等兜底(不重复投递)。单实例部署,fixedDelay 串行执行; + * 多实例部署时会重复发布,幂等兜底使其无害(对账见 outbox 状态)。 + */ +@Component +public class OutboxRelay { + + private static final Logger log = LoggerFactory.getLogger(OutboxRelay.class); + + private static final int BATCH_SIZE = 100; + + private final NotificationOutboxMapper outboxMapper; + private final NotificationMqProducer producer; + private final NotificationProperties properties; + private final ObjectMapper objectMapper; + + public OutboxRelay(NotificationOutboxMapper outboxMapper, + NotificationMqProducer producer, + NotificationProperties properties, + ObjectMapper objectMapper) { + this.outboxMapper = outboxMapper; + this.producer = producer; + this.properties = properties; + this.objectMapper = objectMapper; + } + + @Scheduled(fixedDelayString = "${openblog.notification.outbox.relay-interval-ms:5000}") + public void relay() { + // 跳过刚提交的记录,留业务事务提交窗口(避免读到未提交的脏窗口)。 + LocalDateTime cutoff = LocalDateTime.now() + .minus(Duration.ofMillis(properties.getOutbox().getPublishWindowMs())); + + List pending = outboxMapper.selectList(Wrappers.lambdaQuery(NotificationOutbox.class) + .eq(NotificationOutbox::getStatus, NotificationOutbox.STATUS_PENDING) + .le(NotificationOutbox::getCreatedAt, cutoff) + .orderByAsc(NotificationOutbox::getId) + .last("LIMIT " + BATCH_SIZE)); + + for (NotificationOutbox row : pending) { + try { + producer.publish(toMessage(row)); + outboxMapper.markPublished(row.getId()); + } catch (Exception e) { + // 发布失败:留 PENDING,下轮重扫(至少一次)。last_error / retry_count 留痕便于排查。 + log.warn("outbox 发布失败 id={} messageId={} channel={}", + row.getId(), row.getMessageId(), row.getChannel(), e); + row.setLastError(truncate(e.getMessage())); + row.setRetryCount((row.getRetryCount() == null ? 0 : row.getRetryCount()) + 1); + outboxMapper.updateById(row); + } + } + } + + /** 由 outbox 行重建 NotificationMessage(MQ 载荷)。 */ + private NotificationMessage toMessage(NotificationOutbox row) { + NotificationMessage message = new NotificationMessage(); + message.setMessageId(row.getMessageId()); + message.setChannel(NotificationChannelType.valueOf(row.getChannel())); + message.setRecipient(row.getRecipient()); + message.setSubject(row.getSubject()); + message.setTemplateCode(row.getTemplateCode()); + message.setParams(deserializeParams(row.getParamsJson())); + return message; + } + + private Map deserializeParams(String paramsJson) { + if (paramsJson == null || paramsJson.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(paramsJson, new TypeReference>() {}); + } catch (Exception e) { + log.warn("outbox 参数反序列化失败,按空参数处理。paramsJson={}", paramsJson, e); + return Map.of(); + } + } + + private String truncate(String s) { + if (s == null) { + return null; + } + return s.length() <= 500 ? s : s.substring(0, 500); + } +} diff --git a/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/EmailCodeService.java b/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/EmailCodeService.java new file mode 100644 index 0000000..971be5b --- /dev/null +++ b/OpenBlog-business/src/main/java/com/yqz/openblog/user/service/EmailCodeService.java @@ -0,0 +1,156 @@ +package com.yqz.openblog.user.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.yqz.openblog.common.BizException; +import com.yqz.openblog.config.AuthSecurityProperties; +import com.yqz.openblog.notification.NotificationChannelType; +import com.yqz.openblog.notification.NotificationMessage; +import com.yqz.openblog.notification.NotificationService; +import com.yqz.openblog.notification.NotificationTemplateService; +import com.yqz.openblog.redis.core.RedisKeys; +import com.yqz.openblog.redis.core.RedisOps; +import com.yqz.openblog.user.entity.User; +import com.yqz.openblog.user.repo.UserMapper; +import com.yqz.openblog.user.validator.EmailValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 邮箱注册验证码:生成 / 发送(经 Dubbo 调 email 服务)/ 校验。 + *

+ * 采用「验证码前置」模型:先发码再建号,验证码以邮箱为键存 Redis + * (注册前用户尚未创建,邮箱即注册身份;建号后 users.email ↔ id 关联自然建立)。 + */ +@Service +public class EmailCodeService { + + private static final Logger log = LoggerFactory.getLogger(EmailCodeService.class); + + private static final String SUBJECT = "OpenBlog 注册验证码"; + + private final RedisOps redisOps; + private final AuthSecurityProperties authSecurityProperties; + private final UserMapper userMapper; + private final EmailValidator emailValidator; + private final NotificationService notificationService; + + public EmailCodeService(RedisOps redisOps, + AuthSecurityProperties authSecurityProperties, + UserMapper userMapper, + EmailValidator emailValidator, + NotificationService notificationService) { + this.redisOps = redisOps; + this.authSecurityProperties = authSecurityProperties; + this.userMapper = userMapper; + this.emailValidator = emailValidator; + this.notificationService = notificationService; + } + + /** + * 发送注册验证码到指定邮箱,返回冷却秒数(供前端倒计时)。 + * 前置流程:邮箱格式白名单 → 邮箱未注册 → 冷却检查 → 生成 6 位码入 Redis → 通知层发信(Email 渠道 → Dubbo)。 + * 发信失败时删除已落库的验证码与冷却键,让用户可立即重试。 + */ + public int sendCode(String rawEmail) { + String email = normalizeEmail(rawEmail); + + String emailError = emailValidator.validate(email); + if (emailError != null) { + throw new BizException(4000, emailError); + } + + if (userMapper.selectCount(Wrappers.lambdaQuery(User.class) + .eq(User::getEmail, email)) > 0) { + throw new BizException(4090, "该邮箱已注册,请直接登录"); + } + + AuthSecurityProperties.EmailCode cfg = authSecurityProperties.getEmailCode(); + + String cooldownKey = RedisKeys.emailCooldown(email); + if (redisOps.hasKey(cooldownKey)) { + throw new BizException(4293, "发送过于频繁,请稍后再试"); + } + + // 复用未过期验证码:重发不生成新码,避免旧邮件里的验证码被作废造成混淆。 + String codeKey = RedisKeys.emailCode(email); + String code = redisOps.get(codeKey).orElse(null); + if (code == null) { + code = String.format("%06d", ThreadLocalRandom.current().nextInt(1_000_000)); + } + // 无论新生成还是复用,都刷新验证码 TTL(从本次发送重新计 5 分钟)。 + // 先落库再发信:即便发信失败也保留冷却,防止滥用。 + redisOps.set(codeKey, code, Duration.ofSeconds(Math.max(30, cfg.getCodeTtlSeconds()))); + redisOps.set(cooldownKey, "1", Duration.ofSeconds(Math.max(10, cfg.getResendCooldownSeconds()))); + + // 经统一通知抽象层投递:EMAIL 渠道 → EmailNotificationChannel → Dubbo 调 email 模块。 + // 幂等保障由渠道内部完成(retries=0 + 幂等键 + email_records 唯一索引), + // 见 EmailService.send 幂等逻辑与 docs/dev-experiences.md。 + try { + notificationService.submit(NotificationMessage.builder() + .channel(NotificationChannelType.EMAIL) + .recipient(email) + .subject(SUBJECT) + .templateCode(NotificationTemplateService.REGISTER_VERIFICATION_CODE) + .params(Map.of("code", code)) + .build()); + } catch (BizException e) { + // 发送失败(渠道抛 5002):清理验证码与冷却,允许立即重试。 + redisOps.delete(codeKey); + redisOps.delete(cooldownKey); + log.warn("发送注册验证码失败。email={}", email, e); + throw e; + } + + return cfg.getResendCooldownSeconds(); + } + + /** + * 注册前校验验证码:一次性消费 + 错误次数限制。 + *

+ * 成功路径为 get+delete 两步(RedisOps 无「比较后删除」原语);并发双提交可能都读到同一验证码, + * 但最终由注册流程的邮箱唯一性兜底——同邮箱只会建一个号,因此该竞态无实际危害。 + * 刻意不用 getAndDelete:错误提交会误删掉正确的验证码。 + */ + public void verifyAndConsume(String rawEmail, String code) { + String email = normalizeEmail(rawEmail); + if (code == null || code.isBlank()) { + throw new BizException(4003, "请输入邮箱验证码"); + } + + AuthSecurityProperties.EmailCode cfg = authSecurityProperties.getEmailCode(); + String codeKey = RedisKeys.emailCode(email); + String attemptKey = RedisKeys.emailAttempt(email); + + String stored = redisOps.get(codeKey).orElse(null); + if (stored == null) { + throw new BizException(4003, "验证码错误或已过期,请重新获取"); + } + + if (!stored.equals(code)) { + Long attempts = redisOps.increment(attemptKey).orElse(null); + if (attempts != null && attempts == 1L) { + redisOps.expire(attemptKey, Duration.ofSeconds(Math.max(30, cfg.getCodeTtlSeconds()))); + } + if (attempts != null && attempts >= cfg.getMaxVerifyAttempts()) { + redisOps.delete(codeKey); + redisOps.delete(attemptKey); + throw new BizException(4004, "验证码错误次数过多,请重新获取"); + } + throw new BizException(4002, "验证码错误,请重新输入"); + } + + // 一次性消费 + redisOps.delete(codeKey); + redisOps.delete(attemptKey); + } + + private String normalizeEmail(String rawEmail) { + return rawEmail == null ? "" : rawEmail.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/OpenBlog-business/src/main/resources/application-local.example.yaml b/OpenBlog-business/src/main/resources/application-local.example.yaml index c6e0441..0d61999 100644 --- a/OpenBlog-business/src/main/resources/application-local.example.yaml +++ b/OpenBlog-business/src/main/resources/application-local.example.yaml @@ -10,6 +10,12 @@ spring: port: 6379 password: ${SPRING_DATA_REDIS_PASSWORD:} +# RocketMQ(通知异步投递,P1)。本地开发可先不部署 RocketMQ,同步验证码链路不受影响 +rocketmq: + name-server: 127.0.0.1:9876 + producer: + group: openblog-notification-producer + openblog: cache: # 已发布文章正文 Redis TTL(分钟),可按需覆盖 @@ -26,6 +32,20 @@ openblog: ttl-seconds: 300 login-lockout: enabled: false + email-code: + code-ttl-seconds: 300 + resend-cooldown-seconds: 60 + max-verify-attempts: 5 + notification: + # 统一通知抽象层。当前只 email 渠道生效;SMS / 飞书为未来扩展位 + email: + enabled: true + default-subject: OpenBlog + outbox: + enabled: true + relay-interval-ms: 5000 + publish-window-ms: 1000 + max-retry: 8 jwt: secret: ${OPENBLOG_JWT_SECRET:REPLACE_WITH_LONG_RANDOM_AT_LEAST_32_CHARS} storage: diff --git a/OpenBlog-business/src/main/resources/application.yaml b/OpenBlog-business/src/main/resources/application.yaml index cc757f0..90c4064 100644 --- a/OpenBlog-business/src/main/resources/application.yaml +++ b/OpenBlog-business/src/main/resources/application.yaml @@ -58,6 +58,12 @@ dubbo: consumer: check: false +# RocketMQ(通知异步投递,P1)。name-server 指向部署 RocketMQ 的机器 +rocketmq: + name-server: 10.21.76.221:9876 + producer: + group: openblog-notification-producer + audit: enabled: true async: true @@ -101,6 +107,22 @@ openblog: max-failures-per-ip: 5 failure-window-seconds: 300 lockout-seconds: 900 + email-code: + code-ttl-seconds: 300 + resend-cooldown-seconds: 60 + max-verify-attempts: 5 + notification: + # 统一通知抽象层。当前只 email 渠道生效;SMS / 飞书为未来扩展位 + email: + enabled: true + default-subject: OpenBlog + # 本地消息表 + MQ 异步投递(P1):submitAsync 写 outbox → Relay 发布 → Consumer 投递 + # topic / consumer-group 见 NotificationTopics 常量(单一来源) + outbox: + enabled: true + relay-interval-ms: 5000 + publish-window-ms: 1000 + max-retry: 8 jwt: secret: openblog-jwt-secret-key-please-change-me-0123456789abcdef issuer: openblog diff --git a/OpenBlog-business/src/test/java/com/yqz/openblog/notification/ChannelRegistryTest.java b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/ChannelRegistryTest.java new file mode 100644 index 0000000..93961e3 --- /dev/null +++ b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/ChannelRegistryTest.java @@ -0,0 +1,58 @@ +package com.yqz.openblog.notification; + +import com.yqz.openblog.common.BizException; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ChannelRegistryTest { + + private static class FakeChannel implements NotificationChannel { + private final NotificationChannelType type; + private int sendCount = 0; + + FakeChannel(NotificationChannelType type) { + this.type = type; + } + + @Override + public NotificationChannelType type() { + return type; + } + + @Override + public void send(NotificationMessage message) { + sendCount++; + } + } + + @Test + void routesToRegisteredChannel() { + FakeChannel email = new FakeChannel(NotificationChannelType.EMAIL); + ChannelRegistry registry = new ChannelRegistry(List.of(email)); + + registry.resolve(NotificationChannelType.EMAIL).send(new NotificationMessage()); + + assertEquals(1, email.sendCount, "应分发到 EMAIL 渠道"); + } + + @Test + void unconfiguredChannelFailsClosed() { + ChannelRegistry registry = new ChannelRegistry(List.of()); + + BizException e = assertThrows(BizException.class, + () -> registry.resolve(NotificationChannelType.EMAIL)); + assertEquals(4000, e.getCode()); + } + + @Test + void duplicateRegistrationRejected() { + FakeChannel a = new FakeChannel(NotificationChannelType.EMAIL); + FakeChannel b = new FakeChannel(NotificationChannelType.EMAIL); + + assertThrows(IllegalStateException.class, () -> new ChannelRegistry(List.of(a, b))); + } +} diff --git a/OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationServiceTest.java b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationServiceTest.java new file mode 100644 index 0000000..ab835c8 --- /dev/null +++ b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationServiceTest.java @@ -0,0 +1,75 @@ +package com.yqz.openblog.notification; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yqz.openblog.common.BizException; +import com.yqz.openblog.notification.outbox.NotificationOutbox; +import com.yqz.openblog.notification.outbox.NotificationOutboxMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class NotificationServiceTest { + + private final ChannelRegistry registry = mock(ChannelRegistry.class); + private final NotificationProperties properties = new NotificationProperties(); + private final NotificationOutboxMapper outboxMapper = mock(NotificationOutboxMapper.class); + + private NotificationService newService() { + return new NotificationService(registry, properties, outboxMapper, new ObjectMapper()); + } + + private NotificationMessage emailMsg() { + return NotificationMessage.builder() + .channel(NotificationChannelType.EMAIL) + .recipient("a@b.com") + .templateCode(NotificationTemplateService.REGISTER_VERIFICATION_CODE) + .build(); + } + + @Test + void submitAsyncGeneratesMessageIdAndInsertsPending() { + NotificationMessage message = emailMsg(); + + newService().submitAsync(message); + + assertNotNull(message.getMessageId()); + assertFalse(message.getMessageId().isBlank(), "submitAsync 应生成 messageId"); + + // argThat 需显式类型见证:MyBatis-Plus 3.5.16 BaseMapper 有 insert(T) / insert(Collection) 两个重载, + // 泛型推断在两者间歧义,显式钉死 T=NotificationOutbox。 + verify(outboxMapper).insert(org.mockito.ArgumentMatchers.argThat(row -> { + assertEquals(message.getMessageId(), row.getMessageId(), "outbox 应记录同一 messageId"); + assertEquals(NotificationOutbox.STATUS_PENDING, row.getStatus()); + return true; + })); + } + + @Test + void submitAsyncDisabledFailsClosed() { + properties.getOutbox().setEnabled(false); + + BizException e = assertThrows(BizException.class, () -> newService().submitAsync(emailMsg())); + assertEquals(4000, e.getCode()); + verify(outboxMapper, never()).insert(any(NotificationOutbox.class)); + } + + @Test + void submitAsyncKeepsProvidedMessageId() { + NotificationMessage message = emailMsg(); + message.setMessageId("pre-set-id"); + + newService().submitAsync(message); + + assertEquals("pre-set-id", message.getMessageId(), "已有 messageId 不应被覆盖"); + verify(outboxMapper).insert(org.mockito.ArgumentMatchers.argThat( + row -> "pre-set-id".equals(row.getMessageId()))); + } +} diff --git a/OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationTemplateServiceTest.java b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationTemplateServiceTest.java new file mode 100644 index 0000000..6b01675 --- /dev/null +++ b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/NotificationTemplateServiceTest.java @@ -0,0 +1,44 @@ +package com.yqz.openblog.notification; + +import com.yqz.openblog.common.BizException; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NotificationTemplateServiceTest { + + private final NotificationTemplateService templateService = new NotificationTemplateService(); + + @Test + void rendersCodeIntoVerificationTemplate() { + String html = templateService.render( + NotificationTemplateService.REGISTER_VERIFICATION_CODE, Map.of("code", "123456")); + + assertTrue(html.contains("123456"), "验证码应渲染进正文"); + assertFalse(html.contains("{{code}}"), "占位符应被替换"); + assertTrue(html.contains("OpenBlog 注册验证码"), "模板骨架应保留"); + } + + @Test + void missingParamLeavesPlaceholder() { + String html = templateService.render(NotificationTemplateService.REGISTER_VERIFICATION_CODE, Map.of()); + assertTrue(html.contains("{{code}}"), "缺参时保留占位符(fail-open,便于发现模板参数缺失)"); + } + + @Test + void unknownTemplateFailsClosed() { + BizException e = assertThrows(BizException.class, + () -> templateService.render("no-such-template", Map.of())); + assertEquals(4000, e.getCode()); + } + + @Test + void blankTemplateFailsClosed() { + assertThrows(BizException.class, () -> templateService.render(" ", Map.of())); + } +} diff --git a/OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/NotificationOutboxTest.java b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/NotificationOutboxTest.java new file mode 100644 index 0000000..db960fc --- /dev/null +++ b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/NotificationOutboxTest.java @@ -0,0 +1,34 @@ +package com.yqz.openblog.notification.outbox; + +import com.yqz.openblog.notification.NotificationChannelType; +import com.yqz.openblog.notification.NotificationMessage; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class NotificationOutboxTest { + + @Test + void fromMessageMapsFields() { + NotificationMessage message = NotificationMessage.builder() + .messageId("msg-1") + .channel(NotificationChannelType.EMAIL) + .recipient("a@b.com") + .subject("主题") + .templateCode("register-verification-code") + .build(); + + NotificationOutbox row = NotificationOutbox.from(message, "{\"code\":\"123456\"}"); + + assertEquals("msg-1", row.getMessageId()); + assertEquals("EMAIL", row.getChannel()); + assertEquals("a@b.com", row.getRecipient()); + assertEquals("主题", row.getSubject()); + assertEquals("register-verification-code", row.getTemplateCode()); + assertEquals("{\"code\":\"123456\"}", row.getParamsJson()); + assertEquals(NotificationOutbox.STATUS_PENDING, row.getStatus()); + assertEquals(0, row.getRetryCount()); + assertNull(row.getId(), "id 由 DB 自增"); + } +} diff --git a/OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/OutboxRelayTest.java b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/OutboxRelayTest.java new file mode 100644 index 0000000..ea4bfb8 --- /dev/null +++ b/OpenBlog-business/src/test/java/com/yqz/openblog/notification/outbox/OutboxRelayTest.java @@ -0,0 +1,92 @@ +package com.yqz.openblog.notification.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yqz.openblog.notification.NotificationChannelType; +import com.yqz.openblog.notification.NotificationMessage; +import com.yqz.openblog.notification.NotificationProperties; +import com.yqz.openblog.notification.mq.NotificationMqProducer; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OutboxRelayTest { + + private final NotificationOutboxMapper outboxMapper = mock(NotificationOutboxMapper.class); + private final NotificationMqProducer producer = mock(NotificationMqProducer.class); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final NotificationProperties properties = new NotificationProperties(); + + private OutboxRelay newRelay() { + return new OutboxRelay(outboxMapper, producer, properties, objectMapper); + } + + private NotificationOutbox pendingRow(String messageId, String paramsJson) { + NotificationOutbox row = new NotificationOutbox(); + row.setId(1L); + row.setMessageId(messageId); + row.setChannel(NotificationChannelType.EMAIL.name()); + row.setRecipient("a@b.com"); + row.setSubject("主题"); + row.setTemplateCode("register-verification-code"); + row.setParamsJson(paramsJson); + row.setStatus(NotificationOutbox.STATUS_PENDING); + row.setRetryCount(0); + row.setCreatedAt(LocalDateTime.now().minusSeconds(30)); + return row; + } + + @Test + void publishSuccessMarksPublished() { + NotificationOutbox row = pendingRow("msg-1", "{\"code\":\"123456\"}"); + when(outboxMapper.selectList(any())).thenReturn(List.of(row)); + + newRelay().relay(); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(NotificationMessage.class); + verify(producer).publish(messageCaptor.capture()); + NotificationMessage published = messageCaptor.getValue(); + assertEquals("msg-1", published.getMessageId(), "MQ 载荷应携带同一 messageId"); + assertEquals("123456", published.getParams().get("code"), "参数应反序列化还原"); + assertEquals(NotificationChannelType.EMAIL, published.getChannel()); + + verify(outboxMapper).markPublished(1L); + } + + @Test + void publishFailureKeepsPendingAndRecordsError() { + NotificationOutbox row = pendingRow("msg-2", "{}"); + when(outboxMapper.selectList(any())).thenReturn(List.of(row)); + org.mockito.Mockito.doThrow(new RuntimeException("broker down")) + .when(producer).publish(any(NotificationMessage.class)); + + newRelay().relay(); + + verify(outboxMapper, never()).markPublished(any()); + assertEquals(NotificationOutbox.STATUS_PENDING, row.getStatus(), "发布失败保持 PENDING,下轮重扫"); + assertEquals("broker down", row.getLastError()); + assertEquals(1, row.getRetryCount()); + verify(outboxMapper).updateById(row); + } + + @Test + void publishSuccessCarriesParamsWhenJsonNullSafe() { + NotificationOutbox row = pendingRow("msg-3", null); + when(outboxMapper.selectList(any())).thenReturn(List.of(row)); + + newRelay().relay(); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(NotificationMessage.class); + verify(producer).publish(messageCaptor.capture()); + assertTrue(messageCaptor.getValue().getParams().isEmpty(), "无参数时按空 Map 处理"); + } +} diff --git a/docker/rocketmq/broker.conf b/docker/rocketmq/broker.conf new file mode 100644 index 0000000..fe77c7f --- /dev/null +++ b/docker/rocketmq/broker.conf @@ -0,0 +1,10 @@ +brokerClusterName=DefaultCluster +brokerName=broker-a +brokerId=0 +deleteWhen=04 +fileReservedTime=48 +brokerRole=ASYNC_MASTER +flushDiskType=ASYNC_FLUSH +autoCreateTopicEnable=true +# 必须改成运行 RocketMQ 的机器局域网 IP(producer/consumer 跨机连接 broker 的地址) +brokerIP1=10.21.76.221 diff --git a/docker/rocketmq/docker-compose.yml b/docker/rocketmq/docker-compose.yml new file mode 100644 index 0000000..0bd09d4 --- /dev/null +++ b/docker/rocketmq/docker-compose.yml @@ -0,0 +1,43 @@ +# RocketMQ 4.9.7 单机部署(通知异步投递 P1 使用) +# 在局域网机器(如 10.21.76.221)执行:docker compose up -d +# 注意:broker.conf 的 brokerIP1 必须改成该机器的局域网 IP,否则跨机 producer/consumer 连不上 broker。 +services: + namesrv: + image: apache/rocketmq:4.9.7 + container_name: rocketmq-namesrv + restart: unless-stopped + ports: + - "9876:9876" # nameserver,business 的 rocketmq.name-server 指向这里 + command: sh mqnamesrv + networks: [rmq] + + broker: + image: apache/rocketmq:4.9.7 + container_name: rocketmq-broker + restart: unless-stopped + depends_on: [namesrv] + ports: + - "10911:10911" # 主端口(producer/consumer 走这里) + - "10909:10909" # VIP channel(生产端建议关闭,见 JAVA_OPTS) + - "10912:10912" # HA + environment: + - NAMESRV_ADDR=namesrv:9876 + volumes: + - ./broker.conf:/opt/rocketmq-4.9.7/conf/broker.conf + command: sh mqbroker -c /opt/rocketmq-4.9.7/conf/broker.conf + networks: [rmq] + + # 可选:管理台(查看 topic / 消息 / 死信) + console: + image: apacherocketmq/rocketmq-dashboard:latest + container_name: rocketmq-console + restart: unless-stopped + depends_on: [namesrv] + ports: + - "8088:8080" + environment: + - JAVA_OPTS=-Drocketmq.namesrv.addr=namesrv:9876 -Dcom.rocketmq.sendMessageWithVIPChannel=false + networks: [rmq] + +networks: + rmq: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e202ee6..e00d161 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,7 +10,7 @@ | # | 任务 | 状态 | 目标 | |---|------|------|------| -| 1 | 完善 email 服务,真正实现邮箱注册验证 | 🔲 待排期 | 注册流程真实发送验证邮件 | +| 1 | 完善 email 服务,真正实现邮箱注册验证 | ✅ 已完成 | 注册流程真实发送验证邮件;通知抽象层(统一渠道,当前 email 直发) | | 2 | 首页前端动态化 | 🔲 待排期 | 设计动态首页 | | 3 | 部署 Docker 化 + 启动加速 | 🔲 待排期 | 后端 docker 启动,加快启动 | @@ -54,6 +54,12 @@ - email 模块现有 HTTP 管理接口(`/api/v1/email/*`)是否保留? - 验证码邮件是否需要图形验证码之外的防刷(滑块已有,是否足够)? +### 完成情况(2026-08-28) + +- 注册验证码链路已闭环:business `EmailCodeService`(验证码生成 / Redis 存储 / 冷却 / 校验)→ 统一通知抽象层 → email 模块 Dubbo 直发(幂等:retries=0 + 幂等键 + `email_records.idempotency_key` 唯一索引)。 +- **通知抽象层**(`com.yqz.openblog.notification`):`NotificationChannel` 策略 + `AbstractNotificationChannel` 模板方法 + `NotificationTemplateService` 占位符渲染 + `ChannelRegistry` 路由 + `NotificationService` 门面。当前只实现 EMAIL 渠道(经 Dubbo),SMS / 飞书 / MQ 为预留扩展位,接入时主链路零改动。 +- 重放问题与幂等设计详见 `docs/dev-experiences.md`(2026-08-27)。 + --- ## 任务 2:首页前端动态化 @@ -120,5 +126,6 @@ ## 通用待办(非当前优先级) - 单元测试补充(README 待办项)。 +- 通知服务化(未来):本地消息表 + MQ 异步消费 + 延时队列重试,替换 `NotificationService.submit` 的同步发送实现;短信 / 飞书渠道按同一 Channel 抽象扩展。完整设计见 `docs/designs/notification-mq-async.md`。 - 版本号收敛到根 pom `dependencyManagement`。 - `MybatisPlusMetaObjectHandler` 迁移至 framework 模块(common 抽取时遗留)。 diff --git a/docs/designs/notification-mq-async.md b/docs/designs/notification-mq-async.md new file mode 100644 index 0000000..af5ae5f --- /dev/null +++ b/docs/designs/notification-mq-async.md @@ -0,0 +1,278 @@ +# 通知服务化设计计划书:本地消息表 + MQ 异步消费 + 延时重试 + +> 日期:2026-08-28 +> 状态:设计稿(待评审);**P1 已实施**(outbox + RocketMQ Relay/Consumer + 幂等闭环,见 `docs/ROADMAP.md`) +> 前置:统一通知抽象层已落地(`com.yqz.openblog.notification`,见 `docs/ROADMAP.md` 任务 1 完成情况) + +--- + +## 0. 背景与目标 + +现状:验证码邮件走「同步 Dubbo 直发」,可靠性已闭环(retries=0 + 幂等键 + `email_records.idempotency_key` 唯一索引),且刚重构出统一通知抽象层(Channel 策略 + 模板方法 + Registry + 门面),当前只实现 EMAIL 通道。 + +痛点 / 目标: + +| # | 现状 | 目标 | +|---|------|------| +| 1 | 同步阻塞:业务线程等阿里云发信返回,耗时 >1s | 可容忍延迟的通知异步化,业务不阻塞 | +| 2 | 无重试队列:发信失败只删键报错,不自动补偿 | 投递失败按退避自动重试,超限进死信 + 告警 | +| 3 | 只能邮件 | 多通道:邮件 / 短信 / 飞书(本期仍只设计接缝) | +| 4 | 发送入口内嵌在验证码业务里 | 通知成为独立的可靠投递链路 | + +**核心诉求:消息不丢失、不重复、可靠送达,同时保留现有 Dubbo 直发方式与已实现的抽象层。** + +--- + +## 1. 边界:什么场景该走异步(重要) + +| 场景 | 通道 | 理由 | +|------|------|------| +| 注册/登录验证码 | **保持现状同步 Dubbo 直发** | 强反馈:用户盯着页面等验证码,异步会引入不确定延迟;且它已幂等可靠 | +| 站内信、周报、异常告警、欢迎邮件、营销 | **MQ 异步** | 可容忍秒级延迟,量大、失败可重试、不阻塞主流程 | + +因此不是「全量迁异步」,而是**双路径**:同步 submit 服务强反馈场景;异步 submitAsync 服务其余通知。两条路径共用同一套 Channel 策略与幂等键,只是投递触发方式不同。 + +--- + +## 2. 总体架构 + +``` + 业务服务 (business) 通知投递服务 (OpenBlog-notification)【目标态】 + ┌───────────────────────────────┐ ┌──────────────────────────────────────────────────┐ + │ 业务动作 (注册/评论/告警) │ │ MQ Consumer │ + │ └─ NotificationService │ │ └─ NotificationMessage(messageId, channel, ...) │ + │ ├─ submit() 同步 ──┼───┼─► Dubbo 直发 email 模块(强反馈,现状不动) │ + │ └─ submitAsync() 异步 ──┼───┼─► Channel 策略渲染+投递 │ + │ │ 写 outbox(同事务) │ │ ├─ EmailChannel ──► Dubbo → email 模块 │ + │ ▼ │ │ ├─ SmsChannel ──► 短信服务商 API(扩展位) │ + │ notification_outbox │ │ └─ FeishuChannel ─► webhook(扩展位) │ + │ │ Relay(定时) 发布 │ │ │ 成功→ack + 更新 outbox=SENT │ + │ ▼ │ │ │ 失败→发延时重试消息 / 死信 │ + │ ┌─────── MQ Producer ───────┐ │ │ │ + └───┼──── RocketMQ ─────────────┼─┼───┼──────────────────────────────────────────────────┘ + └──── 通知 topic ────────────┘ │ └──── 延时 topic(失败退避)────┘ +``` + +- **不丢失**:业务写 outbox 与业务动作同事务提交;Relay 保证 outbox → MQ 至少一次发布。 +- **不重复**:`messageId`(UUID) 贯穿 outbox / MQ / 消费端;消费端按 messageId + 通道幂等键去重(email 已天然支持)。 +- **可靠**:RocketMQ 持久化 + 消费成功后 ack;失败走延时队列退避重试,超限死信 + 告警。 + +--- + +## 3. 核心机制逐一设计 + +### 3.1 本地消息表(Transactional Outbox) + +**为什么用 outbox 而不是「直接发 MQ」:** 直接发 MQ 时「业务提交」和「消息入队」是两个独立动作,业务提交后、入队前进程崩溃 → 消息永久丢失。outbox 把「要发的通知」作为一条 DB 记录,与业务动作在**同一个本地事务**里提交,谁也不会先死。 + +**表结构**(新增,business 库;与 email 模块的 `email_records` 职责不同 —— 那是发送结果记录,这是待投递任务): + +```sql +CREATE TABLE IF NOT EXISTS notification_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + message_id VARCHAR(64) NOT NULL COMMENT '全局幂等键(UUID),MQ 消息与消费去重共用', + channel VARCHAR(16) NOT NULL COMMENT 'EMAIL / SMS / FEISHU', + recipient VARCHAR(128) NOT NULL COMMENT '接收方(邮箱/手机号/webhook)', + subject VARCHAR(256) COMMENT '主题', + template_code VARCHAR(64) NOT NULL COMMENT '模板 code,经 NotificationTemplateService 渲染', + params_json JSON COMMENT '模板参数', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' + COMMENT 'PENDING待发布 / PUBLISHED已发布 / SENT已送达 / FAILED失败重试中 / DEAD死信', + retry_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME COMMENT '下次重试时间', + last_error VARCHAR(512) COMMENT '最近一次失败原因', + sent_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_message_id (message_id), + INDEX idx_status_retry (status, next_retry_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` + +**写入流程**(调用方)——`submitAsync` 内部: + +```java +@Transactional +public void submitAsync(NotificationMessage msg) { + msg.setMessageId(genIdempotentKey(msg)); // 幂等键,复用现有模型 + outboxMapper.insert(OutboxRecord.from(msg)); // 与业务动作同一事务 + // 业务动作的其它 DB 写(如建用户)也在此事务内 +} +``` + +**中继 Relay**(`@Scheduled`,每 1~5s):扫 `status=PENDING` 且 `created_at` 早于 N 秒的记录(留提交窗口),发布到 RocketMQ 后置 `PUBLISHED`。Relay 是「至少一次」发布:发布成功置 PUBLISHED;崩溃重启后 PENDING 会再次发布,重复由消费端幂等兜住。 + +**状态机:** + +``` +PENDING ──Relay发布──► PUBLISHED ──消费投递成功──► SENT + │ │ + └──(崩溃重扫再次发布)─────┘ +PUBLISHED ──消费失败──► FAILED ──(延时到期重发消息, retry_count++)──► 回到 PUBLISHED 语义 + └─ retry_count >= MAX ──► DEAD(人工/告警) +``` + +### 3.2 MQ 选型:RocketMQ(推荐) + +| 对比 | RocketMQ | RabbitMQ | Kafka | +|------|----------|----------|-------| +| 与现有生态 | **Dubbo / Nacos 同属阿里系,Spring Boot 集成成熟** | 通用,需装延迟插件 | 流式,延迟需自建 | +| 原生延时消息 | ✅ 内置 18 个延时等级 | ⚠️ 需 rabbitmq_delayed_message_exchange 插件 | ❌ 需自实现 | +| 事务消息 | ✅(本项目用 outbox 替代,非必需) | 部分 | ✅ | +| 部署 | 轻量(nameserver + broker) | 轻量 | 较重 | +| 幂等消费 | 消费端自己保证(本项目已有幂等键) | 同左 | 同左 | + +**结论:RocketMQ**(与 Dubbo/Nacos 同生态、原生延时队列正是本设计的重试基石)。版本与 spring boot starter 依赖开工前确认(当前主流 4.9.x + `rocketmq-spring-boot-starter:2.3.x`)。 + +部署:局域网一台(如 10.21.76.221)Docker 起 `rocketmq-namesrv` + `rocketmq-broker`,与 MySQL/Redis/MinIO/Nacos 同级,不进应用编排。 + +### 3.3 消费与投递 + +Consumer(监听通知 topic)反序列化 `NotificationMessage` → 交给 `ChannelRegistry.resolve(channel).send(...)`(**复用已实现的抽象层,零改动**)→ 成功则 ack 并更新 outbox=SENT;失败不 ack,交给重试。 + +```java +@RocketMQMessageListener(topic = NOTIFICATION_TOPIC, consumerGroup = "notification-consumer") +public class NotificationMqConsumer implements RocketMQListener { + private final NotificationService notificationService; // 复用:渲染 + 路由 + 投递 + private final OutboxMapper outboxMapper; + + @Override + public void onMessage(NotificationMessage msg) { + try { + notificationService.submit(msg); // 与同步路径同一个门面! + outboxMapper.markSent(msg.getMessageId()); + } catch (Exception e) { + // 不抛(避免无限重试),由延时重试机制接管;或抛出让 RocketMQ 按 DLQ 策略处理 + log.warn("MQ 通知投递失败 messageId={}", msg.getMessageId(), e); + throw e; + } + } +} +``` + +**「消费端幂等」为什么这里天然成立**:`messageId` 传递到 email 通道后作为 `EmailSendRequest.idempotencyKey`,email 模块靠 `uk_idempotency_key` 去重 —— 同一条消息无论被 MQ 重投多少次,只发一封。这正好把已经上线的幂等保障**无缝复用**到异步链路。 + +### 3.4 延时重试(RocketMQ 延时消息) + +投递失败(短信/飞书通道失败、email 通道临时不可用)→ Consumer 抛异常 → 重试消息按退避等级再投递。 + +**RocketMQ 内置延时等级**(messageDelayLevel,默认):`1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h` + +| 重试次数 | 延时等级 | 说明 | +|---------|----------|------| +| 第 1 次 | 1s / 5s | 瞬时失败(网络抖动)快速重试 | +| 2~3 次 | 30s / 1m | 中间退避 | +| 4~6 次 | 5m / 10m / 20m | 服务短时不可用等待恢复 | +| ≥7 次 | 30m 起步 | 长退避 | +| > MAX(默认 8) | 死信 | 置 outbox=DEAD,告警(企业微信/飞书群消息)人工介入 | + +实现要点: +- 每轮重试携带 `attempt`,Producer 按 attempt 选延时等级;outbox 的 `retry_count` / `next_retry_at` 同步更新。 +- 重试消息与原始消息同 `messageId` → 幂等仍生效,**重试不会造成重复邮件**。 +- 兜底:Relay 也可定期扫 `FAILED` 且 `next_retry_at <= now` 的记录主动补发(与 MQ 延时双保险),此时 MQ 仅作加速通道。 + +### 3.5 可靠性矩阵(三个「不」如何保证) + +| 承诺 | 机制 | 落点 | +|------|------|------| +| 不丢失(业务提交后必投) | outbox 与业务同事务 + Relay 至少一次发布 + RocketMQ 持久化 | `submitAsync` @Transactional;Relay | +| 不丢失(消费后必送达) | 消费成功才 ack;失败进重试而非丢弃 | `NotificationMqConsumer` | +| 不重复 | messageId 全局唯一(outbox 唯一约束)+ email 幂等键 + 唯一索引(已有) | `uk_message_id`;`EmailRpcServiceImpl` 去重 | +| 可靠送达 | 延时退避重试 + 死信告警 | `3.4` | +| 最终一致 | 状态机驱动,outbox 落库,可对账 | `3.1` 状态机 | + +--- + +## 4. 与已实现抽象层的衔接(关键:调用方零改动) + +现有抽象层已经为异步预留了接缝,本设计几乎只做「加」: + +| 现有组件 | 本设计中的角色 | +|----------|----------------| +| `NotificationService.submit()` | **同步路径不动**;新增 `submitAsync()`(写 outbox),两者可共用校验 | +| `NotificationChannel` / `AbstractNotificationChannel` | **原样复用**,Consumer 调用同一策略 | +| `NotificationTemplateService` | 原样复用(outbox 存 params,消费时渲染) | +| `NotificationMessage` | 增加 `messageId` 字段(幂等键,复用 email 已实现的去重) | +| `EmailNotificationChannel` | 原样复用;email 模块与 Dubbo 直发**保留**,异步链路只是把「触发」换成 MQ | +| `ChannelRegistry` | 原样复用(Consumer 按 channel 路由) | + +**为什么 email 通道异步时仍然走 Dubbo**:email 已是独立服务且幂等闭环,通知消费者调它用 Dubbo 是「保留现有方式」的落点 —— MQ 管「可靠调度」,Dubbo 管「实际投递」,各司其职。短信/飞书通道同理(消费端调服务商 API)。 + +**新增类清单:** + +``` +business 模块: +├── notification/outbox/NotificationOutbox # 实体 + Mapper(MyBatis-Plus) +├── notification/outbox/OutboxRelay # @Scheduled 扫 PENDING → 发布 MQ → PUBLISHED +├── notification/mq/NotificationMqProducer # 发通知消息 / 发延时重试消息 +├── notification/mq/NotificationMqConsumer # 消费 → submit() → ack +├── notification/NotificationService#submitAsync # 写 outbox(@Transactional) +└── NotificationMessage#messageId # 新增字段 + +(目标态)OpenBlog-notification 模块: +├── 平移:NotificationChannel / AbstractNotificationChannel / ChannelRegistry / +│ NotificationTemplateService / NotificationProperties / EmailNotificationChannel +├── SmsNotificationChannel / FeishuNotificationChannel # 扩展位 +└── mq/NotificationMqConsumer 等 +``` + +--- + +## 5. 配置(business + 目标模块) + +```yaml +rocketmq: + name-server: 10.21.76.221:9876 + producer: + group: openblog-notification-producer + +openblog: + notification: + outbox: + enabled: true + relay-cron: "*/5 * * * * *" # 每 5s 扫一次 PENDING + publish-window-ms: 1000 # 跳过刚提交的记录,留事务提交窗口 + max-retry: 8 # 超过进死信 + topic: openblog_notification +``` + +--- + +## 6. 验收标准 + +- [ ] `submitAsync` 后即使业务进程在 Relay 执行前崩溃,重启后通知仍被投递(不丢失) +- [ ] 同一条消息被 MQ 重投 N 次,email 模块只发一封(幂等;现有单测 + `email_records` 验证) +- [ ] email 通道临时不可用时自动退避重试,恢复后投递成功,outbox 状态 PENDING→PUBLISHED→SENT +- [ ] 超过 max-retry 进 DEAD 并触发告警 +- [ ] 同步验证码链路行为完全不变(回归:发 1 封、60s 冷却、失败删键报 5002) + +## 7. 风险与开放问题(开工前确认) + +- **RocketMQ 版本 / starter 依赖**(4.9.x + 2.3.x,还是 5.x)需确认,影响延时等级与 API。 +- **outbox 表放 business 库 vs 目标通知模块库**:设计采用「业务事务内写 outbox」保证原子性,故先放 business 库;服务化平移时整表迁走。 +- 死信告警通道用哪个(邮件 / 飞书群机器人)——可复用本通知体系自身。 +- 消费失败是「抛异常走 RocketMQ 重投」还是「吞掉由延时重试机制接管」二选一,避免双重重试放大。 +- 通知量级预估:当前个人博客量很小,是否需要 MQ 按实际决定,避免过度设计 —— **建议先只落 outbox + 同步发送**(用 outbox 做可靠记录,投递仍同步),确认有量再引入 RocketMQ。见实施路线。 + +## 8. 实施里程碑(渐进,非大爆炸) + +| 阶段 | 内容 | 产出 | +|------|------|------| +| **P0** | 先落 outbox 表 + `submitAsync`(写记录,投递仍走同步 submit) | 通知有可靠留痕,无 MQ 依赖,风险最小 | +| **P1** | 引入 RocketMQ:Relay 发布 + Consumer 消费 + 幂等闭环 | 验证码外的通知走异步;同步验证码不动 | +| **P2** | 延时重试 + 死信告警 | 可靠性闭环 | +| **P3** | 服务化:平移 Channel 到 `OpenBlog-notification`,新增短信 / 飞书通道 | 多通道统一入口 | + +> P0 是「先要可靠性,再要异步」。若最终判定通知量小、无需异步,停在 P0 也有收益(留痕 + 未来可迁移)。 + +--- + +## 附:同步 Dubbo 直发 vs MQ 异步(决策备忘) + +| 维度 | 同步 Dubbo(现状,验证码用) | MQ 异步(本设计,其余通知用) | +|------|------------------------------|------------------------------| +| 反馈时效 | 秒级强反馈 | 秒~分钟级,可容忍 | +| 阻塞 | 业务线程阻塞等外部发信 | 不阻塞 | +| 失败补偿 | 删键报错,人工重试 | 自动退避重试 + 死信告警 | +| 幂等 | ✅ 已闭环 | ✅ 复用同一 messageId / 幂等键 | +| 适用 | 验证码等强反馈 | 站内信 / 告警 / 欢迎 / 营销 | diff --git a/sql/notification-outbox.sql b/sql/notification-outbox.sql new file mode 100644 index 0000000..fa5e4b2 --- /dev/null +++ b/sql/notification-outbox.sql @@ -0,0 +1,23 @@ +-- 通知本地消息表(Transactional Outbox) +-- 业务库(business 同一库)。submitAsync 与业务动作同事务写入,Relay 扫 PENDING 发布到 MQ, +-- 保证「业务提交」与「通知入队」原子,消息不丢失。消费端按 message_id 幂等,不重复。 +-- 说明:与 OpenBlog-email 的 email_records 职责不同 —— 那是发送结果记录,这是待投递任务。 +CREATE TABLE IF NOT EXISTS notification_outbox ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + message_id VARCHAR(64) NOT NULL COMMENT '全局幂等键(UUID),MQ 消息与消费去重共用', + channel VARCHAR(16) NOT NULL COMMENT 'EMAIL / SMS / FEISHU', + recipient VARCHAR(128) NOT NULL COMMENT '接收方(邮箱/手机号/webhook)', + subject VARCHAR(256) COMMENT '主题', + template_code VARCHAR(64) NOT NULL COMMENT '模板 code,经 NotificationTemplateService 渲染', + params_json JSON COMMENT '模板参数', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING' + COMMENT 'PENDING待发布 / PUBLISHED已发布 / SENT已送达 / FAILED失败重试中 / DEAD死信', + retry_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME COMMENT '下次重试时间', + last_error VARCHAR(512) COMMENT '最近一次失败原因', + sent_at DATETIME COMMENT '送达时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_message_id (message_id), + INDEX idx_status_retry (status, next_retry_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;