28 - 异步执行与沙箱安全

后台任务系统、异步工具回调、沙箱隔离执行与线程池设计

一、为什么需要异步执行?

Agent 长任务的挑战

Agent 在实际场景中经常需要执行长时间运行的任务:

核心问题:这些任务如果同步阻塞 ReAct 循环,会导致:① 用户长时间无响应(体验极差);② 占用 Agent 线程池(资源浪费);③ 超时被 kill(任务白做)。异步执行是解决长任务的关键架构。

二、BackgroundTask 系统架构

核心组件

  BackgroundTask 系统架构:

  Agent (ReAct Loop)
     │
     │ 调用工具: submit_task / get_task_status / cancel_task
     ▼
  ┌──────────────────────────────────────────────────────────────┐
  │              BackgroundTaskToolProvider                       │
  │                                                              │
  │  暴露给 Agent 的 3 个工具:                                    │
  │  ├── submit_task(command, timeout)    → 提交后台任务          │
  │  ├── get_task_status(taskId)          → 查询任务状态          │
  │  └── cancel_task(taskId)              → 取消任务              │
  └──────────────────────┬───────────────────────────────────────┘
                         │
                         ▼
  ┌──────────────────────────────────────────────────────────────┐
  │              BackgroundTaskExecutor                           │
  │                                                              │
  │  ├── submit(task):                                           │
  │  │   1. 检查 store.size() < maxTasks (100)                  │
  │  │   2. 包装为 Runnable + CancellationToken                  │
  │  │   3. 提交到 ThreadPool                                    │
  │  │   4. 注册到 BackgroundTaskStore                           │
  │  │                                                           │
  │  ├── cancel(taskId):                                         │
  │  │   1. token.cancel() 设置取消标志                          │
  │  │   2. future.cancel(true) 中断线程                         │
  │  │                                                           │
  │  └── cleanup():                                              │
  │      定期清理已完成/超时的任务记录                             │
  └──────────────────────┬───────────────────────────────────────┘
                         │
                         ▼
  ┌──────────────────────────────────────────────────────────────┐
  │              ThreadPool (background-task)                     │
  │                                                              │
  │  核心线程: 4 | 最大线程: 4 | 队列: LinkedBlockingQueue(50)   │
  │  拒绝策略: AbortPolicy (队列满时拒绝)                        │
  └──────────────────────┬───────────────────────────────────────┘
                         │
                         ▼
  ┌──────────────────────────────────────────────────────────────┐
  │              BackgroundTaskStore                              │
  │                                                              │
  │  ConcurrentHashMap<String, BackgroundTask>                   │
  │                                                              │
  │  存储: taskId → { state, result, future, token, createTime } │
  │  容量上限: maxTasks = 100                                     │
  └──────────────────────────────────────────────────────────────┘

任务状态机

  BackgroundTask 状态机:

                  submit()
                     │
                     ▼
              ┌─────────────┐
              │   PENDING    │ ← 已提交,等待线程
              └──────┬──────┘
                     │ 线程开始执行
                     ▼
              ┌─────────────┐
       ┌──────│   RUNNING    │──────┐
       │      └──────┬──────┘      │
       │             │             │
       │ cancel()    │ 正常完成    │ 异常/超时
       ▼             ▼             ▼
  ┌──────────┐ ┌──────────┐ ┌──────────┐
  │CANCELLED │ │COMPLETED │ │  FAILED  │
  └──────────┘ └──────────┘ └──────────┘

  状态转换规则:
  ├── PENDING → RUNNING     : 线程池分配线程
  ├── PENDING → CANCELLED   : 未执行前取消
  ├── RUNNING → COMPLETED   : 正常执行结束
  ├── RUNNING → FAILED      : 抛出异常 / 超时
  ├── RUNNING → CANCELLED   : 协作式取消
  └── 终态不可变: COMPLETED / FAILED / CANCELLED

三、AsyncToolCallback 接口

接口设计

/**
 * 异步工具回调接口 — 框架级并发优化
 * 与 BackgroundTask 不同:对 Agent 透明,由框架自动管理
 */
public interface AsyncToolCallback {

