# NotifyX **Repository Path**: pengkaiyan/NotifyX ## Basic Information - **Project Name**: NotifyX - **Description**: 业务语义层告警通知平台 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-10-21 - **Last Updated**: 2026-10-27 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # NotifyX **业务语义层告警通知平台** —— 在业务代码里埋语义点,用一行代码 / 一个注解,把「业务发生了什么」可靠地送到人手里。 监控体系盯的是「进程还活着吗」,NotifyX 盯的是「业务还正常吗」:订单创建失败、支付回调超时、对账不平、账号开通通知。 > 架构基线见 [docs/design.md](docs/design.md)(v1.0 评审版)。实现与文档冲突时,以文档章节号为准。 ## 为什么不是又一个日志框架 | 常见做法 | 问题 | | --- | --- | | `mailService.send(...)` 散落在业务里 | 换渠道要改代码发版 | | 直接调钉钉 webhook | 死循环异常 3 分钟发 2 万条,账号被封 | | 出问题查「谁给谁发过什么」 | 没有审计,答不上来 | | 通知风暴时止血 | 只能重启或改代码 | NotifyX 把「发送」收口成一条固定责任链:**场景路由 → 免打扰 → 去重 → 限流 → 投递 → 重试 → 熔断 → 审计**,业务只声明 `scene`。 ## 快速开始 **1. 引依赖** ```xml io.github.pkyit notifyx-spring-boot-starter 1.0.0 io.github.pkyit notifyx-channel-log 1.0.0 ``` > 坐标说明(ADR-001):主坐标是 `notifyx-spring-boot-starter`。 > `notifyx-spring-boot3-starter` 作为 alias 保留,POM 里带 ``, > 老项目不用改也能拿到迁移提示。新项目请直接用主坐标。 **2. 配 3 行 YAML** ```yaml notifyx: scenes: ORDER_CREATE_FAIL: level: ERROR channels: [log] receivers: [ops@example.com] silent-window: 5m ``` **3. 写一行代码** ```java @Service public class OrderService { private final NotifyKit notifyKit; public OrderService(NotifyKit notifyKit) { this.notifyKit = notifyKit; } public Order create(OrderCmd cmd) { try { return doCreate(cmd); } catch (Exception ex) { notifyKit.alarm("ORDER_CREATE_FAIL", ex, Map.of("orderId", cmd.getOrderNo())); throw ex; // ★ 异常照常抛出,NotifyX 只做旁路观察 } } } ``` 没有 Redis、没有 MQ、没有数据库、没有 Admin —— 应用照常启动,通知照常发出。 ## 核心概念 | 术语 | 含义 | | --- | --- | | `scene` | 场景码,全局唯一锚点,模板/通道/接收人/静默期都挂在它上面,`^[A-Z][A-Z0-9_]{0,63}$` | | `NotifyEvent` | 一次业务语义发生,不可变、可序列化 | | `NotifyChannel` | 渠道 SPI:email / webhook / log / 自定义 | | `EventFilter` | 责任链节点,顺序固定,用户可插 900+ | | `dedupeKey` | 指纹,`sha256(scene ‖ bizId ‖ md5(sorted(params)))`,跨实例可复现 | ## 架构 ``` 业务代码 ──► NotifyKit(门面) │ ▼ 有界优先级队列(Level 降序,drop-lowest) │ ▼ ① 全局静默 → ② 场景路由 → ③ 免打扰 → ④ 去重 → ⑤ 场景限流 → ⑥ 全局限流 → 用户 Filter(900+) │ ▼ 渲染(按渠道格式)→ fan-out(receiver × channel) │ ▼ 渠道发送(指数退避 + 抖动 + 熔断)→ 审计 → 失败落盘 ``` **CRITICAL 旁路矩阵**:直通免打扰与限流、不聚合;去重窗口减半且不超过 60 秒(既不失聪也不刷屏)。 ## 模块 | 模块 | 说明 | 依赖纪律 | | --- | --- | --- | | `notifyx-bom` | 统一版本 | — | | `notifyx-core` | 模型 / SPI / 指纹 / 限流 / 模板 / 脱敏 | **仅 SLF4J**,ArchUnit 强制 | | `notifyx-spring` | AOP 注解、SpEL、优先级队列 | core + spring | | `notifyx-spring-boot3-autoconfigure` | 自动装配、配置属性、诊断 | 不直接依赖任何渠道 | | `notifyx-spring-boot-starter` | 业务方唯一依赖(主坐标) | 空聚合 | | `notifyx-spring-boot3-starter` | 归档 alias,`` 指向主坐标 | 依赖壳 | | `notifyx-channel-log` | 兜底日志渠道 | core | | `notifyx-channel-email` | Jakarta Mail | core + jakarta.mail | | `notifyx-channel-webhook` | 通用 HTTP + HMAC 签名 | core | | `notifyx-logback` | L0 日志即告警(Logback Appender) | core + logback(provided) | | `notifyx-sample` | 可运行验收示例 | starter + 三渠道 | ## 接入方式 | 层级 | 方式 | 侵入度 | | --- | --- | --- | | L0 | Logback Appender(`notifyx-logback`):日志即告警,业务代码一行不改 | 0 | | L2 | `@AlarmProtect(scene = "PAY_CALLBACK_FAIL")` / `@NotifyOn(scene = "...", when = "#r.code != 0")` | 一个注解 | | L3 | `@NotifyxScene` 事件或 `notifyx.events.mappings`:业务只发 Spring 事件 | 一次 publish | | L4 | `notifyKit.alarm(scene, ex, ctx...)` / `notifyKit.send(scene, params)` | 一行调用 | ### L0 · 日志即告警(存量项目首选) 引 `notifyx-logback`,在 `logback-spring.xml` 里挂一个 appender: ```xml LOG_ERROR ERROR ALARM orderId=(\w+) 1 Connection reset|Broken pipe 30 1024 0 ``` 业务代码只需要一个 marker: ```java private static final Marker ALARM = MarkerFactory.getMarker("ALARM"); log.error(ALARM, "orderId={} 支付回调超时", orderNo); ``` Appender 的保证:**异步有界队列**(绝不阻塞日志线程)、队列满即丢弃并计数、 内部异常全部吞掉、**双重防自递归**(NotifyX logger 前缀过滤 + 投递线程重入保护)。 Spring 环境自动把 `NotifyKit` 注入 appender;非 Spring 环境手动 `appender.setNotifyKit(kit)`。 > ⚠️ logback 1.5 的解析器不支持在嵌套元素上写属性,`` > 会被静默忽略(启动时会在 logback status 里告警)。请使用上面的子元素写法, > 或扁平写法 `orderId=(\w+)`。 ### L2 · 注解 + AOP(主力用法) `@AlarmProtect` / `@NotifyOn` 一行注解挂在方法上;SpEL 默认禁用方法调用,防止表达式注入。 注解语义:`@AlarmProtect` 只在抛异常时触发,**异常原样抛出**;`@NotifyOn` 在 SpEL 条件为真或抛异常时触发,返回值原样返回。切面内部任何异常都被吞掉并记 warn,绝不影响业务。 ### L3 · Spring Event(彻底解耦) 业务代码只发 Spring 事件,连 `NotifyKit` 都不用注入。让桥接层知道场景有两种方式: **a) 注解事件类(可同时提供结构化参数)** ```java @NotifyxScene(value = "ACCOUNT_CREATED", level = Level.INFO, receivers = {"ops@example.com"}) public record AccountCreatedEvent(String phone) implements NotifyxEventPayload { public Map params() { return Map.of("phone", phone); } } applicationEventPublisher.publishEvent(new AccountCreatedEvent(phone)); ``` **b) 事件类零依赖 NotifyX,用配置映射** ```yaml notifyx: events: mappings: com.example.event.AccountCreatedEvent: ACCOUNT_CREATED ``` 桥接层把「原始事件类名 + `toString()`」放进 `params.__source` 便于审计追溯; 翻译失败只记 warn,**绝不把异常抛回事件发布方**。`notifyx.events.enabled=false` 可整体关闭。 ## 配置(节选) ```yaml notifyx: enabled: true async: enabled: true core-pool-size: 4 queue-capacity: 4096 # 软上限,满了丢优先级最低的 shutdown-await: 10s dedupe: enabled: true max-entries: 100000 aggregate: enabled: false # T0 实例内聚合:窗口内被去重的事件汇总成一条摘要 window: 10s max-entries: 10000 escalation: enabled: false # 连续 N 次未恢复后改走更高触达渠道 rules: - scene: ORDER_CREATE_FAIL consecutive: 5 within: 10m upgrade-to: [sms, webhook] reset-on-success: true reset-scenes: # 显式恢复:收到该场景时清零目标场景 ORDER_CREATE_RECOVERED: [ORDER_CREATE_FAIL] fallback: store: memory # memory | file(file = 落盘到 ~/.notifyx/pending,重启不丢) dir: ~/.notifyx/pending # 仅 file 模式生效 max-files: 10000 max-bytes: 100MB # 超限先删最旧 DEAD,再删最旧文件 compensation: # 自动补偿:定时重投失败记录(默认关闭) enabled: false interval: 60s batch-size: 50 max-attempts: 5 retry-backoff: 5m rate-limit: rules: - scene: ORDER_CREATE_FAIL limit: 10 window: 1m - global: true limit: 100 window: 1m quiet-hours: enabled: true from: "22:00" to: "09:00" timezone: Asia/Shanghai critical-bypass: true global-mute: enabled: false # 一键止血,运行时可由 Admin 切换 events: enabled: true mappings: # L3:事件类 → 场景码 com.example.event.AccountCreatedEvent: ACCOUNT_CREATED channels: email: enabled: true host: smtp.example.com port: 465 ssl: true ``` 完整字段见各 `*Properties` 类(IDE 里敲 `notifyx.` 有补全)。 > 重试:实际尝试次数 = `min(notifyx.retry.max-attempts, 渠道 maxRetries())`;场景 `retry > 0` > 时再取更小值。全局 `max-attempts` 只封顶,不会放大渠道声明;退避基数由渠道 > `retryBackoff()` 决定(内置渠道默认 2s)。`notifyx.retry.enabled=false` 时每个渠道只尝试一次。 > 模板里的 `${...}` 是 **NotifyX 模板语法**,不是 Spring 占位符:模板配置用不做替换的 Binder 绑定, > 所以 `${userName}` 不会被环境变量 `USERNAME` 篡改(这是实测踩到的坑,见 `.memory` D-011)。 ## 扩展:写一个渠道(目标 < 80 行) ```java public class DingTalkChannel implements NotifyChannel { @Override public String name() { return "dingtalk"; } @Override public RenderFormat preferredFormat() { return RenderFormat.MARKDOWN; } @Override public SendResult send(NotifyContext ctx) throws ChannelException { // ctx.renderedTitle() / ctx.renderedContent() 已经渲染好 // 打包报文 → 签名 → POST → 按错误码分类抛 ChannelAuthException / ChannelTimeoutException ... } } ``` 注册成 Bean 即可被发现;`nonRetryable()` 返回的异常类型不会重试,其余按指数退避重试。 ## 扩展:插一个 Filter ```java @Bean public EventFilter debugOnlyFilter() { return new EventFilter() { public String name() { return "debug-only"; } public int order() { return 900; } // 900+ 是用户区 public FilterVerdict doFilter(FilterContext ctx) { return ctx.event().tags().containsKey("debug") ? FilterVerdict.pass() : FilterVerdict.reject("not-debug"); } }; } ``` 定义成 Spring Bean 即自动接入责任链,按 `order()` 升序执行;内置过滤器占用 100–600,`900+` 保留给业务方。 ## 当前版本范围(v1.0.0) 已实现(对应设计书 P0/P1 的 SDK 部分): - core:`NotifyEvent` / `Level` / `NotifyContext` / `SendResult` / 异常体系 / 指纹算法 / 模板引擎 / 内存去重与限流 / 脱敏 / ULID - 责任链:全局静默、场景路由、免打扰、去重(含失败释放)、场景限流、全局限流 - 执行层:优先级队列(drop-lowest)、指数退避 + 抖动、按渠道熔断、审计、失败落盘接口 - 失败落盘:内存(默认)/ 本地文件(按小时分片、JSON、乐观锁、磁盘上限保护,重启不丢) - T1(可选):`notifyx-store-redis` 提供 Redis 分布式去重(SET NX EX)与滑动窗限流(Lua), Redis 故障自动降级内存(降级期间多实例可能重复发送) - JDBC(可选):`notifyx-store-jdbc` 提供审计落库与 JDBC 失败补偿(乐观锁 CAS), 已在 MySQL 8.0 / PostgreSQL 16 实测;DM/Kingbase 的 DDL 适配尚未验证 - 聚合:T0 实例内窗口聚合(默认);有 Redis 且 `notifyx.aggregate.enabled=true` 时自动切到 T1 跨实例聚合(Redis Hash 计数 + `SET NX` 抢锁,只有一个实例发摘要) - 升级:同场景连续 N 次(窗口内)后改走 `upgrade-to` 渠道,支持恢复场景/成功清零 - 接入:`@AlarmProtect` / `@NotifyOn` + SpEL(默认禁用方法调用)、`NotifyKit` 门面 - L3:Spring Event 桥接(`@NotifyxScene` 注解 / 配置映射,`params.__source` 可追溯) - Trace:MDC 里的 `traceId`/`spanId` 自动进入模板、渠道上下文与审计(Micrometer / SkyWalking 均可) - 渠道:log(默认开)、email、webhook(HMAC-SHA256 + 时间戳防重放 + 主机白名单 + 嵌套 JSON;保留头不可覆盖) - L0:`notifyx-logback` Appender(异步有界、噪声过滤、业务主键提取、双重防自递归) - 可观测性:启动诊断表、`/actuator/health` 的 `notifyx` 组件(UP/DEGRADED/DOWN/OUT_OF_SERVICE) - 生命周期:`SmartLifecycle` 优雅关闭(先排空队列、再释放渠道),队列满/关闭均有日志与指标 - 自动装配:全量 `@ConditionalOnMissingBean`、零依赖启动测试(T13)、Micrometer 指标 规划中(设计书后续阶段):Redis 分布式去重/限流、MQ 总线与 consumer、MyBatis-Plus 审计落库、Admin 控制台、IM 渠道(钉钉/企微/飞书)、sms4j 适配。 ## 可观测性与生命周期 启动后会打印一张能力表(渠道、SPI 实现、单机降级提示、熔断阈值),排查时比翻文档快: ```text │ NotifyX dev · mode=local · tier=T0(单机内存) │ 渠道(1 启用 / 1 已注册) │ ✅ log LogChannel │ SPI 装配 │ 去重 MemoryDedupeStore ⚠ 单机内存,多实例部署下能力受限 ``` - `notifyx.diagnostics.banner=false` 关闭诊断表。 - `notifyx.metrics.enabled=false` 关闭 Micrometer 指标;`notifyx.metrics.prefix` 修改指标名前缀 (默认 `notifyx`,末尾点会自动去掉)。 - 引入 `spring-boot-starter-actuator` 后自动出现 `notifyx` 健康组件: `UP` / `DEGRADED`(内存去重/限流或多渠道部分不可用,K8s readiness 仍为 UP)/ `DOWN`(全局静默或无渠道)/ `OUT_OF_SERVICE`(`notifyx.enabled=false`)。 - **默认不做真实探活**(`probe-enabled=false`),避免 K8s 每 10 秒的健康检查变成 8640 条垃圾消息; 需要真实探活时显式开启 `notifyx.actuator.health.probe-enabled=true`。 - 关闭时 `NotifyxLifecycle` 在 `notifyx.async.shutdown-await`(默认 10s)内排空队列,再关闭渠道; K8s 的 `terminationGracePeriodSeconds` 必须大于该值(建议 30s)。 ## Kubernetes 验证 `deploy/k8s/` 提供了在集群内跑通邮件告警的清单与步骤(阿里云公开 JDK 镜像、Secret 注入 SMTP 凭据、 ConfigMap 注入场景配置、`kubectl cp` 冒烟 + 生产 Dockerfile): ```bash mvn -B -pl notifyx-sample -am package -DskipTests # 详见 deploy/k8s/README.md ``` ## Redis 分布式存储(T1,可选) 多实例部署时,用 Redis 做跨实例去重与限流: ```xml io.github.pkyit notifyx-store-redis org.springframework.boot spring-boot-starter-data-redis ``` ```yaml notifyx: store: redis: enabled: true key-prefix: "nx:" failure-threshold: 3 # 连续失败多少次熔断 open-duration: 30s # 熔断期间直接走内存 spring: data: redis: host: 127.0.0.1 port: 6379 ``` - 仅当 classpath 有 `StringRedisTemplate` 时自动装配,并排在 `NotifyxAutoConfiguration` 之前, 管线自动切到 Redis 去重/限流。 - Redis 异常时退回内存实现(降级期间多实例各自去重,**可能重复发送**;重复发比不发好)。 - 验证:`deploy/k8s/redis.yaml` 起 Redis,设置 `NOTIFYX_REDIS_HOST`/`NOTIFYX_REDIS_PORT` 后运行 `RedisStoresLiveTest`。 ## JDBC 审计与失败补偿(可选) ```xml io.github.pkyit notifyx-store-jdbc ``` ```yaml notifyx: audit: store: jdbc # none(默认)| jdbc fallback: store: jdbc # memory(默认)| file | jdbc spring: datasource: url: jdbc:mysql://127.0.0.1:3306/notifyx username: ... password: ... ``` - 建表脚本:`notifyx-store-jdbc/src/main/resources/sql/{mysql,postgresql,h2}/schema.sql`; DM/Kingbase 需按 §12.4 的方言清单单独适配(尚未验证)。 - `nx_audit` 写失败只 warn,不影响发送;`nx_failure_record` 用 `version` 乐观锁 CAS, 与文件/内存实现共用同一状态机。 - 已在真实 MySQL 8.0.46 与 PostgreSQL 16.15 上跑通审计写入与失败记录 CRUD。 - 审计保留期:`JdbcAuditRetention.purgeOlderThan(days)` 分批删除(默认 1000/批), 调度由接入方负责(`@Scheduled` / CronJob),SDK 不内置后台线程。 ## MQ 模式(Kafka,producer-only) `notifyx.mode=mq` 时 SDK 只负责序列化与发布,消费/去重/限流/渲染由独立 consumer 承担: ```xml io.github.pkyit notifyx-bus-kafka ``` ```yaml notifyx: mode: mq bus: kafka bus: kafka: bootstrap-servers: 127.0.0.1:9092 topic: notifyx-events acks: all idempotence: true ``` - Kafka key = `dedupeKey`(同事件保序),value = `NotifyEnvelope` JSON(含 schemaVersion); - RabbitMQ:换 `notifyx-bus-rabbitmq` + `notifyx.bus=rabbitmq`,durable queue + publisher confirm, 配置在 `notifyx.bus.rabbitmq.*`(host/port/username/password/queue); - 发布失败写入 `FailureStore` 兜底,不向业务抛异常; - `onSuccess/onError` 无法跨进程,MQ 模式下只 warn; - consumer 见下一节(Kafka 已实现;RabbitMQ consumer 待补)。 ## MQ Consumer(独立服务) `notifyx-consumer` 消费 Kafka/RabbitMQ 报文,做幂等检查后交给完整责任链: ```bash # Kafka(默认) java -jar notifyx-consumer/target/notifyx-consumer-1.0.0.jar \ --spring.kafka.bootstrap-servers=127.0.0.1:9092 \ --notifyx.consumer.topic=notifyx-events # RabbitMQ(需 producer 先声明 durable 队列) java -jar notifyx-consumer/target/notifyx-consumer-1.0.0.jar \ --notifyx.consumer.transport=rabbitmq \ --notifyx.consumer.rabbitmq.queue=notifyx-events \ --spring.rabbitmq.host=127.0.0.1 --spring.rabbitmq.port=5672 ``` - 单实例起步(天然无重复);多实例需按分区消费 + 持久化幂等; - 幂等实现可配:`notifyx.consumer.idempotency-store=memory|redis|jdbc` - `memory`(默认):TTL 24h + 容量上限,**重启失效**; - `redis`:`SET NX EX 24h`,Redis 异常降级内存(可能重复,不丢通知); - `jdbc`:`nx_idempotency` 唯一键,重复插入即视为重复;连接用 `notifyx.consumer.jdbc-url/user/password`(驱动由部署方提供),DDL 见 `notifyx-consumer/src/main/resources/sql/{mysql,postgresql}/idempotency.sql`; - `async.enabled=false`,按分区同步处理保序;坏报文只记录并 ACK,避免毒消息卡分区。 ## 真实邮件冒烟(可选) `EmailLiveSmokeTest` 只在环境变量齐备时执行,默认跳过(CI 无凭据)。它会**真实发送邮件**: ```powershell $env:NOTIFYX_SMTP_HOST="smtp.163.com"; $env:NOTIFYX_SMTP_PORT="465"; $env:NOTIFYX_SMTP_SSL="true" $env:NOTIFYX_SMTP_USER="you@example.com"; $env:NOTIFYX_SMTP_PASS="<授权码>" $env:NOTIFYX_SMTP_TO="a@example.com,b@example.com" mvn -B -pl notifyx-channel-email test -Dtest=EmailLiveSmokeTest ``` 输出 `LIVE_EMAIL <收件人> status=SUCCESS messageId=...` 即表示 SMTP 认证、TLS 与投递均正常。 凭据只从环境变量读取,不会进入仓库或日志。 ## 兼容性 | NotifyX | Boot 3.5(基线) | Boot 4 | JDK 17 | JDK 21 | | --- | --- | --- | --- | --- | | 1.0.x(开发中) | ✅ | 🧪 编译验证 | ✅ | ✅ | NotifyX 的版本号**不跟随** Spring Boot:`1.x` 表示 SDK 自身 API 稳定性,支持的 Boot 范围由 `spring-boot.version` property 决定。 ## 构建与测试 ```bash mvn clean verify # 编译 + 单测 + 格式/静态规则/覆盖率门禁 mvn -pl notifyx-sample spring-boot:run # 启动示例(需先 mvn install) curl "http://localhost:18080/demo/notify" curl "http://localhost:18080/demo/storm?count=1000" curl "http://localhost:18080/demo/log-error" # L0 日志接入演示 ``` `verify` 阶段执行三道门禁(§17.3): | 门禁 | 插件 | 规则 | | --- | --- | --- | | 格式 | Spotless | 未使用 import、import 顺序、行尾空白、文件末尾换行 | | 静态规则 | Checkstyle | 星号导入、空语句、`equals/hashCode` 不成对、switch 缺 default、漏大括号等 | | 覆盖率 | JaCoCo | core ≥ 65%、logback ≥ 80%、其余 ≥ 40%(设计目标 80%/90%,见 `.memory/STATE.md`) | 当前基线:**282 个测试**(core 123 / spring 20 / autoconfigure 40 / store-redis 22 / store-jdbc 11 / bus-kafka 5 / bus-rabbitmq 4 / consumer 12 / email 8 / logback 20 / webhook 17), 另有 13 个 live 测试(Redis 8、JDBC 2、Kafka 1、RabbitMQ 1、邮件 1)默认跳过; CI 矩阵 JDK 17/21。 ## 发布 发布链路(GPG / Central)只在 `release` profile 激活,日常构建不受影响: ```bash mvn -Prelease -Dgpg.skip=true clean package # 本地验证 sources/javadoc 产物 mvn -Prelease deploy # 上传 Central,人工确认后发布 ``` 完整步骤与常见卡点见 [docs/release.md](docs/release.md)。 ## License [Apache License 2.0](LICENSE)