后台任务系统、异步工具回调、沙箱隔离执行与线程池设计
Agent 在实际场景中经常需要执行长时间运行的任务:
BackgroundTask 系统架构:
Agent (ReAct Loop)
│
│ 调用工具: launch_background_task / query_task_status / cancel_background_task
▼
┌──────────────────────────────────────────────────────────────┐
│ BackgroundTaskToolProvider │
│ │
│ 暴露给 Agent 的 4 个工具: │
│ ├── launch_background_task(name,type,content,timeout) │
│ ├── query_task_status(taskId) │
│ ├── list_background_tasks() │
│ └── cancel_background_task(taskId) │
└──────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ BackgroundTaskExecutor │
│ │
│ ├── submitShellTask / submitPythonTask / submitCustomTask │
│ │ 1. 保存 BackgroundTask │
│ │ 2. 提交到 fixed thread pool │
│ │ 3. Shell/Python 通过 SandboxExecutor 执行 │
│ │ 4. 完成后写入 COMPLETED / FAILED │
│ │ │
│ ├── cancel(taskId): │
│ │ 1. future.cancel(true) │
│ │ 2. 非终态任务标记为 CANCELLED │
│ │ │
│ └── shutdown(): │
│ 取消运行中的 Future 并关闭线程池 │
└──────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ ThreadPool (background-task) │
│ │
│ Executors.newFixedThreadPool(poolSize) │
│ 默认 poolSize=4;任务上限主要在工具层按会话限制 │
└──────────────────────┬───────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ BackgroundTaskStore │
│ │
│ ConcurrentHashMap<String, BackgroundTask> │
│ │
│ 存储: taskId → { status, progress, result, error, time } │
│ local 默认 maxTasks=1000;超量时清理旧终态任务 │
└──────────────────────────────────────────────────────────────┘
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 + 超时 + 环境变量清洗 + 输出截断 | 低 | 最小 | 开发环境、受控任务 |
| Docker | namespace + seccomp + cgroup | 中高 | 中等 | 后续增强:不可信用户代码 |
| VM | 虚拟机完全隔离 | 最高 | 较大 | 后续增强:高安全要求、多租户 |
ProcessSandboxExecutor。SandboxConfig 里预留了 DOCKER 字段,但还没有 DockerSandboxExecutor 实现。Process 沙箱约束: ┌──────────────────────────────────────────────────┐ │ ProcessBuilder 配置 │ │ │ │ 网络限制: │ │ └── 不做 OS 层禁网;networkEnabled 字段为预留 │ │ │ │ 文件系统限制: │ │ ├── 工作目录: /tmp/agent-sandbox │ │ └── 仅设置进程工作目录,不是强文件系统隔离 │ │ │ │ 资源限制: │ │ ├── 超时: SandboxConfig.timeoutSeconds (默认30s) │ │ ├── destroyForcibly() 强制终止超时进程 │ │ ├── 输出大小限制: maxOutputBytes (默认1MB) │ │ └── stdout / stderr 分别读取并截断 │ │ │ │ 环境变量: │ │ └── 移除 API_KEY / SECRET / TOKEN / PASSWORD 等 │ │ │ │ 支持语言: │ │ ├── executePython(): python3 │ │ └── executeCommand(): /bin/sh -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 (进程数上限) │ │ │ │ 适合后续承载: │ │ ├── 不可信用户代码 │ │ ├── 代码生成验证 (编译 + 运行) │ │ └── 更强多租户隔离 │ └──────────────────────────────────────────────────┘
线程池隔离设计:
┌─────────────────────────────────────────────────────────────┐
│ Tomcat 主线程池 │
│ 核心: 200 | 最大: 200 | 队列: 无界 │
│ 用途: HTTP 请求处理 │
│ 特点: 不应被 Agent 任务阻塞 │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ background-task Pool │ │ async-tool Pool │
│ │ │ │
│ 核心: 4 | 最大: 4 │ │ 核心: 8 | 最大: 8 │
│ fixedThreadPool │ │ fixedThreadPool(配置值) │
│ 单会话活跃任务≤10 │ │ orTimeout + 取消令牌 │
│ │ │ │
│ 用途: │ │ 用途: │
│ Agent 后台长任务 │ │ 并发工具执行 │
│ (分钟级) │ │ (秒级) │
│ │ │ │
│ 场景: │ │ 场景: │
│ · 代码编译 │ │ · graph-parallel 模式 │
│ · 网页爬取 │ │ · 多工具并发调用 │
│ · 文件处理 │ │ · API 并发请求 │
└──────────────────────┘ └──────────────────────────┘
隔离原因:
├── background-task 是长任务(分钟级),不能挤占工具执行线程
├── async-tool 是短任务(秒级),需要快速响应
└── 两者都不能影响 Tomcat 处理 HTTP 请求
# agent-config.yml / interceptor-hook-config.yml — 异步执行配置
agent:
defaults:
background-task:
enabled: true
pool-size: 4
default-timeout-seconds: 300
max-tasks: 100
async-tool:
enabled: true
default-timeout-ms: 60000
pool-size: 8
# SandboxConfig 当前主要由代码构造:
# defaults(): isolationLevel=PROCESS, timeoutSeconds=30,
# workDir=/tmp/agent-sandbox, maxOutputBytes=1MB。
# DOCKER 字段为后续 DockerSandboxExecutor 预留。
AtomicBoolean 包装。工具实现者在循环中主动检查 token.isCancelled(),如果为 true 则主动退出。这是协作式取消——取消是"请求"而非"强制"。对于长时间 IO 阻塞操作(如 Socket 读取),通过关闭底层 socket/stream 触发 InterruptedException 或 SocketException,间接中断阻塞。Java 废弃 Thread.stop() 是因为强制终止可能导致锁不释放、数据不一致等严重问题。
BackgroundTaskToolProvider 当前限制单会话最多 10 个活跃后台任务,超过会直接返回错误,提示等待或取消旧任务;存储层 BackgroundTaskStore 默认最多保留 1000 条记录,超量时清理已终结且较旧的任务。面试时不要讲成全局 100 个任务或线程池队列满自动拒绝。
SandboxExecutor 扩展为 Docker 或 microVM 实现,通过 namespace、seccomp、cgroup 和只读文件系统做强隔离。
CompletableFuture.orTimeout() 触发超时,随后取消令牌并调用 future.cancel(true),具体资源释放依赖工具实现响应取消或中断。后台 Shell/Python 任务走 ProcessSandboxExecutor,超时后调用 process.destroyForcibly() 并返回 timedOut 结果。当前没有单独的 watchdog/abandon 状态扫描器。
launch_background_task/query_task_status/list_background_tasks/cancel_background_task 操作,Agent 需要主动查询状态,适合爬虫、编译等长时间任务。AsyncToolCallback:框架级并发优化(秒级),对 Agent 更透明,结果由框架收集并返回,适合 graph-parallel 模式下的多工具并发调用。简言之:BackgroundTask 是"Agent 管理的任务队列",AsyncToolCallback 是"框架透明的并发加速"。
ProcessSandboxExecutor 会拿到非 0 exitCode,BackgroundTaskExecutor 将任务标记为 FAILED 并保存输出/错误信息。若是 JVM 自身崩溃或服务被强杀,当前本地内存态任务无法可靠补偿;Redis 运行态只能保存任务快照和统计,不能让已经中断的进程自动恢复执行。面试时可以把“启动恢复 orphan RUNNING 任务”作为后续增强点。
ProcessSandboxExecutor 分别读取子进程 stdout 和 stderr,按 maxOutputBytes 截断,最后将两者合并为任务结果或错误信息。当前没有约定子进程必须输出 JSON,也没有“超大结果写 result.json 再返回路径”的机制;大输出主要靠截断保护。
Executors.newFixedThreadPool(poolSize),默认 4 个工作线程;并发过高时任务会排队等待。真正的前置保护主要是每会话活跃任务数限制和存储清理策略。异步工具池则使用有界队列加 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。