    /**
     * 异步执行工具逻辑
     * @param request   工具调用请求(参数、上下文)
     * @param token     协作式取消令牌
     * @return          CompletableFuture 包装的结果
     */
    CompletableFuture<ToolResult> executeAsync(
        ToolRequest request,
        CancellationToken token
    );

    /**
     * 工具超时时间(默认 30s)
     */
    default Duration getTimeout() {
        return Duration.ofSeconds(30);
    }
}

协作式取消机制

/**
 * CancellationToken — 基于 AtomicBoolean 的协作式取消
 * Java 没有 Thread.stop(),取消是"请求"而非"强制"
 */
public class CancellationToken {
    private final AtomicBoolean cancelled = new AtomicBoolean(false);

    public void cancel() { cancelled.set(true); }
    public boolean isCancelled() { return cancelled.get(); }

    // 在工具实现中需要主动检查
    // while (!token.isCancelled()) { ... }
}

超时执行保障

// 框架层面的超时保障
CompletableFuture<ToolResult> future = tool.executeAsync(request, token);

// orTimeout 在指定时间后触发 TimeoutException
future.orTimeout(tool.getTimeout().toMillis(), TimeUnit.MILLISECONDS)
      .exceptionally(ex -> {
          if (ex instanceof TimeoutException) {
              token.cancel();  // 通知工具停止
              return ToolResult.error("Tool execution timed out");
          }
          return ToolResult.error(ex.getMessage());
      });

与 Graph-Parallel 模式集成

  Graph-Parallel 模式下的并发工具执行:

  Agent ReAct Loop
     │
     │ LLM 返回多个 tool_call
     ▼
  ┌────────────────────────────────────────────────────┐
  │ ParallelToolExecutor                                │
  │                                                    │
  │  tool_call_1 ──┐                                   │
  │  tool_call_2 ──┼──▶ async-tool ThreadPool (8线程)  │
  │  tool_call_3 ──┘    各工具并发执行                  │
  │                                                    │
  │  CompletableFuture.allOf(f1, f2, f3)               │
  │      .orTimeout(maxParallelTimeout)                │
  │                                                    │
  │  结果收集 → 组装为 tool_results → 返回 ReAct Loop  │
  └────────────────────────────────────────────────────┘

四、沙箱执行环境

Spring Bean 注册ProcessSandboxExecutor 已标注 @Component,由 Spring 容器管理。 BackgroundTaskExecutor 通过构造器注入复用同一个沙箱实例,其他组件也可通过 @Autowired SandboxExecutor 独立注入使用。

安全隔离层级

隔离级别实现方式安全性性能开销适用场景
ProcessProcessBuilder + 超时 kill最小开发环境、受信代码
Dockernamespace + seccomp + cgroup中高中等生产环境、用户代码
VM虚拟机完全隔离最高较大高安全要求、多租户

当前 Process 级别约束

  Process 沙箱约束:

  ┌──────────────────────────────────────────────────┐
  │ ProcessBuilder 配置                               │
  │                                                  │
  │ 网络限制:                                         │
  │ ├── 不直接限制(Process级别无法限制网络)         │
  │ └── 依赖防火墙规则 / Docker 网络策略              │
  │                                                  │
  │ 文件系统限制:                                     │
  │ ├── 工作目录: /tmp/sandbox/{taskId}/             │
  │ ├── 只允许读写工作目录下的文件                    │
  │ └── PATH 限制: 只允许特定命令                    │
  │                                                  │
  │ 资源限制:                                         │
  │ ├── 超时: configurable (默认 60s)                │
  │ ├── kill -9 强制终止超时进程                      │
  │ ├── 输出大小限制: maxOutputSize (默认 1MB)       │
  │ └── redirectErrorStream 合并 stderr              │
  │                                                  │
  │ 支持语言:                                         │
  │ ├── Python  (python3 命令)                       │
  │ ├── JavaScript (node 命令)                       │
  │ └── Shell (bash -c 命令)                         │
  └──────────────────────────────────────────────────┘

