后台任务系统、异步工具回调、沙箱隔离执行与线程池设计
Agent 在实际场景中经常需要执行长时间运行的任务:
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
/**
* 异步工具回调接口 — 框架级并发优化
* 与 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 模式下的并发工具执行:
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 独立注入使用。
| 隔离级别 | 实现方式 | 安全性 | 性能开销 | 适用场景 |
|---|---|---|---|---|
| Process | ProcessBuilder + 超时 kill | 低 | 最小 | 开发环境、受信代码 |
| Docker | namespace + seccomp + cgroup | 中高 | 中等 | 生产环境、用户代码 |
| VM | 虚拟机完全隔离 | 最高 | 较大 | 高安全要求、多租户 |
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 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 # 临时文件系统
AtomicBoolean 包装。工具实现者在循环中主动检查 token.isCancelled(),如果为 true 则主动退出。这是协作式取消——取消是"请求"而非"强制"。对于长时间 IO 阻塞操作(如 Socket 读取),通过关闭底层 socket/stream 触发 InterruptedException 或 SocketException,间接中断阻塞。Java 废弃 Thread.stop() 是因为强制终止可能导致锁不释放、数据不一致等严重问题。
BackgroundTaskStore 维护一个 ConcurrentHashMap,在 submit() 前检查 size() >= maxTasks,超过则直接拒绝(返回错误 tool result:"后台任务数已达上限")。Agent 收到拒绝后可以选择:① 等待——调用 get_task_status 等已有任务完成后重新提交;② 取消旧任务——调用 cancel_task 释放资源;③ inline 执行——退化为同步执行(适合短任务)。100 的限制是为了防止资源耗尽,实际应根据服务器配置调整。
kill -9 强制终止;③ redirectErrorStream + 限制输出大小。但不够安全——进程仍可访问宿主机文件系统、网络、环境变量。生产级防护需要 Docker:namespace 隔离(PID/NET/MNT/USER)+ seccomp profile(禁止危险系统调用)+ cgroup 资源限制(memory/cpu/pids)。终极方案是 VM 级隔离(如 Firecracker microVM),但启动延迟较大。选择取决于安全等级要求和性能预算。
CompletableFuture.orTimeout() 触发 TimeoutException → 调用 future.cancel(true) 设置线程中断标志 → 工具实现中的 finally 块释放资源(关闭连接、删除临时文件等)。极端情况:超时线程不响应中断(如死循环中不检查中断标志)→ 线程池 watchdog 定期扫描长时间运行的线程 → 标记为 abandon 状态 → 等待下次调度时回收。ProcessBuilder 的场景更简单:直接 process.destroyForcibly()(对应 kill -9),操作系统负责回收子进程资源。
submit_task/get_task_status/cancel_task 三个工具操作,Agent 需要主动查询状态,适合爬虫、编译等长时间任务。AsyncToolCallback:框架级并发优化(秒级),对 Agent 完全透明——Agent 不知道工具是同步还是异步执行的,结果由框架自动收集并返回,适合 graph-parallel 模式下的多工具并发调用。简言之:BackgroundTask 是"Agent 管理的任务队列",AsyncToolCallback 是"框架透明的并发加速"。
ScheduledExecutorService)定期扫描所有 RUNNING 状态的任务,发现超过 timeout 的任务 → 标记为 FAILED,记录原因"execution timeout";② JVM shutdown hook 在进程退出时尝试将所有 RUNNING 状态任务标记为 FAILED。如果是子进程 OOM(沙箱中),Process.waitFor() 返回非零退出码 → 正常进入 FAILED 路径。服务重启后 BackgroundTaskStore 初始化时清理所有 orphan 任务(状态为 RUNNING 但进程已不存在)。
ProcessBuilder.getInputStream() 读取子进程的 stdout 作为返回值。通信协议:子进程以 JSON 格式将结果序列化到 stdout,父进程反序列化。大结果处理:如果输出超过 maxOutputSize(默认 1MB),子进程将结果写入临时文件(/tmp/sandbox/{taskId}/result.json),stdout 只返回文件路径,类似 LargeResultEviction 机制。stderr 通过 redirectErrorStream 合并到 stdout,由父进程统一解析(区分正常输出和错误信息)。
ThreadPoolExecutor 的 RejectedExecutionHandler 决定策略。当前实现使用 AbortPolicy——队列满时抛出 RejectedExecutionException,BackgroundTaskExecutor 捕获异常后返回错误 tool result。Agent 收到拒绝后可选择:重试(等待一段时间后重新 submit)、降级为同步执行(对短任务可行)。替代策略 CallerRunsPolicy 让提交线程自己执行任务(自然限流),但会阻塞 Agent 的 ReAct 循环,不适合后台长任务。async-tool 池使用 CallerRunsPolicy 是因为工具执行通常较短(秒级),阻塞可接受。
基于 Spring Security 的 JWT 无状态认证 + RBAC 角色权限体系。所有 API 请求通过 JWT Token 认证,管理类接口通过角色控制访问权限。
JWT 认证完整流程:
1. 用户登录 (POST /api/auth/login)
├── 验证用户名/密码 (BCrypt)
└── 返回 JWT Token
2. 后续请求
├── Header: Authorization: Bearer <token>
├── JwtAuthenticationFilter 提取并验证 Token
└── 设置 SecurityContext (线程级安全上下文)
3. Token 过期
└── 返回 401, 前端跳转登录页
| 组件 | 职责 |
|---|---|
SecurityConfig | 安全配置:JWT 无状态、CORS、路由规则 |
JwtTokenProvider | JWT 创建与验证 |
JwtAuthenticationFilter | 从 Authorization 头提取并验证 JWT |
BCrypt | 密码编码(单向哈希,不可逆) |
SecretCryptoService | AES 加密存储 API Key,数据库中不存明文 |
| 角色 | 权限范围 |
|---|---|
| ADMIN | 全部权限:用户管理、角色管理、权限管理、密钥池、系统监控 |
| USER | 基础权限:对话、文档管理、Agent 使用 |
/api/roles/** → ADMIN only /api/admin/** → ADMIN only /api/permissions → ADMIN only 其他 → 认证用户(任意角色)
UserEntity → 关联 RoleEntity(多对多)RoleEntity → 关联 PermissionEntity(多对多,细粒度权限)AdminSeedService:启动时自动创建超级管理员账号PermissionSeedService:启动时自动初始化权限数据| Controller | 路径 | 功能 |
|---|---|---|
AuthController | /api/auth | 登录、注册、Token 刷新 |
AdminUserController | /api/admin/users | 用户管理(ADMIN) |
RoleController | /api/roles | 角色管理(ADMIN) |
PermissionController | /api/permissions | 权限管理(ADMIN) |
SecretCryptoService 的 AES 加密存储,数据库中不存明文,即使数据库泄露也无法直接获取密钥。
通知系统是平台的基础设施层,为各业务模块提供 统一、可扩展、模板化 的消息推送能力。当前已实现邮件通道,架构上预留了企业微信、站内推送等扩展点。与异步任务系统紧密关联——邮件发送本身就是 @Async 异步操作,定时提醒依赖 Spring @Scheduled 调度框架。
通知系统架构:
业务调用方
├── 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) │
└────────────────────────────────────────┘
通知通道抽象为 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 | 状态 | 说明 |
|---|---|---|---|
EMAIL | ✅ 已实现 | Spring JavaMailSender + @Async | |
| WECOM | WECOM | 🔜 预留 | 企业微信应用消息 API |
| PUSH | PUSH | 🔜 预留 | 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_CODE | verification-code.html | 验证码 | code, validMinutes |
STORAGE_EXPIRY_REMINDER | storage-expiry-reminder.html | 食品过期提醒 | userName, itemName, expiryDate, daysLeft, locationPath |
STORAGE_RETURN_REMINDER | storage-return-reminder.html | 借出归还提醒 | userName, itemName, borrowerName, expectedReturnDate |
STORAGE_CUSTOM_REMINDER | storage-custom-reminder.html | 通用提醒 | userName, title, content, remindAt |
BIRTHDAY_REMINDER | birthday-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 |
|---|---|---|---|
| 物品提醒扫描 | fixedDelay | 60秒 | hub.storage.reminder-scan-interval-ms |
| 过期食品扫描 | cron | 每天 3:00 | hub.storage.expiry-scan-cron |
| 生日提醒扫描 | cron | 每天 8:00 | hub.birthday.reminder-scan-cron |
| 生日日期刷新 | cron | 每天 0:05 | hub.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 * * ?" # 生日日期刷新
EmailService 的两个发送方法均标注 @Async,由 Spring 异步线程池执行,EmailNotificationChannel.send() 返回 true 仅表示"已提交"而非"已送达";② 定时调度依赖 Spring @Scheduled,与 BackgroundTask 共享 Spring 的调度基础设施;③ 分布式安全——StorageReminderScheduler 和 BirthdayReminderScheduler 均使用 DistributedJobGuard(基于 Redis 分布式锁),与 BackgroundTaskExecutor 的分布式保障机制一脉相承。通知系统可以看作异步执行架构在"消息推送"领域的具体应用。
StorageReminderScheduler 采用"先发后标"策略——先调用 dispatcher.sendEmail(),成功后才 markReminderSent(),确保发送失败时提醒不被错误标记,下次扫描会重试。不重发:BirthdayReminderScheduler 使用 lastTriggeredYear 字段记录上次触发年份,同一年不会重复发送;StorageReminderScheduler 通过将状态从 PENDING 改为 SENT 防止重复处理。极端情况:如果标记 SENT 的写库操作失败(如数据库宕机),可能导致重发——但"重发一次提醒"的影响远小于"漏掉提醒",这是有意为之的权衡。
{{key}}),不涉及条件判断、循环等复杂逻辑,引入完整模板引擎过度设计;② 零依赖——TemplateEngine 无任何第三方依赖,hub-common 模块保持轻量;③ 安全控制——render() 方法自动进行 HTML 实体转义(& < > " '),renderRaw() 方法用于已安全的内容,开发者对安全策略有完全掌控。如果未来模板复杂度增加(如条件渲染、列表循环),可平滑迁移到 Thymeleaf。