Docker 沙箱增强(生产级)

  Docker 沙箱增强:

  ┌──────────────────────────────────────────────────┐
  │ Docker Container                                  │
  │                                                  │
  │ Namespace 隔离:                                   │
  │ ├── PID namespace   → 进程隔离                   │
  │ ├── NET namespace   → 网络隔离 (--network=none)  │
  │ ├── MNT namespace   → 文件系统隔离               │
  │ └── USER namespace  → 用户权限隔离               │
  │                                                  │
  │ Seccomp Profile:                                  │
  │ ├── 禁止: fork, exec (限制后续进程创建)          │
  │ ├── 禁止: mount, chroot (防止挂载攻击)           │
  │ └── 禁止: socket (限制网络创建)                  │
  │                                                  │
  │ Cgroup 资源限制:                                  │
  │ ├── --memory=256m    (内存上限)                   │
  │ ├── --cpus=0.5       (CPU 上限)                  │
  │ └── --pids-limit=50  (进程数上限)                │
  │                                                  │
  │ 使用场景:                                         │
  │ ├── DataAgent SQL 执行                           │
  │ ├── 代码生成验证 (编译 + 运行)                   │
  │ └── 用户上传脚本执行                             │
  └──────────────────────────────────────────────────┘

五、线程池设计

三池隔离架构

  线程池隔离设计:

  ┌─────────────────────────────────────────────────────────────┐
  │                     Tomcat 主线程池                          │
  │  核心: 200 | 最大: 200 | 队列: 无界                        │
  │  用途: HTTP 请求处理                                        │
  │  特点: 不应被 Agent 任务阻塞                                │
  └─────────────────────────────────────────────────────────────┘
          │                                    │
          ▼                                    ▼
  ┌──────────────────────┐        ┌──────────────────────────┐
  │ background-task Pool │        │    async-tool Pool        │
  │                      │        │                          │
  │ 核心: 4 | 最大: 4    │        │ 核心: 8 | 最大: 8        │
  │ 队列: Bounded(50)    │        │ 队列: Bounded(100)       │
  │ 拒绝: AbortPolicy    │        │ 拒绝: CallerRunsPolicy   │
  │                      │        │                          │
  │ 用途:                │        │ 用途:                    │
  │ Agent 后台长任务     │        │ 并发工具执行             │
  │ (分钟级)             │        │ (秒级)                   │
  │                      │        │                          │
  │ 场景:                │        │ 场景:                    │
  │ · 代码编译           │        │ · graph-parallel 模式    │
  │ · 网页爬取           │        │ · 多工具并发调用         │
  │ · 文件处理           │        │ · API 并发请求           │
  └──────────────────────┘        └──────────────────────────┘

  隔离原因:
  ├── background-task 是长任务(分钟级),不能挤占工具执行线程
  ├── async-tool 是短任务(秒级),需要快速响应
  └── 两者都不能影响 Tomcat 处理 HTTP 请求

六、配置参考

# application.yml — 异步执行与沙箱配置
agent:
  async:
    # 后台任务配置
    background-task:
      enabled: true
      max-tasks: 100                 # 最大并发任务数
      thread-pool:
        core-size: 4                 # 核心线程数
        max-size: 4                  # 最大线程数
        queue-capacity: 50           # 等待队列容量
        thread-name-prefix: bg-task- # 线程名前缀(便于监控)
      default-timeout: 300s          # 默认任务超时(5分钟)
      cleanup-interval: 60s          # 过期任务清理间隔

    # 异步工具执行配置
    async-tool:
      enabled: true
      thread-pool:
        core-size: 8
        max-size: 8
        queue-capacity: 100
        thread-name-prefix: async-tool-
      default-timeout: 30s           # 工具默认超时
      max-parallel-timeout: 60s      # 并发执行总超时

  # 沙箱配置
  sandbox:
    enabled: true
    type: PROCESS                    # PROCESS / DOCKER / VM
    work-dir: /tmp/sandbox           # 沙箱工作目录
    max-output-size: 1MB             # 输出大小限制
    default-timeout: 60s             # 沙箱执行超时
    allowed-commands:                # 允许的命令白名单
      - python3
      - node
      - bash

    # Docker 沙箱增强配置(type=DOCKER 时生效)
    docker:
      image: sandbox-runner:latest
      network: none                  # 禁止网络
      memory: 256m                   # 内存限制
      cpus: 0.5                      # CPU 限制
      pids-limit: 50                 # 进程数限制
      read-only: true                # 只读文件系统
      tmpfs: /tmp:size=64m           # 临时文件系统

七、面试高频问题

Q1: CompletableFuture的协作式取消是如何实现的?Java没有Thread.stop()?
A: CancellationToken 是一个 AtomicBoolean 包装。工具实现者在循环中主动检查 token.isCancelled(),如果为 true 则主动退出。这是协作式取消——取消是"请求"而非"强制"。对于长时间 IO 阻塞操作(如 Socket 读取),通过关闭底层 socket/stream 触发 InterruptedExceptionSocketException,间接中断阻塞。Java 废弃 Thread.stop() 是因为强制终止可能导致锁不释放、数据不一致等严重问题。
Q2: 后台任务的最大数量限制(100)是如何enforced的?超出怎么办?
A: BackgroundTaskStore 维护一个 ConcurrentHashMap,在 submit() 前检查 size() >= maxTasks,超过则直接拒绝(返回错误 tool result:"后台任务数已达上限")。Agent 收到拒绝后可以选择:① 等待——调用 get_task_status 等已有任务完成后重新提交;② 取消旧任务——调用 cancel_task 释放资源;③ inline 执行——退化为同步执行(适合短任务)。100 的限制是为了防止资源耗尽,实际应根据服务器配置调整。
Q3: 沙箱逃逸风险如何防范?ProcessBuilder级别的隔离够用吗?
A: ProcessBuilder 级别隔离有限:① 限制 PATH(只允许特定命令白名单);② 超时 kill -9 强制终止;③ redirectErrorStream + 限制输出大小。但不够安全——进程仍可访问宿主机文件系统、网络、环境变量。生产级防护需要 Docker:namespace 隔离(PID/NET/MNT/USER)+ seccomp profile(禁止危险系统调用)+ cgroup 资源限制(memory/cpu/pids)。终极方案是 VM 级隔离(如 Firecracker microVM),但启动延迟较大。选择取决于安全等级要求和性能预算。
Q4: 异步工具执行超时后,已分配的资源如何清理?
A: CompletableFuture.orTimeout() 触发 TimeoutException → 调用 future.cancel(true) 设置线程中断标志 → 工具实现中的 finally 块释放资源(关闭连接、删除临时文件等)。极端情况:超时线程不响应中断(如死循环中不检查中断标志)→ 线程池 watchdog 定期扫描长时间运行的线程 → 标记为 abandon 状态 → 等待下次调度时回收。ProcessBuilder 的场景更简单:直接 process.destroyForcibly()(对应 kill -9),操作系统负责回收子进程资源。
Q5: BackgroundTask和AsyncToolCallback有什么区别?什么时候用哪个?
A: BackgroundTask:Agent 显式管理的长任务(分钟级),通过 submit_task/get_task_status/cancel_task 三个工具操作,Agent 需要主动查询状态,适合爬虫、编译等长时间任务。AsyncToolCallback:框架级并发优化(秒级),对 Agent 完全透明——Agent 不知道工具是同步还是异步执行的,结果由框架自动收集并返回,适合 graph-parallel 模式下的多工具并发调用。简言之:BackgroundTask 是"Agent 管理的任务队列",AsyncToolCallback 是"框架透明的并发加速"。
Q6: 如果后台任务执行到一半OOM了,任务状态如何更新?
A: 两重保护机制:① 任务超时检查线程(ScheduledExecutorService)定期扫描所有 RUNNING 状态的任务,发现超过 timeout 的任务 → 标记为 FAILED,记录原因"execution timeout";② JVM shutdown hook 在进程退出时尝试将所有 RUNNING 状态任务标记为 FAILED。如果是子进程 OOM(沙箱中),Process.waitFor() 返回非零退出码 → 正常进入 FAILED 路径。服务重启后 BackgroundTaskStore 初始化时清理所有 orphan 任务(状态为 RUNNING 但进程已不存在)。
Q7: 沙箱中执行的代码如何与Agent主进程通信结果?
A: 通过标准 IO 管道:ProcessBuilder.getInputStream() 读取子进程的 stdout 作为返回值。通信协议:子进程以 JSON 格式将结果序列化到 stdout,父进程反序列化。大结果处理:如果输出超过 maxOutputSize(默认 1MB),子进程将结果写入临时文件(/tmp/sandbox/{taskId}/result.json),stdout 只返回文件路径,类似 LargeResultEviction 机制。stderr 通过 redirectErrorStream 合并到 stdout,由父进程统一解析(区分正常输出和错误信息)。
Q8: 多个Agent同时提交后台任务,线程池满了怎么办?
A: ThreadPoolExecutorRejectedExecutionHandler 决定策略。当前实现使用 AbortPolicy——队列满时抛出 RejectedExecutionExceptionBackgroundTaskExecutor 捕获异常后返回错误 tool result。Agent 收到拒绝后可选择:重试(等待一段时间后重新 submit)、降级为同步执行(对短任务可行)。替代策略 CallerRunsPolicy 让提交线程自己执行任务(自然限流),但会阻塞 Agent 的 ReAct 循环,不适合后台长任务。async-tool 池使用 CallerRunsPolicy 是因为工具执行通常较短(秒级),阻塞可接受。

🔐 认证与权限体系 — 项目实战

体系概述

基于 Spring Security 的 JWT 无状态认证 + RBAC 角色权限体系。所有 API 请求通过 JWT Token 认证,管理类接口通过角色控制访问权限。

核心设计:无状态 JWT 认证 — 服务端不存储 Session,每个请求自包含认证信息,天然适合分布式部署和水平扩展。

JWT 认证流程

  JWT 认证完整流程:

  1. 用户登录 (POST /api/auth/login)
     ├── 验证用户名/密码 (BCrypt)
     └── 返回 JWT Token

  2. 后续请求
     ├── Header: Authorization: Bearer <token>
     ├── JwtAuthenticationFilter 提取并验证 Token
     └── 设置 SecurityContext (线程级安全上下文)

  3. Token 过期
     └── 返回 401, 前端跳转登录页

核心安全组件

组件职责
SecurityConfig安全配置:JWT 无状态、CORS、路由规则
JwtTokenProviderJWT 创建与验证
JwtAuthenticationFilter从 Authorization 头提取并验证 JWT
BCrypt密码编码(单向哈希,不可逆)
SecretCryptoServiceAES 加密存储 API Key,数据库中不存明文

RBAC 角色权限

角色权限范围
ADMIN全部权限:用户管理、角色管理、权限管理、密钥池、系统监控
USER基础权限:对话、文档管理、Agent 使用

API 路由级别控制

/api/roles/**     → ADMIN only
/api/admin/**     → ADMIN only
/api/permissions  → ADMIN only
其他               → 认证用户(任意角色)

权限实体关系

相关 Controller

Controller路径功能
AuthController/api/auth登录、注册、Token 刷新
AdminUserController/api/admin/users用户管理(ADMIN)
RoleController/api/roles角色管理(ADMIN)
PermissionController/api/permissions权限管理(ADMIN)
Q: 为什么选择 JWT 无状态认证而非 Session?
A: 三个原因:① 分布式友好:JWT 自包含认证信息,不需要 Session 共享(无需 Redis Session 存储),任何节点都能独立验证;② 水平扩展:新增服务实例无需同步 Session 状态,直接扩容;③ 跨域支持:JWT 通过 HTTP Header 传递,天然支持 CORS 跨域场景。缺点是 Token 一旦签发无法主动失效(需等过期),通过短有效期 + 刷新 Token 机制缓解。密钥安全方面,API Key 使用 SecretCryptoService 的 AES 加密存储,数据库中不存明文,即使数据库泄露也无法直接获取密钥。

📧 通知系统 — 多通道消息推送

系统概述

通知系统是平台的基础设施层,为各业务模块提供 统一、可扩展、模板化 的消息推送能力。当前已实现邮件通道,架构上预留了企业微信、站内推送等扩展点。与异步任务系统紧密关联——邮件发送本身就是 @Async 异步操作,定时提醒依赖 Spring @Scheduled 调度框架。

核心思路:SPI 接口抽象通道 → Dispatcher 统一编排 → 模板引擎渲染内容 → Channel 投递消息。扩展新通道只需实现接口 + 注册 Bean,零代码侵入。

架构全景

  通知系统架构:

  业务调用方
  ├── EmailCodeService  (验证码)
  ├── StorageReminderScheduler (物品收纳提醒)
  └── BirthdayReminderScheduler (生日管家提醒)
         │
         │ dispatcher.sendEmail(recipient, subject, template, variables)
         ▼
  ┌──────────────────────────────────────────────────────────────┐
  │              NotificationDispatcher (调度器)                  │
  │                                                              │
  │  1. TemplateRegistry.getTemplate(template)                   │
  │     → 从 classpath 加载 HTML 模板(ConcurrentHashMap 缓存)  │
  │                                                              │
  │  2. TemplateEngine.render(html, variables)                   │
  │     → {{key}} 占位符替换(自动 HTML 转义防 XSS)             │
  │     → 自动补充公共变量: year = Year.now()                    │
  │                                                              │
  │  3. 构建 NotificationRequest (Builder 模式)                  │
  │     → recipient / subject / htmlBody / template / metadata   │
  │                                                              │
  │  4. channelMap.get("EMAIL").send(request)                    │
  │     → 路由到对应 NotificationChannel 实现                    │
  └────────────────────────┬─────────────────────────────────────┘
                           │
              ┌────────────┼──────────────┐
              ▼            ▼              ▼
       ┌───────────┐ ┌──────────┐  ┌──────────┐
       │   EMAIL   │ │  WECOM   │  │   PUSH   │
       │  (已实现)  │ │  (预留)  │  │  (预留)  │
       └─────┬─────┘ └──────────┘  └──────────┘
             │
             ▼
       ┌────────────────────────────────────────┐
       │ EmailNotificationChannel               │
       │                                        │
       │ · htmlBody 非空 → sendHtmlEmail()      │
       │ · htmlBody 空   → sendSimpleEmail()    │
       │ · 两者皆空      → 跳过 + 日志警告     │
       └─────────────┬──────────────────────────┘
                     │
                     ▼
       ┌────────────────────────────────────────┐
       │ EmailService (@Async 异步发送)         │
       │                                        │
       │ · JavaMailSender (Spring Boot Starter)  │
       │ · MimeMessageHelper (HTML + UTF-8)      │
       │ · SMTP over SSL (port 465)             │
       └────────────────────────────────────────┘

SPI 接口与通道扩展

通知通道抽象为 NotificationChannel SPI 接口,位于 hub-common 模块:

// hub-common: com.example.agenthub.common.notification.NotificationChannel
public interface NotificationChannel {
    /** 通道类型标识符,如 "EMAIL"、"WECOM"、"PUSH" */
    String channelType();

    /** 发送通知。返回 true = 已成功提交(异步通道不等于已送达) */
    boolean send(NotificationRequest request);
}

NotificationDispatcher 在构造时自动收集所有 NotificationChannel Bean,按 channelType() 建立路由表:

// 自动发现机制 — 构造器注入
public NotificationDispatcher(TemplateRegistry registry, List<NotificationChannel> channels) {
    this.channelMap = new HashMap<>();
    for (NotificationChannel channel : channels) {
        this.channelMap.put(channel.channelType(), channel);
    }
    log.info("通知调度器初始化完成,已注册通道: {}", channelMap.keySet());
}

扩展新通道只需两步:① 实现 NotificationChannel 接口;② 注册为 Spring Bean (@Component)。

通道channelType状态说明
EMAILEMAIL✅ 已实现Spring JavaMailSender + @Async
WECOMWECOM🔜 预留企业微信应用消息 API
PUSHPUSH🔜 预留WebSocket / SSE 实时推送

模板引擎

自研轻量级模板引擎,使用 {{key}} 占位符语法,零外部依赖。

// TemplateEngine — 两种渲染模式
// 1. 带 HTML 转义(默认,防 XSS)
String result = TemplateEngine.render(template, Map.of(
    "userName", "张三",
    "itemName", "牛奶",
    "expiryDate", "2026-05-05"
));

// 2. 不转义(适用于已安全的 HTML 片段)
String result = TemplateEngine.renderRaw(template, variables);

TemplateRegistry 从 classpath 加载 HTML 模板并缓存到 ConcurrentHashMap,支持 evict() 热刷新。

已注册模板

枚举值模板文件描述关键变量
VERIFICATION_CODEverification-code.html验证码code, validMinutes
STORAGE_EXPIRY_REMINDERstorage-expiry-reminder.html食品过期提醒userName, itemName, expiryDate, daysLeft, locationPath
STORAGE_RETURN_REMINDERstorage-return-reminder.html借出归还提醒userName, itemName, borrowerName, expectedReturnDate
STORAGE_CUSTOM_REMINDERstorage-custom-reminder.html通用提醒userName, title, content, remindAt
BIRTHDAY_REMINDERbirthday-reminder.html生日提醒userName, contactName, relationship, birthdayDate, daysLeft, age

所有模板自动注入 year 变量(当前年份),由 Dispatcher 的 renderTemplate() 补充。

定时提醒调度

两个 Scheduler 组件负责定时扫描到期提醒并通过 Dispatcher 发送邮件通知。

  StorageReminderScheduler — 物品收纳:

  @Scheduled(fixedDelay = 60000ms)         @Scheduled(cron = "0 0 3 * * ?")
  scanAndSendReminders()                   scanExpiredItems()
     │                                        │
     ├── jobGuard.tryAcquire(55s)              ├── jobGuard.tryAcquire(5min)
     ├── findDueReminders()                    └── markExpiredItems()
     ├── 遍历: 查用户→选模板→发邮件
     ├── 成功后 markReminderSent()
     └── 统计: total / sent / failed

  提醒类型映射:
  ├── EXPIRY   → STORAGE_EXPIRY_REMINDER
  ├── RETURN   → STORAGE_RETURN_REMINDER
  └── 其他     → STORAGE_CUSTOM_REMINDER

  ────────────────────────────────────────────────

  BirthdayReminderScheduler — 生日管家:

  @Scheduled(cron = "0 0 8 * * ?")         @Scheduled(cron = "0 5 0 * * ?")
  scanAndSendReminders()                   updatePassedBirthdays()
     │                                        │
     ├── jobGuard.tryAcquire(4min)             ├── jobGuard.tryAcquire(3min)
     ├── findDueReminders()                    └── refreshAllNextBirthdays()
     ├── 遍历: 查用户→查联系人→算年龄
     ├── 构建变量 (含星座/生肖/农历)
     ├── 邮件主题:
     │   ├── 当天: "今天是 XX 的生日!"
     │   └── 提前: "XX 的生日还有 N 天"
     ├── dispatcher.sendEmail() → BIRTHDAY_REMINDER
     └── 成功后 markReminderTriggered() + logEvent()

分布式安全:所有定时任务通过 DistributedJobGuard(基于 Redis 分布式锁)保证集群环境下仅单节点执行。lease 参数指定锁的最大持有时间,防止宕机后锁不释放。

定时任务调度方式默认值配置 Key
物品提醒扫描fixedDelay60秒hub.storage.reminder-scan-interval-ms
过期食品扫描cron每天 3:00hub.storage.expiry-scan-cron
生日提醒扫描cron每天 8:00hub.birthday.reminder-scan-cron
生日日期刷新cron每天 0:05hub.birthday.refresh-cron

邮件验证码服务

EmailCodeService 提供 6 位数字验证码的发送与校验,基于 Redis 存储 + 频率限制。

  验证码发送流程:

  sendCode("user@example.com", "register")
     │
     ├── 频率限制: Redis key "email:rate:register:user@example.com"
     │   └── 存在 → 抛异常 "发送过于频繁,请60秒后再试"
     │
     ├── 生成验证码: SecureRandom → 6 位数字 (如 "382916")
     │
     ├── 存入 Redis:
     │   ├── "email:code:register:user@example.com" → "382916" (TTL: 5分钟)
     │   └── "email:rate:register:user@example.com" → "1"      (TTL: 60秒)
     │
     └── dispatcher.sendEmail()
         ├── 模板: VERIFICATION_CODE
         ├── 变量: code="382916", validMinutes="5"
         └── 主题: "Nexora 平台 — 注册验证码"

  验证流程:
  verifyCode("user@example.com", "register", "382916")
     ├── 从 Redis 读取 stored code
     ├── 比对成功 → 删除 key → 返回 true
     └── 比对失败 → 返回 false
参数说明
验证码长度6 位数字SecureRandom 生成,密码学安全
有效期5 分钟Redis KEY TTL
频率限制60 秒同一邮箱 + 类型 60 秒内仅允许一次

邮件服务配置

# application.yml — 邮件服务配置
spring:
  mail:
    host: smtp.163.com                    # SMTP 服务器地址
    port: 465                             # SMTP 端口(SSL)
    username: ${MAIL_USERNAME:xxx}        # 发件人邮箱账号(环境变量注入)
    password: ${MAIL_PASSWORD:xxx}        # 邮箱授权码(非登录密码)
    default-encoding: UTF-8
    properties:
      mail.smtp.ssl.enable: true          # 启用 SSL 加密
      mail.smtp.socketFactory.class: javax.net.ssl.SSLSocketFactory
      mail.smtp.socketFactory.port: 465
      mail.smtp.auth: true                # 启用 SMTP 认证

# 定时调度配置(使用默认值时无需显式配置)
hub:
  storage:
    reminder-scan-interval-ms: 60000      # 物品提醒扫描间隔
    expiry-scan-cron: "0 0 3 * * ?"       # 过期食品扫描
  birthday:
    reminder-scan-cron: "0 0 8 * * ?"     # 生日提醒扫描
    refresh-cron: "0 5 0 * * ?"           # 生日日期刷新
Q: 通知系统与异步任务系统有什么关系?
A: 两者在多个层面关联:① 邮件发送本身就是异步的——EmailService 的两个发送方法均标注 @Async,由 Spring 异步线程池执行,EmailNotificationChannel.send() 返回 true 仅表示"已提交"而非"已送达";② 定时调度依赖 Spring @Scheduled,与 BackgroundTask 共享 Spring 的调度基础设施;③ 分布式安全——StorageReminderSchedulerBirthdayReminderScheduler 均使用 DistributedJobGuard(基于 Redis 分布式锁),与 BackgroundTaskExecutor 的分布式保障机制一脉相承。通知系统可以看作异步执行架构在"消息推送"领域的具体应用。
Q: 如何保证邮件不漏发也不重发?
A: 不漏发StorageReminderScheduler 采用"先发后标"策略——先调用 dispatcher.sendEmail(),成功后才 markReminderSent(),确保发送失败时提醒不被错误标记,下次扫描会重试。不重发BirthdayReminderScheduler 使用 lastTriggeredYear 字段记录上次触发年份,同一年不会重复发送;StorageReminderScheduler 通过将状态从 PENDING 改为 SENT 防止重复处理。极端情况:如果标记 SENT 的写库操作失败(如数据库宕机),可能导致重发——但"重发一次提醒"的影响远小于"漏掉提醒",这是有意为之的权衡。
Q: 模板引擎为什么自研而不用 Thymeleaf / FreeMarker?
A: 三个原因:① 极简需求——当前模板仅需简单变量替换({{key}}),不涉及条件判断、循环等复杂逻辑,引入完整模板引擎过度设计;② 零依赖——TemplateEngine 无任何第三方依赖,hub-common 模块保持轻量;③ 安全控制——render() 方法自动进行 HTML 实体转义(& < > " '),renderRaw() 方法用于已安全的内容,开发者对安全策略有完全掌控。如果未来模板复杂度增加(如条件渲染、列表循环),可平滑迁移到 Thymeleaf。