docs: translate design and selection documents to Chinese

Translate the seven remaining English documents under docs/ in place,
preserving all technical content, code blocks, identifiers, links, and
decision IDs (D1-D19, F1-F15, P0-P5). Add a categorized Chinese
navigation index (docs/README.md) with per-document summaries, the
storage supersession chain, and task-based reading paths.
This commit is contained in:
pnoker
2026-08-28 02:07:45 +08:00
parent 8be0bbc73d
commit 843ee444ca
8 changed files with 1311 additions and 1285 deletions
+130
View File
@@ -0,0 +1,130 @@
# IoT DC3 文档导航(docs/)
> 本文件是 `docs/` 目录的中文导读与分类索引:13 篇文档分三类,逐篇给出摘要、状态与阅读建议。
> 仓库工程规范见根目录 [AGENTS.md](../AGENTS.md)。目录内全部文档均为简体中文。
## 总览
| 分类 | 文档 | 状态 |
|------|------|------|
| 选型指南 | [db-dialects.md](./db-dialects.md) 关系库选型 | 有效 |
| 选型指南 | [tsdb-stores.md](./tsdb-stores.md) 时序存储选型 | 有效 |
| 选型指南 | [mq-brokers.md](./mq-brokers.md) 消息中间件选型 | 有效 |
| 设计/架构 | [design/relational-r2dbc.md](./design/relational-r2dbc.md) R2DBC 关系访问层 | 已批准(2026-08-28),待实施 |
| 设计/架构 | [design/tsdb-abstraction.md](./design/tsdb-abstraction.md) 时序存储 Port | 已落地 |
| 设计/架构 | [design/mq-abstraction.md](./design/mq-abstraction.md) 消息中间件 Port | 已落地 |
| 设计/架构 | [design/storage-abstraction.md](./design/storage-abstraction.md) 存储抽象总纲 | 部分被取代(§3/§4) |
| 设计/架构 | [design/mcp-runtime-overhaul.md](./design/mcp-runtime-overhaul.md) MCP 运行时重构 | 提案,未实施 |
| 设计/架构 | [design/dc3-client-sdk.md](./design/dc3-client-sdk.md) 客户端 SDK | 提案(phase 0 已落地) |
| 设计/架构 | [design/frontend-three-terminal-ux.md](./design/frontend-three-terminal-ux.md) 前端三终端 UX ADR | ADR |
| 分析对比 | [comparison-dgiot.md](./comparison-dgiot.md) DG-IoT 对比借鉴 | 开放评审,未排期 |
| 分析对比 | [comparison-supos-free.md](./comparison-supos-free.md) supOS-Free 对比借鉴 | 开放评审,未排期 |
| 分析对比 | [dc3-thing-model-review.md](./dc3-thing-model-review.md) 物模型代码级核实 | 事实判定(非路线图) |
---
## 一、选型指南(部署 / 运维决策用)
三篇对应三个可插拔维度:关系库、时序库、消息中间件。**部署换什么、门槛是什么、能力差异是什么,先看这里。**
### 1. [关系库选型指南](./db-dialects.md)
- **回答的问题**:PostgreSQL / MySQL / MariaDB 三方言怎么选,差异到底在哪。
- **关键结论**:PostgreSQL 默认;MySQL 硬门槛 ≥8.0(窗口函数、CTE、SKIP LOCKED);MariaDB ≥10.6。
- **最有用的一张表**:"现状矩阵"——upsert 三种方言形态(`ON CONFLICT` / `AS new` 行别名 / `VALUES(col)`)、RETURNING 已弃用、
咨询锁(`pg_advisory_xact_lock` 事务级 vs `GET_LOCK` 会话级须 try/finally)、`operate_time` 触发器 vs 列属性、三方言契约套件 8/8。
### 2. [时序存储选型指南](./tsdb-stores.md)
- **回答的问题**:TimescaleDB / TDengine / InfluxDB / IoTDB 四库,哪些能力是真支持、哪些是如实拒绝。
- **关键结论**:矩阵按适配器**实际声明**发布而非预估——如 IoTDB 无法按 driver 分组计数(driver 是 measurement 非路径层)、
InfluxDB 无精确 PERCENTILE(门面精算)、直方图能力两家声明 false(面板零桶降级)。
- **默认**:TimescaleDB(内嵌 PG,部署最简)。
### 3. [消息 broker 选型指南](./mq-brokers.md)
- **回答的问题**:内部异步面选哪个 broker;南向设备面 MQTT 怎么与之共处("两平面一原则")。
- **关键结论**:RabbitMQ 默认;Kafka / Pulsar / ActiveMQ / MQTT 5 已认证;RocketMQ 实验性(未认证,自担风险)。
- **注意**:能力矩阵里"延迟消息"多数是本地回退(fallback)而非原生;Kafka 适配器不配 SASL/TLS。
---
## 二、设计与架构文档(design/)
### 存储演进主线(取代链,先看这张图再读文档)
```text
storage-abstraction.md(2026-08-17 总纲)
├─ §1-2 三层存储模型、§6 统一配置面 .......... 仍有效(总纲骨架)
├─ §3 关系方言机制(MyBatis databaseId 双方言)──已落地 R1/R2,后继──→ relational-r2dbc.md(R2DBC 重写,已批准)
└─ §4 时序存储 Port .......................... 已被取代──→ tsdb-abstraction.md(已落地)
```
### 4. [基于 Spring Data R2DBC 的关系访问层设计](./design/relational-r2dbc.md)
- **一句话**:用 Spring Data R2DBC 替换 MyBatis-Plus + JDBC,auth 中心试点,目标是 GraalVM native + 端到端响应式 + 方言自由。
- **读什么**:§3 事实清单(F1–F15,全部经代码核实);§5 决策表 **D1D19**(每方言每服务模块、TCK 纪律、编译期租户隔离、
Facade/gRPC 阻塞边界、timestamptz 编解码、雪花 workerId 分配);§12 P0P5 阶段计划(P2P3 为 go/no-go 闸门)。
- **注意**:分页信封是一次性硬切换(D8,不搞兼容);auth 的 HTTP 分页契约会被有意破坏。
### 5. [时序存储抽象(TSDB Port)](./design/tsdb-abstraction.md)
- **一句话**:把位值时序存储抽成 Port,`dc3-tsdb-core` + 四适配器(timescale/tdengine/influxdb/iotdb)+ TCK,Phase 13 已落地并 TCK 认证。
- **读什么**:port 必须承载的语义(§4)、核心 API(§6);能力差异的落地细节对应 [tsdb-stores.md](./tsdb-stores.md)。
### 6. [消息中间件抽象(MQ Port)](./design/mq-abstraction.md)
- **一句话**:center ↔ driver 异步平面的 broker 抽象,`dc3-mq-core` + 六适配器(rocketmq 实验)+ TCK,已交付。
- **读什么**:核心 API(§6)、订阅模式与目的地映射(§7)、逐 broker 的硬语义(§8:延迟消息、死信、MQTT 两平面分离)。
### 7. [存储抽象总纲](./design/storage-abstraction.md) *部分被取代*
- **一句话**:整个"可插拔存储"叙事的起点;关系/时序两半已分别演进出后继文档,**只有 §1–2(三层模型)、§5(向量占位)、§6(统一配置面)仍是现行参考**。
- **读法**:当历史背景或总纲骨架读,不要按 §3/§4 实施。
### 8. [MCP 运行时全面重构:内聚的授权契约](./design/mcp-runtime-overhaul.md) *提案*
- **一句话**:现状一次 `tools/call` 要三次网关→auth 冗余往返且有正确性缺口;目标一次内聚往返、响应式非阻塞、真实 input schema、异步审计。
- **读什么**:§2.3 的三冗余一缺口(问题陈述最精彩)、§5 目标契约、§9 备选方案。
- **关联**:实施依赖 relational-r2dbc 的 auth 响应式改造节奏。
### 9. [dc3-sdk:框架无关客户端 SDK](./design/dc3-client-sdk.md) *提案(phase 0 已落地)*
- **一句话**:网关 HTTP 契约现在被 dc3-web(30 个 api 模块)和 dc3-cli(手写 fetch)重复实现,未来 native app 会是第三份;
目标一个 TypeScript SDK 实现一次,三端消费。
- **读什么**:边界规则(可 lint 强制)、迁移计划;依赖前端 ADR 的边界纪律。
### 10. [前端三终端 UX 架构(ADR)](./design/frontend-three-terminal-ux.md)
- **一句话**:dc3-web 从桌面优先(1280px 硬底线)转向桌面/平板/手机三终端架构,用公理化推导替代零散 `@media` 补丁。
- **读什么**:公理、分层模型、断点契约、边界复用纪律——前端任何新页面开工前都该过一遍。
---
## 三、分析与对比(参考性,未进实施排期)
### 11. [DG-IoT 与 IoT DC3 对比借鉴](./comparison-dgiot.md)
- **一句话**:跨项目对比提炼可借鉴点,并澄清双方物模型差异;开放评审中,**不是承诺路线图**。
### 12. [supOS-Free 与 IoT DC3 对比借鉴](./comparison-supos-free.md)
- **一句话**:同上,对标 supOS-Free;结论与 storage/mq 抽象设计互相印证。
### 13. [DC3 物模型(Profile)代码级核实与最终判定](./dc3-thing-model-review.md)
- **一句话**:用 grep 全仓 + 逐文件读源码的方式回答"DC3 的 Profile 是否等价于物模型",证据落到类/方法/表名,给出最终判定。
- **价值**:回答"DC3 有没有物模型"这一常见问题的权威出处。
---
## 按任务找文档(推荐阅读路径)
| 你想做什么 | 按顺序读 |
|------------|----------|
| 部署 / 换库 / 换 broker | [db-dialects](./db-dialects.md) → [tsdb-stores](./tsdb-stores.md) → [mq-brokers](./mq-brokers.md) |
| 理解存储层现状与演进 | [storage-abstraction §12](./design/storage-abstraction.md) → [tsdb-abstraction](./design/tsdb-abstraction.md) → [db-dialects](./db-dialects.md) → [relational-r2dbc](./design/relational-r2dbc.md) |
| 参与 auth R2DBC 迁移 | [relational-r2dbc 全文](./design/relational-r2dbc.md)(重点 §5 决策表 D1D19)→ [db-dialects](./db-dialects.md) → 根目录 AGENTS.md |
| 前端 / 客户端开发 | [frontend-three-terminal-ux](./design/frontend-three-terminal-ux.md) → [dc3-client-sdk](./design/dc3-client-sdk.md) |
| MCP / AI 集成 | [mcp-runtime-overhaul](./design/mcp-runtime-overhaul.md) → [relational-r2dbc §5 D13](./design/relational-r2dbc.md)(OAuth/MCP 聚合拆分) |
| 了解项目定位 / 竞品差异 | [comparison-dgiot](./comparison-dgiot.md) → [comparison-supos-free](./comparison-supos-free.md) → [dc3-thing-model-review](./dc3-thing-model-review.md) |
+48 -51
View File
@@ -1,26 +1,25 @@
# dc3-sdk: Framework-Agnostic Client SDK (Design)
# dc3-sdk:框架无关的客户端 SDK(设计)
Status: proposed (phase 0 landed) · Scope: web / cli / future app · Drivers: A1 (device-independent semantics), boundary
discipline in docs/design/frontend-three-terminal-ux.md
状态:提案(phase 0 已落地) · 范围:web / cli / 未来 app · 驱动因素:A1(设备无关语义)、
docs/design/frontend-three-terminal-ux.md 中的边界纪律
## Problem
## 问题
The HTTP gateway contract is currently implemented twice:
HTTP 网关契约目前被实现了两次:
- dc3-web/src/api/ (30 modules) — thin axios wrappers, coupled to
`@/config/axios` (Element Plus notifications, router-guard 401 flow) and Vite `@/` aliases.
- dc3-cli/src/core/client.ts — hand-rolled fetch calls for auth (token/salt, token/generate, token/cancel).
- dc3-web/src/api/30 个模块)——薄 axios 封装,与
`@/config/axios`Element Plus 通知、路由守卫 401 流程)及 Vite `@/` 别名耦合。
- dc3-cli/src/core/client.ts——为认证(token/salttoken/generatetoken/cancel)手写的 fetch 调用。
A native app (React Native) would need a third implementation. Every new endpoint, header, and error-code interpretation
must be re-implemented per client — a standing violation of A1.
原生 appReact Native)则需要第三套实现。每个新端点、请求头与错误码解释都必须逐客户端重复实现——
这是对 A1 的持续性违反。
## Goal
## 目标
One TypeScript SDK that implements the gateway contract exactly once and is consumed by web, CLI, and future app
clients. The SDK is framework-agnostic:
no Vue, no Element Plus, no vue-i18n, no axios hard-dependency.
一个 TypeScript SDK,将网关契约精确实现一次,供 webCLI 与未来的 app 客户端共同消费。该 SDK 框架无关:
无 Vue、无 Element Plus、无 vue-i18n、不硬依赖 axios。
## Package layout
## 包结构
```text
dc3-sdk/
@@ -36,7 +35,7 @@ dc3-sdk/
└── package.json
```
### Core contracts
### 核心契约
```ts
// HTTP transport abstraction — web injects axios, RN/node injects fetch.
@@ -60,53 +59,51 @@ interface SdkOptions {
}
```
Auth/session: SDK exposes salt/generate/cancel/change-password and a
`TokenStore` abstraction (web: httpOnly cookie — no-op; CLI: memory/file; app: Keychain via host adapter). The SDK never
decides storage policy.
认证/会话:SDK 暴露 salt/generate/cancel/change-password 以及
`TokenStore` 抽象(webhttpOnly cookie——空实现;CLI:内存/文件;app:经宿主适配器访问 Keychain)。存储策略
永远不由 SDK 决定。
### Semantic layer (L1) portability
### 语义层(L1)可移植性
Entity schemas already became framework-free in this iteration:
实体 schema 在本次迭代中已经去框架化:
- `EntityListConfig` and friends carry labels as translated strings produced by a
`Translator = (key, params?) => string` Vue's ComposerTranslation satisfies it structurally; 16 config modules no
longer import vue-i18n.
- Next step: move `EntityListConfig` type into the SDK's types layer so web and app render the same schema (configs stay
in the host apps; the shape is shared).
- `EntityListConfig` 及相关类型以翻译后的字符串承载标签,字符串由
`Translator = (key, params?) => string` 生成——Vue ComposerTranslation 在结构上满足该签名;16 个配置模块
不再 import vue-i18n
- 下一步:把 `EntityListConfig` 类型移入 SDK types 层,使 web app 渲染同一份 schema
(配置仍留在宿主应用中,共享的是形状)。
Future option: backend-served JSON schemas via the OpenAPI export (`make openapi`) replace hand-authored configs without
client changes.
未来选项:通过 OpenAPI 导出(`make openapi`)由后端下发 JSON schema,在不改动客户端的前提下取代手写配置。
## Boundary rules (lint-enforceable)
## 边界规则(可用 lint 强制)
Inside dc3-sdk, imports of the following are forbidden:
dc3-sdk 内部,禁止 import 以下内容:
- vue / vue-router / pinia / element-plus / vue-i18n
- `@/config/*` host infrastructure
- any browser-only global except behind `typeof window` guards
- `@/config/*` 宿主基础设施
- 任何仅浏览器才有的全局对象,除非位于 `typeof window` 守卫之后
Enforce with eslint `no-restricted-imports` / `import/no-extraneous-dependencies`
rules in the SDK package config.
在 SDK 包配置中用 eslint `no-restricted-imports` / `import/no-extraneous-dependencies`
规则强制执行。
## Migration plan
## 迁移计划
1. **Phase 0 (landed)**: de-framework L1 configs — `Translator` type, zero vue-i18n imports in the 16 entity config
modules.
2. **Phase 1**: scaffold `dc3-sdk` package (pnpm workspace), port
`PageQuery/PageResult`, `R<T>` envelope, error taxonomy; vitest with mock adapter.
3. **Phase 2**: port the 30 API wrapper modules; contract tests against the backend OpenAPI spec (or recorded fixtures).
4. **Phase 3**: dc3-web consumes the SDK — delete `src/api`, keep the axios adapter + interceptors as the host glue; run
full e2e (608 unit tests + Playwright) before removing anything.
5. **Phase 4**: dc3-cli swaps its hand-rolled fetch auth for the SDK (removes the duplicated salt/generate/cancel flow).
6. **Phase 5**: app client consumes the SDK with a fetch/Keychain adapter.
1. **Phase 0(已落地)**L1 配置去框架化——引入 `Translator` 类型,16 个实体配置模块零 vue-i18n import
2. **Phase 1**:搭建 `dc3-sdk` 包(pnpm workspace),移植
`PageQuery/PageResult``R<T>` 信封、错误分类体系;使用 mock 适配器的 vitest。
3. **Phase 2**:移植 30 个 API 封装模块;针对后端 OpenAPI 规范(或录制的 fixture)做契约测试。
4. **Phase 3**dc3-web 接入 SDK——删除 `src/api`,保留 axios 适配器 + 拦截器作为宿主粘合层;删除任何内容之前
先跑完整 e2e608 个单元测试 + Playwright)。
5. **Phase 4**dc3-cli 用 SDK 替换其手写的 fetch 认证(移除重复的 salt/generate/cancel 流程)。
6. **Phase 5**app 客户端通过 fetch/Keychain 适配器消费 SDK。
## Verification
## 验证
- SDK unit tests run in node with zero DOM/Vue imports (CI assertion:
`import ... from 'vue'` absent in SDK source).
- Web regression gate: pnpm check + lint + full vitest + Playwright e2e.
- CLI gate: dc3-cli vitest suites keep passing against a mock adapter.
- SDK 单元测试在 node 中运行,零 DOM/Vue importCI 断言:
SDK 源码中不存在 `import ... from 'vue'`)。
- Web 回归门禁:pnpm check + lint + 完整 vitest + Playwright e2e
- CLI 门禁:dc3-cli vitest 套件针对 mock 适配器保持通过。
## Revision
## 修订记录
2026-08: v1 — proposal with Phase 0 landed (Translator de-frameworking, 16 modules).
2026-08:v1——提案,Phase 0 已落地(Translator 去框架化,16 个模块)。
+89 -102
View File
@@ -1,63 +1,56 @@
# Frontend Three-Terminal UX Architecture (ADR)
# 前端三终端 UX 架构(ADR
Status: accepted · Scope: dc3-web (and future dc3 app client) · Drivers: first-principles UX engineering
状态:已接受 · 范围:dc3-web(以及未来的 dc3 app 客户端)· 驱动因素:第一性原理 UX 工程
## Context
## 背景
dc3-web is a Vue 3 management console for IoT operators. Its experience targets three device classes (desktop, tablet,
mobile) and must later accommodate a native app as a fourth host of the mobile experience. The legacy codebase was
desktop-first: a hard `min-width: 1280px` floor on
`body` and the layout shell, fixed header columns, a fixed 220px settings aside, and no JavaScript breakpoint system.
dc3-web 是面向 IoT 运维人员的 Vue 3 管理控制台。其体验面向三类设备(桌面、平板、移动),并且后续必须容纳一个原生 app,
作为移动体验的第四个宿主。遗留代码库是桌面优先的:`body` 与布局壳上硬性的 `min-width: 1280px` 下限、固定的表头列、
固定的 220px 设置侧栏(aside),而且没有 JavaScript 断点系统。
Ad hoc responsive fixes (a few `@media` blocks, `el-col` responsive props on ten pages) proved that patches do not
scale: every new page reintroduces the desktop assumptions. This ADR replaces patching with an architecture derived from
first principles.
临时性的响应式修补(少量 `@media` 块、十个页面上的 `el-col` 响应式属性)已经证明补丁不可扩展:每个新页面都会重新引入
桌面假设。本 ADR 用一套从第一性原理推导出的架构取代打补丁。
## First principles
## 第一性原理
Experience quality reduces to four physical quantities, valid on every device:
体验质量可以归结为四个物理量,在任何设备上都成立:
| Element | Question | Budget |
| 要素 | 问题 | 预算 |
|------------|--------------------------------------------------------|-------------------|
| Perceive | Does the user notice key state instantly? | < 100ms |
| Understand | Is the information hierarchy readable at first glance? | first screen < 3s |
| Act | Can the user complete a task with minimal motor cost? | fewest steps |
| Feedback | Does every action answer visibly and predictably? | < 300ms |
| 感知 | 用户能否即刻注意到关键状态? | < 100ms |
| 理解 | 信息层级是否一眼可读? | 首屏 < 3s |
| 操作 | 用户能否以最小的动作成本完成任务? | 步骤最少 |
| 反馈 | 每个操作是否可见且可预期地得到回应? | < 300ms |
Devices differ in exactly four variables: input modality (mouse vs thumb), viewport geometry, usage context (long
sessions vs alarm response), and compute/bandwidth. The product is an operations console: state monitoring plus
high-consequence actions over long sessions.
设备之间的差异恰好落在四个变量上:输入模态(鼠标 vs 拇指)、视口几何、使用情境(长时间会话 vs 告警响应),以及算力/带宽。
这个产品是一个运维管理控制台:长时间会话中的状态监控,加上高后果操作。
From these, the architecture derives seven axioms. Every axiom is a rule with a falsifiable violation signature so it
can be enforced in review and CI.
由此,该架构推导出七条公理。每条公理都是一条带有可证伪违反特征的规则,从而可以在评审与 CI 中加以强制。
## Axioms
## 公理
**A1. Content semantics are device-independent.** Entity models, operation models, and schemas are authored once and
consumed by every presentation host. *Violation signature:* a second copy of a field definition for a different device.
**A1. 内容语义与设备无关。**实体模型、操作模型与 schema 只编写一次,由每个呈现宿主消费。*违反特征:*为另一台设备复制出
第二份字段定义。
**A2. Presentation is rebuilt per device class, not shrunk.** Layout follows container geometry via fluid primitives
(`minmax`, `auto-fit`, wrapping flex); coarse device-class switches are the only job of breakpoints. *Violation
signature:* a growing pile of one-off `@media` patches.
**A2. 呈现按设备类重建,而不是缩放。**布局经流式原语(`minmax``auto-fit`、可换行 flex)跟随容器几何;粗糙的设备类切换
才是断点的唯一职责。*违反特征:*一次性 `@media` 补丁越堆越多。
**A3. Input capability decides interaction mode.** Hover, right-click, and inline editing are mouse language; 44x44
targets, bottom sheets, and swipes are thumb language. Pointer capability (fine/coarse, hover) is a runtime property,
not a media-query guess — a tablet with a keyboard still deserves mouse interactions. *Violation signature:* hover-only
actions, or touch targets below 44px.
**A3. 输入能力决定交互模式。**悬停、右键与行内编辑是鼠标语言;44x44 目标、底部抽屉与滑动是拇指语言。指针能力
fine/coarse、悬停)是运行期属性,不是媒体查询的猜测——带键盘的平板仍然配得上鼠标交互。*违反特征:*只有悬停才能触发的
操作,或低于 44px 的触控目标。
**A4. Feedback latency is the perceived product.** 100ms synchronous feedback, <1s skeletons, optimistic updates beyond
that. *Violation signature:* a button that does nothing until the network answers.
**A4. 反馈时延就是被感知的产品。**100ms 同步反馈、<1s 骨架屏,再往上是乐观更新。*违反特征:*在网络应答之前毫无反应的
按钮。
**A5. Consistency carries the user's memory.** Tokens -> components -> page templates, each with a single source of
truth; no hardcoded colors, radii, or magic widths. *Violation signature:* non-token color/spacing literals in SCSS.
**A5. 一致性承载用户的记忆。**Token -> 组件 -> 页面模板,各自拥有单一事实来源;没有硬编码颜色、圆角或魔法宽度。*违反
特征:*SCSS 中非 token 的颜色/间距字面量。
**A6. State is part of the experience.** Preferences (theme, density, locale), navigation position, and draft forms
survive device and session boundaries. *Violation signature:* user context reset on device switch or refresh.
**A6. 状态是体验的一部分。**偏好(主题、密度、语言)、导航位置与草稿表单要跨越设备与会话边界存活。*违反特征:*切换设备或
刷新后用户上下文被重置。
**A7. Accessibility is the quality baseline.** Contrast, keyboard focus, reduced motion, and zoom are non-negotiable —
they double as the spec for outdoor/site inspection scenarios.
**A7. 无障碍是质量基线。**对比度、键盘焦点、减弱动效与缩放没有商量余地——它们同时也是户外/现场巡检场景的规格。
## Layer model
## 分层模型
```text
L1 Semantic entity model + operation model + schemas (summary/detail) device-independent, single truth
@@ -67,82 +60,76 @@ L4 Patterns 4 page templates: monitor / list / detail / edit — three physica
L5 Quality measurement -> gates -> regression -> revision (closed loop)
```
Each layer depends only on the one below it. Changes propagate downward, never sideways.
每一层只依赖紧邻的下一层。变更只向下传播,绝不横向扩散。
## Boundary discipline (reuse contract)
## 边界纪律(复用契约)
The web UI shell (L3/L4) is not reusable by a native app; the layers below the rendering boundary are. To keep that
reuse real, the following are forbidden in L1/L2 and in a future shared `dc3-sdk` package:
Web UI 壳(L3/L4)对原生 app 不可复用;渲染边界之下的层则可以复用。为了让这种复用真实成立,以下内容在 L1/L2 以及未来
共享的 `dc3-sdk` 包中一律禁止:
- imports of `@/config/*` Vue/Element Plus infrastructure (axios instance,
`ElMessage`, vue-i18n `ComposerTranslation`);
- Element Plus or Vue types in entity schema definitions (labels are i18n keys, not translation functions);
- device-specific values in token sources (tokens are host-independent values; SCSS/CSS are one rendering of them).
- 导入 `@/config/*` Vue/Element Plus 基础设施(axios 实例、`ElMessage`、vue-i18n `ComposerTranslation`);
- 实体 schema 定义中出现 Element Plus 或 Vue 类型(label 是 i18n 键,不是翻译函数);
- token 源中出现设备特定值(token 是与宿主无关的值;SCSS/CSS 只是它们的一种渲染)。
Enforcement: ADR review + lint rules (no non-token literals in SCSS, no framework imports below L2).
强制手段:ADR 评审 + lint 规则(SCSS 中不得出现非 token 字面量,L2 之下不得导入框架)。
## Breakpoint contract
## 断点契约
Single contract, aligned with Element Plus `el-col` semantics (A5):
单一契约,与 Element Plus `el-col` 语义对齐(A5):
| Tier | Range | Terminal |
|------|---------------|------------------------|
| xs | < 768px | mobile |
| sm | 768 - 991px | tablet |
| md | 992 - 1199px | tablet / small desktop |
| lg | 1200 - 1919px | desktop |
| xl | >= 1920px | wide desktop |
| 档位 | 范围 | 终端 |
|------|---------------|-------------------------|
| xs | < 768px | 手机 |
| sm | 768 - 991px | 平板 |
| md | 992 - 1199px | 平板 / 小桌面 |
| lg | 1200 - 1919px | 桌面 |
| xl | >= 1920px | 宽屏桌面 |
JavaScript (`useBreakpoint`) and CSS must both read this contract — no second, hand-rolled breakpoint set anywhere.
JavaScript`useBreakpoint`)与 CSS 必须都读取这一契约——任何地方都不允许出现第二套手搓断点。
## Decisions (derived, not chosen)
## 决策(推导而得,而非任意挑选)
1. Mobile navigation: drawer menu, not bottom tabs — the task distribution is long-tail (home/alarms frequent, 40+
settings pages rare), which bottom tabs model badly.
2. Mobile tables: summary card lists driven by the L1 summary schema, not horizontal scrolling. Cross-row comparison —
the table's purpose — is destroyed by horizontal panning; a sticky first column is the accepted interim state.
3. Dark mode: in scope, as a product of L2 tokens, for long-session eye load and sharper alarm contrast, not as
decoration.
4. Visual layer: tokenized Element Plus, no bespoke component library — bespoke UI violates A5 economics (maintenance
grows with component count).
1. 移动端导航:抽屉菜单,而非底部标签——任务分布是长尾的(首页/告警高频,40+ 个设置页面低频),底部标签对这种分布建模
很差。
2. 移动端表格:由 L1 摘要 schema 驱动的摘要卡片列表,而非横向滚动。跨行比较——表格的本意——会被横向平移摧毁;粘性首列
是被接受的过渡状态。
3. 深色模式:在范围内,作为 L2 token 的产物,为的是长会话的眼部负担与更锐利的告警对比,而非装饰。
4. 视觉层:token 化的 Element Plus,不做专属组件库——专属 UI 违背 A5 的经济学(维护成本随组件数量增长)。
## Acceptance criteria
## 验收标准
- No page-level horizontal scroll from 360px to 2560px (table containers exempt);
`document.documentElement.scrollWidth <= window.innerWidth`.
- Lighthouse mobile: perf >= 90, CLS <= 0.1, LCP <= 2.5s.
- 100% touch targets >= 44x44; dialogs full-screen below 768px.
- Zero hardcoded color/spacing literals in SCSS (token lint gate).
- Playwright runs desktop, tablet, and mobile viewport projects; axe scan clean; visual regression on critical pages.
- 360px 2560px 之间无页面级横向滚动(表格容器豁免);
`document.documentElement.scrollWidth <= window.innerWidth`
- Lighthouse 移动端:perf >= 90CLS <= 0.1LCP <= 2.5s
- 100% 的触控目标 >= 44x44768px 以下对话框全屏。
- SCSS 中零硬编码颜色/间距字面量(token lint 闸门)。
- Playwright 运行桌面、平板与移动视口项目;axe 扫描干净;关键页面执行视觉回归。
## Revision
## 修订记录
2026-08: v1 — adopted with the L2 token/breakpoint/theme foundation (dc3-web src/styles/tokens.scss, theme.scss,
src/composables/useBreakpoint.ts, src/store/modules/app.ts).
2026-08v1 —— 随 L2 token/断点/主题基座一起采纳(dc3-web src/styles/tokens.scsstheme.scss
src/composables/useBreakpoint.tssrc/store/modules/app.ts)。
2026-08: v2 — shell three-terminal forms shipped: shared NavMenu (horizontal ellipsis / vertical drawer), Settings
sidebar menu extracted and hosted in aside (desktop) / collapsed rail (tablet) / drawer (mobile), responsive login
panels, compact mobile pagination in ToolCard. Verified via artifacts/viewport-check.mjs: zero page-level overflow and
correct per-terminal DOM at 1440/834/390px viewports against the mock build.
2026-08v2 — 布局壳三终端形态发布:共享 NavMenu(水平省略 / 垂直抽屉)、Settings 侧栏菜单抽取出来并托管为
aside(桌面)/ 折叠栏(平板)/ 抽屉(移动)、响应式登录面板、ToolCard 内的移动端紧凑分页。经 artifacts/viewport-check.mjs
验证:在 1440/834/390px 视口下对照 mock 构建零页面级溢出,且各终端 DOM 正确。
2026-08: v3 — L1 de-frameworking (dc3-client-sdk Phase 0): Translator contract replaces vue-i18n ComposerTranslation
across all 16 entity config modules; dc3-sdk extraction design documented in docs/design/dc3-client-sdk.md.
2026-08v3 — L1 去框架化(dc3-client-sdk Phase 0):全部 16 个实体配置模块中 Translator 契约取代 vue-i18n
ComposerTranslationdc3-sdk 抽取设计记录于 docs/design/dc3-client-sdk.md
2026-08: v4 — measurement gate shipped: Playwright gains chromium-desktop (1440x900), chromium-tablet (834x1112, touch),
and chromium-mobile (393x851, touch) projects; tests/e2e/specs/responsive.spec.ts gates the A2 overflow criterion
(scrollWidth <= clientWidth on login/home/settings), A3 shell adaptation (menu strip vs drawer, aside vs drawer), and an
A7 accessible-name smoke probe. All 12 gate tests green against the mock build. CI (ci-web.yml) runs the gate
automatically via pnpm test:e2e with chromium only. Fixes recorded: playwright 1.61.1/1.62.0 version split aligned to
1.62.0; ToolCard refresh/sort icon buttons gained aria-labels. Lighthouse budget and axe-core scans remain CI follow-ups
(no new deps this iteration).
2026-08v4 —— 度量闸门发布:Playwright 新增 chromium-desktop1440x900)、chromium-tablet834x1112,触控)与
chromium-mobile393x851,触控)项目;tests/e2e/specs/responsive.spec.ts 为以下内容设闸:A2 溢出判据
login/home/settings 上 scrollWidth <= clientWidth)、A3 布局壳适配(菜单条 vs 抽屉、aside vs 抽屉),以及 A7 可访问名称
冒烟探测。对照 mock 构建全部 12 个闸门测试通过。CIci-web.yml)经 pnpm test:e2e 且仅用 chromium 自动运行该闸门。已记录
修复:playwright 1.61.1/1.62.0 的版本分裂对齐到 1.62.0ToolCard 刷新/排序图标按钮补上 aria-label。Lighthouse 预算与
axe-core 扫描仍是 CI 后续事项(本轮不新增依赖)。
2026-08: v5 — contract hardening + first acceptance gaps closed. (1) Breakpoint contract now literal-free: the last
hand-rolled width thresholds (Home 1024/1280/640, alarm Overview 1024/1280/640, AgenticAssistant 900) replaced with
$breakpoint-* tokens, mapping single-column collapses to sm-max, 3-col stat grids to md-max, and 1-col to xs-max. tests/guardrails/breakpoint-contract.test.ts enforces A5: any @media (min/max-width) in src must reference a
$breakpoint-* token. (2) A3 dialog criterion shipped: theme.scss re-shapes .el-dialog to the viewport (calc (100vw -
16px), capped height, scrolling body) below xs-max, overriding Element Plus inline widths; gated by a mobile-only
Playwright test (dialog never wider than viewport, zero page overflow). (3) A2 gate coverage extended beyond the shell:
nine template-sweep routes (monitor/list/detail/history families — alarm overview, device, driver, profile, label,
point_value, alarm/point, event/command history) now gated on every terminal, 48 gate tests total. Still open: L4
template rollout to the remaining ~100 views (mobile summary-card lists blocked on the L1 summary schema decision),
Lighthouse budget + axe-core in CI, token lint.
2026-08v5 —— 契约加固 + 首批验收缺口闭合。(1) 断点契约现已去字面量:最后一批手搓宽度阈值(Home 1024/1280/640、告警
Overview 1024/1280/640AgenticAssistant 900)替换为 $breakpoint-* token,把单列折叠映射到 sm-max、3 列统计网格映射到
md-max、1 列映射到 xs-maxtests/guardrails/breakpoint-contract.test.ts 强制 A5src 中任何 @media (min/max-width) 必须
引用 $breakpoint-* token(2) A3 对话框判据落地:theme.scss 在 xs-max 以下将 .el-dialog 重塑为贴合视口
calc (100vw - 16px)、限高、主体滚动),覆盖 Element Plus 的内联宽度;由一个仅移动端的 Playwright 测试设闸(对话框永不
宽于视口、页面零溢出)。(3) A2 闸门覆盖扩展到布局壳之外:九条模板横扫路由(monitor/list/detail/history 家族——告警概览、
设备、驱动、profile、label、point_value、alarm/point、事件/命令历史)现已在每个终端设闸,闸门测试合计 48 个。仍待解决:
L4 模板推广到其余约 100 个视图(移动端摘要卡片列表受阻于 L1 摘要 schema 决策)、CI 中的 Lighthouse 预算 + axe-core、
token lint。
+167 -173
View File
@@ -1,125 +1,122 @@
# Design: MCP Runtime Overhaul — Cohesive Authorization Contract
# 设计:MCP 运行时全面重构 —— 内聚的授权契约
| | |
|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
| **Status** | Proposed — not yet implemented |
| **Date** | 2026-08-18 |
| **Scope** | MCP runtime plane: `mcp_runtime.proto`, `McpRuntimeFacade`, `McpGatewayController`, auth-side `OAuthMcpRuntimeServiceImpl` + `McpRuntimeServer` |
| **Target** | One cohesive gateway→auth round-trip per MCP method; reactive, non-blocking; real input schemas; async audit |
| **Related** | [`mq-abstraction.md`](./mq-abstraction.md) — broker port used for the async audit channel, if adopted |
| **Discussion** | open for review before implementation starts |
| **状态** | 提案 —— 尚未实施 |
| **日期** | 2026-08-18 |
| **范围** | MCP 运行时平面:`mcp_runtime.proto``McpRuntimeFacade``McpGatewayController`auth 侧的 `OAuthMcpRuntimeServiceImpl` + `McpRuntimeServer` |
| **目标** | 每个 MCP 方法一次内聚的 gateway→auth 往返;响应式、非阻塞;真实 input schema;异步审计 |
| **相关** | [`mq-abstraction.md`](./mq-abstraction.md) —— 异步审计通道(若采用)所用的 broker port |
| **讨论** | 实施启动前开放评审 |
## 1. Summary
## 1. 摘要
The MCP runtime that sits behind `McpGatewayController` (the gateway's MCP JSON-RPC resource server) currently reaches
the auth center through a **synchronous facade with five fine-grained RPCs**, which forces the gateway to behave like a
remote database client: it first calls
`Introspect` to recover the token context, then spreads `tenantId` / `principalId` /
`connectionId` across `ResolveTool` and `AuthorizeToolCall`, and finally calls `Audit`. One `tools/call` therefore costs
**4 gRPC round-trips, ~810 DB queries, one blocking-thread hop per call**, and — as a correctness gap — returns tools
whose `inputSchema` is a static empty envelope instead of the real JSON schema.
位于 `McpGatewayController`(网关的 MCP JSON-RPC 资源服务器)背后的 MCP 运行时,目前通过一个**带有五个细粒度 RPC 的
同步 Facade** 访问 auth 中心,这迫使网关表现得像一个远程数据库客户端:它先调用
`Introspect` 取回令牌上下文,再把 `tenantId` / `principalId` /
`connectionId` 摊到 `ResolveTool``AuthorizeToolCall` 两个调用里,最后调用 `Audit`。因此一次 `tools/call` 的代价是
**4 次 gRPC 往返、约 8–10 次 DB 查询、每次调用一次阻塞线程跳转**,并且——作为一个正确性缺口——返回的工具
`inputSchema` 是静态的空壳信封,而不是真实的 JSON schema。
This document proposes a **breaking, non-compatible overhaul** of that runtime contract:
本文档提出对这一运行时契约进行**破坏性、不兼容的全面重构**:
- **3 RPCs instead of 5**: `ListTools`, `CallTool`, `Audit`.
- **The bearer token is the only input** for `ListTools` / `CallTool`; the auth center is the single place that
understands a token, so it does verification + visibility + authorization in one decision inside the same call.
- `CallTool` returns the **decision, the resolved tool, and the principal context** together, so the gateway no longer
needs a separate introspection round-trip before forwarding to a backend.
- The facade becomes **reactive** (`Mono`/future stubs); the `blocking() + boundedElastic` hop disappears.
- `inputSchema` is carried end-to-end so external agents see real tool parameters.
- `Audit` is decoupled from the call path (fire-and-forget or broker event).
- **3 RPC 取代 5 个**`ListTools``CallTool``Audit`
- `ListTools` / `CallTool` **以 bearer token 作为唯一输入**auth 中心是唯一理解令牌的地方,
因此它在同一次调用内部以一个决策完成校验 + 可见性 + 授权。
- `CallTool` 一次性返回**决策、解析出的工具与主体上下文**,网关在转发给后端之前不再需要单独的自省往返。
- Facade 变为**响应式**`Mono`/future stub);`blocking() + boundedElastic` 跳转消失。
- `inputSchema` 端到端透传,外部 agent 看到的是真实的工具参数。
- `Audit` 与调用路径解耦(fire-and-forget 或 broker 事件)。
## 2. Background — how the MCP runtime works today
## 2. 背景 —— MCP 运行时今天如何工作
Verified against the tree on 2026-08-18. File references are exact.
已对照 2026-08-18 的代码树核实。文件引用均为确切路径。
### 2.1 The contract today
### 2.1 现行契约
`dc3-api/dc3-api-auth/src/main/protobuf/api/common/auth/mcp_runtime.proto` declares five RPCs:
`dc3-api/dc3-api-auth/src/main/protobuf/api/common/auth/mcp_runtime.proto` 声明了五个 RPC
| RPC | Purpose | Called per tools/call? |
| RPC | 用途 | 每次 tools/call 都调用? |
|---------------------|-----------------------------------------------------------------------------|------------------------|
| `Introspect` | Validate the OAuth bearer token, return tenant/principal/connection context | yes (every request) |
| `ListTools` | List tools visible to the connection | no (tools/list only) |
| `ResolveTool` | Resolve one tool to its backend invocation metadata | yes |
| `AuthorizeToolCall` | Enforce high-risk confirmation + idempotency, return a decision | yes |
| `Audit` | Store one audit record | yes |
| `Introspect` | 校验 OAuth bearer token,返回租户/主体/连接上下文 | 是(每个请求) |
| `ListTools` | 列出该连接可见的工具 | 否(仅 tools/list |
| `ResolveTool` | 把一个工具解析为其后端调用元数据 | |
| `AuthorizeToolCall` | 强制高风险确认 + 幂等,返回决策 | |
| `Audit` | 存储一条审计记录 | |
`McpRuntimeFacade` (`dc3-common-facade-api`) mirrors this as **synchronous** methods:
`introspect(String)`, `listTools(...)`, `resolveTool(...)`, `authorizeToolCall(...)`,
`audit(...)`. Its gRPC implementation (`McpRuntimeGrpcFacade`) uses an injected blocking stub;
`GrpcFacadeSupport.call` only adds a deadline. Connection reuse is fine — the stub is a shared bean — the problem is
*what* is called and *how often*.
`McpRuntimeFacade``dc3-common-facade-api`)以**同步**方法镜像这一契约:
`introspect(String)``listTools(...)``resolveTool(...)``authorizeToolCall(...)`
`audit(...)`。它的 gRPC 实现(`McpRuntimeGrpcFacade`)使用注入的阻塞 stub
`GrpcFacadeSupport.call` 只增加了一个 deadline。连接复用没有问题——stub 是共享 bean——问题在于
*调用的是什么*以及*调用频率*。
### 2.2 One `tools/call`, step by step
### 2.2 一次 `tools/call` 的逐步分解
The gateway path is `McpGatewayController.mcp(...)``dispatch(...)`
`McpGatewayClient.callTool(...)`. Each hop and its verified cost:
网关路径是 `McpGatewayController.mcp(...)``dispatch(...)`
`McpGatewayClient.callTool(...)`。每一跳及其核实过的成本:
| Step | Where | Cost |
| 步骤 | 位置 | 成本 |
|---------------------|----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ① introspect | `OAuthMcpRuntimeServiceImpl.introspect` | 1× local JWT verify (`verifyWith(publicKey())`) + **4 DB queries**: `selectAuthorizationByAccessTokenJti`, `selectConnectionById`, `principalManager.getById`, `tenantMembershipService.isTenantMember` |
| ② resolveTool | `resolveVisibleTool` | `selectVisibleToolByName` (1) + `updateConnectionLastUsed` (1) |
| ③ authorizeToolCall | `authorizeToolCall`re-runs `resolveVisibleTool` | **repeats** `selectVisibleToolByName` + `updateConnectionLastUsed` (2 more); only HIGH risk then queries the confirmation ticket |
| ④ invokeBackend | gateway `WebClient` | 1 HTTP forward (the actual business call) |
| ⑤ audit | `audit``insert` | 1 DB write (post-hoc, failure swallowed) |
| ① introspect | `OAuthMcpRuntimeServiceImpl.introspect` | 1× 本地 JWT 校验(`verifyWith(publicKey())`+ **4 DB 查询**`selectAuthorizationByAccessTokenJti``selectConnectionById``principalManager.getById``tenantMembershipService.isTenantMember` |
| ② resolveTool | `resolveVisibleTool` | `selectVisibleToolByName`1 次)+ `updateConnectionLastUsed`1 次) |
| ③ authorizeToolCall | `authorizeToolCall`重新执行 `resolveVisibleTool` | **重复执行** `selectVisibleToolByName` + `updateConnectionLastUsed`(再多 2 次);仅 HIGH 风险随后查询确认票据 |
| ④ invokeBackend | 网关 `WebClient` | 1 HTTP 转发(真正的业务调用) |
| ⑤ audit | `audit``insert` | 1 DB 写入(事后执行,失败被吞掉) |
Plus **4 gateway→auth gRPC round-trips** (`Introspect`, `ResolveTool`, `AuthorizeToolCall`,
`Audit`), each wrapped in `blocking(...)` which subscribes on `boundedElastic` — one blocking thread per in-flight call.
再加上 **4 gateway→auth gRPC 往返**`Introspect``ResolveTool``AuthorizeToolCall`
`Audit`),每次都包在 `blocking(...)` 里并在 `boundedElastic` 上订阅——每个在途调用占用一个阻塞线程。
### 2.3 Three redundancies and one correctness gap
### 2.3 三处冗余与一个正确性缺口
1. **Introspection is fully re-run on every request, with no cache.** The four DB queries cannot be dropped (OAuth
introspection must check revocation, principal enablement, and tenant membership), but the same token pays them again
on every tool call in an agent loop.
2. **Resolve and authorize duplicate the visibility query.** `authorizeToolCall` explicitly re-runs `resolveVisibleTool`
("Re-run the full visibility/whitelist/scope check"). The result is identical; `selectVisibleToolByName` and
`updateConnectionLastUsed` each execute twice.
3. **`updateConnectionLastUsed` is non-critical telemetry executed synchronously — twice.**
4. **Correctness gap: `inputSchema` is lost.** `GrpcMcpToolDefinitionDTO` has **no**
`input_schema` field (the proto comment says "excluding the static JSON schema envelope"), and
`McpRuntimeGrpcFacade.toDTO` hard-codes `DEFAULT_INPUT_SCHEMA`. An external agent therefore receives an empty schema
envelope for every tool instead of the real parameters that
`McpOpenApiAggregator` already computed and `OAuthMcpRuntimeServiceImpl.inputSchemaOf` already reads from `tool_ext`.
1. **自省在每个请求上完整重跑,没有任何缓存。** 这四次 DB 查询不能省略(OAuth
自省必须检查吊销、主体启用状态与租户成员关系),但在 agent 循环里,同一个令牌在每次工具调用时都要再付一遍。
2. **resolve 与 authorize 重复执行可见性查询。** `authorizeToolCall` 显式重跑 `resolveVisibleTool`
("重新执行完整的可见性/白名单/scope 检查")。结果完全相同;`selectVisibleToolByName`
`updateConnectionLastUsed` 各执行两次。
3. **`updateConnectionLastUsed` 是非关键遥测,却同步执行——而且执行两次。**
4. **正确性缺口:`inputSchema` 丢失。** `GrpcMcpToolDefinitionDTO` **没有**
`input_schema` 字段(proto 注释写明"不含静态 JSON schema 信封"),并且
`McpRuntimeGrpcFacade.toDTO` 硬编码了 `DEFAULT_INPUT_SCHEMA`。因此外部 agent 对每个工具收到的都是空的 schema
信封,而不是 `McpOpenApiAggregator` 已经算出、`OAuthMcpRuntimeServiceImpl.inputSchemaOf` 已经从 `tool_ext`
读出的真实参数。
## 3. Goals / Non-Goals
## 3. 目标 / 非目标
**Goals**
**目标**
- Reduce one `tools/call` to **2 gateway→auth round-trips** (`CallTool` + async `Audit`).
- Make the token the single input to `ListTools` / `CallTool`; auth is the only token authority.
- Eliminate the duplicate visibility query (`selectVisibleToolByName` once, not twice).
- Make the runtime **reactive end-to-end**; remove `blocking()` + `boundedElastic`.
- Carry the **real `inputSchema`** through `tools/list` so external agents see true parameters.
- Decouple audit from the call path.
- Drop `Introspect`, `ResolveTool`, `AuthorizeToolCall` as gateway-facing RPCs — **no backward compatibility**, no
dual-mode shim.
- 把一次 `tools/call` 降为 **2 gateway→auth 往返**`CallTool` + 异步 `Audit`)。
- 让令牌成为 `ListTools` / `CallTool` 的唯一输入;auth 是唯一的令牌权威。
- 消除重复的可见性查询(`selectVisibleToolByName` 一次,而非两次)。
- 让运行时**端到端响应式**;移除 `blocking()` + `boundedElastic`
- 让**真实 `inputSchema`** 随 `tools/list` 透传,外部 agent 看到真实参数。
- 将审计与调用路径解耦。
- 移除面向网关的 `Introspect``ResolveTool``AuthorizeToolCall` RPC——**不保留向后兼容**,也不做
双模式 shim
**Non-Goals**
**非目标**
- No change to the MCP wire protocol the client sees (still JSON-RPC 2.0 + OAuth bearer).
- No change to the management plane (`McpManagementController`) or the frontend MCP settings pages.
- No change to `McpOpenApiAggregator` / `dc3_api` tool-catalog generation.
- No change to how backend services authenticate downstream principal headers (HMAC + JSON header stay as-is).
- 客户端看到的 MCP 线上协议不变(仍是 JSON-RPC 2.0 + OAuth bearer)。
- 管理平面(`McpManagementController`)与前端 MCP 设置页不变。
- `McpOpenApiAggregator` / `dc3_api` 工具目录的生成方式不变。
- 后端服务认证下游 principal 头的方式不变(HMAC + JSON header 维持原样)。
## 4. Design principles
## 4. 设计原则
1. **Decision, not lookup.** The gateway asks "may this token call this tool, and where does it go?"
in one request; it does not assemble the answer from three lookups.
2. **Auth owns the token.** `ListTools` and `CallTool` take the raw token; auth parses, verifies, and resolves context
internally. The gateway never reconstructs tenant/principal context from claims.
3. **One authoritative visibility check.** Visibility + risk + confirmation + idempotency are decided in one place,
once, inside `CallTool`.
4. **Reactive throughout.** Facade returns `Mono`; gRPC uses future/async stubs; gateway stays on the WebFlux event
loop.
5. **Telemetry is off-path.** Audit is fire-and-forget (or a broker event); it can never delay or fail a call.
1. **要决策,不是查询。** 网关在一个请求里问"这个令牌能否调用这个工具、它该转发到哪里";
而不是从三次查询拼出答案。
2. **auth 拥有令牌。** `ListTools` `CallTool` 接收原始令牌;auth 在内部解析、校验并还原上下文。
网关绝不从 claims 重建租户/主体上下文。
3. **唯一一次权威可见性检查。** 可见性 + 风险 + 确认 + 幂等在同一处、只此一次、于 `CallTool`
内部裁决。
4. **全程响应式。** Facade 返回 `Mono`gRPC 使用 future/异步 stub;网关留在 WebFlux 事件
循环上。
5. **遥测在路径之外。** 审计是 fire-and-forget(或 broker 事件);它永远不能延迟或使一次调用失败。
## 5. Target contract
## 5. 目标契约
### 5.1 Protobuf
`mcp_runtime.proto` shrinks to three RPCs. Message names keep the existing `Grpc` convention.
`mcp_runtime.proto` 收缩为三个 RPC。消息名沿用既有的 `Grpc` 命名约定。
```proto
service McpRuntimeApi {
@@ -135,14 +132,14 @@ service McpRuntimeApi {
}
```
Removed: `Introspect`, `ResolveTool`, `AuthorizeToolCall` and their request/response messages
(`GrpcMcpIntrospectRequest`, `GrpcRMcpIntrospectDTO`, `GrpcMcpIntrospectDTO`,
`GrpcMcpToolListRequest`, `GrpcMcpToolResolveRequest`, `GrpcRMcpToolResolveDTO`,
`GrpcMcpToolResolveDTO`, `GrpcMcpToolAuthorizeRequest`, `GrpcRMcpToolAuthorizeDTO`). The shared enums
(`GrpcMcpRiskLevel`, `GrpcMcpDecision`, `GrpcMcpPrincipalType`,
`GrpcMcpAuditStatus`) stay.
移除:`Introspect``ResolveTool``AuthorizeToolCall` 及其请求/响应消息
`GrpcMcpIntrospectRequest``GrpcRMcpIntrospectDTO``GrpcMcpIntrospectDTO`
`GrpcMcpToolListRequest``GrpcMcpToolResolveRequest``GrpcRMcpToolResolveDTO`
`GrpcMcpToolResolveDTO``GrpcMcpToolAuthorizeRequest``GrpcRMcpToolAuthorizeDTO`)。共享枚举
`GrpcMcpRiskLevel``GrpcMcpDecision``GrpcMcpPrincipalType`
`GrpcMcpAuditStatus`)保留。
New / changed messages:
新增 / 变更的消息:
```proto
message GrpcMcpListToolsRequest {
@@ -193,9 +190,8 @@ message GrpcMcpCallToolDTO {
}
```
`GrpcMcpToolResolveDTO` is retained (it is still the `tool` sub-message inside
`GrpcMcpCallToolDTO`) and gains `input_schema` so the gateway can forward a tool's schema to a backend when a future
backend needs it.
`GrpcMcpToolResolveDTO` 保留(它仍是 `GrpcMcpCallToolDTO` 内部的 `tool` 子消息),并新增
`input_schema`,以便未来的后端需要时,网关可以把工具的 schema 转发给后端。
### 5.2 Facade
@@ -207,41 +203,40 @@ public interface McpRuntimeFacade {
}
```
- `McpRuntimeGrpcFacade` switches from `McpRuntimeApiBlockingStub` to the async
`McpRuntimeApiStub` (future→`Mono`) and drops `GrpcFacadeSupport.call` in favor of reactive error translation.
- New DTOs `McpCallToolRequestDTO` / `McpCallToolResponseDTO` mirror the proto; the response carries
`McpPrincipalContextDTO` in place of the old `McpIntrospectResponseDTO`.
- `McpRuntimeGrpcFacade` `McpRuntimeApiBlockingStub` 切换到异步的
`McpRuntimeApiStub`future→`Mono`),并去掉 `GrpcFacadeSupport.call`,改用响应式错误转换。
- DTO `McpCallToolRequestDTO` / `McpCallToolResponseDTO` 与 proto 一一对应;响应以
`McpPrincipalContextDTO` 取代旧的 `McpIntrospectResponseDTO`
### 5.3 Auth service
### 5.3 Auth 服务
`OAuthMcpRuntimeServiceImpl` replaces `introspect` + `resolveVisibleTool` +
`authorizeToolCall` with two cohesive operations:
`OAuthMcpRuntimeServiceImpl` 用两个内聚操作取代 `introspect` + `resolveVisibleTool` +
`authorizeToolCall`
- `listTools(token)`: `parseAccessToken`active-authorization check → connection/principal/ membership checks
`listVisibleTools``toolToMcp` (now with real `inputSchema`).
- `callTool(request)`: same token verification, then **one** `selectVisibleToolByName` + visibility/scope/risk decision,
then the confirmation/idempotency gate (HIGH risk only), returning decision + resolved tool + principal context.
- `listTools(token)``parseAccessToken`有效授权检查 → 连接/主体/成员关系检查
`listVisibleTools``toolToMcp`(现在带真实 `inputSchema`)。
- `callTool(request)`:同样的令牌校验,然后**一次** `selectVisibleToolByName` + 可见性/scope/风险决策,
随后是确认/幂等闸门(仅 HIGH 风险),返回决策 + 解析出的工具 + 主体上下文。
`McpRuntimeServer` mirrors this: three gRPC methods, `toGrpc` builders for the new messages, and
`inputSchema` serialization out of `tool_ext` (reusing the existing `inputSchemaOf` logic).
`McpRuntimeServer` 与之对应:三个 gRPC 方法、为新消息准备的
`toGrpc` 构建器,以及从 `tool_ext` 序列化 `inputSchema`(复用既有的 `inputSchemaOf` 逻辑)。
### 5.4 Gateway
### 5.4 网关
`McpGatewayController` dispatch simplifies to:
`McpGatewayController` 的分发简化为:
- `tools/list``mcpRuntimeFacade.listTools(token)` → JSON-RPC result.
- `tools/call` → `mcpRuntimeFacade.callTool(request(token, toolName, digest, confirmId,
idempotencyKey, client meta))`; on `AUTHORIZED`, forward to the backend using
`tool.serviceName/apiPath/httpMethod` and build `X_AUTH_PRINCIPAL` from `principal`; on
`CONFIRM_REQUIRED`, return the confirm prompt; on `REJECTED`, return the denial.
- `audit` → fire-and-forget (no longer awaited before returning).
- `tools/list``mcpRuntimeFacade.listTools(token)` → JSON-RPC 结果。
- `tools/call``mcpRuntimeFacade.callTool(request(token, toolName, digest, confirmId, idempotencyKey, client meta))`
`AUTHORIZED` 时使用
`tool.serviceName/apiPath/httpMethod` 转发到后端,并由 `principal` 构建 `X_AUTH_PRINCIPAL`
`CONFIRM_REQUIRED` 时返回确认提示;`REJECTED` 时返回拒绝。
- `audit` → fire-and-forget(返回前不再等待)。
The `blocking(...)` helper, the `toLong(context.getTenantId())` scattering, and the standalone introspection call all
disappear.
`blocking(...)` 辅助方法、`toLong(context.getTenantId())` 的四处散布,以及独立的自省调用全部消失。
## 6. Request flows
## 6. 请求流程
### 6.1 Before (today, one tools/call)
### 6.1 之前(今天,一次 tools/call
```text
client → gateway ─ Introspect ──────────────→ auth (JWT + 4 DB)
@@ -252,7 +247,7 @@ client → gateway ─ Introspect ──────────────→
gateway ←─ decision / tool / context ── (assembled from 3 responses)
```
### 6.2 After (target, one tools/call)
### 6.2 之后(目标,一次 tools/call
```text
client → gateway ─ CallTool(token, tool, digest, confirm, key, client meta) ─→ auth
@@ -263,64 +258,63 @@ client → gateway ─ CallTool(token, tool, digest, confirm, key, client meta)
gateway ←─ decision + tool + principal ── (one response)
```
## 7. Migration plan
## 7. 迁移计划
Breaking, no compatibility shim. Each phase must leave the tree compiling and tests green.
破坏性变更,无兼容 shim。每个阶段结束时代码树必须可编译、测试保持绿色。
| Phase | Change | Files |
| 阶段 | 变更 | 文件 |
|-------|-----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| 1 | Rewrite `mcp_runtime.proto` (3 RPCs, new messages, `input_schema`), regenerate stubs | `dc3-api/dc3-api-auth/.../mcp_runtime.proto` |
| 2 | Reactive facade + gRPC impl (`Mono`, async stub) | `McpRuntimeFacade`, `McpRuntimeGrpcFacade`, new request/response DTOs |
| 3 | Auth service + server: merge introspect/resolve/authorize into `listTools`/`callTool`; real `inputSchema` | `OAuthMcpRuntimeServiceImpl`, `McpRuntimeServer` |
| 4 | Gateway: token-direct dispatch, drop `blocking()`, async audit | `McpGatewayController` |
| 5 | Rewrite affected tests | `McpRuntimeServerTest`, `McpGatewayControllerTest`, `OAuthMcpRuntimeServiceImplTest`, facade tests |
| 1 | 重写 `mcp_runtime.proto`3 个 RPC、新消息、`input_schema`),重新生成 stub | `dc3-api/dc3-api-auth/.../mcp_runtime.proto` |
| 2 | 响应式 Facade + gRPC 实现(`Mono`、异步 stub | `McpRuntimeFacade``McpRuntimeGrpcFacade`、新的请求/响应 DTO |
| 3 | Auth 服务 + 服务端:把 introspect/resolve/authorize 合并进 `listTools`/`callTool`;真实 `inputSchema` | `OAuthMcpRuntimeServiceImpl``McpRuntimeServer` |
| 4 | 网关:凭令牌直接分发,去掉 `blocking()`,异步审计 | `McpGatewayController` |
| 5 | 重写受影响的测试 | `McpRuntimeServerTest``McpGatewayControllerTest``OAuthMcpRuntimeServiceImplTest`、Facade 测试 |
Frontend is **untouched** (the MCP settings pages use `McpManagementController`, not this gateway runtime).
前端**完全不动**(MCP 设置页使用的是 `McpManagementController`,不是这个网关运行时)。
## 8. Verification
## 8. 验证
- `mvn -s .mvn/settings.xml -q -DskipTests compile` after each phase.
- `mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-auth -am` and
`-pl dc3-common/dc3-common-gateway -am` for the rewritten tests.
- Contract assertions to preserve:
- one `tools/call` = one `CallTool` RPC + one async `Audit` (assert via test doubles),
- `selectVisibleToolByName` invoked exactly once per call,
- `tools/list` returns non-default `inputSchema` for a fixture tool,
- HIGH-risk still issues `CONFIRM_REQUIRED` + confirmId, idempotency key still deduplicates.
- `pnpm check` in `dc3-web` to confirm no frontend regression.
- 每个阶段之后运行 `mvn -s .mvn/settings.xml -q -DskipTests compile`
-`mvn -s .mvn/settings.xml test -pl dc3-common/dc3-common-auth -am`
`-pl dc3-common/dc3-common-gateway -am` 运行重写后的测试。
- 需要保留的契约断言:
- 一次 `tools/call` = 一次 `CallTool` RPC + 一次异步 `Audit`(通过测试替身断言),
- `selectVisibleToolByName` 每次调用恰好执行一次,
- `tools/list` 为夹具工具返回非默认 `inputSchema`
- HIGH 风险仍产生 `CONFIRM_REQUIRED` + confirmId,幂等键仍去重。
-`dc3-web` 中运行 `pnpm check`,确认前端无回归。
## 9. Alternatives considered
## 9. 已考虑的备选方案
1. **Cache introspection instead of reshaping the contract** (keeps 5 RPCs). Rejected: it papers over the synchronous
facade, the duplicate visibility query, and the missing schema; the round-trip count and the gateway's
context-scattering remain.
2. **Local JWT verification in the gateway + short-TTL jti deny-list.** Removes the introspection round-trip entirely,
but moves token authority into the gateway and adds a revocation-propagation channel. Rejected as the primary design
because it blurs the auth boundary; it can be layered later as a pure optimization on top of the 3-RPC contract.
3. **Keep `ResolveTool` and only merge the rest.** Rejected: resolve is a sub-step of the call decision, not an
independent operation; returning the tool inside `CallTool` is strictly simpler.
1. **缓存自省结果而不重塑契约**(保留 5 RPC)。已否决:它只是掩盖同步
Facade、重复的可见性查询和缺失的 schema;往返次数与网关的上下文散布依然存在。
2. **网关本地 JWT 校验 + 短 TTL jti 拒绝列表。** 彻底消除自省往返,
但把令牌权威移进了网关,并引入一条吊销传播通道。因其模糊 auth 边界而否决作为主设计;
日后可以作为纯优化叠加在 3-RPC 契约之上。
3. **保留 `ResolveTool`,只合并其余部分。** 已否决:resolve 是调用决策的一个子步骤,不是独立操作;
把工具放进 `CallTool` 的返回里严格更简单。
## 10. Open questions
## 10. 开放问题
1. **Audit transport.** Fire-and-forget `Mono` (smaller change) vs a RabbitMQ event through the MQ abstraction (fully
off-path, but depends on broker availability). Leaning broker event, to be confirmed.
2. **`inputSchema` in `GrpcMcpToolResolveDTO`.** Should the forwarded backend ever need the schema, or is schema only a
`tools/list` concern? Current proposal adds it defensively.
3. **Token verification cost.** With 5→3 RPCs, the 4 introspection DB queries still run once per
`CallTool`. A short-TTL `jti → context` cache inside auth (invalidated on revoke/disable) is a follow-up, not part of
this contract overhaul.
1. **审计传输。** fire-and-forget 的 `Mono`(改动更小)还是通过 MQ 抽象发 RabbitMQ 事件(完全在路径之外,
但依赖 broker 可用性)。倾向 broker 事件,待确认。
2. **`GrpcMcpToolResolveDTO` 中的 `inputSchema`。** 被转发的后端是否终究需要 schema,
还是 schema 只是 `tools/list` 的关注点?当前提案防御性地加上了它。
3. **令牌校验成本。** RPC 从 5 收敛到 3 之后,4 次自省 DB 查询在每次
`CallTool` 时仍会执行。auth 内部的短 TTL `jti → context` 缓存(在吊销/禁用时失效)是后续工作,
不属于本次契约重构。
## 11. Appendix — current call-site inventory
## 11. 附录 —— 现有调用点盘点
Verified 2026-08-18.
已于 2026-08-18 核实。
| Artifact | Location | Notes |
| 构件 | 位置 | 说明 |
|---------------|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
| Proto | `dc3-api/dc3-api-auth/src/main/protobuf/api/common/auth/mcp_runtime.proto` | 5 RPCs, 260 lines |
| Facade | `dc3-common-facade-api/.../McpRuntimeFacade.java` | 5 synchronous methods |
| gRPC impl | `dc3-common-facade-grpc/.../McpRuntimeGrpcFacade.java` | blocking stub + `GrpcFacadeSupport` |
| Gateway | `dc3-common-gateway/.../McpGatewayController.java` | JSON-RPC dispatch, `blocking()`+boundedElastic, `invokeBackend`, `audit` |
| Auth service | `dc3-common-auth/.../OAuthMcpRuntimeServiceImpl.java` | `introspect`, `resolveVisibleTool`, `authorizeToolCall` |
| gRPC server | `dc3-common-auth/.../McpRuntimeServer.java` | 5 server methods |
| Schema source | `dc3-common-auth/.../tool/McpOpenApiAggregator.java` | `inputSchema` computed but not carried over gRPC |
| Tests | `McpRuntimeServerTest`, `McpGatewayControllerTest`, `OAuthMcpRuntimeServiceImplTest` | rewrite in Phase 5 |
| Proto | `dc3-api/dc3-api-auth/src/main/protobuf/api/common/auth/mcp_runtime.proto` | 5 RPC260 |
| Facade | `dc3-common-facade-api/.../McpRuntimeFacade.java` | 5 个同步方法 |
| gRPC 实现 | `dc3-common-facade-grpc/.../McpRuntimeGrpcFacade.java` | 阻塞 stub + `GrpcFacadeSupport` |
| 网关 | `dc3-common-gateway/.../McpGatewayController.java` | JSON-RPC 分发、`blocking()`+boundedElastic`invokeBackend``audit` |
| Auth 服务 | `dc3-common-auth/.../OAuthMcpRuntimeServiceImpl.java` | `introspect``resolveVisibleTool``authorizeToolCall` |
| gRPC 服务端 | `dc3-common-auth/.../McpRuntimeServer.java` | 5 个服务端方法 |
| Schema 来源 | `dc3-common-auth/.../tool/McpOpenApiAggregator.java` | 已计算 `inputSchema` 但未随 gRPC 传递 |
| 测试 | `McpRuntimeServerTest``McpGatewayControllerTest``OAuthMcpRuntimeServiceImplTest` | 在第 5 阶段重写 |
+393 -420
View File
@@ -1,161 +1,145 @@
# Design: Pluggable Message Broker Abstraction (MQ Port)
# 设计:可插拔消息中间件抽象(MQ Port
| | |
|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Status** | Delivered: port + five adapters (rabbitmq / kafka / activemq / mqtt / pulsar) TCK-certified; rocketmq adapter experimental (opt-in TCK, not yet certified) |
| **Date** | 2026-08-17, revised 2026-08-19 (re-verified after commit `956de3dd3`; MQTT decoupling) |
| **Scope** | `dc3-common-*` messaging layer (center ↔ driver async plane) |
| **Target** | RabbitMQ (default), Kafka, RocketMQ, Pulsar, ActiveMQ (Artemis / Classic), MQTT 5 (EMQX / HiveMQ / NanoMQ / …) |
| **Related** | [`storage-abstraction.md`](./storage-abstraction.md) — relational + time-series pluggability, same profile/TCK mechanism |
| **Discussion** | open for review before Phase 1 starts |
| | |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **状态** | 已交付:Port + 五个适配器(rabbitmq / kafka / activemq / mqtt / pulsar)已通过 TCK 认证;rocketmq 适配器为实验性(TCK 可选启用,尚未认证) |
| **日期** | 2026-08-17,修订于 2026-08-19(提交 `956de3dd3` 后复核;MQTT 解耦) |
| **范围** | `dc3-common-*` 消息层(中心↔驱动 异步平面) |
| **目标** | RabbitMQ(默认)、KafkaRocketMQPulsarActiveMQArtemis / Classic)、MQTT 5EMQX / HiveMQ / NanoMQ / … |
| **相关** | [`storage-abstraction.md`](./storage-abstraction.md) —— 关系 + 时序可插拔,同一套 profile/TCK 机制 |
| **讨论** | Phase 1 启动前开放评审 |
## 1. Summary
## 1. 摘要
IoT DC3 currently talks to RabbitMQ through its native Spring AMQP API (`RabbitTemplate`,
`@RabbitListener`, `Channel`, `CorrelationData`) from three business modules. As a globally distributed open-source
project we want deployers — and the community — to be able to run the platform on whichever broker their environment
already standardizes on.
IoT DC3 目前在三个业务模块中通过 Spring AMQP 原生 API`RabbitTemplate``@RabbitListener``Channel`
`CorrelationData`)与 RabbitMQ 交互。作为一个全球分布的开源项目,我们希望部署者——以及社区——能够把平台运行在
其环境已经标准化的任意中间件上。
The proposal: introduce a thin **port** (`dc3-mq-core`) that abstracts the *semantics* the platform actually uses —
logical topics, load-balanced vs broadcast subscription, delayed delivery, dead-lettering, batch consumption with
transactional acknowledgement, bounded redelivery, publisher confirmation — plus one adapter module per broker. RabbitMQ
comes first as a byte-for-byte wire-compatible migration of the existing code; a broker-neutral contract-test suite
(TCK) makes community adapters for other brokers possible with a clear acceptance bar. One of the adapters is **MQTT
5**, so the internal plane can also run over whichever MQTT broker the deployment already operates.
本方案:引入一个薄 **Port**`dc3-mq-core`),抽象平台实际使用的*语义*——逻辑主题、负载均衡订阅与广播订阅、
延迟投递、死信、带事务性确认的批量消费、有界重投、发布者确认——并为每个中间件提供一个适配器模块。RabbitMQ
率先落地,作为现有代码逐字节线级兼容的迁移;一套中间件中立(broker-neutral)的契约测试套件(TCK)以明确的
验收门槛让社区为其他中间件贡献适配器成为可能。其中一个适配器是 **MQTT 5**,因此内部平面也可以运行在部署环境
已在运营的任意 MQTT 中间件之上。
The design deliberately does **not** try to model broker topology (exchanges, bindings, partitions). It models the two
subscription modes every mainstream broker can express, and negotiates everything else through declared capabilities
with documented fallbacks.
本设计刻意 **** 去建模中间件拓扑(exchangebinding、分区)。它只建模每个主流中间件都能表达的两种订阅模式,
其余一切通过声明的能力进行协商,并配有文档化的回退(fallback)。
MQTT plays two distinct roles in the platform and both must treat the broker as a free selection: the **device-access
plane** (vendor-neutral by protocol already) and — via the new adapter — the **internal async plane** itself. §7.1 draws
the boundary.
MQTT 在平台中扮演两个不同的角色,二者都必须把中间件视为自由选择:**南向设备面**(协议层面本就厂商中立)以及
——通过新适配器——**内部异步平面**本身。§7.1 划定了这一边界。
Two recent platform changes (2026-08-18, commit `956de3dd3` — lease-fenced durable telemetry)
strengthen rather than invalidate this design: the driver now owns durability through a mandatory SQLite outbox in front
of the broker, and point-value ingestion is transactional and idempotent (schema-versioned payloads with `messageId`
dedupe and fencing tokens). Both reduce how much the port has to demand from the broker.
两项近期的平台变更(2026-08-18,提交 `956de3dd3`——租约 fencing 的持久化遥测)是加强而非否定本设计:驱动现在
通过中间件前置的强制性 SQLite 发件箱(outbox) 掌控持久性,且点位值摄入是事务性且幂等的(带 schema 版本的载荷,
`messageId` 去重与 fencing token)。两者都降低了 Port 对中间件的要求。
## 2. Background — how RabbitMQ is used today
## 2. 现状 —— RabbitMQ 目前的使用方式
Accurate as of 2026-08-19, re-verified after commit `956de3dd3` (2026-08-18). All numbers verified against the tree.
信息截至 2026-08-19 准确,在提交 `956de3dd3`2026-08-18)之后复核。所有数字均已对照代码树核实。
**Topology** (names centralized in
`dc3-common/dc3-common-constant/.../driver/RabbitConstant.java`):
**拓扑**(名称集中定义于
`dc3-common/dc3-common-constant/.../driver/RabbitConstant.java`):
- **15 declared topic exchanges** (`dc3.e.<domain>`): state, alarm, metadata, point_command, value, mqtt,
state_timeout_delay, state_timeout_check, command, command_result, command_dead, event (declared in
`dc3-common-rabbitmq/.../ExchangeConfig.java`) plus point_value_dead, point_command_dead, point_command_result
(declared in
`dc3-common-data/.../DataTopicConfig.java`). Two of them are dead weight: `dc3.e.mqtt` is declared but has no binding,
producer, or consumer, and the `register` exchange/queue constants in `RabbitConstant` are declared and referenced
nowhere — both are Phase 1 cleanup candidates (§12; routing through an MQTT broker belongs to the §7.1 adapter, not a
bespoke exchange).
- **19 declared queue definitions** — 16 center-side (load-balanced and dead-letter) in
`DataTopicConfig` and 3 driver-side patterns in `dc3-common-driver/.../DriverTopicConfig.java`, the latter
instantiated per driver client. The driver-side command queues carry a lease-coupled `x-expires` (§8.8); the metadata
queue is auto-delete with 30 s TTL.
- Routing keys `dc3.r.<domain>.<service>`; environment prefix via the `dc3.rabbit.tag`
system property.
- **15 个已声明的 topic exchange**`dc3.e.<domain>`):statealarmmetadatapoint_commandvaluemqtt
state_timeout_delaystate_timeout_checkcommandcommand_resultcommand_deadevent(声明于
`dc3-common-rabbitmq/.../ExchangeConfig.java`),外加 point_value_deadpoint_command_deadpoint_command_result
(声明于
`dc3-common-data/.../DataTopicConfig.java`)。其中两个是无用的累赘:`dc3.e.mqtt` 已声明但没有任何 binding
生产者或消费者,`RabbitConstant` 中的 `register` exchange/queue 常量声明了却无处引用——两者都是 Phase 1 的
清理候选(§12;经 MQTT 中间件路由属于 §7.1 适配器的职责,而非定制 exchange)。
- **19 个已声明的队列定义** —— `DataTopicConfig` 中 16 个中心侧(负载均衡与死信)队列,
`dc3-common-driver/.../DriverTopicConfig.java` 中 3 个驱动侧模式,后者按每个驱动客户端实例化。驱动侧命令队列
携带与租约耦合的 `x-expires`(§8.8);元数据队列为 auto-deleteTTL 30 秒。
- 路由键 `dc3.r.<domain>.<service>`;环境前缀通过 `dc3.rabbit.tag` 系统属性指定。
**Producers** (14 raw `rabbitTemplate.convertAndSend` call sites in main code; the driver's 8 business send methods
funnel through 6 raw sites):
**生产者**(主代码中 14 处裸 `rabbitTemplate.convertAndSend` 调用点;驱动的 8 个业务发送方法汇入其中 6 处裸调用点):
| Side | Wrapped behind interface? | Sites |
| | 是否有接口封装? | 调用点 |
|----------------|---------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|
| Driver | Yes `DriverSenderService``DriverSenderServiceImpl` | 8 sends (6 raw call sites) + `BufferServiceImpl` outbox republish |
| Data center | No — direct `RabbitTemplate` | `CommandHistoryServiceImpl`, `PointCommandServiceImpl`, `DriverStateServiceImpl`, `NotifyTaskSender`, `EntityStateExpiryScanner` |
| Manager center | No — direct `RabbitTemplate` | `MetadataEventListener` |
| 驱动 | 是 —`DriverSenderService``DriverSenderServiceImpl` | 8 个发送(6 处裸调用点)+ `BufferServiceImpl` 发件箱(outbox) 重发布 |
| 数据中心 | 否 —— 直接使用 `RabbitTemplate` | `CommandHistoryServiceImpl``PointCommandServiceImpl``DriverStateServiceImpl``NotifyTaskSender``EntityStateExpiryScanner` |
| 管理中心 | 否 —— 直接使用 `RabbitTemplate` | `MetadataEventListener` |
**Consumers** — 16 `@RabbitListener` methods, all manual-ack via `Channel` +
`RabbitAckUtil`, split across three container factories (latency-tuned default, high-throughput, and — since
`956de3dd3` — the batch-enabled
`pointValueRabbitListenerContainerFactory` for the point-value path): 13 in `dc3-common-data`
(`data/rabbit/*` receivers, `NotifyWorker`, `EntityStateExpiryScanner`) and 3 in
`dc3-common-driver` (`receiver/rabbit/*`). The point-value **dead** queue intentionally has no listener: since
`956de3dd3` it is a consumer-less quarantine (§8.2).
**消费者** 16 `@RabbitListener` 方法,全部通过 `Channel` + `RabbitAckUtil` 手动确认,分布于三个容器工厂
(低延迟调优的默认工厂、高吞吐工厂,以及——自 `956de3dd3` 起——点位值路径启用批量的
`pointValueRabbitListenerContainerFactory`):13 个位于 `dc3-common-data`
`data/rabbit/*` 接收器、`NotifyWorker``EntityStateExpiryScanner`),3 个位于
`dc3-common-driver``receiver/rabbit/*`)。点位值 **死信** 队列刻意没有监听器:自
`956de3dd3` 起它是无消费者的隔离区(§8.2)。
**Module coupling**`dc3-common-data`, `dc3-common-driver`, `dc3-common-manager` and
`dc3-common-facade-local-manager` depend directly on `dc3-common-rabbitmq`, so the Spring AMQP API leaks into every
business module. A parallel module, `dc3-common-mqtt`
(Spring Integration MQTT + Eclipse Paho v3 client), carries the driver-side MQTT device-access plane; only
`dc3-driver-mqtt` depends on it, and it contains no broker-vendor code — EMQX is a deployment default, not a code
dependency (§7.1).
**模块耦合** `dc3-common-data``dc3-common-driver``dc3-common-manager`
`dc3-common-facade-local-manager` 直接依赖 `dc3-common-rabbitmq`,因此 Spring AMQP API 泄漏进每个
业务模块。另一个并行模块 `dc3-common-mqtt`
Spring Integration MQTT + Eclipse Paho v3 客户端)承载驱动侧的 MQTT 南向设备面;只有
`dc3-driver-mqtt` 依赖它,且其中不含任何中间件厂商代码——EMQX 是部署默认值,不是代码
依赖(§7.1)。
**Eight RabbitMQ-specific semantics** the abstraction must carry (the hard part — plain send/receive is easy):
**抽象必须承载的八项 RabbitMQ 特有语义**(难点所在——普通的收发很容易):
| # | Semantic | Current implementation |
| # | 语义 | 当前实现 |
|---|--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 1 | Delayed messages | TTL + DLX: 45 s driver-timeout check, 10 s device-scan tick (`DriverStateServiceImpl`, `EntityStateExpiryScanner`, delay/check exchanges in `DataTopicConfig`) |
| 2 | Per-instance broadcast | Driver metadata sync via auto-delete queue (TTL 30 s) named per driver client (`DriverTopicConfig`) |
| 3 | Dead-letter queues | point_value (live-queue TTL 7 d → DLX; dead queue is a consumer-less quarantine since `956de3dd3`), point_command, command DLX + dedicated dead receivers (`PointCommandDeadReceiver`, `CommandDeadReceiver`) |
| 4 | Manual ack + bounded back-pressure | manual ack everywhere; the point-value path acks a whole broker batch only after the PostgreSQL transaction commits (`PointValueReceiver` + `PointValueRabbitConfig`); back-pressure is bounded by prefetch ≥ batchSize with the broker as durable buffer (the old data-side ingest buffer and its buffer-full `nack(requeue=true)` were removed in `956de3dd3`) |
| 5 | Durable outbox + publisher confirm | driver persists every point value to a **mandatory SQLite outbox** (WAL, `synchronous=FULL`) before publishing; the confirm future and a retry scheduler delete or republish (`BufferServiceImpl`, `PointValueBuffer`) |
| 6 | Queue TTL as retention guard | state/alarm 30 s, event/command_result 60 s, notify_task 24 h, point_value 7 d, driver-side command queues 30 s (`DataTopicConfig`, `DriverTopicConfig`) |
| 7 | Batch consumption | consumer-side batching on the point-value path: batch listener + consumer batch, batch-level bounded retry with backoff, exhaustion rejects the whole batch to DLX (`PointValueRabbitConfig`) |
| 8 | Lease-coupled per-instance queue lifecycle | driver command queues expire (`x-expires` from `driver.lease.queue-expires`) with the driver lease; metadata broadcast queues are auto-delete with 30 s TTL (`DriverTopicConfig`) |
| 1 | 延迟消息 | TTL + DLX45 秒驱动超时检查、10 秒设备扫描 tick`DriverStateServiceImpl``EntityStateExpiryScanner`delay/check exchange `DataTopicConfig` |
| 2 | 每实例广播 | 驱动元数据同步,通过按驱动客户端命名的 auto-delete 队列(TTL 30 秒)(`DriverTopicConfig` |
| 3 | 死信队列 | point_value(活队列 TTL 7 → DLX;死队列自 `956de3dd3` 起为无消费者的隔离区)、point_commandcommand DLX + 专用死信接收器(`PointCommandDeadReceiver``CommandDeadReceiver` |
| 4 | 手动确认 + 有界背压 | 到处手动确认;点位值路径仅在 PostgreSQL 事务提交后才确认整个中间件批次(`PointValueReceiver` + `PointValueRabbitConfig`);背压以 prefetch ≥ batchSize 为界,中间件充当持久缓冲(旧的数据侧摄入缓冲及其缓冲满时的 `nack(requeue=true)` 已在 `956de3dd3` 移除) |
| 5 | 持久发件箱(outbox) + 发布者确认 | 驱动在发布前把每个点位值持久化到 **强制性 SQLite 发件箱(outbox)**WAL`synchronous=FULL`);确认 future 与重试调度器负责删除或重发布(`BufferServiceImpl``PointValueBuffer` |
| 6 | 队列 TTL 作为保留守卫 | state/alarm 30 秒、event/command_result 60 秒、notify_task 24 小时、point_value 7 天、驱动侧命令队列 30 秒(`DataTopicConfig``DriverTopicConfig` |
| 7 | 批量消费 | 点位值路径上的消费者侧批量:批量监听器 + 消费者批量,批次级有界退避重试,耗尽后将整个批次拒绝进 DLX`PointValueRabbitConfig` |
| 8 | 与租约耦合的每实例队列生命周期 | 驱动命令队列随驱动租约过期(`x-expires` 取自 `driver.lease.queue-expires`);元数据广播队列为 auto-deleteTTL 30 秒(`DriverTopicConfig` |
Two load-bearing facts make the whole abstraction feasible:
两个承重事实使整个抽象可行:
- **The delayed-timeout paths already treat the database lease as the source of truth**
(`leaseVersion` idempotency check in `DriverTimeoutCheckReceiver`). The MQ delay is only a *trigger*, so a broker
without native delayed delivery can fall back to a local scheduler without breaking correctness.
- **At-least-once + idempotent consumers is now implemented, not assumed**: point-value payloads carry a schema-v1
envelope (`schemaVersion`, `messageId`, `sequence`,
`fencingToken`, `driverNode`) validated on ingest, and Data-Center ingestion is transactional and idempotent
(`956de3dd3`).
- **延迟超时路径已经把数据库租约当作事实来源**
`DriverTimeoutCheckReceiver` 中的 `leaseVersion` 幂等检查)。MQ 延迟只是一个 *触发器*,因此不具备原生延迟投递的
中间件可以回退(fallback) 到本地调度器而不破坏正确性。
- **至少一次 + 幂等消费者现在已实现,而非假设**:点位值载荷携带 schema-v1
信封(`schemaVersion``messageId``sequence`
`fencingToken``driverNode`)并在摄入时校验,数据中心摄入是事务性且幂等的
`956de3dd3`)。
Cross-cutting concerns that already exist and must survive the abstraction unchanged:
JSON payload convention (Jackson) — formalized on the point-value path by the schema-v1 envelope, `X-Request-Id` MDC
propagation across the broker hop (`MdcRequestIdMessagePostProcessor` / `MdcRequestIdListenerAdvice`), persistent
delivery, tenant-scoped payloads.
已经存在且必须在抽象中原样保留的横切关注点:
JSON 载荷约定(Jackson)——在点位值路径上由 schema-v1 信封固化,`X-Request-Id` MDC 跨中间件跳传播
`MdcRequestIdMessagePostProcessor` / `MdcRequestIdListenerAdvice`),持久化投递,租户作用域载荷。
## 3. Goals / Non-Goals
## 3. 目标 / 非目标
**Goals**
**目标**
- Business modules (`dc3-common-data`, `dc3-common-driver`, `dc3-common-manager`) compile against a broker-neutral API
with **zero** broker classes on the compile classpath.
- One adapter module per broker; exactly one adapter active at runtime, selected by
`dc3.mq.type` (default `rabbitmq`).
- The MQTT broker is a free deployment selection in both of its roles: the device-access plane stays vendor-neutral by
protocol (EMQX is a compose default, not a code dependency), and the internal plane may optionally run over any MQTT 5
broker via the
`dc3-mq-mqtt` adapter (§7.1).
- Existing RabbitMQ deployments keep working through the migration: physical exchange/queue/routing-key names
byte-for-byte identical, no forced redeploy of the whole fleet at once.
- The point-value path's throughput semantics survive the abstraction: batch delivery with post-commit batch
acknowledgement and bounded redelivery.
- Third-party driver authors remain unaffected: `DriverSenderService` signature does not change.
- Community-contributed adapters have a mechanical acceptance bar (the TCK).
- 业务模块(`dc3-common-data``dc3-common-driver``dc3-common-manager`)面向中间件中立 API 编译,编译类路径上
**零** 中间件类。
- 每个中间件一个适配器模块;运行时恰好激活一个适配器,由
`dc3.mq.type` 选择(默认 `rabbitmq`)。
- MQTT 中间件在其两个角色中都是自由的部署选择:南向设备面在协议层面保持厂商中立(EMQX 是 compose 默认值,
不是代码依赖),内部平面可选地通过
`dc3-mq-mqtt` 适配器运行在任意 MQTT 5 中间件之上(§7.1)。
- 现有 RabbitMQ 部署在迁移期间持续可用:物理 exchange/queue/路由键名称逐字节一致,不强制一次性重新部署全部实例。
- 点位值路径的吞吐语义在抽象之后保持不变:批量投递、提交后批次确认、有界重投。
- 第三方驱动作者不受影响:`DriverSenderService` 签名不变。
- 社区贡献的适配器有一个机械化的验收门槛(TCK)。
**Non-Goals**
**非目标**
- Exactly-once delivery or cross-broker distributed transactions. The contract is **at-least-once + idempotent
consumers** — formally implemented on the point-value path (messageId dedupe, fencing tokens) and required by
convention elsewhere.
- Multi-broker topologies (bridging RabbitMQ to Kafka etc.).
- Rewriting the device-access MQTT plane (`dc3-common-mqtt`, `dc3-driver-mqtt`) — those are protocol drivers, not the
internal async plane. §7.1 records the boundary and the vendor-neutrality guardrails instead.
- Hiding performance/ordering differences between brokers (documented, not erased).
- 恰好一次投递或跨中间件分布式事务。契约是 **至少一次 + 幂等
消费者**——在点位值路径上正式实现(messageId 去重、fencing token),其余路径按约定要求。
- 多中间件拓扑(把 RabbitMQ 桥接到 Kafka 等)。
- 重写南向设备面的 MQTT 平面(`dc3-common-mqtt``dc3-driver-mqtt`)——它们是协议驱动,不是
内部异步平面。§7.1 改为记录边界与厂商中立护栏。
- 隐藏中间件之间的性能/顺序差异(文档化,而非抹平)。
## 4. Design principles
## 4. 设计原则
1. **Abstract semantics, not broker features.** The unifying level is *logical topic + subscription mode*.
Exchange/binding/partition/tag models are adapter internals. The two subscription modes (`LOAD_BALANCE`, `BROADCAST`)
are the lowest common semantics all five target brokers can express.
2. **Capability negotiation with graceful degradation, not lowest common denominator.**
The adapter declares what it supports; the core applies documented fallbacks (e.g. delayed message → local scheduler)
and logs the negotiated result at startup. We never dumb the API down to what JMS can do.
3. **One envelope format.** Payload is always JSON bytes; type info and trace context ride in standardized headers
(`dc3-type`, `X-Request-Id`, `tenant-id`), and payload-level schema versioning is already in production on the
point-value path (schema-v1 envelope). Serialization lives in the API layer — no reliance on broker-side type
converters (today's `__TypeId__` header is a Spring AMQP internal). This keeps the wire format identical across
brokers.
1. **抽象语义,而非中间件特性。** 统一层次是 *逻辑主题 + 订阅模式*
exchange/binding/分区/tag 模型属于适配器内部。两种订阅模式(`LOAD_BALANCE``BROADCAST`
是全部五个目标中间件都能表达的最低公共语义。
2. **能力协商与优雅降级,而非最低公共分母。**
适配器声明自己支持什么;核心应用文档化的回退(fallback)(如延迟消息 → 本地调度器),
并在启动时记录协商结果。我们绝不把 API 降到 JMS 能做到的水平。
3. **单一信封格式。** 载荷始终是 JSON 字节;类型信息与追踪上下文搭载在标准化 header
`dc3-type``X-Request-Id``tenant-id`)中,且载荷级 schema 版本化已在点位值路径上生产使用
(schema-v1 信封)。序列化位于 API 层——不依赖中间件侧的类型转换器(今天的 `__TypeId__` header 是 Spring AMQP
内部机制)。这保证线级格式在各中间件间完全一致。
## 5. Module layout
## 5. 模块布局
Mirrors the existing facade-module organization (contract module + per-transport implementations):
镜像现有的 facade 模块组织方式(契约模块 + 每种传输一个实现):
```
dc3-mq/ # top-level aggregator: the broker-selection family
@@ -173,11 +157,11 @@ dc3-mq/ # top-level aggregator: the broker-selection fa
└── dc3-mq-tck/ # Broker-neutral contract test suite (Testcontainers)
```
(Restructured 2026-08-20 out of `dc3-common` into the top-level `dc3-mq` aggregator — the family is a deployment
selection, not shared plumbing; Java packages stay
`io.github.pnoker.common.mq.*`.)
2026-08-20 重组:从 `dc3-common` 迁出,成为顶层 `dc3-mq` 聚合模块——该家族是部署
选择,不是共享基础设施;Java 包名保持
`io.github.pnoker.common.mq.*`。)
Dependency direction after migration:
迁移后的依赖方向:
```
dc3-common-data ─┐
@@ -187,11 +171,11 @@ dc3-common-manager ─┘ ▲
dc3-mq-rabbitmq / -kafka / -rocketmq / -pulsar / -activemq / -mqtt
```
Adapters are selected by `dc3.mq.type` and activated via
`@ConditionalOnProperty(prefix = "dc3.mq", name = "type", havingValue = "...")`, the same pattern `dc3.facade.mode` uses
for facade transports.
适配器由 `dc3.mq.type` 选择,并通过
`@ConditionalOnProperty(prefix = "dc3.mq", name = "type", havingValue = "...")` 激活——与 `dc3.facade.mode`
facade 传输使用的模式相同。
## 6. Core API
## 6. 核心 API
```java
package io.github.pnoker.common.mq;
@@ -285,383 +269,372 @@ public record BrokerCapabilities(
) {}
```
Business-side shape after migration — a single-message receiver becomes:
迁移后的业务侧形态——单消息接收器变为:
```java
@Dc3Listener(topic = MqTopic.COMMAND, mode = LOAD_BALANCE, profile = LATENCY)
public void commandReceive(MqReceived<CommandBO> message, Acknowledgment ack) { ... }
```
and the batch point-value receiver keeps its throughput semantics:
批量点位值接收器保持其吞吐语义:
```java
@Dc3Listener(topic = MqTopic.POINT_VALUE, mode = LOAD_BALANCE, profile = THROUGHPUT, delivery = BATCH)
public void pointValueReceive(List<MqReceived<PointValueBO>> messages, Acknowledgment ack) { ... }
```
batchSize, prefetch and the retry bounds bind from configuration (`BatchConsumerProperties`
today) rather than annotation literals, so ops can tune them per deployment.
batchSizeprefetch 与重试上界从配置绑定(今天是 `BatchConsumerProperties`)而非注解字面量,
以便运维按部署调优。
`Channel`, `Message`, delivery tags and `RabbitAckUtil` disappear from business code. The
`@Dc3Listener` annotation is processed by `dc3-mq-core` core, which registers the subscription with the active adapter
mirroring how `@RabbitListener` is processed today, minus the broker API.
`Channel``Message`、投递标签与 `RabbitAckUtil` 从业务代码中消失。The
`@Dc3Listener` 注解由 `dc3-mq-core` 核心处理,它向激活的适配器注册订阅—
镜像今天 `@RabbitListener` 的处理方式,只是去掉了中间件 API
Poison handling within a batch is batch-granular — matching today's behavior, where one invalid message causes the whole
batch to retry and then dead-letter (§8.7). Whether the port should eventually offer per-message extraction is open
question §13.7.
批次内的毒消息处理是批次粒度的——与今天的行为一致:一条无效消息导致整个
批次重试然后死信(§8.7)。Port 最终是否应提供按消息提取是开放
问题 §13.7
## 7. Subscription modes and destination mapping
## 7. 订阅模式与目的地映射
The two subscription modes are the central modeling decision:
两种订阅模式是核心建模决策:
- **LOAD_BALANCE** — competing consumers, each message handled once platform-wide. Used by point values, states, alarms,
commands, events, notify tasks.
- **BROADCAST** — every running instance gets its own copy. Used by driver metadata sync (each driver process must
refresh its local cache) and the device-scan tick fan-out.
- **LOAD_BALANCE** —— 竞争消费者,每条消息在全平台只被处理一次。用于点位值、状态、告警、
命令、事件、通知任务。
- **BROADCAST** —— 每个运行实例都获得自己的副本。用于驱动元数据同步(每个驱动进程必须
刷新本地缓存)与设备扫描 tick 扇出。
Physical mapping per broker (namespace = today's `dc3.rabbit.tag` environment prefix):
各中间件的物理映射(namespace = 今天的 `dc3.rabbit.tag` 环境前缀):
| Semantic | RabbitMQ (adapter keeps current names) | Kafka | RocketMQ | Pulsar | ActiveMQ (JMS) | MQTT 5 |
| 语义 | RabbitMQ(适配器保持现有名称) | Kafka | RocketMQ | Pulsar | ActiveMQ (JMS) | MQTT 5 |
|---------------------|-----------------------------------------------------------|------------------------------------------------|-----------------------------------------------|--------------------------------------|----------------------------------------------------|------------------------------------------------------|
| LOAD_BALANCE | shared durable queue bound `rk.*` | consumer group on topic `dc3.<topic>` | CLUSTERING consume mode | Shared subscription `dc3-<topic>` | JMS Queue | shared subscription `$share/<group>/dc3/<topic>` |
| BROADCAST | per-instance auto-delete queue (current design) | per-instance `groupId = group + instanceId` | BROADCASTING consume mode | Exclusive subscription per instance | JMS Topic, unshared durable subscriber | normal subscription, persistent session per instance |
| Partition key | routing-key suffix `.<service>` | record key → partition | message key (routing only, no ordering) | key (routing only, shared sub) | `dc3-partition-key` property | `dc3-partition-key` user property |
| Key-pattern routing | broker topic bindings | client-side router (`KeyMatcher`/`KeyRoutes`) | client-side router | client-side router | client-side router | client-side router |
| Batch | consumer batch (`setBatchListener`, prefetch ≥ batchSize) | poll loop (`max.poll.records`) | batch consumption (`consumeMessageBatchSize`) | batch receive API | adapter drains within a short window (synthesized) | adapter drains within a short window (synthesized) |
| Instance expiry | `x-expires` / auto-delete + TTL (§8.8) | ❌ offsets persist — documented cleanup policy | subscription group config | subscription expiry policy | no direct equivalent — documented cleanup policy | session expiry interval ⚠️ (§13.8) |
| Namespace | name prefix (today) | topic prefix | namespace | Pulsar tenant/namespace (native fit) | destination prefix | topic prefix (`dc3/<topic>`, slash-separated) |
| LOAD_BALANCE | 绑定 `rk.*` 的共享持久队列 | topic `dc3.<topic>` 上的消费者组 | CLUSTERING 消费模式 | 共享订阅 `dc3-<topic>` | JMS Queue | 共享订阅 `$share/<group>/dc3/<topic>` |
| BROADCAST | 每实例 auto-delete 队列(现有设计) | 每实例 `groupId = group + instanceId` | BROADCASTING 消费模式 | 每实例独占订阅 | JMS Topic,非共享持久订阅 | 普通订阅,每实例持久会话 |
| 分区键 | 路由键后缀 `.<service>` | record key → 分区 | 消息 key(仅路由,不保证顺序) | key(仅路由,共享订阅) | `dc3-partition-key` 属性 | `dc3-partition-key` 用户属性 |
| Key 模式路由 | 中间件 topic binding | 客户端侧路由器(`KeyMatcher`/`KeyRoutes` | 客户端侧路由器 | 客户端侧路由器 | 客户端侧路由器 | 客户端侧路由器 |
| 批量 | 消费者批量(`setBatchListener`prefetch ≥ batchSize | poll 循环(`max.poll.records` | 批量消费(`consumeMessageBatchSize` | 批量接收 API | 适配器在短窗口内排空(合成) | 适配器在短窗口内排空(合成) |
| 实例过期 | `x-expires` / auto-delete + TTL§8.8 | ❌ offset 持久存在 —— 文档化清理策略 | 订阅组配置 | 订阅过期策略 | 无直接等价物 —— 文档化清理策略 | 会话过期间隔 ⚠️§13.8 |
| 命名空间 | 名称前缀(今天) | topic 前缀 | namespace | Pulsar tenant/namespace(原生契合) | destination 前缀 | topic 前缀(`dc3/<topic>`,斜杠分隔) |
Ordering note (documented, not hidden): RabbitMQ today guarantees nothing; Kafka with
`partitionKey = driver service` gives per-driver ordering — strictly stronger than the current semantics, and it matters
for point-value ingestion order.
顺序说明(文档化,而非隐藏):RabbitMQ 今天不提供任何顺序保证;Kafka
`partitionKey = driver service` 提供每驱动顺序——严格强于当前语义,且它对
点位值摄入顺序很重要。
### 7.1 The MQTT boundary — two planes, one principle
### 7.1 MQTT 边界 —— 两平面一原则
MQTT appears in two distinct roles in the platform. Both must treat the MQTT broker as a free, swappable selection
same principle as the rest of this design:
MQTT 在平台中以两个不同角色出现。二者都必须把 MQTT 中间件视为自由、可替换的选择—
与本设计其余部分遵循同一原则:
1. **Device-access plane (southbound)**`dc3-common-mqtt` + `dc3-driver-mqtt`. This is protocol access, not the
internal async plane, and it is already vendor-neutral by construction: a standard Eclipse Paho v3 client (MQTT
3.1.1) configured entirely through `dc3.driver.mqtt.*` (URL, auth, TLS material, topic prefix). EMQX is the
compose-bundled default (`docker-compose-optional.yml`), nothing more — Mosquitto, HiveMQ, NanoMQ or VerneMQ drop in
via configuration alone. Guardrails that keep it that way: no broker-vendor management API, rule engine, or
plugin-specific code in Java modules; vendor specifics live in deploy configuration only. The client library pins the
*protocol version* (3.1.1 today), not the vendor — upgrading to an MQTT 5 client is an independent, optional library
change.
2. **Internal async plane (optional broker)** the `dc3-mq-mqtt` adapter. MQTT 5 shared subscriptions express
LOAD_BALANCE; a normal subscription per instance expresses BROADCAST; QoS 1 gives per-message ack and PUBACK
confirmation (§8.4). This lets a deployment consolidate: one MQTT broker serving both planes, or a smaller stack
without RabbitMQ at all. The gaps fall back through the standard mechanisms (delay → local scheduler, DLQ → explicit
topic, batch → synthesized windowing), and the TCK — including group durability with all instances down (§11 case
13) — is the acceptance bar, because shared-subscription retention semantics vary between brokers (§13.8).
1. **南向设备面(southbound** `dc3-common-mqtt` + `dc3-driver-mqtt`。这是协议接入,不是
内部异步平面,且它在构造上已厂商中立:标准 Eclipse Paho v3 客户端(MQTT
3.1.1),完全通过 `dc3.driver.mqtt.*`URL、认证、TLS 材料、topic 前缀)配置。EMQX 是
compose 捆绑的默认值(`docker-compose-optional.yml`),仅此而已——MosquittoHiveMQNanoMQ VerneMQ 仅凭
配置即可替换。保持这一性质的护栏:Java 模块中不含任何中间件厂商管理 API、规则引擎或
插件专属代码;厂商细节只存在于部署配置中。客户端库锁定的是
*协议版本*(今天是 3.1.1),不是厂商——升级到 MQTT 5 客户端是一次独立的、可选的库
变更。
2. **内部异步平面(可选中间件)** `dc3-mq-mqtt` 适配器。MQTT 5 共享订阅表达
LOAD_BALANCE;每实例普通订阅表达 BROADCASTQoS 1 提供逐消息确认与 PUBACK
确认(§8.4)。这让部署得以整合:一个 MQTT 中间件同时服务两个平面,或彻底去掉
RabbitMQ 的更小技术栈。能力缺口通过标准机制回退(fallback)(延迟 → 本地调度器、DLQ → 显式
topic、批量 → 合成窗口),而 TCK——包括全体实例宕机时的组持久性(§11 用例
13)——是验收门槛,因为共享订阅的保留语义因中间件而异(§13.8)。
The two planes stay independent: running the internal plane over MQTT changes nothing for the device-access drivers, and
swapping the device-access broker changes nothing for the internal plane.
两个平面保持独立:内部平面改跑 MQTT 对南向设备面的驱动毫无影响,替换南向设备面的中间件对内部平面也毫无影响。
## 8. Hard semantics, broker by broker
## 8. 逐中间件梳理的硬语义
### 8.1 Delayed messages
### 8.1 延迟消息
| Broker | Mechanism |
| 中间件 | 机制 |
|----------|------------------------------------------------------------------------|
| RabbitMQ | current TTL + DLX pattern (unchanged); delayed-message plugin optional |
| RocketMQ | delay levels / arbitrary timing (5.x) |
| Pulsar | delayed delivery API |
| ActiveMQ | JMS scheduled delivery (`AMQ_SCHEDULED_DELAY`) |
| Kafka | **none** → API-layer fallback: local `ScheduledExecutor` re-send |
| MQTT | **none**same local scheduler fallback |
| RabbitMQ | 现有 TTL + DLX 模式(不变);延迟消息插件可选 |
| RocketMQ | 延迟级别 / 任意定时(5.x) |
| Pulsar | 延迟投递 API |
| ActiveMQ | JMS 定时投递(`AMQ_SCHEDULED_DELAY` |
| Kafka | **** → API 层回退(fallback):本地 `ScheduledExecutor` 重发 |
| MQTT | ****同样的本地调度器回退(fallback) |
Fallback safety: consumers of delayed messages (`DriverTimeoutCheckReceiver`, device scan) are already idempotent
against the DB lease (`leaseVersion`). Multiple or late triggers are harmless. The fallback runs in whichever instance
sent the message; startup logs state `delayedMessage=false → local scheduler fallback active`.
回退(fallback) 安全性:延迟消息的消费者(`DriverTimeoutCheckReceiver`、设备扫描)已经针对数据库租约
`leaseVersion`)幂等。多次或迟到触发无害。回退(fallback) 运行在发送该消息的实例中;启动日志输出
`delayedMessage=false → local scheduler fallback active`
### 8.2 Dead-letter queues
### 8.2 死信队列
`reject(false)` is the single entry point. The adapter maps it to:
`reject(false)` 是唯一入口。适配器把它映射为:
| Broker | Mechanism |
| 中间件 | 机制 |
|----------|-------------------------------------------------------------------------|
| RabbitMQ | current DLX wiring (point_value / point_command / command) |
| Kafka | explicit `dc3.<topic>.dlq` topic written by the adapter on exhaustion |
| RocketMQ | built-in `%RETRY%group` / `%DLQ%group`, surfaced as logical dead-letter |
| Pulsar | DLQ policy + `maxRedeliverCount` |
| ActiveMQ | JMS redelivery policy + DLQ |
| MQTT | explicit `dc3/<topic>.dlq` topic written by the adapter on exhaustion |
| RabbitMQ | 现有 DLX 接线(point_value / point_command / command |
| Kafka | 重试耗尽时由适配器写入显式 `dc3.<topic>.dlq` topic |
| RocketMQ | 内建 `%RETRY%group` / `%DLQ%group`,以逻辑死信形式呈现 |
| Pulsar | DLQ 策略 + `maxRedeliverCount` |
| ActiveMQ | JMS 重投策略 + DLQ |
| MQTT | 重试耗尽时由适配器写入显式 `dc3/<topic>.dlq` topic |
The dedicated dead receivers (`CommandDeadReceiver`, `PointCommandDeadReceiver`) re-declare their subscriptions against
the logical dead-letter topics. The point-value dead queue has **no consumer by design** since `956de3dd3` — it is a
quarantine for poison batches and 7-day-unconsumed values; the port models it as a dead-letter topic without a
subscription. Its retention is an open question (§13.6). The 7-day TTL on the **live** point-value queue becomes a topic
retention policy attribute — see §8.6.
专用死信接收器(`CommandDeadReceiver``PointCommandDeadReceiver`)针对逻辑死信主题重新声明订阅。点位值死信队列自
`956de3dd3`**设计上就没有消费者**——它是毒批次与 7 天未消费值的隔离区;Port 把它建模为没有订阅的死信主题。
其保留策略是开放问题(§13.6)。 **活** 点位值队列上的 7 天 TTL 变为主题
保留策略属性——见 §8.6。
### 8.3 Acknowledgement, retry and back-pressure
### 8.3 确认、重试与背压
| Operation | RabbitMQ | Kafka | RocketMQ | Pulsar | ActiveMQ | MQTT 5 |
| 操作 | RabbitMQ | Kafka | RocketMQ | Pulsar | ActiveMQ | MQTT 5 |
|--------------------------------------|------------------------------------------------------------|-----------------------------------|-----------------------------|---------------------------|-----------------------|-----------------------------------------------|
| `ack()` | basicAck (single or batch-multiple) | offset commit (batched) | CONSUME_SUCCESS | ack | acknowledge | PUBACK (QoS 1) |
| `reject(true)` | basicNack requeue | seek back, no commit | RECONSUME_LATER | negative ack / redelivery | rollback | adapter-level retry loop (broker has no nack) |
| `reject(false)` | basicReject → DLX | write to `.dlq` topic | built-in retry→DLQ | DLQ policy | redelivery-policy DLQ | publish to `.dlq` topic |
| bounded retry (batch retry settings) | stateless retry advice + recoverer (today's batch factory) | in-memory retry loop, then `.dlq` | `%RETRY%group` (native fit) | redelivery backoff + DLQ | redelivery policy | adapter in-memory loop |
| back-pressure | prefetch ≥ batchSize, broker as durable buffer | consumer `pause()`/`resume()` | suspend current queue | flow control / queue size | session recover | receive maximum (MQTT 5 flow control) |
| `ack()` | basicAck(单条或批量 multiple | offset 提交(批量) | CONSUME_SUCCESS | ack | acknowledge | PUBACKQoS 1 |
| `reject(true)` | basicNack 重新入队 | seek 回退,不提交 | RECONSUME_LATER | negative ack / 重投 | rollback | 适配器级重试循环(中间件没有 nack) |
| `reject(false)` | basicReject → DLX | 写入 `.dlq` topic | 内建 retry→DLQ | DLQ 策略 | 重投策略 DLQ | 发布到 `.dlq` topic |
| 有界重试(批量重试配置) | 无状态重试 advice + recoverer(今天的批量工厂) | 内存重试循环,然后 `.dlq` | `%RETRY%group`(原生契合) | 重投退避 + DLQ | 重投策略 | 适配器内存循环 |
| 背压 | prefetch ≥ batchSize,中间件充当持久缓冲 | 消费者 `pause()`/`resume()` | 挂起当前队列 | 流控 / 队列大小 | 会话恢复 | receive maximumMQTT 5 流控) |
The bounded-retry row matters: since `956de3dd3` the point-value factory wraps delivery in a stateless retry advice with
exponential backoff, and exhaustion rejects the whole batch to the DLX. The shared batch-consumer retry settings
(`BatchConsumerProperties`, bound from `dc3.data.point.batch.*`) drive that bound on every adapter instead of
per-adapter improvisation — Kafka needs an in-memory loop, RocketMQ gets it natively from `%RETRY%group`.
有界重试这一行很关键:自 `956de3dd3` 起,点位值工厂用带指数退避的无状态重试 advice 包装投递,
耗尽后把整个批次拒绝进 DLX。共享的批量消费者重试配置
`BatchConsumerProperties`,从 `dc3.data.point.batch.*` 绑定)在所有适配器上驱动这一上界,而不是
各适配器各自即兴发挥——Kafka 需要内存循环,RocketMQ 则从 `%RETRY%group` 原生获得。
The old buffer-full `nack(requeue=true)` pattern is gone from the codebase (the data-side ingest buffer was removed;
`NotifyWorker` deliberately acks failures as FAILED instead of requeueing). Back-pressure is now "broker as durable
buffer + bounded prefetch", which maps cleanly to every target broker.
旧的缓冲满 `nack(requeue=true)` 模式已从代码库中移除(数据侧摄入缓冲已删除;
`NotifyWorker` 刻意把失败按 FAILED 确认而不是重新入队)。背压现在是“中间件充当持久
缓冲 + 有界 prefetch”,能干净地映射到每个目标中间件。
Contract documented for consumers: **at-least-once**, redelivery possible, idempotency required. `redeliveryCount` on
`MqReceived` is best-effort (exact on Kafka via retry topic count, RocketMQ reconsume times, Pulsar redelivery count;
approximate elsewhere).
面向消费者的契约文档:**至少一次**,可能重投,要求幂等。`MqReceived` 上的
`redeliveryCount` 是尽力而为(Kafka 经重试 topic 计数、RocketMQ reconsume 次数、Pulsar 重投计数为精确值;
其余为近似值)。
### 8.4 Publisher confirmation and the driver outbox
### 8.4 发布者确认与驱动发件箱(outbox)
Since `956de3dd3` the driver already owns durability: every point value is persisted to a **mandatory SQLite outbox**
(WAL, `synchronous=FULL`, startup validation, missing configuration fails fast) *before* `convertAndSend`, and
`956de3dd3` 起驱动已掌控持久性:每个点位值都在 `convertAndSend` *之前* 持久化到 **强制性 SQLite 发件箱(outbox)**
WAL`synchronous=FULL`、启动校验、缺配置即快速失败),并且
`BufferServiceImpl` +
`PointValueBuffer` run a claim-before-publish retry scheduler over pending rows.
`PointValueBuffer` 对待处理行运行先认领后发布的重试调度器。
After migration, the outbox itself stays driver-side and broker-neutral — only the final
`convertAndSend` + `CorrelationData`-future plumbing swaps for `sendAsync` +
`SendConfirmation`. Broker mapping: RabbitMQ publisher confirms; Kafka `acks=all` future; RocketMQ send result; Pulsar
send future; MQTT QoS 1 PUBACK (per-message confirm — the strongest of the non-RabbitMQ set); **JMS has no
confirmation** → best-effort (fire-and-forget with failure-driven republish from the outbox), marked in the capability
matrix.
迁移后,发件箱(outbox) 本身仍留在驱动侧且中间件中立——只有最后的
`convertAndSend` + `CorrelationData`-future 管线换成 `sendAsync` +
`SendConfirmation`。中间件映射:RabbitMQ 发布者确认;Kafka `acks=all` futureRocketMQ 发送结果;Pulsar
发送 futureMQTT QoS 1 PUBACK(逐消息确认——非 RabbitMQ 阵营中最强的);**JMS 没有确认**
→ 尽力而为(发后即忘,失败时从发件箱(outbox) 重发布),在能力矩阵中标注。
Consequence worth stating explicitly: **publisher confirm is no longer load-bearing for durability**. Because the outbox
owns persistence, `publisherConfirm=false` brokers are fully acceptable — confirm becomes a latency/republish-frequency
optimization, not a correctness requirement. The ActiveMQ adapter is therefore a first-class citizen, not a degraded
mode.
值得明确指出的结论:**发布者确认不再是持久性的承重机制**。因为发件箱(outbox)
负责持久化,`publisherConfirm=false` 的中间件完全可接受——确认变成延迟/重发布频率的
优化,而非正确性要求。因此 ActiveMQ 适配器是一等公民,不是降级
模式。
### 8.5 Envelope, tracing, tenant context
### 8.5 信封、链路追踪与租户上下文
- `dc3-type` header carries the payload class name; the API layer deserializes — replaces Spring AMQP's `__TypeId__`.
- Payload-level schema versioning is already in production on the point-value path (schema-v1: `schemaVersion`,
`messageId`, `sequence`, `fencingToken`, `driverNode`, validated in `PointValueReceiver`); the port adopts the same
convention — schema fields live in the payload, transport hints in headers.
- `X-Request-Id` propagation (currently `MdcRequestIdMessagePostProcessor` /
`MdcRequestIdListenerAdvice`) moves into `dc3-mq-core` core; all brokers support string headers.
- Tenant id remains inside the payload (and mirrored as a header for operational filtering); tenant scoping rules are
unaffected by the broker choice.
- `dc3-type` header 携带载荷类名;由 API 层反序列化——取代 Spring AMQP `__TypeId__`
- 载荷级 schema 版本化已在点位值路径上生产使用(schema-v1`schemaVersion`
`messageId``sequence``fencingToken``driverNode`,在 `PointValueReceiver` 中校验);Port 采用同一
约定——schema 字段放在载荷中,传输提示放在 header 中。
- `X-Request-Id` 传播(当前为 `MdcRequestIdMessagePostProcessor` /
`MdcRequestIdListenerAdvice`)移入 `dc3-mq-core` 核心;所有中间件都支持字符串 header
- 租户 id 留在载荷内(并镜像为一个 header 便于运维过滤);租户作用域规则不受中间件选择影响。
### 8.6 Queue-level message TTL as retention policy
### 8.6 队列级消息 TTL 作为保留策略
A RabbitMQ-specific usage the port must model: queues carry per-queue message TTL as a retention/expiry guard
state/alarm 30 s (stale lifecycle events are worthless), event/command_result 60 s, notify_task 24 h (runaway-backlog
guard when outbound channels are stuck), point_value 7 d (then dead-lettered, §8.2), driver-side command queues 30 s.
Port 必须建模的一种 RabbitMQ 特有用法:队列携带每队列消息 TTL 作为保留/过期守卫—
state/alarm 30 秒(过期的生命周期事件毫无价值)、event/command_result 60 秒、notify_task 24 小时(出站通道卡死时
的失控积压守卫)、point_value 7 天(随后死信,§8.2)、驱动侧命令队列 30 秒。
This becomes a `retention` attribute on the topic registry, with per-broker semantics documented rather than identical:
RabbitMQ TTL drops per message from the head of the queue; Kafka/RocketMQ/Pulsar retention is time-based log retention
(drops whole old segments — effectively the same "don't keep forever" guarantee); JMS TTL and MQTT 5 message expiry are
per message. The distinction only matters for exact expiry timing, which no consumer in the codebase relies on (expired
state events are simply dropped, and the DB remains the source of truth).
这变成主题注册表上的一个 `retention` 属性,各中间件语义文档化而非完全一致:
RabbitMQ TTL 从队头逐消息丢弃;Kafka/RocketMQ/Pulsar 的保留是基于时间的日志保留
(丢弃整个旧段——实际上等同于“不永久保存”的保证);JMS TTL MQTT 5 消息过期是
逐消息的。区别只在精确过期时点上才有影响,代码库中没有任何消费者依赖它(过期的
状态事件直接丢弃,数据库仍是事实来源)。
### 8.7 Batch consumption and transactional ack
### 8.7 批量消费与事务性确认
The point-value path — the platform's highest-volume stream — runs on consumer-side batching since `956de3dd3`:
点位值路径——平台吞吐量最大的流——自 `956de3dd3` 起运行在消费者侧批量之上:
- `PointValueRabbitConfig` declares a third container factory: batch listener + consumer batch, `prefetch ≥ batchSize`,
bounded retry with backoff, exhaustion → whole batch to the point-value DLX.
- `PointValueReceiver` receives `List<Message>`, validates the schema-v1 envelope per message, persists history + latest
projections in **one PostgreSQL transaction**, and only then `basicAck(lastTag, multiple=true)` — ack and commit are
atomic from the consumer's perspective.
- `PointValueRabbitConfig` 声明第三个容器工厂:批量监听器 + 消费者批量、`prefetch ≥ batchSize`
带退避的有界重试、耗尽 → 整批进入点位值 DLX
- `PointValueReceiver` 接收 `List<Message>`,逐消息校验 schema-v1 信封,在 **单个 PostgreSQL 事务** 中持久化历史 +
最新值投影,然后才 `basicAck(lastTag, multiple=true)`——从消费者视角看,确认与提交是
原子的。
| Broker | Batch mechanism |
| 中间件 | 批量机制 |
|----------|------------------------------------------------------------------------------------------------------------------------------------|
| RabbitMQ | consumer batch (`setBatchListener` + `setConsumerBatchEnabled`), current design |
| Kafka | poll loop with `max.poll.records`; offsets committed after the handler returns |
| RocketMQ | batch consumption (`consumeMessageBatchSize`) |
| Pulsar | batch receive API |
| ActiveMQ | no native consumer batch → adapter drains available messages within a short window (synthesized, capability `batchDelivery=false`) |
| MQTT | no native consumer batch → same adapter-side windowing (synthesized, `batchDelivery=false`) |
| RabbitMQ | 消费者批量(`setBatchListener` + `setConsumerBatchEnabled`),现有设计 |
| Kafka | `max.poll.records` 的 poll 循环;处理器返回后提交 offset |
| RocketMQ | 批量消费(`consumeMessageBatchSize` |
| Pulsar | 批量接收 API |
| ActiveMQ | 无原生消费者批量 → 适配器在短窗口内排空可用消息(合成,能力 `batchDelivery=false` |
| MQTT | 无原生消费者批量 → 同样的适配器侧窗口(合成,`batchDelivery=false` |
Port model: `DeliveryMode.BATCH` + `MqBatchListener` + the shared batch retry settings. The TCK verifies that `ack()`
after batch processing commits every message in the batch and that retry exhaustion dead-letters rather than drops
(§11).
Port 模型:`DeliveryMode.BATCH` + `MqBatchListener` + 共享批量重试配置。TCK 验证批量处理后的 `ack()`
会提交批内每条消息,且重试耗尽走死信而非丢弃
§11)。
Known trade-off (documented, not hidden): one poison message retries and dead-letters the **whole batch** today.
Batch-level granularity is the Spring default and keeps the cross-broker story simple; per-message extraction is open
question §13.7.
已知取舍(文档化,而非隐藏):今天一条毒消息会导致 **整个批次** 重试并死信。
批次级粒度是 Spring 默认行为,也让跨中间件的叙事保持简单;按消息提取是开放
问题 §13.7
### 8.8 Per-instance queue lifecycle (lease-coupled expiry)
### 8.8 每实例队列生命周期(租约耦合的过期)
Driver-side subscriptions are per-instance and must not outlive their driver:
驱动侧订阅是每实例的,且不得比其驱动存活更久:
- command/point-command queues carry `x-expires = driver.lease.queue-expires` — a dead driver's queue disappears with
its lease instead of silently accumulating commands that would be delivered to a stale instance on restart;
- the metadata broadcast queue is auto-delete with 30 s message TTL, bounding staleness from dead instances.
- command/point-command 队列携带 `x-expires = driver.lease.queue-expires`——失效驱动的队列随其租约消失,
而不是默默堆积命令、在重启后投递给一个过期实例;
- 元数据广播队列是 auto-delete,消息 TTL 30 秒,限制失效实例造成的陈旧。
Port model: `instanceTtl` on `SubscriptionSpec` + capability `subscriptionExpiry`. RabbitMQ maps to `x-expires`
/auto-delete natively; RocketMQ/Pulsar have subscription-level equivalents; Kafka offsets simply persist (stale groups
are cosmetic — documented cleanup policy via admin tooling); JMS has no direct equivalent (documented cleanup policy);
MQTT 5 maps to the session expiry interval of the persistent session. Neither fallback affects correctness, because
command senders already validate driver lease/ownership before dispatch.
Port 模型:`SubscriptionSpec` 上的 `instanceTtl` + 能力 `subscriptionExpiry`RabbitMQ 原生映射到 `x-expires`
/auto-deleteRocketMQ/Pulsar 有订阅级等价物;Kafka offset 直接持久存在(过期组只是观感问题——通过管理工具
文档化清理策略);JMS 没有直接等价物(文档化清理策略);
MQTT 5 映射到持久会话的会话过期间隔。两种回退(fallback) 都不影响正确性,因为
命令发送方在分发前已经校验驱动租约/所有权。
## 9. Capability matrix (published, per adapter)
## 9. 能力矩阵(按适配器发布)
Implementation status (revised 2026-08-25): five adapters — rabbitmq, kafka, activemq (Artemis), mqtt 5 and pulsar — are
implemented and certified by the TCK against live brokers. The **rocketmq adapter is experimental and not yet
certified**: its contract suite is opt-in (`TCK_ROCKETMQ_NAMESRV`) and self-describes as not-yet-certified, so it ships
for evaluation, not as a production selection. Certified columns reflect the implemented behavior (e.g. rabbit delays
arbitrary messages through the port fallback, rocketmq delay levels would quantize; the rocketmq classic client replays
topic backlog for brand-new consumer groups regardless of consumeFromWhere, so that adapter seeds fresh groups to the
latest offset and warms up not-yet-created topics on subscribe — the warm-up probe carries a marker property no business
listener ever sees; pulsar subscriptions start at the latest position natively, which matches the fresh-queue semantics
without seeding).
实现状态(2026-08-25 修订):五个适配器——rabbitmqkafkaactivemqArtemis)、mqtt 5 pulsar——
已实现并通过 TCK 对照真实中间件完成认证。**rocketmq 适配器为实验性且尚未认证**:其契约套件为可选启用
`TCK_ROCKETMQ_NAMESRV`),且自述为尚未认证,因此它面向评估发布,不是生产选择。“已认证”列反映的是已实现的行为
(例如 rabbit 经 Port 回退(fallback) 延迟任意消息,rocketmq 延迟级别会量化;rocketmq classic 客户端对全新消费者组
会无视 consumeFromWhere 重放 topic 积压,因此该适配器把新组播种到最新 offset,并在订阅时预热尚未创建的
topic——预热探针携带业务监听器永远看不到的标记属性;pulsar 订阅原生从最新位置开始,无需播种即符合新队列语义)。
| Capability | RabbitMQ ✅ | Kafka ✅ | ActiveMQ ✅ | MQTT 5 ✅ | RocketMQ ⚠️ experimental | Pulsar ✅ |
| 能力 | RabbitMQ ✅ | Kafka ✅ | ActiveMQ ✅ | MQTT 5 ✅ | RocketMQ ⚠️ 实验性 | Pulsar ✅ |
|--------------------------|-----------------------|-----------------------|-----------------------------------------|-----------------------------|------------------------------------|-----------------------|
| Delayed message | fallback* | ❌ → local fallback | ✅ JMS scheduled | ❌ → local fallback | fallback (levels quantize) | ✅ native |
| Native DLQ | DLX + quarantine | adapter `.dlq` topic | adapter `.dlq` queue | adapter `/dlq` topic | adapter `-dlq` topic | adapter `.dlq` topic |
| Broadcast | ✅ per-instance queue | ✅ (instance groups) | ✅ topic consumer | ✅ plain filter | ✅ BROADCASTING | ✅ |
| Per-message ack | ✅ | offset (approx) | ✅ client ack | ✅ QoS 1 | ✅ | ✅ |
| Publisher confirm | ✅ confirms | ✅ (acks=all) | ❌ best-effort (outbox covers it, §8.4) | ✅ (PUBACK) | ✅ sync send | ✅ |
| Batch delivery | ✅ native | ✅ native | ⚠️ synthesized | ⚠️ synthesized | ✅ consumer batch | ✅ batch receive |
| Per-key ordering | ❌ | ✅ | ❌ | ❌ | ❌ (keys carried for routing only) | ❌ (shared sub) |
| Key-pattern routing | ✅ broker bindings | ✅ client-side router | ✅ client-side router | ✅ client-side router | ✅ client-side router | ✅ client-side router |
| Subscription expiry | ✅ x-expires | ❌ documented | ❌ documented | ⚠️ session expiry | ❌ documented | ❌ documented |
| Group durability offline | ✅ durable queue | ✅ log retention | ✅ durable subscription | ⚠️ broker-dependent (§13.8) | ✅ offsets | ✅ |
| Retention | queue TTL | retention config | subscription retention | broker-dependent | retention | retention/TTL |
| 延迟消息 | 回退(fallback)* | ❌ → 本地回退(fallback) | ✅ JMS 定时 | ❌ → 本地回退(fallback) | 回退(fallback)(级别量化) | ✅ 原生 |
| 原生 DLQ | DLX + 隔离区 | 适配器 `.dlq` topic | 适配器 `.dlq` 队列 | 适配器 `/dlq` topic | 适配器 `-dlq` topic | 适配器 `.dlq` topic |
| 广播 | ✅ 每实例队列 | ✅(实例组) | ✅ topic 消费者 | ✅ 普通过滤 | ✅ BROADCASTING | ✅ |
| 逐消息确认 | ✅ | offset(近似) | ✅ 客户端确认 | ✅ QoS 1 | ✅ | ✅ |
| 发布者确认 | ✅ confirms | ✅acks=all | ❌ 尽力而为(发件箱(outbox) 兜底,§8.4 | ✅PUBACK | ✅ 同步发送 | ✅ |
| 批量投递 | ✅ 原生 | ✅ 原生 | ⚠️ 合成 | ⚠️ 合成 | ✅ 消费者批量 | ✅ 批量接收 |
| 按 key 顺序 | ❌ | ✅ | ❌ | ❌ | ❌key 仅用于路由) | ❌(共享订阅) |
| Key 模式路由 | ✅ 中间件 binding | ✅ 客户端侧路由器 | ✅ 客户端侧路由器 | ✅ 客户端侧路由器 | ✅ 客户端侧路由器 | ✅ 客户端侧路由器 |
| 订阅过期 | ✅ x-expires | ❌ 文档化 | ❌ 文档化 | ⚠️ 会话过期 | ❌ 文档化 | ❌ 文档化 |
| 组离线持久性 | ✅ 持久队列 | ✅ 日志保留 | ✅ 持久订阅 | ⚠️ 取决于中间件(§13.8 | ✅ offset | ✅ |
| 保留 | 队列 TTL | 保留配置 | 订阅保留 | 取决于中间件 | 保留 | 保留/TTL |
\* rabbit intrinsic TTL+DLX delays (STATE_TIMEOUT / DEVICE_SCAN) work server-side as before; arbitrary per-message
delays use the port fallback (capability false).
\* rabbit 固有的 TTL+DLX 延迟(STATE_TIMEOUT / DEVICE_SCAN)照旧在服务端生效;任意逐消息
延迟使用 Port 回退(fallback)(能力为 false)。
This table is user-facing documentation ("which broker should I pick?") and the startup negotiation log summarizes it
per deployment.
这张表是面向用户的文档(“我该选哪个中间件?”),启动协商日志按部署对它做摘要。
## 10. Alternatives considered
## 10. 已考虑的备选方案
**Spring Cloud Stream** gives official RabbitMQ/Kafka binders for free, but: the RocketMQ binder is
Alibaba-maintained, the Pulsar binder has thin community coverage, ActiveMQ has no modern binder; and its functional
programming model fights our per-driver dynamic subscriptions, TTL+DLX delays, manual-ack back-pressure, batch-ack and
confirm-outbox patterns. Rejected as the port layer — though an individual adapter may internally build on SCStream
later; the port is the contract we control.
**Spring Cloud Stream**— 免费提供官方 RabbitMQ/Kafka binder,但是:RocketMQ binder
Alibaba 维护,Pulsar binder 社区覆盖薄弱,ActiveMQ 没有现代 binder;且其函数式
编程模型与我们按驱动的动态订阅、TTL+DLX 延迟、手动确认背压、批量确认与
确认-发件箱(outbox) 模式相冲突。作为 Port 层被否决——不过单个适配器以后可以在内部基于 SCStream
构建;Port 是我们掌控的契约。
**JMS 2.0 as the core API** — rejected. Lowest common denominator: no delayed delivery standard, weak DLQ semantics, no
publisher confirmation, broadcast/group semantics map poorly, no batch consumption. JMS remains the implementation
technology of the ActiveMQ adapter only.
**JMS 2.0 作为核心 API** —— 否决。最低公共分母:没有延迟投递标准、DLQ 语义弱、没有
发布者确认、广播/组语义映射不佳、没有批量消费。JMS 仅保留为 ActiveMQ 适配器的实现
技术。
**Apache Camel as the port** rejected. An integration framework as the contract layer inverts the dependency (the
platform's core messaging on a routing DSL), drags a large dependency graph into every service, and still leaves the
DC3-specific semantics (batch post-commit ack, lease-coupled subscription expiry) to be modeled somewhere. Adapters stay
plain client libraries.
**Apache Camel 作为 Port**— 否决。把集成框架当作契约层颠倒了依赖(平台的核心消息建立在路由 DSL
之上),给每个服务拖入庞大的依赖图,而且 DC3 特有语义(批量提交后确认、租约耦合的订阅过期)仍需要在某处建模。
适配器保持纯客户端库。
**Do nothing / RabbitMQ-only** rejected for a globally distributed project: Kafka and RocketMQ are the default choices
in many enterprise environments (especially CN ecosystem for RocketMQ), and "bring your own broker" is a recurring
community ask for on-prem integration.
**什么都不做 / 仅支持 RabbitMQ**— 对一个全球分布的项目而言被否决:Kafka RocketMQ 是许多企业环境的默认选择
(RocketMQ 尤其在国内生态),“自带中间件”是社区在本地化集成中反复提出的诉求。
## 11. TCK — the community extension mechanism
## 11. TCK —— 社区扩展机制
`dc3-mq-tck` contains one broker-neutral contract suite executed against each adapter via Testcontainers (rabbitmq,
kafka, pulsar, artemis, an MQTT 5 broker — EMQX or NanoMQ; the rocketmq suite is opt-in via
`TCK_ROCKETMQ_NAMESRV` and not yet certified):
`dc3-mq-tck` 包含一套中间件中立的契约套件,通过 Testcontainers 对每个适配器执行(rabbitmq
kafkapulsarartemis、一个 MQTT 5 中间件——EMQX NanoMQrocketmq 套件通过
`TCK_ROCKETMQ_NAMESRV` 可选启用,尚未认证):
1. send → receive (round-trip, envelope fidelity, headers, `dc3-type` deserialization)
2. LOAD_BALANCE: exactly one consumer receives each message across 2 instances
3. BROADCAST: both instances receive each message
4. delay: message not delivered before the deadline, delivered after
5. `reject(false)`dead-letter topic receives the message
6. `reject(true)`redelivery observed (at-least-once)
7. `sendAsync` confirmation fires with the correct outcome
8. requestId header survives the hop (MDC restored)
9. back-pressure: rejected/full-path message is not lost
10. BATCH delivery: batch callback receives ≥ 1 messages; `ack()` commits the whole batch (no redelivery after restart)
11. Bounded retry: observed attempts ≤ the configured bound, then dead-letter — never silent drop
12. instanceTtl: where `subscriptionExpiry=true`, an idle per-instance subscription is removed after the TTL
(timing-tolerant assertion)
13. LOAD_BALANCE with no live instance: messages published while the entire group is down are retained and delivered
when an instance starts (queue-level durability; MQTT shared-subscription brokers vary here — §13.8)
1. send → receive(往返、信封保真、header`dc3-type` 反序列化)
2. LOAD_BALANCE:跨 2 个实例时每条消息恰好被一个消费者接收
3. BROADCAST:两个实例都接收每条消息
4. delay:截止时间之前不投递,之后投递
5. `reject(false)`死信主题收到消息
6. `reject(true)`观察到重投(至少一次)
7. `sendAsync` 确认以正确结果触发
8. requestId header 在跳转后仍存在(MDC 恢复)
9. 背压:被拒绝/经完整路径的消息不丢失
10. BATCH 投递:批量回调收到 ≥ 1 条消息;`ack()` 提交整个批次(重启后无重投)
11. 有界重试:观察到的尝试次数 ≤ 配置上界,然后死信——绝不静默丢弃
12. instanceTtl:在 `subscriptionExpiry=true` 的地方,空闲的每实例订阅在 TTL 之后被移除
(对时序宽容的断言)
13. LOAD_BALANCE 且无存活实例:全组宕机期间发布的消息被保留,并在实例启动时投递
(队列级持久性;MQTT 共享订阅中间件在此表现不一——§13.8
**An adapter passes the TCK ⇒ it is compliant.** This is the acceptance bar for community adapters (EMQX-bridged
transports, Redis Streams, SQS…). The existing
`RabbitDeliveryIT` / `RabbitTestHarness` assertions migrate into this suite; the E2E suite keeps running unchanged
against the rabbitmq adapter as the migration gate.
**适配器通过 TCK 即视为合规。** 这是社区适配器(EMQX 桥接
传输、Redis StreamsSQS…)的验收门槛。现有
`RabbitDeliveryIT` / `RabbitTestHarness` 断言迁入本套件;E2E 套件在迁移门槛期间继续原样运行于
rabbitmq 适配器之上。
## 12. Migration plan
## 12. 迁移计划
Wire compatibility is the invariant: no existing RabbitMQ deployment should notice the refactor.
线级兼容是不变量:任何现有 RabbitMQ 部署都不应察觉这次重构。
- **Phase 1 — extract the port, RabbitMQ adapter moves unchanged.**
Create `dc3-mq-core` + `dc3-mq-rabbitmq`. Move `RabbitConfig`,
`ExchangeConfig`, `DataTopicConfig`, `DriverTopicConfig`, `PointValueRabbitConfig`, MDC propagation, ack helpers into
the adapter with **identical physical names**. Convert the 14 raw producer call sites (8 driver send methods, outbox
republish, 6 center-side sends) and 16 listeners to the new API (`@Dc3Listener`), including the batch point-value
receiver. Business-module poms swap `dc3-common-rabbitmq` `dc3-mq-core` +
`dc3-mq-rabbitmq`. *Gate: existing E2E (`RabbitDeliveryIT` etc.) runs unmodified and green.*
- **Phase 2 — TCK + Kafka adapter.** Highest global demand. Partition-key ordering per driver is a documented upgrade
over the RabbitMQ baseline.
- **Phase 3 — RocketMQ (CN ecosystem demand), Pulsar, ActiveMQ/Artemis, MQTT 5.** The MQTT adapter lets a deployment run
both planes (device access + internal async) on one broker — or drop RabbitMQ entirely where an MQTT broker is already
operated. Publish the capability matrix, `dc3.mq.type` compose profiles per broker.
- **Throughout** — `DriverSenderService` interface is untouched; third-party driver JARs compiled against it keep
working.
- **Phase 1 —— 抽取 PortRabbitMQ 适配器原样搬迁。**
创建 `dc3-mq-core` + `dc3-mq-rabbitmq`。把 `RabbitConfig`
`ExchangeConfig``DataTopicConfig``DriverTopicConfig``PointValueRabbitConfig`MDC 传播、确认辅助类以
**完全相同的物理名称** 移入适配器。把 14 处裸生产者调用点(8 个驱动发送方法、发件箱(outbox)
重发布、6 处中心侧发送)与 16 个监听器转换到新 API`@Dc3Listener`),包括批量点位值
接收器。业务模块 pom 把 `dc3-common-rabbitmq` 换成 `dc3-mq-core` +
`dc3-mq-rabbitmq`。*门槛:现有 E2E`RabbitDeliveryIT` 等)不改一行、全绿通过。*
- **Phase 2 — TCK + Kafka 适配器。** 全球需求最高。按驱动的分区键顺序是文档化的、超越
RabbitMQ 基线的升级。
- **Phase 3 — RocketMQ(国内生态需求)、PulsarActiveMQ/ArtemisMQTT 5** MQTT 适配器让部署可以在一个中间件上
运行两个平面(南向设备面 + 内部异步)——或在已运营 MQTT 中间件的地方彻底去掉
RabbitMQ。发布能力矩阵、按中间件的 `dc3.mq.type` compose profile
- **贯穿始终** — `DriverSenderService` 接口不动;针对它编译的第三方驱动 JAR 持续可用。
Suggested cleanups riding along Phase 1:
随 Phase 1 一并进行的建议清理:
- `RabbitConstant` moves out of `dc3-common-constant`'s `constant/driver` package: logical names become `MqTopic` in the
port; physical names become adapter-private (`RabbitNames`).
- Delete dead topology: the `register` exchange/queue/routing constants (declared and referenced nowhere) and the unused
`dc3.e.mqtt` exchange + `QUEUE_MQTT` (no binding, no producer, no consumer). Re-introduce only when a real consumer
exists.
- Packages `data/rabbit`, `receiver/rabbit``data/mq/listener`, `driver/mq/listener`.
- `dc3.rabbit.tag``dc3.mq.namespace` (rabbitmq adapter maps it to the legacy property for compatibility).
- `RabbitConstant` 移出 `dc3-common-constant` `constant/driver` 包:逻辑名称变成 Port 中的 `MqTopic`
物理名称变成适配器私有(`RabbitNames`)。
- 删除死拓扑:`register` exchange/queue/路由常量(声明了却无处引用)与未使用的
`dc3.e.mqtt` exchange + `QUEUE_MQTT`(无 binding、无生产者、无消费者)。只在真实消费者
存在时再引入。
- `data/rabbit``receiver/rabbit``data/mq/listener``driver/mq/listener`
- `dc3.rabbit.tag``dc3.mq.namespace`rabbitmq 适配器为兼容把它映射到旧属性)。
## 13. Open questions
## 13. 开放问题
1. **Pulsar tenancy** map `dc3.mq.namespace` to Pulsar tenant/namespace natively, or flatten to topic prefix? (Native
is cleaner but requires tenant provisioning docs.)
2. **Envelope evolution**align `dc3-type`/headers with CloudEvents 1.0 attribute names for third-party
interoperability? Cheap now, breaking later; leaning yes for `type` /
`traceparent`. The schema-v1 payload envelope strengthens the case: transport headers and payload schema should
evolve on one roadmap.
3. **Kafka back-pressure semantics**`pause()` needs a resume signal the current
`Acknowledgment` API doesn't express; may need `Acknowledgment.defer()` or a listener-container-level hook. Decide
during Phase 2 TCK work.
4. **BROADCAST queue TTLs**the 30 s auto-delete TTL on metadata queues bounds staleness on dead instances;
non-RabbitMQ brokers express this via subscription expiry or heartbeat — confirm per-adapter strategy in the TCK.
5. **Single-process mode (`dc3-center-single`)** confirm whether it should get an in-process/no-broker adapter
(`dc3-mq-local`) for the smallest deployments, reusing the facade `local` precedent.
6. **Point-value dead-queue retention** — the quarantine queue has no consumer and no TTL; a stuck deployment grows it
without bound. Decide: queue TTL, size-capped alerting, or a minimal auditing consumer.
7. **Batch poison granularity** — today one poison message dead-letters the whole batch. Per-message extraction
(dead-letter only the offender, ack the rest) is friendlier but cross-broker messier (Kafka needs a reprocess loop).
Decide what the TCK mandates; batch-granular is the safe default.
8. **MQTT shared-subscription group semantics** — MQTT 5 standardizes `$share` delivery to one *online* member but is
silent on retention while no member is online (broker-specific). TCK case 13 decides compliance per broker; the
capability matrix must call the behavior out for deployers weighing the MQTT adapter.
1. **Pulsar 租户模型**— 把 `dc3.mq.namespace` 原生映射到 Pulsar tenant/namespace,还是压平为 topic 前缀?(原生
更干净,但需要租户开通文档。)
2. **信封演进** 为了第三方互操作,把 `dc3-type`/header 对齐到 CloudEvents 1.0 属性名?现在改便宜、以后改是
破坏性的;对 `type` /
`traceparent` 倾向采纳。schema-v1 载荷信封强化了这一理由:传输 header 与载荷 schema
在同一路线图上演进。
3. **Kafka 背压语义** `pause()` 需要一个当前
`Acknowledgment` API 无法表达的恢复信号;可能需要 `Acknowledgment.defer()` 或监听器容器级钩子。在
Phase 2 TCK 工作中决定。
4. **BROADCAST 队列 TTL** 元数据队列 30 auto-delete TTL 限制了失效实例造成的陈旧;
RabbitMQ 中间件通过订阅过期或心跳表达——在 TCK 中确认各适配器策略。
5. **单进程模式(`dc3-center-single`**— 确认是否应为最小部署提供一个进程内/无中间件适配器
`dc3-mq-local`),复用 facade `local` 的先例。
6. **点位值死信队列保留** —— 隔离队列既无消费者也无 TTL;卡住的部署会让它
无界增长。需要决定:队列 TTL、按容量上限告警,或一个最小审计消费者。
7. **批量毒消息粒度** —— 今天一条毒消息会把整个批次死信。按消息提取
(只死信问题消息、确认其余)更友好,但跨中间件更麻烦(Kafka 需要重处理循环)。
决定 TCK 强制什么;批次粒度是安全默认值。
8. **MQTT 共享订阅组语义** MQTT 5 `$share` 的投递标准化为投给一个 *在线* 成员,但对没有成员在线时的保留
保持沉默(因中间件而异)。TCK 用例 13 逐中间件判定合规;
能力矩阵必须向权衡 MQTT 适配器的部署者明确指出该行为。
## 14. Appendix — current call-site inventory (Phase 1 checklist)
## 14. 附录 —— 当前调用点清单(Phase 1 检查清单)
Producers (14 raw call sites, main code):
生产者(14 处裸调用点,主代码):
| Module | Class | Sends to |
| 模块 | 类 | 发往 |
|---------|----------------------------------------------------|---------------------------------------------------------------------------|
| driver | `DriverSenderServiceImpl` (8 methods, 6 raw sites) | STATE, ALARM ×2, POINT_VALUE, POINT_COMMAND_RESULT, COMMAND_RESULT, EVENT |
| driver | `BufferServiceImpl` | POINT_VALUE (outbox republish + retry scheduler) |
| data | `DriverStateServiceImpl` | STATE_TIMEOUT_DELAY (45 s) |
| data | `EntityStateExpiryScanner` | STATE_TIMEOUT_DELAY (scan tick), DEVICE_SCAN |
| data | `CommandHistoryServiceImpl` | COMMAND |
| data | `PointCommandServiceImpl` | POINT_COMMAND |
| data | `NotifyTaskSender` | NOTIFY_TASK (via alarm exchange) |
| manager | `MetadataEventListener` | METADATA |
| driver | `DriverSenderServiceImpl`8 个方法,6 处裸调用点) | STATEALARM ×2POINT_VALUEPOINT_COMMAND_RESULTCOMMAND_RESULTEVENT |
| driver | `BufferServiceImpl` | POINT_VALUE(发件箱(outbox) 重发布 + 重试调度器) |
| data | `DriverStateServiceImpl` | STATE_TIMEOUT_DELAY45 秒) |
| data | `EntityStateExpiryScanner` | STATE_TIMEOUT_DELAY(扫描 tick)、DEVICE_SCAN |
| data | `CommandHistoryServiceImpl` | COMMAND |
| data | `PointCommandServiceImpl` | POINT_COMMAND |
| data | `NotifyTaskSender` | NOTIFY_TASK(经 alarm exchange |
| manager | `MetadataEventListener` | METADATA |
Consumers (16):
消费者(16 个):
| Module | Listener | Topic / role | Mode |
| 模块 | 监听器 | 主题 / 角色 | 模式 |
|--------|-------------------------------------------------------|-------------------------|--------------------------------------------------------|
| data | `DriverStateReceiver`, `DeviceStateReceiver` | STATE | LOAD_BALANCE |
| data | `DriverAlarmReceiver`, `DeviceAlarmReceiver` | ALARM | LOAD_BALANCE |
| data | `PointValueReceiver` (BATCH, post-commit ack) | POINT_VALUE | LOAD_BALANCE |
| data | `PointCommandResultReceiver`, `CommandResultReceiver` | result topics | LOAD_BALANCE |
| data | `PointCommandDeadReceiver`, `CommandDeadReceiver` | dead letters | LOAD_BALANCE |
| data | `DriverStateReceiver``DeviceStateReceiver` | STATE | LOAD_BALANCE |
| data | `DriverAlarmReceiver``DeviceAlarmReceiver` | ALARM | LOAD_BALANCE |
| data | `PointValueReceiver`BATCH,提交后确认) | POINT_VALUE | LOAD_BALANCE |
| data | `PointCommandResultReceiver``CommandResultReceiver` | 结果主题 | LOAD_BALANCE |
| data | `PointCommandDeadReceiver``CommandDeadReceiver` | 死信 | LOAD_BALANCE |
| data | `EventReportReceiver` | EVENT | LOAD_BALANCE |
| data | `DriverTimeoutCheckReceiver` | delay check | LOAD_BALANCE |
| data | `DriverTimeoutCheckReceiver` | 延迟检查 | LOAD_BALANCE |
| data | `NotifyWorker` | NOTIFY_TASK | LOAD_BALANCE |
| data | `EntityStateExpiryScanner` (listener) | device scan | LOAD_BALANCE |
| data | `EntityStateExpiryScanner`(监听器) | 设备扫描 | LOAD_BALANCE |
| driver | `MetadataReceiver` | METADATA | **BROADCAST** |
| driver | `CommandReceiver`, `PointCommandReceiver` | COMMAND / POINT_COMMAND | LOAD_BALANCE (per-service queue, lease-coupled expiry) |
| driver | `CommandReceiver``PointCommandReceiver` | COMMAND / POINT_COMMAND | LOAD_BALANCE(每服务队列,租约耦合过期) |
The point-value dead queue deliberately has no listener (quarantine, §8.2).
点位值死信队列刻意没有监听器(隔离区,§8.2)。
+191 -214
View File
@@ -1,82 +1,77 @@
# Design: Relational Access Layer on Spring Data R2DBC (per-dialect, per-service modules)
# 设计:基于 Spring Data R2DBC 的关系访问层(每方言、每服务模块)
| | |
|----------------|------------------------------------------------------------------------------------------------------------------------------|
| **Status** | Approved 2026-08-28 — §5 decisions confirmed (D14 deferred to the P1 TCK spike; D18/D19 added at first review) |
| **Date** | 2026-08-28 |
| **Scope** | relational persistence layer: `dc3-common-auth` + `dc3-db` family first; `dc3-common-manager` / `dc3-common-data` follow |
| **Target** | replace MyBatis-Plus + JDBC with Spring Data R2DBC; end-to-end reactive; GraalVM native-image ready |
| **Supersedes** | `storage-abstraction.md` §3 (relational dialect mechanism — "MyBatis is the port"); its TSDB half already moved to `tsdb-abstraction.md` |
| **Related** | `mq-abstraction.md`, `tsdb-abstraction.md`the family pattern this design extends to the relational layer |
| | |
|------------|------------------------------------------------------------------------------------------------------------------------------|
| **状态** | 已批准 2026-08-28 — §5 决策已确认(D14 推迟至 P1 TCK spikeD18/D19 在首次评审时增补) |
| **日期** | 2026-08-28 |
| **范围** | 关系持久层:`dc3-common-auth` + `dc3-db` 家族先行;`dc3-common-manager` / `dc3-common-data` 随后跟进 |
| **目标** | 用 Spring Data R2DBC 取代 MyBatis-Plus + JDBC;端到端响应式;GraalVM native image 就绪 |
| **取代** | `storage-abstraction.md` §3(关系方言机制——“MyBatis 即 Port”);其 TSDB 一半已移入 `tsdb-abstraction.md` |
| **相关** | `mq-abstraction.md``tsdb-abstraction.md` 本设计将该家族模式扩展到关系层 |
## 1. Motivation
## 1. 动机
Three drivers, in priority order:
三个驱动因素,按优先级排序:
1. **GraalVM native images.** `native-maven-plugin` 1.1.10 is already managed in the root POM, but MyBatis-Plus blocks the
path: runtime mapper proxies, reflection-driven bean wiring, and jsqlparser-based SQL rewriting are all hostile to
closed-world compilation. Spring Data R2DBC ships native support (hints built in since Boot 3; drivers are
Netty-based). The dependency swap is the enabling step for native builds of every center service.
2. **True end-to-end reactive.** Controllers are WebFlux today, but Service → Manager → Mapper is synchronous blocking,
bridged by `BaseController.async(...)` onto `Schedulers.boundedElastic()` with manual `TenantContextHolder`
set/clear. A reactive persistence layer removes the bridge, the thread-pool hop, and the ThreadLocal lifecycle
hazard in one move.
3. **Dialect freedom.** The current dialect mechanism is MyBatis `databaseId` forks inside shared XML (42 statements
across 6 files), gated by a "portable-first" discipline. R2DBC has no `databaseId`; the natural shape is one
implementation module per dialect holding native SQL — the same contract/adapter/TCK structure the MQ and TSDB
families already use (`RepositoryService` port in `dc3-common-repository`, adapters in `dc3-tsdb-*`, TCK in
`dc3-tsdb-tck`).
1. **GraalVM native image** `native-maven-plugin` 1.1.10 已在根 POM 中托管版本,但 MyBatis-Plus 挡住了这条路:运行期
mapper 代理、反射驱动的 bean 装配、基于 jsqlparser 的 SQL 改写,全都与封闭世界(closed-world)编译相抵触。Spring Data
R2DBC 自带 native 支持(Boot 3 起内置 hints;驱动基于 Netty)。这次依赖替换是每个 center 服务得以 native 构建的前置一步。
2. **真正的端到端响应式。** 控制器今天已是 WebFlux,但 Service → Manager → Mapper 链路是同步阻塞的,靠
`BaseController.async(...)` 桥接到 `Schedulers.boundedElastic()`,并手工 set/clear `TenantContextHolder`。响应式持久层
一步移除这座桥、这次线程池跳转和 ThreadLocal 生命周期隐患。
3. **方言自由。** 当前的方言机制是共享 XML 内按 MyBatis `databaseId` 分叉(42 条语句散布在 6 个文件中),靠“可移植优先”
的纪律把关。R2DBC 没有 `databaseId`;自然的形态是每个方言一个实现模块、承载原生 SQL——与 MQ 和 TSDB 家族已在使用的
契约/适配器/TCK 结构相同(`dc3-common-repository` 中的 `RepositoryService` Port、`dc3-tsdb-*` 中的适配器、
`dc3-tsdb-tck` 中的 TCK)。
Weighting, stated plainly: driver 1 is the hard requirement. Driver 2, for auth itself, is an architectural-consistency
investment — auth is a management plane (login/CRUD, human-driven QPS) and no latency or throughput gain is claimed from
removing the boundedElastic hop; its payoffs (native image, no ThreadLocal lifecycle hazard, readiness for the
high-concurrency data/manager wave) arrive later. The cost — rewriting 12 services plus the OAuth/MCP runtime as
reactive chains, with harder debugging — is accepted and bounded by the P2/P3 go/no-go gate, the TCK, and e2e.
权重,直说:驱动因素 1 是硬性要求。对 auth 本身而言,驱动因素 2 是一笔架构一致性投资——auth 是管理面(登录/CRUD、人为
驱动的 QPS),移除 boundedElastic 跳换并不宣称带来时延或吞吐收益;它的回报(native image、消除 ThreadLocal 生命周期隐患、
为高并发的 data/manager 浪潮做好准备)要到后面才兑现。代价——把 12 个 service 加上 OAuth/MCP 运行时重写为响应式链路、
调试更难——已被接受,并由 P2/P3 的 go/no-go 闸门、TCK 与 e2e 加以约束。
What does **not** change: deploy-time selection via `dc3.db.type` (postgres | mysql | mariadb), the initdb seed SQL
(executed by container entrypoints, independent of the access layer), table schemas, and the HTTP/gRPC surface.
**不会**改变的部分:经 `dc3.db.type`postgres | mysql | mariadb)的部署期选择、initdb 种子 SQL(由容器 entrypoint 执行,
与访问层无关)、表结构,以及 HTTP/gRPC 契约面。
## 2. Goals / Non-goals
## 2. 目标 / 非目标
**Goals**
**目标**
- auth center is the pilot: fully R2DBC, zero MyBatis on its classpath, native image compiles and boots.
- One module per (dialect × service), holding that dialect's native SQL — no shared XML, no `databaseId`.
- Repository behavior TCK: same suite green on PostgreSQL, MySQL, MariaDB via Testcontainers.
- Tenant isolation preserved with **stronger** guarantees than today (see §6).
- gRPC contracts unchanged; HTTP contracts unchanged **except** the deliberately redesigned pagination
envelope (D8 — nested `page` object; one-step cutover, no compatibility window).
- 鉴权中心(auth)是试点:全面 R2DBCclasspath 上零 MyBatisnative image 可编译、可启动。
- 每个(方言 × 服务)一个模块,承载该方言的原生 SQL——没有共享 XML,没有 `databaseId`
- Repository 行为 TCK:同一套套件经 Testcontainers 在 PostgreSQLMySQLMariaDB 上全绿。
- 租户隔离得以保留,且保证**强于**今天(见 §6)。
- gRPC 契约不变;HTTP 契约不变,**唯独**刻意重设计的分页信封除外(D8——嵌套 `page` 对象;一步切换,无兼容窗口)。
**Non-goals**
**非目标**
- Runtime hot-swap of the database engine (unchanged: deploy-time selection, restart to switch).
- Rewriting manager/data in this track — they follow the same pattern later; their MyBatis stack is untouched.
- MyBatis-Plus feature parity. Only the features actually used by auth are rebuilt (inventory in §3).
- ORM-level schema management. initdb stays authoritative; no Flyway/Liquibase introduced.
- 数据库引擎的运行期热替换(不变:部署期选择,重启切换)。
- 在本轨中重写 manager/data——它们稍后按同一模式跟进;其 MyBatis 技术栈原封不动。
- MyBatis-Plus 的功能对齐。只重建 auth 实际用到的功能(盘点见 §3)。
- ORM 级的 schema 管理。initdb 保持权威;不引入 Flyway/Liquibase
## 3. Current-state facts that shape the design
## 3. 塑造设计的现状事实
Verified inventory (2026-08-28), each fact constrains a decision:
已核实的盘点(2026-08-28),每条事实都约束一项决策:
| # | Fact | Consequence |
| # | 事实 | 推论 |
|---|------|-------------|
| F1 | Of 16 Manager interfaces in `dc3-common-auth`, **15 are empty** (`extends IService<XxxDO>`, zero methods). Only `IdentityAuditLogManager` has a custom method. | The contract is a blank page — define it from what Services actually need, not from `IService`. |
| F2 | Service layer leans on `IService` + `LambdaQueryWrapper` (fuzzy queries, `page(...)`, `getOne(wrapper)`, `wrapper.apply("1 = 0")`, two-step membership→`IN principalIds` tenant filtering for tenant-less tables). | Dynamic-query logic moves into named repository methods taking the existing Query BOs (D7). |
| F3 | `OAuthMcpMapper` is a plain hand-written mapper (30 methods, no `BaseMapper`), injected **directly by biz layer** (`OAuthMcpRuntimeServiceImpl`), writing 7 tables; all `databaseId` forks for auth live in its XML. `ResourceRegistryLockMapper` (advisory lock) is also hand-written. | Already repository-shaped; migration is SQL relocation + reactive signatures. It is the largest single aggregate (D13). |
| F4 | `TenantContextHolder` is ThreadLocal (in `dc3-common-constant`); written in exactly 3 entry kinds: `BaseController.async`, local facades, gRPC servers (manual set/clear). Consumer is `TenantLineHandlerImpl` (fail-closed: no tenant`TenantNotScopedException`). Hand-written XML SQL already carries explicit `tenant_id = #{tenantId}` — the interceptor only covers wrapper paths. | Explicit tenant parameters in the new contract are a continuation of the existing pattern, not new discipline (D5). ThreadLocal stays for MyBatis-era modules only. |
| F5 | 11 tables have no `tenant_id` column (whitelist in `TenantLineHandlerImpl`): `dc3_tenant`, `dc3_principal`, `dc3_user`, `dc3_local_credential`, `dc3_external_identity`, `dc3_resource`, `dc3_role_resource_bind`, `dc3_api`, `dc3_mcp_tool_catalog`, `dc3_mcp_connection_tool`, `dc3_menu`. | Tenant-less repositories are named as such in the contract; no runtime whitelist needed (D6). |
| F6 | IDs are application-side snowflake everywhere: `@TableId(type = ASSIGN_ID)` (60+ DOs) plus explicit `IdWorker.getId()` in the OAuth/MCP path. No DB sequences. | Swap in a small first-party snowflake generator; no schema change (D10). |
| F7 | Logical delete via `@TableLogic` (47 DOs, `deleted` flag; partial unique indexes like `... WHERE deleted = 0`). | Explicit `deleted = 0` predicates in implementations; contract documents the semantics (D9). |
| F8 | `create_time` / `operate_time` are purely DB-side (PG trigger functions / MySQL `ON UPDATE CURRENT_TIMESTAMP`); updates deliberately null out `operate_time` and re-read. | Inserts exclude time columns; keep re-read pattern; zero schema change (D11). |
| F9 | 19 `@Transactional(rollbackFor = Exception.class)` sites in auth (9 in `OAuthMcpRuntimeServiceImpl`, 3 in `ResourceRegistrySyncServiceImpl`, 3 in `MenuServiceImpl`, 3 in `ServiceAccountServiceImpl`). Plus one unmanaged 3-table write in `UserController.add/delete` (principal → user → membership) — a pre-existing defect. | Reactive transactions with `R2dbcTransactionManager`; fix the UserController gap during migration (D12). |
| F10 | Pagination is one uniform pattern: `Page<DO>` + `PageUtil.page(Pages)` in 12 ServiceImpls; HTTP returns MyBatis-Plus `Page<VO>` (a leaked framework type — internal fields serialize into the JSON); gRPC uses proto `GrpcPage` (data/driver contracts only — auth's gRPC surface has no pagination); OAuth/MCP does manual `limit/offset` + `count`. | Redesign the envelope (D8): frontend consumption is funneled through `PageResult` in `types/common.ts` + `usePagedList`/`useEntityListPage` + `mock/response.ts`, reading only `records/total/size/current` — one coordinated frontend switch. |
| F11 | SQL exceptions have no dedicated translation; business uniqueness is check-then-insert (`getOne``DuplicateException`); fallback advice returns 500. | Keep check-then-insert as primary; add a thin `R2dbcException` mapping helper (D17). |
| F12 | `dc3-common-dal` (label/group tables) is only used by manager; auth merely imports `DictionaryBO` from it, and `DictionaryForAuthService` reads `dc3_tenant` via `TenantManager`. data/agentic depend on dal in POM but reference it zero times in code. | auth pilot does **not** touch `dc3-common-dal`. Scope shrinks. |
| F13 | Datasource per center service is a single `master` route (dynamic-datasource), URL `currentSchema=dc3_auth` (PG) / per-service database (MySQL); Hikari max 64. | R2DBC needs one connection factory + `r2dbc-pool`; dynamic-datasource drops out of auth entirely. |
| F14 | Selection mechanism today: `dc3.db.type``@ConditionalOnProperty` per dialect jar + EnvironmentPostProcessor profile activation; adapter jars are opt-in Maven deps (auth ships postgres by default; mysql/mariadb only in TCK). | Same mechanism reused; no new selection concept (D3). |
| F15 | Existing `dc3-db-{postgres,mysql,mariadb}` jars carry MyBatis-specific config (pagination `DbType`, timestamptz TypeHandler, driver-class-name). manager/data still need them during transition. | Leave the existing dialect modules untouched; R2DBC config lives in the new per-service dialect modules (D3). |
| F1 | `dc3-common-auth` 的 16 个 Manager 接口中,**15 个是空的**`extends IService<XxxDO>`,零方法)。只有 `IdentityAuditLogManager` 拥有自定义方法。 | 契约是一张白纸——按 Service 实际需要来定义它,而不是按 `IService` |
| F2 | Service 层依赖 `IService` + `LambdaQueryWrapper`(模糊查询、`page(...)``getOne(wrapper)``wrapper.apply("1 = 0")`、针对无租户表的两步 membership→`IN principalIds` 租户过滤)。 | 动态查询逻辑移入具名 repository 方法,接收既有 Query BOD7)。 |
| F3 | `OAuthMcpMapper` 是纯手写 mapper30 个方法,无 `BaseMapper`),被 biz 层**直接注入**`OAuthMcpRuntimeServiceImpl`),写 7 张表;auth 的全部 `databaseId` 分叉都位于其 XML 中。`ResourceRegistryLockMapper`(咨询锁)同样是手写的。 | 它已经是 Repository 的形状;迁移即 SQL 搬家 + 响应式签名。它是最大的单一聚合(D13)。 |
| F4 | `TenantContextHolder` 基于 ThreadLocal(位于 `dc3-common-constant`);写入点恰好有 3 类入口:`BaseController.async`、本地 FacadegRPC 服务器(手工 set/clear)。消费方是 `TenantLineHandlerImpl`fail-closed:无租户`TenantNotScopedException`)。手写 XML SQL 已携带显式 `tenant_id = #{tenantId}`——拦截器只覆盖 wrapper 路径。 | 新契约中的显式租户参数是既有模式的延续,不是新纪律(D5)。ThreadLocal 只留给 MyBatis 时代的模块。 |
| F5 | 11 张表没有 `tenant_id` 列(`TenantLineHandlerImpl` 中的白名单):`dc3_tenant``dc3_principal``dc3_user``dc3_local_credential``dc3_external_identity``dc3_resource``dc3_role_resource_bind``dc3_api``dc3_mcp_tool_catalog``dc3_mcp_connection_tool``dc3_menu` | 无租户 repository 在契约中以名称标明;无需运行期白名单(D6)。 |
| F6 | ID 处处都是应用侧雪花 ID`@TableId(type = ASSIGN_ID)`60+ 个 DO),OAuth/MCP 路径另有显式 `IdWorker.getId()`。无数据库序列。 | 换成一个小的第一方雪花 ID 生成器;无 schema 变更(D10)。 |
| F7 | `@TableLogic` 的逻辑删除(47 个 DO`deleted` 标志;形如 `... WHERE deleted = 0` 的部分唯一索引)。 | 实现中写显式 `deleted = 0` 谓词;契约将该语义文档化(D9)。 |
| F8 | `create_time` / `operate_time` 纯属数据库侧(PG 触发器函数 / MySQL `ON UPDATE CURRENT_TIMESTAMP`);更新时故意把 `operate_time` 置空并回读。 | 插入排除时间列;保留回读模式;零 schema 变更(D11)。 |
| F9 | auth 中有 19 处 `@Transactional(rollbackFor = Exception.class)``OAuthMcpRuntimeServiceImpl` 9 处、`ResourceRegistrySyncServiceImpl` 3 处、`MenuServiceImpl` 3 处、`ServiceAccountServiceImpl` 3 处)。另有 `UserController.add/delete` 中一处不受管理的 3 表写(principal → user → membership)——既有缺陷。 | 用 `R2dbcTransactionManager` 做响应式事务;迁移期间修复 UserController 缺口(D12)。 |
| F10 | 分页是统一的一种模式:12 个 ServiceImpl 中 `Page<DO>` + `PageUtil.page(Pages)`HTTP 返回 MyBatis-Plus `Page<VO>`(泄漏的框架类型——内部字段序列化进 JSON);gRPC proto `GrpcPage`(仅 data/driver 契约——auth gRPC 契约面没有分页);OAuth/MCP 手工 `limit/offset` + `count` | 重新设计信封(D8):前端消费收拢到 `types/common.ts``PageResult` + `usePagedList`/`useEntityListPage` + `mock/response.ts`,只读 `records/total/size/current`——一次协调好的前端切换。 |
| F11 | SQL 异常没有专门转译;业务唯一性靠 check-then-insert`getOne``DuplicateException`);兜底 advice 返回 500 | check-then-insert 仍为主路径;增加一个薄的 `R2dbcException` 映射助手(D17)。 |
| F12 | `dc3-common-dal`label/group 表)只有 manager 在用;auth 仅从中导入 `DictionaryBO``DictionaryForAuthService` `TenantManager``dc3_tenant`。data/agentic 在 POM 中依赖 dal,但代码中零引用。 | auth 试点**不**触碰 `dc3-common-dal`。范围缩小。 |
| F13 | 每个 center 服务的数据源是单一 `master` 路由(dynamic-datasource),URL `currentSchema=dc3_auth`PG/ 每服务一个数据库(MySQL);Hikari max 64 | R2DBC 只需一个连接工厂 + `r2dbc-pool`dynamic-datasource 从 auth 中完全退出。 |
| F14 | 今天的选择机制:`dc3.db.type` 每方言 jar 的 `@ConditionalOnProperty` + EnvironmentPostProcessor profile 激活;适配器 jar 是可选的 Maven 依赖(auth 默认自带 postgresmysql/mariadb 只在 TCK 中)。 | 复用同一机制;没有新的选择概念(D3)。 |
| F15 | 既有 `dc3-db-{postgres,mysql,mariadb}` jar 携带 MyBatis 专属配置(分页 `DbType`timestamptz TypeHandlerdriver-class-name)。过渡期 manager/data 仍需要它们。 | 既有方言模块原样保留;R2DBC 配置放进新的每服务方言模块(D3)。 |
## 4. Target architecture
## 4. 目标架构
```text
dc3-common/dc3-common-auth
@@ -97,185 +92,167 @@ dc3-db/dc3-db-r2dbc-core ← new, pure types, ZERO Spring/R2DBC deps:
manager/data's MyBatis stack until their own migration)
```
Dependency direction: `dc3-db-auth-{dialect} → dc3-common-auth (contract) + dc3-db-r2dbc-core (pure types) + dc3-db-r2dbc-boot + spring-data-r2dbc + dialect r2dbc driver`. The contract module depends only on `dc3-db-r2dbc-core` and never sees Spring Data, driver types, or MyBatis — the core/boot split is what keeps D2 true while D4 still lets contracts use `PageResult`.
依赖方向:`dc3-db-auth-{dialect} → dc3-common-auth (contract) + dc3-db-r2dbc-core (pure types) + dc3-db-r2dbc-boot + spring-data-r2dbc + dialect r2dbc driver`。契约模块只依赖 `dc3-db-r2dbc-core`,永远接触不到 Spring Data、驱动类型或 MyBatis——core/boot 拆分正是让 D2 保持成立、同时 D4 仍允许契约使用 `PageResult` 的关键。
Selection reuses F14 exactly: each `dc3-db-auth-{dialect}` auto-configuration is `@ConditionalOnProperty(prefix = "dc3.db", name = "type", havingValue = "{dialect}")` (postgres `matchIfMissing = true`), contributes its `ConnectionFactory`-based config via its own profile yml, and a missing-adapter mismatch fails fast at boot — same as `MybatisPlusConfig` does today.
选择机制精确复用 F14:每个 `dc3-db-auth-{dialect}` 自动配置都是 `@ConditionalOnProperty(prefix = "dc3.db", name = "type", havingValue = "{dialect}")`postgres `matchIfMissing = true`),经由自己的 profile yml 提供基于 `ConnectionFactory` 的配置;适配器缺失或不匹配时启动即快速失败——与今天 `MybatisPlusConfig` 的做法相同。
## 5. Decision checklist
## 5. 决策清单
Each entry: context → options → **recommendation** → consequence. Status column is the review tracker.
每条决策:背景 → 备选项 → **建议** → 后果。状态列是评审跟踪表。
| # | Decision | Recommendation | Status |
|---|----------|----------------|--------|
| D1 | **Contract home** — where repository interfaces live. Options: (a) `dc3-common-auth` `repository` package; (b) `dc3-db-core` as a contract hub. | **(a)** — follows the TSDB precedent (`RepositoryService` lives in `dc3-common-repository`, adapters depend on it). Keeps `dc3-db-core` from becoming a business omnibus; implementation → contract dependency is clean. | **Confirmed 2026-08-28** |
| D2 | **Contract style** — plain `Mono`/`Flux` interfaces vs extending Spring Data repository types. | **Plain interfaces, zero framework types.** Dialect modules may internally extend `ReactiveCrudRepository` and adapt. TCK tests the contract, not Spring Data. | **Confirmed 2026-08-28** |
| D3 | **Module layout** — nested under existing dialect modules vs flat under `dc3-db`; if flat, which naming axis. | **Flat, service-first**: `dc3-db/dc3-db-auth-{postgres,mysql,mariadb}` (later `dc3-db-manager-*`, `dc3-db-data-*`). Service-first because day-to-day work opens the three dialect implementations of one contract side by side, `dc3-center-auth`'s dependency list shows a single prefix, and TCK contracts are organized per business module. Existing `dc3-db-*` modules untouched (F15). Nesting rejected: `dc3-db-postgres` is a jar serving a retiring MyBatis transition and would need aggregator/inheritance contortions. | **Confirmed 2026-08-28** |
| D4 | **Shared R2DBC infrastructure** — new `dc3-db-r2dbc-core` vs scattering helpers. | **Two thin modules, split by dependency weight**: `dc3-db-r2dbc-core` holds pure types only — first-party snowflake (MP-compatible shape: timestamp+worker+sequence, so IDs stay trend-ordered across the transition), `PageResult<T>`/`PageInfo`, `R2dbcException`business exception mapping — with zero Spring/R2DBC deps, so the contract module may depend on it without violating D2; `dc3-db-r2dbc-boot` holds the Spring-dependent shared helpers (ConnectionFactory/pool auto-config conventions, custom conversions, D19 codec, observability) where spring-data-r2dbc is a compile dep. No MyBatis anywhere. | **Confirmed 2026-08-28** (amended: core/boot split so D2 and D4 stop contradicting) |
| D5 | **Tenant passing** — explicit `tenantId` parameters vs Reactor Context propagation vs keeping ThreadLocal. | **Explicit parameters** (first argument, `Long tenantId`). Compile-time fail-closed — stronger than today's runtime exception; continues what hand-written SQL already does (F4); kills the ThreadLocal lifecycle hazard on the R2DBC path. All three entry kinds already hold `tenantId` explicitly (Controller via security context, gRPC via request field, local facade via parameter) — the change deletes the ThreadLocal detour rather than adding parameter threading. `TenantContextHolder` remains untouched for manager/data's MyBatis stack. | **Confirmed 2026-08-28** |
| D6 | **Tenant-less surfaces** — how `runIgnore`/whitelist semantics carry over. | **Named method variants** on tenant-less repositories (F5 tables): no `runIgnore`, no global whitelist. System/login paths call methods whose names say they are unscoped (e.g. `findByLoginName`), and every tenant-scoped method simply does not exist without a tenant argument. | **Confirmed 2026-08-28** |
| D7 | **Dynamic queries** — port `LambdaQueryWrapper` semantics how? | **Named repository methods per Query BO** (`Mono<PageResult<UserDO>> listByQuery(Long tenantId, UserQuery q)`); each dialect renders its own SQL. No criteria-builder port — the 12 fuzzyQuery methods are finite, reviewable SQL. | **Confirmed 2026-08-28** |
| D8 | **Pagination** — neutral type and envelope shape. | **Redesigned, one-step cutover, no compatibility window (not MP-compatible): nested page object.** `PageResult<T> = { records: T[], page: PageInfo }`, `PageInfo = { current, size, total, pages }`, both in `dc3-db-r2dbc-core` (D4). Why nested instead of a flat MP-shaped neutral type (flat would be zero-cost today but pays forever): a paged response keeps exactly two top-level keys — payload and metadata — so future pagination metadata (sort echo, approximate-total flags, cursors) extends `PageInfo` without ever touching the payload or accreting new top-level fields, which is precisely the accretion mechanism that leaked MP's internal fields in the first place (F10); `PageInfo` is reusable wherever only counts matter; frontend gets one generic `PageResult<T>` with typed metadata. `orders` are not echoed back (the caller knows what it sent). `PageUtil`'s clamping/default-ordering moves into the repository layer. Why no compatibility layer: the break is paid exactly once — a dual-format window would keep the leaked shape alive through the manager/data migrations and force a second break later; the shape changes here, at the moment the persistence layer is rewritten anyway. Bounded cutover: dc3-web (four funnel touchpoints — `types/common.ts`, `usePagedList`, `useEntityListPage`, `mock/response.ts` — now reading `records` + `page.*`), openapi snapshots, and e2e switch in the same change set; external REST integrators get a documented breaking change via release notes and regenerated OpenAPI. auth's gRPC contracts carry no pagination, so protos are untouched. `PageResult`/`PageInfo` become the project-wide envelope as manager/data migrate. | **Confirmed 2026-08-28** (re-review: one-step cutover, compatibility explicitly rejected) |
| D9 | **Logical delete** — how `@TableLogic` semantics survive. | **Explicit SQL predicates** (`deleted = 0` in every read, `SET deleted = 1` in deletes) + Javadoc on each contract method stating whether deleted rows are filtered. Partial unique indexes (`WHERE deleted = 0`) continue to enforce uniqueness. | **Confirmed 2026-08-28** |
| D10 | **ID generation** — MP `IdWorker` is unavailable post-migration. | **First-party snowflake in `dc3-db-r2dbc-core`**, same bit layout as MP `ASSIGN_ID`; called explicitly before insert (the `OAuthMcpRuntimeServiceImpl` pattern already does exactly this — generalize it). **WorkerId allocation is a correctness invariant, not a detail**: explicit env assignment (`DC3_DB_WORKER_ID`-style; StatefulSet ordinal / fixed per replica), deterministic IP-hash fallback for single-node dev, and startup detection that refuses a duplicate workerId+datacenter pair within one deployment; the TCK adds a two-instance uniqueness contract. The assigned space must also not collide with the MP `IdWorker` instances still writing the same tables from `dc3-center-single` during the dual-stack transition. | **Confirmed 2026-08-28** (amended: workerId allocation rule) |
| D11 | **Timestamps** — who writes `create_time`/`operate_time`. | **DB stays authoritative** (F8): inserts exclude both columns, updates never set `operate_time`, business code re-reads after update where it needs the trigger value. Zero schema change, zero behavior change. | **Confirmed 2026-08-28** |
| D12 | **Transactions** — reactive transaction strategy. | **`@Transactional(rollbackFor = Exception.class)` on reactive methods + `R2dbcTransactionManager`** auto-configured per dialect module. All 19 existing sites carry over (F9); the unmanaged `UserController.add/delete` 3-table write is wrapped in a service-level transaction as part of the migration (defect fix, noted in §11). | **Confirmed 2026-08-28** |
| D13 | **OAuth/MCP surface split** — one `OAuthMcpRepository` mirroring the 30-method mapper vs per-aggregate repositories. | **Per-aggregate split**: `OAuthClientRepository`, `OAuthAuthorizationRepository`, `McpConnectionRepository` (+ `dc3_mcp_connection_tool``replaceConnectionTools` is a connection aggregate operation), `McpToolCatalogRepository`, `McpAuditLogRepository` (+ `dc3_mcp_tool_confirmation`; exact home decided at implementation by the aggregate-root rule). The 7 tables (F3) map to 5 focused contracts; dialect-forked statements (JSON casts, upsert idioms) land in each dialect module. Reactive transactions bind to the `ConnectionFactory`, not the repository, so biz-level `@Transactional` composes across repositories freely. | **Confirmed 2026-08-28** |
| D14 | **MySQL driver** `r2dbc-mysql` (community) vs `r2dbc-mariadb` (official) against MySQL servers. | **Decided by TCK spike, both candidates wired**: MySQL has no official R2DBC driver; the community driver is well-regarded but volunteer-maintained, while the official MariaDB driver also speaks the MySQL protocol (caveats: `caching_sha2_password`, JSON codec). Gate: whichever passes the full TCK on `mysql:8.4` stays; record the rationale here. MariaDB dialect uses `r2dbc-mariadb` (no contest). | Pending — P1 spike |
| D15 | **Query style inside dialect modules.** | **Spring Data interfaces + `@Query` native SQL as the default; `DatabaseClient` for batch upserts and connection-bound operations** (advisory locks — see §8). Free choice per statement, invisible above the contract (D2). | **Confirmed 2026-08-28** |
| D16 | **Coexistence & cutover** — MyBatis removal timing for auth. | **Hard cutover per service**: auth ships R2DBC-only (drops `dc3-db-core`, dynamic-datasource, `mapping/*.xml`, `@MapperScan`); `dc3-center-single` runs both stacks against the same database until manager/data migrate (two pools during transition — accepted, bounded). | **Confirmed 2026-08-28** |
| D17 | **Error translation**SQL exception mapping. | **Thin helper in `dc3-db-r2dbc-core`**: `R2dbcDataIntegrityViolationException`existing `DuplicateException`/`BusinessException` mapping, applied in dialect implementations at natural points. Check-then-insert stays the primary UX path (F11); the mapping is a backstop so constraint violations no longer surface as 500s. | **Confirmed 2026-08-28** |
| D18 | **Facade & gRPC server boundary**auth's non-HTTP seams, absent from earlier drafts. Auth services are also exposed via 7 blocking facade contracts (`TokenFacade.checkValid` returns `boolean`, plus Permission/User/Tenant/LocalCredential/ResourceRegistry/McpRuntime), 7 gRPC servers (`grpc/*Server.java``StreamObserver` callbacks wrapping services in `TenantContextHolder.runIgnore`), and the local facade impls; consumers are the gateway (blocking gRPC stub) and every WebFlux service's security chain via `FacadePermissionProvider`. Options: (a) make facade contracts reactive now; (b) keep blocking contracts and bridge explicitly. | **(b) — blocking edge, explicit and temporary.** Facade contracts stay synchronous in this track; the reactive→blocking bridge lives only in auth's gRPC servers and local facade impls, executed on gRPC executor / boundedElastic threads (never an event loop — guard against event-loop `block()` errors), and `runIgnore` wrappers become unscoped method variants (§6). This keeps the pilot's blast radius bounded — option (a) would drag the gateway `FilterServiceImpl`, `dc3-common-facade-grpc`, and shared `dc3-common-web` into the auth track. "True end-to-end reactive" holds controller→repository inside auth; the edge stays synchronous by decision until the facade wave lands with manager/data, at which point `TokenGrpcFacade`, `FilterServiceImpl`, and `FacadePermissionProvider` simplify. In-process gRPC tests keep the blocking `GrpcInProcessExtension` harness. | **Confirmed 2026-08-28** |
| D19 | **Timestamp codec** — who converts `timestamptz``LocalDateTime` (successor to `TimestamptzLocalDateTimeTypeHandler`). The R2DBC PostgreSQL driver maps `timestamptz` natively to `OffsetDateTime`, not `LocalDateTime`; MySQL/MariaDB `DATETIME` needs no conversion. | **First-party `R2dbcCustomConversions` in `dc3-db-r2dbc-boot`** (D4 split): registers UTC converters (OffsetDateTime ↔ LocalDateTime) for the PG dialect, preserving F8/D11 semantics and the §9 round-trip contract; MySQL/MariaDB modules use default mappings. Decided explicitly because it is the top R2DBC-PG migration pitfall and would otherwise surface as a red TCK item with no owner. | **Confirmed 2026-08-28** |
| # | 决策 | 建议 | 状态 |
|---|------|------|------|
| D1 | **契约归属**——Repository 接口放在哪里。备选项:(a) `dc3-common-auth` `repository` 包;(b) `dc3-db-core` 作为契约枢纽。 | **(a)**——沿袭 TSDB 先例(`RepositoryService` 放在 `dc3-common-repository`,适配器依赖它)。避免 `dc3-db-core` 变成业务大杂烩;实现 → 契约的依赖方向干净。 | **已确认 2026-08-28** |
| D2 | **契约风格**——纯 `Mono`/`Flux` 接口,还是扩展 Spring Data repository 类型。 | **纯接口,零框架类型。**方言模块可以在内部扩展 `ReactiveCrudRepository` 并做适配。TCK 测的是契约,不是 Spring Data | **已确认 2026-08-28** |
| D3 | **模块布局**——嵌在既有方言模块之下,还是在 `dc3-db` 下平铺;若平铺,按哪个命名轴。 | **平铺、服务优先**`dc3-db/dc3-db-auth-{postgres,mysql,mariadb}`(后续 `dc3-db-manager-*``dc3-db-data-*`)。服务优先,因为日常工作会把同一契约的三个方言实现并排打开,`dc3-center-auth` 的依赖列表呈现单一前缀,TCK 契约也按业务模块组织。既有 `dc3-db-*` 模块不动(F15)。否决嵌套:`dc3-db-postgres` 是一个服务于行将退役的 MyBatis 过渡期的 jar,嵌套将需要聚合器/继承层面的别扭扭曲。 | **已确认 2026-08-28** |
| D4 | **共享 R2DBC 基础设施**——新建 `dc3-db-r2dbc-core`,还是把助手散落各处。 | **两个薄模块,按依赖重量拆分**`dc3-db-r2dbc-core` 只放纯类型——第一方雪花 ID(与 MP 兼容的形状:timestamp+worker+sequence,保证 ID 在过渡期保持趋势有序)、`PageResult<T>`/`PageInfo``R2dbcException`业务异常映射——零 Spring/R2DBC 依赖,契约模块可以依赖它而不违反 D2`dc3-db-r2dbc-boot` 放依赖 Spring 的共享助手(ConnectionFactory/连接池自动配置约定、自定义转换、D19 编解码、可观测性),spring-data-r2dbc 只在这里是编译期依赖。任何地方都不碰 MyBatis。 | **已确认 2026-08-28**(修订:core/boot 拆分,使 D2 D4 不再互相矛盾) |
| D5 | **租户传递**——显式 `tenantId` 参数,还是 Reactor Context 传播,还是保留 ThreadLocal | **显式参数**(第一个参数,`Long tenantId`)。编译期 fail-closed——比今天的运行期异常更强;延续手写 SQL 已有的做法(F4);消灭 R2DBC 路径上的 ThreadLocal 生命周期隐患。三类入口本就显式持有 `tenantId`Controller 经安全上下文,gRPC 经请求字段,本地 Facade 经参数)——这一改动是删除 ThreadLocal 的绕路,而不是新增一路参数传递。`TenantContextHolder` manager/data MyBatis 技术栈原样保留。 | **已确认 2026-08-28** |
| D6 | **无租户契约面**——`runIgnore`/白名单语义如何延续。 | 无租户 repository(F5 各表)上采用**具名方法变体**:没有 `runIgnore`,没有全局白名单。系统/登录路径调用的方法,名字本身就说明其无作用域(如 `findByLoginName`);每个租户作用域方法在没有租户参数的情况下干脆不存在。 | **已确认 2026-08-28** |
| D7 | **动态查询**——如何移植 `LambdaQueryWrapper` 语义? | **每个 Query BO 对应具名 repository 方法**`Mono<PageResult<UserDO>> listByQuery(Long tenantId, UserQuery q)`);每个方言渲染自己的 SQL。不做 criteria-builder 式移植——12 个 fuzzyQuery 方法是有限、可评审的 SQL | **已确认 2026-08-28** |
| D8 | **分页**——中立类型与信封形状。 | **重新设计、一步切换、无兼容窗口(不与 MP 兼容):嵌套 page 对象。**`PageResult<T> = { records: T[], page: PageInfo }``PageInfo = { current, size, total, pages }`,二者都放在 `dc3-db-r2dbc-core`(D4)。为什么嵌套而非扁平的 MP 形状中立类型(扁平今天零成本,却要永远偿还):分页响应恰好保持两个顶层键——载荷与元数据——未来的分页元数据(排序回显、近似总数标志、游标)只扩展 `PageInfo`,永不触碰载荷、也不累积新的顶层字段,而累积正是当初把 MP 内部字段泄漏出来的机制(F10);凡是只关心计数的地方 `PageInfo` 都可复用;前端得到一个泛型 `PageResult<T>`,元数据带类型。`orders` 不回显(调用方知道自己发了什么)。`PageUtil` 的钳制/默认排序移入 repository 层。为什么不设兼容层:这次破坏恰好只付一次——双格式窗口会让泄漏的形状在 manager/data 迁移期间继续存活,并迫使日后二次破坏;形状就在此处改变,何况持久层本来就在此刻重写。切换范围有界:dc3-web(四个收拢触点——`types/common.ts``usePagedList``useEntityListPage``mock/response.ts`——现改为读取 `records` + `page.*`)、openapi 快照与 e2e 在同一变更集内切换;外部 REST 集成方经由发布说明与重新生成的 OpenAPI 获得书面记录的破坏性变更。auth gRPC 契约不带分页,proto 因此不动。随着 manager/data 迁移,`PageResult`/`PageInfo` 成为项目级信封。 | **已确认 2026-08-28**(复审:一步切换,明确否决兼容方案) |
| D9 | **逻辑删除**——`@TableLogic` 语义如何存续。 | **显式 SQL 谓词**(每次读取带 `deleted = 0`,删除用 `SET deleted = 1`)+ 每个契约方法的 Javadoc 注明是否过滤已删除行。部分唯一索引(`WHERE deleted = 0`)继续保障唯一性。 | **已确认 2026-08-28** |
| D10 | **ID 生成**——迁移后 MP `IdWorker` 不可用。 | **`dc3-db-r2dbc-core` 中的第一方雪花 ID**,位布局与 MP `ASSIGN_ID` 相同;插入前显式调用(`OAuthMcpRuntimeServiceImpl` 的既有模式已经正是这么做的——把它推广开来)。**workerId 分配是正确性不变量,不是细节**:显式环境变量分配(`DC3_DB_WORKER_ID` 风格;StatefulSet 序号 / 每副本固定值)、单节点开发用确定性的 IP-hash 兜底,以及启动检测——同一部署内拒绝重复的 workerId+datacenter 组合;TCK 增加双实例唯一性契约。双栈过渡期,已分配空间还必须不与 `dc3-center-single` 中仍在写同一批表的 MP `IdWorker` 实例冲突。 | **已确认 2026-08-28**(修订:workerId 分配规则) |
| D11 | **时间戳**——`create_time`/`operate_time` 由谁写。 | **数据库保持权威**F8):插入排除两列,更新绝不设置 `operate_time`,需要触发器值的业务代码在更新后回读。零 schema 变更,零行为变更。 | **已确认 2026-08-28** |
| D12 | **事务**——响应式事务策略。 | **响应式方法上的 `@Transactional(rollbackFor = Exception.class)` + `R2dbcTransactionManager`**,由每个方言模块自动配置。既有 19 处全部平移(F9);`UserController.add/delete` 那处不受管理的 3 表写作为迁移的一部分包进 service 级事务(缺陷修复,见 §11)。 | **已确认 2026-08-28** |
| D13 | **OAuth/MCP 契约面拆分**——一个镜像 30 方法 mapper 的 `OAuthMcpRepository`,还是按聚合拆分 repository。 | **按聚合拆分**`OAuthClientRepository``OAuthAuthorizationRepository``McpConnectionRepository`+ `dc3_mcp_connection_tool`——`replaceConnectionTools` connection 聚合的操作)、`McpToolCatalogRepository``McpAuditLogRepository`+ `dc3_mcp_tool_confirmation`;确切归属在实现时按聚合根规则决定)。7 张表(F3)映射为 5 个聚焦契约;方言分叉语句(JSON castupsert 惯用法)落在各自方言模块。响应式事务绑定的是 `ConnectionFactory` 而非 repository,因此 biz 级 `@Transactional` 可以自由跨 repository 组合。 | **已确认 2026-08-28** |
| D14 | **MySQL 驱动**——对 MySQL 服务器用 `r2dbc-mysql`(社区)还是 `r2dbc-mariadb`(官方)。 | **由 TCK spike 决定,两个候选都接好线**MySQL 没有官方 R2DBC 驱动;社区驱动口碑良好但由志愿者维护,官方 MariaDB 驱动同样说 MySQL 协议(注意点:`caching_sha2_password`JSON 编解码)。闸门:在 `mysql:8.4` 上通过完整 TCK 的那个留下;理由记录于此。MariaDB 方言用 `r2dbc-mariadb`(没有悬念)。 | 待定 —— P1 spike |
| D15 | **方言模块内部的查询风格。** | **默认用 Spring Data 接口 + `@Query` 原生 SQL;批量 upsert 与连接绑定操作用 `DatabaseClient`**(咨询锁——见 §8)。逐语句自由选择,在契约层之上不可见(D2)。 | **已确认 2026-08-28** |
| D16 | **共存与切换**——auth 移除 MyBatis 的时机。 | **每服务硬切换**auth 只随 R2DBC 发布(去掉 `dc3-db-core`dynamic-datasource`mapping/*.xml``@MapperScan`);`dc3-center-single` 在 manager/data 迁移完之前对同一数据库同时运行两套技术栈(过渡期两个连接池——已接受,有界)。 | **已确认 2026-08-28** |
| D17 | **错误转译**——SQL 异常映射。 | **`dc3-db-r2dbc-core` 中的薄助手**`R2dbcDataIntegrityViolationException`既有 `DuplicateException`/`BusinessException` 的映射,在方言实现中的自然位置应用。check-then-insert 仍是主要 UX 路径(F11);该映射是兜底,使约束冲突不再以 500 的形式暴露。 | **已确认 2026-08-28** |
| D18 | **Facade gRPC 服务器边界**——auth 的非 HTTP 接缝,早期草案中缺席。auth 服务还经由 7 个阻塞的 Facade 契约(`TokenFacade.checkValid` 返回 `boolean`,另有 Permission/User/Tenant/LocalCredential/ResourceRegistry/McpRuntime)、7 个 gRPC 服务器(`grpc/*Server.java`——`StreamObserver` 回调把服务包进 `TenantContextHolder.runIgnore`)以及本地 Facade 实现暴露;消费方是网关(阻塞 gRPC stub)和每个 WebFlux 服务经 `FacadePermissionProvider` 的安全链。备选项:(a) 现在就将 Facade 契约改为响应式;(b) 保留阻塞契约并显式架桥。 | **(b)——阻塞边缘,显式且临时。**Facade 契约在本轨保持同步;响应式→阻塞桥只存在于 auth gRPC 服务器与本地 Facade 实现中,在 gRPC executor / boundedElastic 线程上执行(绝不在事件循环上——防范事件循环上的 `block()` 错误),`runIgnore` 包装改为无作用域方法变体(§6)。这使试点的爆炸半径有界——选项 (a) 会把网关的 `FilterServiceImpl``dc3-common-facade-grpc` 和共享的 `dc3-common-web` 拖进 auth 轨道。“真正的端到端响应式”在 auth 内部覆盖 controller→repository;边缘经决策保持同步,直到 Facade 浪潮随 manager/data 落地,届时 `TokenGrpcFacade``FilterServiceImpl``FacadePermissionProvider` 得以简化。进程内 gRPC 测试继续使用阻塞的 `GrpcInProcessExtension` 装置。 | **已确认 2026-08-28** |
| D19 | **时间戳编解码**——`timestamptz``LocalDateTime` 由谁转换(`TimestamptzLocalDateTimeTypeHandler` 的后继)。R2DBC PostgreSQL 驱动原生把 `timestamptz` 映射为 `OffsetDateTime`,而非 `LocalDateTime`MySQL/MariaDB `DATETIME` 无需转换。 | **`dc3-db-r2dbc-boot` 中的第一方 `R2dbcCustomConversions`**(D4 拆分):为 PG 方言注册 UTC 转换器(OffsetDateTime ↔ LocalDateTime),保持 F8/D11 语义与 §9 的往返契约;MySQL/MariaDB 模块使用默认映射。显式决策,因为它是 R2DBC-PG 迁移的头号陷阱,否则会以一条无主的红色 TCK 项浮出水面。 | **已确认 2026-08-28** |
## 6. Tenant isolation on R2DBC
## 6. R2DBC 上的租户隔离
The contract makes tenant scope a **type-level property**:
契约把租户作用域变成**类型级属性**:
- Every repository for a tenant-owned table exposes only `tenantId`-carrying methods — code that lacks a tenant cannot
compile a query, which upgrades today's runtime `TenantNotScopedException` to a compile-time guarantee.
- Repositories over the 11 tenant-less tables (F5) have no tenant parameters at all; cross-tenant reads on those tables
(e.g. user → memberships) go through explicit two-step service logic exactly as today.
- System paths (startup sync, expiry scanners, login-before-context, MCP runtime) use unscoped method variants with
explicit names — replacing `TenantContextHolder.runIgnore(...)` threading on the auth path.
- TCK ships **negative tests**: cross-tenant reads must return empty/absent on every list/get; tenant-less repositories
must not gain tenant filters; constraint violations on cross-tenant writes stay mapped (D17).
- 租户拥有表的每个 repository 只暴露携带 `tenantId` 的方法——缺租户的代码无法编译出查询,这把今天的运行期
`TenantNotScopedException` 升级为编译期保证。
- 覆盖 11 张无租户表(F5)的 repository 完全没有租户参数;对这些表的跨租户读取(如 user → memberships)照今天一样经过
显式的两步 service 逻辑。
- 系统路径(启动同步、过期扫描器、上下文之前的登录、MCP 运行时)使用名字明示的无作用域方法变体——取代 auth 路径上的
`TenantContextHolder.runIgnore(...)` 穿线。
- TCK 提供**否定测试**:跨租户读取在每个 list/get 上必须返回空/不存在;无租户 repository 不得长出租户过滤;跨租户写上的
约束冲突保持可映射(D17)。
`TenantContextHolder`, `TenantLineHandlerImpl` and the tenant interceptor are **not modified** — they keep serving
manager/data until their migration removes them.
`TenantContextHolder``TenantLineHandlerImpl` 与租户拦截器**不做修改**——它们继续服务 manager/data,直到其迁移移除它们。
## 7. Repository contract conventions
## 7. Repository 契约约定
- Package `io.github.pnoker.common.auth.repository`; one interface per aggregate; parameter objects are existing
BOs/Query objects and domain DOs.
- Method naming follows the project's CRUD verb policy (`add/delete/update/getById/list...`; `select*` reserved for
persistence-flavored reads — same rule as AGENTS.md, applied to the new layer).
- Return shapes: `Mono<X>` for 0..1, `Flux<X>` for many, `Mono<PageResult<X>>` for paged; writes return `Mono<Void>`
or the re-read entity where the business needs DB-computed values (F8 pattern).
- Transactions are declared in services/biz (as today, F9); repositories stay transaction-unaware.
- No ORM annotations are required on DOs by the contract; dialect modules may keep private `@Table` row classes and
map to domain DOs, or annotate shared DOs directly if clean — decided per aggregate during implementation, TCK is
indifferent (D2 keeps this an implementation detail).
- `io.github.pnoker.common.auth.repository`;每个聚合一个接口;参数对象是既有 BO/Query 对象与领域 DO。
- 方法命名遵循项目 CRUD 动词策略(`add/delete/update/getById/list...``select*` 保留给持久化风味的读取——与 AGENTS.md
同一规则,应用于新层)。
- 返回形状:`Mono<X>` 表示 0..1`Flux<X>` 表示多个,`Mono<PageResult<X>>` 表示分页;写入返回 `Mono<Void>`,或在业务
需要数据库计算值时返回回读实体(F8 模式)。
- 事务在 services/biz 中声明(同今天,F9);repository 对事务无感知。
- 契约不要求 DO 上有 ORM 注解;方言模块可以保留私有 `@Table` 行类再映射到领域 DO,若干净也可直接注解共享 DO——实现期间
按聚合决定,TCK 不关心(D2 使这一点保持为实现细节)。
## 8. Dialect implementation conventions
## 8. 方言实现约定
Per dialect module (`dc3-db-auth-postgres` as the reference):
每个方言模块(以 `dc3-db-auth-postgres` 为参照):
- **Config**: `application-auth-{dialect}.yml` providing `spring.r2dbc.*` (URL template from the same
`DC3_DB_*` env family — PG `options=search_path=dc3_auth`, URL-encoded, same search-path semantics as today's
JDBC URL; MySQL/MariaDB per-service database) and `r2dbc-pool` sizing equivalent to today's Hikari settings (F13).
Profile activated by the module's EnvironmentPostProcessor, mirroring F14. Observability parity is part of the
config contract: r2dbc-pool Micrometer metrics and a connection health indicator, registered by
`dc3-db-r2dbc-boot`, replacing Hikari's metrics.
- **Standard CRUD**: Spring Data repository interfaces extending `ReactiveCrudRepository` where the shape fits.
- **Dialect-locked statements** — the statements that motivated per-dialect modules. Each dialect module writes its
native form; the table below is the migration inventory (auth-relevant):
- **配置**`application-auth-{dialect}.yml` 提供 `spring.r2dbc.*`URL 模板来自同一 `DC3_DB_*` 环境变量家族——PG 为
`options=search_path=dc3_auth`URL 编码,与今天 JDBC URL 相同的 search-path 语义;MySQL/MariaDB 为每服务一个数据库)
与等价于今天 Hikari 设置的 `r2dbc-pool` 容量(F13)。Profile 由模块的 EnvironmentPostProcessor 激活,镜像 F14。
可观测性对等是配置契约的一部分:r2dbc-pool 的 Micrometer 指标与连接健康指示器,由 `dc3-db-r2dbc-boot` 注册,取代
Hikari 的指标。
- **标准 CRUD**:形状合适处使用扩展 `ReactiveCrudRepository` 的 Spring Data repository 接口。
- **方言锁定语句**——正是它们催生了每方言模块。每个方言模块写出自己的原生形式;下表是迁移盘点(与 auth 相关的):
| Concern | PostgreSQL | MySQL 8 | MariaDB | Note |
|---------|------------|---------|---------|------|
| Upsert | `INSERT ... ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE ... AS new` (alias form; `VALUES()` removed in 8.4) | `ON DUPLICATE KEY UPDATE ... VALUES(col)` | returning rows differs: PG `RETURNING` vs re-select (established TCK pattern) |
| Advisory lock | `pg_advisory_xact_lock(hashtext(?))` in-tx | `GET_LOCK(?, 10)` **session-level — must run on one pooled connection and pair with `RELEASE_LOCK`** | same as MySQL | R2DBC hazard: use connection-bound execution (`Mono.usingWhen` / `Connection` API) for the MySQL/MariaDB form |
| JSON columns | native JSON codec | `CAST(? AS JSON)` binding (no backslash escapes / use bind params) | no `CAST AS JSON` — plain parameter text | contract type is `String`; conversion is dialect-internal |
| String ops in catalog queries | `||`, `regexp_replace(..., 'g')` | `CONCAT`, global-by-default replace | as MySQL | from current `OAuthMcpMapper` forks |
| Pagination | `LIMIT ? OFFSET ?` | `LIMIT ?, ?` | as MySQL | count query per repository method |
| 关注点 | PostgreSQL | MySQL 8 | MariaDB | 说明 |
|--------|------------|---------|---------|------|
| upsert | `INSERT ... ON CONFLICT ... DO UPDATE` | `ON DUPLICATE KEY UPDATE ... AS new`(别名形式;`VALUES()` 在 8.4 中移除) | `ON DUPLICATE KEY UPDATE ... VALUES(col)` | 返回行的方式不同:PG `RETURNING` vs 再查询(既有 TCK 模式) |
| 咨询锁 | `pg_advisory_xact_lock(hashtext(?))` 事务内 | `GET_LOCK(?, 10)` **会话级——必须在单条池化连接上运行并与 `RELEASE_LOCK` 成对** | MySQL | R2DBC 风险点:MySQL/MariaDB 形式须用连接绑定执行(`Mono.usingWhen` / `Connection` API |
| JSON 列 | 原生 JSON 编解码 | `CAST(? AS JSON)` 绑定(不得反斜杠转义 / 用绑定参数) | `CAST AS JSON`——普通参数文本 | 契约类型是 `String`;转换在方言内部完成 |
| 目录查询中的字符串操作 | `||``regexp_replace(..., 'g')` | `CONCAT`、默认全局替换 | MySQL | 来自当前 `OAuthMcpMapper` 分叉 |
| 分页 | `LIMIT ? OFFSET ?` | `LIMIT ?, ?` | MySQL | 每个 repository 方法一条 count 查询 |
- **Lock × transaction composition rule** (binds the advisory-lock row above): `pg_advisory_xact_lock` participates in
the surrounding `@Transactional` and auto-releases at commit; the MySQL/MariaDB `GET_LOCK`/`RELEASE_LOCK` pair runs
on one pooled connection via `Mono.usingWhen` (release wired to cancellation as well) and **must not execute inside
a Spring-managed transaction** — the transaction binds its own connection, and lock-connection + tx-connection under
pool pressure can starve into deadlock. Lock repository methods are transaction-exempt by contract; the TCK ships a
negative test for the pairing.
- **Snowflake IDs** are generated app-side before insert (D10) — no `RETURNING id` dependency, all dialects identical.
- **Native hints**: dialect modules register `RuntimeHints` for their row classes (`@RegisterReflectionForBinding`)
and contribute driver-specific hints; verified by the native smoke test (§10).
- **锁 × 事务组合规则**(约束上表咨询锁一行):`pg_advisory_xact_lock` 参与外层 `@Transactional` 并在提交时自动释放;
MySQL/MariaDB `GET_LOCK`/`RELEASE_LOCK` 对经 `Mono.usingWhen` 在单条池化连接上运行(释放同时挂接到取消),且**绝不
在 Spring 管理的事务内执行**——事务绑定自己的连接,池压力下锁连接 + 事务连接可能互相饿死成死锁。锁 repository 方法在
契约上豁免事务;TCK 为该配对提供否定测试。
- **雪花 ID** 在插入前由应用侧生成(D10)——不依赖 `RETURNING id`,各方言完全一致。
- **native hints**:方言模块为其行类注册 `RuntimeHints``@RegisterReflectionForBinding`),并贡献驱动专属 hints;由
native 冒烟测试验证(§10)。
## 9. TCK 2.0 — repository behavior contracts
## 9. TCK 2.0 — repository 行为契约
`dc3-db-tck` gains a second suite alongside the existing mapper contracts (which remain until manager/data migrate):
`dc3-db-tck` 在既有 mapper 契约(保留至 manager/data 迁移完)之外新增第二套套件:
- One abstract contract test per repository interface; three concrete subclasses (Postgres / MySQL / MariaDB) on
Testcontainers, fixtures identical to today's images (`postgres`, `mysql:8.4`, `mariadb:10.11`, same initdb seeds).
- Coverage classes: CRUD + logical-delete semantics; fuzzy/paged listing equivalence (incl. default `create_time DESC`
ordering and `PageUtil` clamping); upsert idempotency under the three dialect idioms; advisory-lock acquire/release
(including the MySQL/MariaDB same-connection requirement); tenant negative tests (§6); JSON round-trip; UTC
timestamp round-trip (TIMESTAMPTZ ↔ `LocalDateTime`, the current `TimestamptzLocalDateTimeTypeHandler` contract).
- Gate: new mapper/repository work must extend the contract suite — this is the discipline mechanism replacing
`databaseId` routing.
- 每个 repository 接口一个抽象契约测试;三个具体子类(Postgres / MySQL / MariaDB)跑在 Testcontainers 上,夹具与今天的
镜像一致(`postgres``mysql:8.4``mariadb:10.11`,同样的 initdb 种子)。
- 覆盖类别:CRUD + 逻辑删除语义;模糊/分页列表等价性(含默认 `create_time DESC` 排序与 `PageUtil` 钳制);三种方言惯用法
下的 upsert 幂等性;咨询锁获取/释放(含 MySQL/MariaDB 同连接要求);租户否定测试(§6);JSON 往返;UTC 时间戳往返
TIMESTAMPTZ ↔ `LocalDateTime`,即现有 `TimestamptzLocalDateTimeTypeHandler` 契约)。
- 闸门:新的 mapper/repository 工作必须扩展契约套件——这就是取代 `databaseId` 路由的纪律机制。
## 10. Native verification
## 10. Native 验证
The pilot's exit criterion, in order:
试点的退出判据,按顺序:
1. `dc3-center-auth` builds with `mvn -Pnative` (plugin already managed in the root POM) against the R2DBC stack.
2. The native binary boots against a Testcontainers PostgreSQL, serves a representative endpoint slice
(login → token → one tenant-scoped list → one MCP tool-catalog query), and passes the TCK contract suite run
natively (allowed to be a subset — record which).
3. Image size / RSS / startup time are recorded in this document as the baseline for manager/data to beat.
1. `dc3-center-auth` `mvn -Pnative`(插件已在根 POM 托管)针对 R2DBC 技术栈完成构建。
2. native 二进制对 Testcontainers PostgreSQL 启动,服务一段有代表性的端点切片(登录 → token → 一次租户作用域列表 →
一次 MCP 工具目录查询),并以 native 方式跑通 TCK 契约套件(允许是子集——记录是哪些)。
3. 镜像大小 / RSS / 启动时间记录在本文档中,作为 manager/data 要超越的基线。
If any blocking native issue surfaces (driver hints, reflection misses), it is a design-level finding: fix in
`dc3-db-r2dbc-core`/dialect modules, never by re-adding MyBatis to auth.
若浮现任何阻塞性 native 问题(驱动 hints、反射遗漏),那是设计级发现:在 `dc3-db-r2dbc-core`/方言模块中修复,绝不能用
给 auth 重新加回 MyBatis 的方式解决。
## 11. Transition and coexistence
## 11. 过渡与共存
- **auth cutover is hard** (D16): one commit removes `dc3-db-core`, `dynamic-datasource`, `@MapperScan`,
`mapping/*.xml`, the 16 Manager shells, and `TenantContextHolder` usage from the auth path. No long-lived
dual-persistence inside auth.
- **`dc3-center-single`** temporarily carries both stacks (auth on R2DBC, manager/data on MyBatis) against the same
database — two pools, accepted and bounded by the transition.
- **Facade/gRPC edge stays synchronous through the transition (D18)**: the gateway and manager/data keep consuming
blocking facade contracts; the reactive→blocking bridge is confined to auth's gRPC servers and local facade impls
on non-event-loop threads, and is retired when the facade wave lands with manager/data.
- **Uncommitted MariaDB XML forks in the worktree** (`OAuthMcpMapper.xml` etc.): land or drop them independently of
this design — their *semantic* findings (CAST-AS-JSON absence, `AS new` vs `VALUES()`, GET_LOCK pairing) are already
folded into §8 and survive the XML's deletion.
- **Defect fix riding along** (D12): `UserController.add/delete`'s unmanaged principal→user→membership write gets a
service-level reactive transaction during rewrite.
- **Common reuse across services**: when manager/data migrate, they copy the module pattern
(`dc3-db-manager-{dialect}`, ...); `dc3-db-r2dbc-core` and TCK infrastructure are shared from day one.
- **auth 的切换是硬切换**(D16):一个提交从 auth 路径移除 `dc3-db-core``dynamic-datasource``@MapperScan`
`mapping/*.xml`、16 个 Manager 壳与 `TenantContextHolder` 的使用。auth 内部不搞长期的双持久化。
- **`dc3-center-single`** 临时同时承载两套技术栈(auth 在 R2DBC 上,manager/data 在 MyBatis 上)指向同一数据库——两个
连接池,已接受并由过渡期限定。
- **Facade/gRPC 边缘在整个过渡期保持同步(D18**:网关与 manager/data 继续消费阻塞的 Facade 契约;响应式→阻塞桥仅限于
auth 的 gRPC 服务器与本地 Facade 实现、位于非事件循环线程,并在 Facade 浪潮随 manager/data 落地时退役。
- **工作区中未提交的 MariaDB XML 分叉**`OAuthMcpMapper.xml` 等):无论落地还是丢弃都与本设计相互独立——它们的*语义*
发现(缺 CAST-AS-JSON、`AS new` vs `VALUES()`、GET_LOCK 配对)已经吸收进 §8,XML 删除后依然留存。
- **顺路修复的缺陷**D12):`UserController.add/delete` 中不受管理的 principal→user→membership 写入在重写时补上
service 级响应式事务。
- **跨服务复用公共部分**:manager/data 迁移时复制该模块模式(`dc3-db-manager-{dialect}`,……);`dc3-db-r2dbc-core`
TCK 基础设施从第一天起就是共享的。
## 12. Phased plan and gates
## 12. 分阶段计划与闸门
| Phase | Content | Gate |
|-------|---------|------|
| P0 | Design sign-off (this document; all §5 decisions Confirmed). | Approved doc committed. **Done 2026-08-28** (D14 deferred to its spike). |
| P1 | Skeletons: `dc3-db-r2dbc-core` (snowflake + workerId rule (D10), `PageResult`, exception mapping), `dc3-db-r2dbc-boot` (auto-config, D19 codec), module shells ×3, TCK harness extension, auto-config + fail-fast selection wiring. | `make test` green; empty contract wired end-to-end on PG. |
| P2 | **Vertical slice**: `TenantRepository` (pure CRUD + paging) and `ResourceRegistryLockRepository` (advisory lock, most dialect-sensitive) — contract → 3 dialects → TCK → reactive service → controller. | TCK 3/3 green; PG dev stack boots and serves tenant CRUD. |
| P3 | Native spike on the slice: `dc3-center-auth` `-Pnative` compile + boot + endpoint smoke. | §10 criteria on the slice. |
| P4 | Rollout by aggregate: users/principals/credentials → roles/binds/memberships → menus/APIs/resources + registry sync → service accounts/audit → OAuth/MCP (largest, last). Each aggregate lands with its TCK contracts and its migrated StepVerifier tests (Appendix A). | Full TCK green ×3; auth E2E (`dc3-e2e`) green. |
| P5 | Auth MyBatis removal (D16), `dc3-center-auth` native image as the deliverable, docs updated (`db-dialects.md`, this file's status), MySQL driver decision (D14) recorded. | Native boot; zero `mybatis` strings on auth classpath. |
| 阶段 | 内容 | 闸门 |
|------|------|------|
| P0 | 设计签核(本文档;§5 全部决策为已确认)。 | 已批准文档完成提交。**2026-08-28 完成**D14 推迟至其 spike)。 |
| P1 | 骨架:`dc3-db-r2dbc-core`(雪花 ID + workerId 规则(D10)、`PageResult`、异常映射)、`dc3-db-r2dbc-boot`(自动配置、D19 编解码)、模块壳 ×3TCK 装置扩展、自动配置 + fail-fast 选择接线。 | `make test` 全绿;空契约在 PG 上端到端接通。 |
| P2 | **垂直切片**`TenantRepository`(纯 CRUD + 分页)与 `ResourceRegistryLockRepository`(咨询锁,方言最敏感)——契约 → 3 个方言 → TCK → 响应式 service → controller | TCK 3/3 全绿;PG 开发栈可启动并提供租户 CRUD |
| P3 | 在切片上做 native spike`dc3-center-auth` `-Pnative` 编译 + 启动 + 端点冒烟。 | 切片满足 §10 判据。 |
| P4 | 按聚合推广:users/principals/credentials → roles/binds/memberships → menus/APIs/resources + registry sync → service accounts/audit → OAuth/MCP(最大,最后)。每个聚合连同其 TCK 契约与其迁移后的 StepVerifier 测试(附录 A)一起落地。 | 完整 TCK ×3 全绿;auth E2E`dc3-e2e`)全绿。 |
| P5 | auth 移除 MyBatisD16)、`dc3-center-auth` native image 作为交付物、文档更新(`db-dialects.md`、本文件状态)、记录 MySQL 驱动决策(D14)。 | native 启动;auth classpath 上零 `mybatis` 字样。 |
P2P3 are the go/no-go point: if the slice proves the pattern, P4 is mechanical; if it doesn't, only the slice is
thrown away.
P2P3 go/no-go 节点:如果切片证明了该模式,P4 就是机械劳动;如果证明不了,扔掉的只有切片。
## 13. Open questions
## 13. 待解问题
1. **D14 driver data** — actual TCK results for `r2dbc-mysql` vs `r2dbc-mariadb`-against-MySQL (P1 spike output).
2. **Row-class strategy** (§7): annotated shared DOs vs private row classes per dialect — pick one convention during
P2 and record it here.
3. **Connection pool sizing under native/R2DBC** — whether 64 max (F13) is still right for reactive demand patterns;
measure during P3.
4. **`dc3-e2e` coverage** — which auth E2E flows must exist before P4 rollout is allowed to start.
5. **History of `storage-abstraction.md`** — mark §3 superseded by this document (one-line status edit) or retire the
whole file once manager/data land (its TSDB half is already superseded).
6. **Observability parity** — exact r2dbc-pool metric set, health-indicator wiring, and the slow-query logging
convention to be implemented in `dc3-db-r2dbc-boot` (P2); measure against the Hikari baseline from F13.
1. **D14 驱动数据**——`r2dbc-mysql` `r2dbc-mariadb`-against-MySQL 的真实 TCK 结果(P1 spike 产出)。
2. **行类策略**(§7):直接注解共享 DO,还是每方言私有行类——P2 期间定下一个约定并记录于此。
3. **native/R2DBC 下的连接池容量**——max 64(F13)对响应式需求模式是否仍然合适;P3 期间测量。
4. **`dc3-e2e` 覆盖**——允许 P4 推广开始之前必须存在哪些 auth E2E 流程。
5. **`storage-abstraction.md` 的处置**——标记 §3 被本文档取代(一行状态编辑),或在 manager/data 落地后退役整个文件
(其 TSDB 一半已被取代)。
6. **可观测性对等**——`dc3-db-r2dbc-boot`P2)中要实现的确切 r2dbc-pool 指标集、健康指示器接线与慢查询日志约定;对照
F13 的 Hikari 基线测量。
## Appendix A — auth migration inventory
## 附录 A — auth 迁移盘点
- **Mappers (18)**: 16 `BaseMapper` shells (map 1:1 to 15 empty Managers + `IdentityAuditLogManager`) → standard
repository contracts; `OAuthMcpMapper` (30 methods, 7 tables) → 5 aggregate repositories (D13);
`ResourceRegistryLockMapper` (advisory lock) → `ResourceRegistryLockRepository`.
- **`@Transactional` sites (19)**: enumerated in F9; all carry to D12.
- **Dialect-forked statements in auth**: `ResourceRegistryLockMapper.xml` (lock, 3 forms), `OAuthMcpMapper.xml`
(JSON casts ×4, catalog string ops ×2, upsert idioms ×2) — semantics table in §8.
- **Services to rewrite reactive**: 12 paged/fuzzy Services + `OAuthMcpRuntimeServiceImpl` +
`ResourceRegistrySyncServiceImpl` + `DictionaryForAuthService` (reads `dc3_tenant`, F12).
- **Facade & gRPC servers (the edge, D18)**: the 7 facade contracts stay blocking; the 7 gRPC servers
(`TokenServer`, `PermissionServer`, `UserServer`, `TenantServer`, `LocalCredentialServer`,
`ResourceRegistryServer`, `McpRuntimeServer`) and the 7 local facade impls gain the explicit reactive→blocking
bridge (non-event-loop threads) and swap `runIgnore` wrappers for unscoped method variants (§6).
- **Tests (17 files)**: blocking service/biz, controller, and gRPC tests (e.g. `TokenServiceImplTest`,
`OAuthMcpRuntimeServiceImplTest`, `TokenServerTest`, `McpRuntimeServerTest`) rewritten with `StepVerifier`;
in-process gRPC tests keep `GrpcInProcessExtension` against the D18 blocking edge; ThreadLocal-based context
tests migrate to explicit-parameter assertions.
- **Not in scope**: `dc3-common-dal` (F12), all manager/data/agentic DAL, DDL/initdb, `dc3-db-core` and existing
dialect modules (F15).
- **Mapper18 个)**16 个 `BaseMapper` 壳(与 15 个空 Manager + `IdentityAuditLogManager` 一一对应)→ 标准 repository
契约;`OAuthMcpMapper`30 个方法,7 张表)→ 5 个聚合 repositoryD13);`ResourceRegistryLockMapper`(咨询锁)→
`ResourceRegistryLockRepository`
- **`@Transactional` 位置(19 处)**:F9 已列举;全部平移至 D12
- **auth 中的方言分叉语句**`ResourceRegistryLockMapper.xml`(锁,3 种形式)、`OAuthMcpMapper.xml`JSON cast ×4、目录
字符串操作 ×2upsert 惯用法 ×2)——语义表见 §8
- **待重写为响应式的 Service**:12 个分页/模糊 Service + `OAuthMcpRuntimeServiceImpl` + `ResourceRegistrySyncServiceImpl`
+ `DictionaryForAuthService`(读 `dc3_tenant`F12)。
- **Facade gRPC 服务器(边缘,D18**:7 个 Facade 契约保持阻塞;7 个 gRPC 服务器(`TokenServer``PermissionServer`
`UserServer``TenantServer``LocalCredentialServer``ResourceRegistryServer``McpRuntimeServer`)与 7 个本地 Facade
实现加上显式响应式→阻塞桥(非事件循环线程),并把 `runIgnore` 包装换成无作用域方法变体(§6)。
- **测试(17 个文件)**:阻塞式 service/biz、controller 与 gRPC 测试(如 `TokenServiceImplTest`
`OAuthMcpRuntimeServiceImplTest``TokenServerTest``McpRuntimeServerTest`)以 `StepVerifier` 重写;进程内 gRPC 测试
针对 D18 阻塞边缘继续使用 `GrpcInProcessExtension`;基于 ThreadLocal 的上下文测试迁移为显式参数断言。
- **不在范围内**`dc3-common-dal`F12)、manager/data/agentic 的全部 DAL、DDL/initdb、`dc3-db-core` 与既有方言模块
F15)。
+230 -255
View File
@@ -1,41 +1,36 @@
# Design: Pluggable Storage Abstraction (Relational Dialect + Time-Series Port)
# 设计:可插拔存储抽象(关系库方言 + 时序 Port
| | |
|----------------|---------------------------------------------------------------------------------------------------------------------------------|
| **Status** | Implemented — R1/R2 landed (dual-dialect seed + forks + contract suite; selection guide at [db-dialects.md](../db-dialects.md)) |
| **Date** | 2026-08-17 |
| **Revised** | 2026-08-19 — inventory refreshed after the driver-lease commit (956de3dd3); §6.1 added |
| **Scope** | persistence layer: relational core + point-value time-series store |
| **Target** | Relational: PostgreSQL (default), MySQL 8 — Time-series: TimescaleDB (default), TDengine, InfluxDB, IoTDB |
| **Related** | [`mq-abstraction.md`](./mq-abstraction.md) — the third pluggable dimension |
| **Discussion** | open for review before implementation starts |
| | |
|------------|---------------------------------------------------------------------------------------------------------------------------------|
| **状态** | 已实现 —— R1/R2 已落地(双方言种子 + 方言分支 + 契约套件;选型指南见 [db-dialects.md](../db-dialects.md) |
| **日期** | 2026-08-17 |
| **修订** | 2026-08-19 —— 驱动租约提交(956de3dd3)之后刷新盘点;新增 §6.1 |
| **范围** | 持久层:关系库核心 + 位值时序存储 |
| **目标** | 关系库:PostgreSQL(默认)、MySQL 8 —— 时序:TimescaleDB(默认)、TDengineInfluxDBIoTDB |
| **相关** | [`mq-abstraction.md`](./mq-abstraction.md) —— 第三个可插拔维度 |
| **讨论** | 实施启动前开放评审 |
## 1. Summary
## 1. 摘要
IoT DC3 should let a deployer pick the relational database, the time-series store, and the message broker
**independently** — any combination must be a supported deployment, e.g.
`MySQL + TDengine + RocketMQ` for one company, `PostgreSQL + TimescaleDB + Kafka` for another. This document designs the
two storage dimensions; the broker dimension is covered by the companion MQ design.
IoT DC3 应当允许部署者**独立地**选择关系数据库、时序存储与消息中间件——任意组合都必须是一种受支持的部署形态,例如
一家公司用 `MySQL + TDengine + RocketMQ`,另一家用 `PostgreSQL + TimescaleDB + Kafka`。本文设计其中两个存储维度;
消息中间件维度由姊妹篇 MQ 设计覆盖。
The key structural insight: today TimescaleDB runs as an **extension inside the main PostgreSQL instance** (single `dc3`
database, single datasource, `dc3_point_value`
hypertable in the `dc3_history` schema), so "replace the time-series store" currently has no deployment boundary to swap
at. But the code is closer to ready than the deployment:
a `RepositoryService` port already exists (`dc3-common-repository`) and carries almost the entire point-value surface —
but the dashboard read surface still bypasses it. The work is therefore:
关键的结构性判断:如今 TimescaleDB 作为**扩展运行在主 PostgreSQL 实例内部**(单一 `dc3` 数据库、单一数据源,
`dc3_history` schema 中的 `dc3_point_value`
超表),因此"替换时序存储"目前在部署上没有可供切换的边界。但代码比部署更接近就绪:
一个 `RepositoryService` port`dc3-common-repository`)已经存在,承载了几乎整个位值操作面——
但看板读取面仍在绕开它。因此要做的工作是:
- **Relational layer** — not a new port: MyBatis *is* the port. What is needed is a **dialect mechanism**: a
dialect-neutral infra module, portable-first SQL policy, a
`databaseId` fork for the genuinely dialect-bound statements (9 today, across 4 mappers — concentrated in the
driver-lease subsystem), and per-dialect seed SQL. Only 7 of 51 mapper files contain PostgreSQL-specific syntax today.
- **Time-series layer** — promote the existing `RepositoryService` port to a real swappable boundary: extract the
implementation into per-store modules, fold the dashboard read surface (≈10 bypass statements) into the port,
neutralize the SQL-flavored parts of the interface (pagination, aggregate semantics), and add per-store adapters.
- Both layers share the same mechanism family as the MQ design: **profiles + capability matrix + TCK**, giving the
project one consistent pluggability story:
`dc3.facade.mode` (today) → `dc3.db.type`, `dc3.repository.type`, `dc3.mq.type`.
- **关系库层** —— 不需要新的 portMyBatis *就是* port。需要的是一套**方言机制**:一个方言中立的基建模块、可移植优先的
SQL 策略、针对真正绑定方言语句的 `databaseId` 方言分支(当前 9 条,分布在 4 个 mapper——集中于驱动租约子系统),
以及按方言划分的种子 SQL。当前 51 个 mapper 文件中只有 7 个包含 PostgreSQL 特有语法。
- **时序层** —— 把既有的 `RepositoryService` port 提升为真正可切换的边界:把实现抽取到按库划分的模块、把看板读取面
(约 10 条旁路语句)收编进 port、中和接口中 SQL 风味的部分(分页、聚合语义),并增加按库的适配器。
- 两个层与 MQ 设计共享同一机制家族:**profile + 能力矩阵 + TCK**,让项目拥有一个一致的可插拔叙事:
`dc3.facade.mode`(现状)→ `dc3.db.type``dc3.repository.type``dc3.mq.type`
## 2. The three-layer storage model
## 2. 三层存储模型
```
┌─────────────────────────────────────────────────────────────────┐
@@ -52,57 +47,56 @@ but the dashboard read surface still bypasses it. The work is therefore:
└─────────────────────────────────────────────────────────────────┘
```
Orthogonality matrix — every cell is a valid deployment:
正交性矩阵——每个单元格都是一种有效部署:
| | TimescaleDB (in-PG) | TDengine | InfluxDB | IoTDB |
|---------------------|---------------------|----------|----------|-------|
| **PostgreSQL core** | ✅ today's default | ✅ | ✅ | ✅ |
| **MySQL core** | n/a (PG-only) | ✅ | ✅ | ✅ |
| **PostgreSQL 核心** | ✅ 当前默认 | ✅ | ✅ | ✅ |
| **MySQL 核心** | 不适用(仅 PG | ✅ | ✅ | ✅ |
Note the bottom-left cell: with an external time-series store, the relational database loses its only heavy
PostgreSQL-only feature (the hypertable), and `timescale` collapses into `postgres`. The two migration tracks are
independent and mutually simplifying.
注意左下角单元格:时序存储一旦外置,关系数据库就失去了它唯一重度依赖 PostgreSQL 的特性(超表),
`timescale` 也随之坍缩为 `postgres`。两条迁移轨道相互独立,且互为简化。
## 3. Layer 1 — Relational dialect
## 3. 第 1 层 —— 关系库方言
### 3.1 Current coupling (verified inventory)
### 3.1 现状耦合(已核实的盘点)
**51 mapper XML files** across `dc3-common-auth` (18), `dc3-common-dal` (4),
`dc3-common-data` (11), `dc3-common-manager` (18). **7 contain PostgreSQL-specific syntax** — the 2026-08-18
driver-lease commit (`956de3dd3`) added one file (`DriverLeaseMapper`) and converted another (`PointValueMapper`: its
**51 mapper XML 文件**,分布于 `dc3-common-auth`18)、`dc3-common-dal`4)、
`dc3-common-data`11)、`dc3-common-manager`18)。其中 **7 个包含 PostgreSQL 特有语法**——2026-08-18
驱动租约提交(`956de3dd3`)新增了一个文件(`DriverLeaseMapper`),并改造了另一个(`PointValueMapper`:其
`DISTINCT ON`
batch-latest statement was replaced by `ON CONFLICT` upserts):
批量最新值语句已被 `ON CONFLICT` upsert 取代):
| File | Syntax | Semantics |
| 文件 | 语法 | 语义 |
|------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| `dc3-common-data/.../EntityStateMapper.xml` | 2 stmts: `ON CONFLICT ... RETURNING` (`upsertEntityState`, + `json_build_object`), `UPDATE ... FROM ... RETURNING` (`claimExpiredDevices`) | entity-state upsert + expired-lease claim, both returning rows (driver heartbeat hot path) |
| `dc3-common-data/.../PointValueMapper.xml` | 2 stmts: `ON CONFLICT DO NOTHING ... RETURNING`, `ON CONFLICT DO UPDATE` | idempotent batch insert + latest-value upsert (durable telemetry path) |
| `dc3-common-data/.../DashboardMapper.xml` | `time_bucket(INTERVAL ...)` | dashboard time-series aggregation (TimescaleDB) — belongs to Layer 2, see §4.2 |
| `dc3-common-data/.../AlertMapper.xml` | `COUNT(*) FILTER (WHERE)` ×3, `generate_series` + `date_trunc` | alert stats + calendar-day spine |
| `dc3-common-auth/.../ResourceRegistryLockMapper.xml` | `pg_advisory_xact_lock(hashtext())` | registry distributed lock |
| `dc3-common-auth/.../OAuthMcpMapper.xml` | `::text` casts | parameter null-check idiom |
| `dc3-common-manager/.../DriverLeaseMapper.xml` | 4 stmts: `pg_advisory_xact_lock`, `ON CONFLICT ... DO UPDATE ... RETURNING` ×3 | driver lease acquire / renew / device-claim (hot path, new in `956de3dd3`) |
| `dc3-common-data/.../EntityStateMapper.xml` | 2 条语句:`ON CONFLICT ... RETURNING``upsertEntityState`,含 `json_build_object`)、`UPDATE ... FROM ... RETURNING``claimExpiredDevices` | 实体状态 upsert + 过期租约认领,均返回行(驱动心跳热路径) |
| `dc3-common-data/.../PointValueMapper.xml` | 2 条语句:`ON CONFLICT DO NOTHING ... RETURNING``ON CONFLICT DO UPDATE` | 幂等批量插入 + 最新值 upsert(持久遥测路径) |
| `dc3-common-data/.../DashboardMapper.xml` | `time_bucket(INTERVAL ...)` | 看板时序聚合(TimescaleDB)——属于第 2 层,见 §4.2 |
| `dc3-common-data/.../AlertMapper.xml` | `COUNT(*) FILTER (WHERE)` ×3`generate_series` + `date_trunc` | 告警统计 + 日历日骨架 |
| `dc3-common-auth/.../ResourceRegistryLockMapper.xml` | `pg_advisory_xact_lock(hashtext())` | 注册表分布式锁 |
| `dc3-common-auth/.../OAuthMcpMapper.xml` | `::text` 类型转换 | 参数判空惯用法 |
| `dc3-common-manager/.../DriverLeaseMapper.xml` | 4 条语句:`pg_advisory_xact_lock``ON CONFLICT ... DO UPDATE ... RETURNING` ×3 | 驱动租约获取 / 续期 / 设备认领(热路径,`956de3dd3` 新增) |
**Infrastructure module** `dc3-common-postgres` hardcodes `org.postgresql.Driver`,
`PaginationInnerInterceptor(DbType.POSTGRE_SQL)`, `TimestamptzLocalDateTimeTypeHandler`, and the `postgres` profile
activation. Every business module depends on it.
**基建模块** `dc3-common-postgres`— 硬编码了 `org.postgresql.Driver`
`PaginationInnerInterceptor(DbType.POSTGRE_SQL)``TimestamptzLocalDateTimeTypeHandler`,以及 `postgres` profile
的激活。每个业务模块都依赖它。
**DDL specifics** (seed `dc3/dependencies/postgres/initdb/`): `TIMESTAMPTZ` columns throughout; 5 schemas (`dc3_auth`,
`dc3_data`, `dc3_manager`, `dc3_history`,
`dc3_agentic`) resolved via `search_path` mapper table names are conveniently **unqualified**; `JSON` columns
(portable type, not `jsonb`), defaulted as
`'{}'::JSON` — MySQL has no literal JSON defaults, its seed uses expression defaults (`DEFAULT ('{}')`, requires
8.0.13+). **plpgsql trigger functions** are additional MySQL-incompatible seed logic: a trivial `update_operate_time()`
per schema (MySQL:
`ON UPDATE CURRENT_TIMESTAMP`), and — from the driver-lease commit — three
`track_driver_device_revision_*` statement-level triggers using transition tables and
`INSERT ... ON CONFLICT` (MySQL has neither statement-level triggers nor transition tables; this revision-counter logic
must move into the application layer or be re-expressed). Extensions installed: `timescaledb` (used — Layer 2), `vector`
(installed, **no columns use it**), `age` (installed; no `cypher()` usage exists in any mapper — see open questions).
**DDL 细节**(种子位于 `dc3/dependencies/postgres/initdb/`):全库使用 `TIMESTAMPTZ` 列;5 个 schema`dc3_auth`
`dc3_data``dc3_manager``dc3_history`
`dc3_agentic`)通过 `search_path` 解析——mapper 表名因此得以**不带限定**`JSON`
(可移植类型,而非 `jsonb`),默认值为
`'{}'::JSON` MySQL 不支持字面量 JSON 默认值,其种子改用表达式默认值(`DEFAULT ('{}')`,要求
8.0.13+)。**plpgsql 触发器函数**是另一部分与 MySQL 不兼容的种子逻辑:每个 schema 一个简单的 `update_operate_time()`
MySQL 对应:
`ON UPDATE CURRENT_TIMESTAMP`),以及——来自驱动租约提交——三个
使用过渡表(transition table)和 `INSERT ... ON CONFLICT``track_driver_device_revision_*` 语句级触发器
(MySQL 既没有语句级触发器,也没有过渡表;这套修订计数逻辑必须上移到应用层或重新表达)。已安装的扩展:`timescaledb`
(在用——第 2 层)、`vector`
(已安装,**没有任何列使用它**)、`age`(已安装;任何 mapper 中都不存在 `cypher()` 用法——见开放问题)。
### 3.2 Design
### 3.2 设计
**Module split** — same family as facade/MQ profiles:
**模块拆分** —— 与 facade/MQ profile 同一家族:
```
dc3-common-postgres ──► dc3-common-jdbc # dialect-neutral: TenantLineHandler,
@@ -111,93 +105,89 @@ dc3-common-postgres ──► dc3-common-jdbc # dialect-neutral: TenantLine
dc3-db-mysql # driver, DbType, DATETIME(6) mapping
```
Selected by `dc3.db.type` via `@ConditionalOnProperty`, the established
`dc3.facade.mode` pattern.
`dc3.db.type` 通过 `@ConditionalOnProperty` 选择,沿用既有的
`dc3.facade.mode` 模式。
**Portable-first SQL policy.** The default action for a PG-specific statement is a portable rewrite that performs
equivalently on PostgreSQL — the fork is reserved for statements where portability costs semantics or performance:
**可移植优先的 SQL 策略。** 对 PG 特有语句的默认处理,是改写为一种在 PostgreSQL 上表现相当的等价可移植形式——
方言分支仅保留给那些可移植化会损失语义或性能的语句:
| PG-only syntax | Portable rewrite (runs on both) | Fork needed? |
| PG 特有语法 | 可移植改写(两库皆可运行) | 是否需要方言分支? |
|---------------------------------|--------------------------------------|----------------------------------------------------------------------|
| `COUNT(*) FILTER (WHERE x)` | `SUM(CASE WHEN x THEN 1 ELSE 0 END)` | no |
| `::text` casts | drop / rewrite null-check idiom | no |
| `generate_series` date spine | recursive CTE (MySQL 8 ✔, PG ✔) | no |
| `ON CONFLICT ... RETURNING` | — | **fork**: `INSERT ... ON DUPLICATE KEY UPDATE` + same-tx re-select |
| `UPDATE ... FROM ... RETURNING` | — | **fork**: MySQL has no `RETURNING`; multi-table `UPDATE` + re-select |
| `pg_advisory_xact_lock` | — | **fork**: `GET_LOCK()` / lock-table unique constraint |
| `time_bucket(...)` | — | **moves to Layer 2** (TSDB adapter concern) |
| `COUNT(*) FILTER (WHERE x)` | `SUM(CASE WHEN x THEN 1 ELSE 0 END)` | |
| `::text` 类型转换 | 去掉 / 改写参数判空惯用法 | |
| `generate_series` 日期骨架 | 递归 CTEMySQL 8 ✔PG ✔ | 否 |
| `ON CONFLICT ... RETURNING` | — | **方言分支**`INSERT ... ON DUPLICATE KEY UPDATE` + 同事务 re-select |
| `UPDATE ... FROM ... RETURNING` | — | **方言分支**MySQL 没有 `RETURNING`;多表 `UPDATE` + re-select |
| `pg_advisory_xact_lock` | — | **方言分支**`GET_LOCK()` / 锁表唯一约束 |
| `time_bucket(...)` | — | **移入第 2 层**TSDB 适配器关注点) |
(`DISTINCT ON``ROW_NUMBER()` was a planned portable rewrite until 2026-08-18; the statement it applied to was
replaced by `ON CONFLICT` upserts and is now a fork — see §3.1.)
`DISTINCT ON``ROW_NUMBER()` 曾是计划中的可移植改写,直到 2026-08-18;它所作用的语句已被
`ON CONFLICT` upsert 取代,如今成为一条方言分支——见 §3.1。)
Forks use MyBatis' native `databaseIdProvider`: `<select id="x" databaseId="mysql">`. Expected fork surface after the
portable rewrites: **9 statements across 4 mappers**
`EntityStateMapper` ×2, `PointValueMapper` ×2, `DriverLeaseMapper` ×4,
`ResourceRegistryLockMapper` ×1. The driver-lease subsystem (lease acquire/renew, durable telemetry, revision triggers)
is now the single largest PG-idiomatic cluster; its semantics are portable, only their expression is not.
方言分支使用 MyBatis 原生的 `databaseIdProvider``<select id="x" databaseId="mysql">`。可移植改写完成后的预期分支面:
**9 条语句、分布在 4 个 mapper**
`EntityStateMapper` ×2`PointValueMapper` ×2`DriverLeaseMapper` ×4
`ResourceRegistryLockMapper` ×1。驱动租约子系统(租约获取/续期、持久遥测、修订触发器)
如今是最大的单一 PG 惯用语法簇;其语义可移植,只是表达方式不可移植。
**Schema → database.** The 5 PG schemas map to 5 MySQL databases; because mapper table names are unqualified, the
connection's selected database plays the role `search_path`
plays today. No mapper changes.
**schema → 数据库。** 5 个 PG schema 映射为 5 个 MySQL 数据库;由于 mapper 表名不带限定,
连接所选定的数据库就扮演了如今 `search_path`
的角色。mapper 无需任何改动。
**Time semantics.** `TIMESTAMPTZ``DATETIME(6)` plus an explicit **store-and-transfer-in-UTC** convention (already
implicit in
`TimestamptzLocalDateTimeTypeHandler`); documented and asserted in the TCK.
**时间语义。** `TIMESTAMPTZ``DATETIME(6)`,外加一条显式的**以 UTC 存储与传输**的约定(在
`TimestamptzLocalDateTimeTypeHandler` 中已是隐式做法);该约定写入文档并在 TCK 中以断言锁定。
**Seed SQL.** Per-dialect directories, backend-owned as today:
`dc3/dependencies/postgres/initdb/` (existing) and `dc3/dependencies/mysql/initdb/`. The `dc3_point_value` DDL and all
TimescaleDB statements move to the `timescale`
repository adapter (§4) — with an external TSDB the relational seed contains no extension DDL at all. Seed-side
divergences beyond column types: the plpgsql trigger functions (§3.1) — `update_operate_time()` maps to
`ON UPDATE CURRENT_TIMESTAMP`, the driver-device revision triggers need re-implementation (application layer or MySQL
row-level triggers) — and `'{}'::JSON` defaults map to expression defaults.
**种子 SQL** 按方言划分目录,与今天一样由后端仓库维护:
`dc3/dependencies/postgres/initdb/`(既有)与 `dc3/dependencies/mysql/initdb/``dc3_point_value` DDL 与全部
TimescaleDB 语句移入 `timescale`
仓储适配器(§4)——时序库外置后,关系种子中完全不包含任何扩展 DDL。列类型之外的种子侧差异:plpgsql 触发器函数
(§3.1)——`update_operate_time()` 映射为
`ON UPDATE CURRENT_TIMESTAMP`,驱动设备修订触发器需要重新实现(应用层或 MySQL
行级触发器)——以及 `'{}'::JSON` 默认值映射为表达式默认值。
**Hard requirement:** MySQL 8.0+ (window functions, CTEs, recursive CTEs). MariaDB 10.5+ follows for free; older MySQL
is out of scope.
**硬性要求:** MySQL 8.0+(窗口函数、CTE、递归 CTE)。MariaDB 10.5+ 顺带即可支持;更老的 MySQL 不在范围内。
### 3.3 Relational TCK
### 3.3 关系库 TCK
`make test-it` (DAL integration tests) executed against two Testcontainers images (`postgres` and `mysql:8`) with
identical fixtures; identical results = compliant. CI runs both — this is also the discipline mechanism that keeps new
mappers portable or explicitly forked.
`make test-it`DAL 集成测试)针对两个 Testcontainers 镜像(`postgres` `mysql:8`)以完全相同的夹具执行;
结果一致即视为合规。CI 两个都跑——这也是确保新增 mapper 保持可移植、或显式走分支的纪律机制。
## 4. Layer 2 — Time-series store (`RepositoryService` port)
## 4. 第 2 层 —— 时序存储(`RepositoryService` port
> **Superseded 2026-08-20**: the time-series track now has its own full design at
> [`tsdb-abstraction.md`](./tsdb-abstraction.md) (standalone-store targets, TCK gate,
> latest-value-stays-relational decision). The sketch below is kept for history.
> **已于 2026-08-20 被取代**:时序轨道现在拥有自己的完整设计,见
> [`tsdb-abstraction.md`](./tsdb-abstraction.md)(独立存储目标、TCK 门槛、
> 最新值留在关系库的决策)。下面的草稿仅作历史记录保留。
### 4.0 (historical sketch)
### 4.0(历史草稿)
### 4.1 What exists today
### 4.1 今天的现状
- **Port**: `dc3-common-repository` defines `RepositoryService` — 8 operations that cover the entire point-value domain:
`savePointValue(s)`,
`listHistoryPointValue`, `selectLatestPointValue`, `listLatestPointValues`,
`listPagePointValue`, `aggregateInWindow`, `samplesInWindow` — plus
`RepositoryStrategyFactory` (named-strategy registry) and `ActiveRepositoryProfileConfig`.
- **Single implementation**: `PostgresRepositoryServiceImpl` (inside
`dc3-common-data`), registered under `StrategyConstant.Storage.POSTGRES`; treats the TimescaleDB hypertable as a plain
PG table via `PointValueManager`/`PointValueMapper`.
- **Flow**: `PointValueReceiver` (MQ) `PointValueIngestBuffer`
`PointValueServiceImpl``PointValueLocalCacheService` (latest-value cache) +
`RepositoryService` (persistence). Alarm long-window evaluation reads via
`RepositoryWindowDataSource` → the same port.
- **Deployment**: TimescaleDB is an in-PG extension — single instance, single datasource, no swap boundary.
- **Port**`dc3-common-repository` 定义了 `RepositoryService`——覆盖位值全域的 8 个操作:
`savePointValue(s)`
`listHistoryPointValue``selectLatestPointValue``listLatestPointValues`
`listPagePointValue``aggregateInWindow``samplesInWindow`——外加
`RepositoryStrategyFactory`(命名策略注册表)与 `ActiveRepositoryProfileConfig`
- **单一实现**`PostgresRepositoryServiceImpl`(位于
`dc3-common-data` 内),以 `StrategyConstant.Storage.POSTGRES` 注册;通过 `PointValueManager`/`PointValueMapper`
把 TimescaleDB 超表当作普通 PG 表处理。
- **数据流**`PointValueReceiver`MQ`PointValueIngestBuffer`
`PointValueServiceImpl``PointValueLocalCacheService`(最新值缓存)+
`RepositoryService`(持久化)。告警长窗口评估通过
`RepositoryWindowDataSource` 读取——同样走这个 port
- **部署形态**TimescaleDB 是 PG 内的扩展——单实例、单数据源,没有可切换的边界。
### 4.2 Gaps to close before the store is truly swappable
### 4.2 存储真正可换之前需要补齐的缺口
1. **The dashboard read surface bypasses the port.** The data-side
`DashboardMapper.xml` reads `dc3_point_value` directly in **9 of its 10 statements**
(`countInRange`, `countTotal`, `timeseries`, `top`, `latestStream`,
`latencyHistogram`, `hourlyActivity`, `silentSources`, `coverageGapItems`); the manager-side `DashboardMapper.xml`
adds a 10th (`FROM dc3_history.dc3_point_value`). Only `timeseries` uses `time_bucket` — the others are portable SQL,
but portable or not they all break the moment the point-value store is external. And several are richer than a
bucketed aggregation (latency histogram bins, silent-source detection, coverage gaps, activity grid): designing the
neutral read port — or re-expressing these dashboards on top of port primitives — is the largest single T1 workload,
not a one-statement move (open question §8).
2. **Extract the implementation** into per-store modules; selection by
`dc3.repository.type`:
1. **看板读取面绕开了 port** 数据侧
`DashboardMapper.xml` 的 **10 条语句中有 9 条**直接读 `dc3_point_value`
`countInRange``countTotal``timeseries``top``latestStream`
`latencyHistogram``hourlyActivity``silentSources``coverageGapItems`);manager 侧的 `DashboardMapper.xml`
又加了第 10 条(`FROM dc3_history.dc3_point_value`)。只有 `timeseries` 用到 `time_bucket`——其余都是可移植 SQL
但无论可移植与否,位值存储一旦外置它们全会失效。而且其中几条比分桶聚合更复杂(延迟直方图分箱、静默源检测、
覆盖缺口、活跃度网格):设计中立的读取 port——或基于 port 原语重新表达这些看板——是 T1 中最大的一块单独工作量,
不是挪一条语句了事(开放问题 §8)。
2. **把实现抽取**为按库划分的模块;由
`dc3.repository.type` 选择:
```
dc3-repository-timescale # today's PostgresRepositoryServiceImpl, moved
@@ -206,44 +196,41 @@ mappers portable or explicitly forked.
dc3-repository-iotdb # Apache IoTDB adapter
```
3. **Neutralize the SQL-flavored surface** of the port: `Page` (a MyBatis-Plus type)
and SQL aggregate semantics leak through. Design target: a cursor-friendly pagination abstraction (time-descending
cursor; offset optional capability) and a store-neutral aggregate enum — TDengine `INTERVAL`, InfluxDB 3 SQL and
IoTDB all express AVG/MIN/MAX/SUM/COUNT windows, so the enum maps cleanly.
4. **Data-model mapping** per adapter — tenant isolation rides the store's native dimension (tenant safety rule
preserved):
3. **中和 port 的 SQL 风味表面**`Page`(一个 MyBatis-Plus 类型)
SQL 聚合语义从 port 泄漏出去。设计目标:一个对游标友好的分页抽象(按时间降序的游标;offset 作为可选能力)
与一个库中立的聚合枚举——TDengine `INTERVAL`InfluxDB 3 SQL
IoTDB 都能表达 AVG/MIN/MAX/SUM/COUNT 窗口,因此该枚举可以干净映射。
4. 每个适配器的**数据模型映射**——租户隔离依托各存储的原生维度(租户安全规则得以保留):
| Concept | Timescale | TDengine 3 | InfluxDB 3 | IoTDB |
| 概念 | Timescale | TDengine 3 | InfluxDB 3 | IoTDB |
|---------|-----------|------------|------------|-------|
| series identity | (tenant_id, device_id, point_id) PK | super table + tags `tenant/device/point` | measurement + tags | path `tenant.device.point` |
| tenant scoping | WHERE tenant_id | tag filter (indexed) | tag filter | path prefix filter |
| latest value | index / `DISTINCT ON` | `LAST()` | `last()` / SQL | `last` query |
| window aggregate | `time_bucket` | `INTERVAL(...)` | SQL `date_bin` | group-by-time window |
| retention | drop-chunks policy / compression | `KEEP` per database | retention policy | TTL |
| write path | SQL batch | schemaless / STABLE batch insert | line protocol / SQL | session batch insert |
| 序列标识 | (tenant_id, device_id, point_id) 主键 | 超级表 + 标签 `tenant/device/point` | measurement + 标签 | 路径 `tenant.device.point` |
| 租户作用域 | WHERE tenant_id | 标签过滤(有索引) | 标签过滤 | 路径前缀过滤 |
| 最新值 | 索引 / `DISTINCT ON` | `LAST()` | `last()` / SQL | `last` 查询 |
| 窗口聚合 | `time_bucket` | `INTERVAL(...)` | SQL `date_bin` | 按时间分组的窗口 |
| 保留策略 | drop-chunks 策略 / 压缩 | 每数据库 `KEEP` | retention policy | TTL |
| 写入路径 | SQL 批量 | schemaless / STABLE 批量插入 | line protocol / SQL | session 批量插入 |
5. **Retention & lifecycle** as declared capabilities (timescale compression, TDengine `KEEP`, Influx retention, IoTDB
TTL) rather than hidden DDL.
6. **Deployment topology**: an external TSDB is a second datasource with its own compose service and profile
(`dc3/dependencies/<store>/`, image per store, mirroring the broker-per-adapter pattern). With an external store the
main PG no longer needs TimescaleDB at all.
5. **保留与生命周期**作为显式声明的能力(timescale 压缩、TDengine `KEEP`Influx retentionIoTDB
TTL),而不是隐藏的 DDL
6. **部署拓扑**:外置 TSDB 是第二个数据源,拥有自己的 compose 服务与 profile
`dc3/dependencies/<store>/`,每个库一个镜像,与每个适配器配一个 broker 的模式一致)。有了外置存储,
主 PG 完全不再需要 TimescaleDB
### 4.3 Time-series TCK
### 4.3 时序 TCK
One suite, N containers: write a fixed fixture of point values, then assert
一套套件,N 个容器:写入固定的位值夹具,然后断言
`listHistoryPointValue` / `listLatestPointValues` / `aggregateInWindow` /
`samplesInWindow` return equivalent results; tenant-isolation negative tests (cross-tenant reads return nothing);
retention test (expired data disappears). Passing the TCK is the acceptance bar for community stores (e.g. someone
bringing Cassandra or ClickHouse adapters later).
`samplesInWindow` 返回等价结果;租户隔离反向测试(跨租户读取不返回任何数据);
保留测试(过期数据消失)。通过 TCK 是社区存储的验收门槛(例如日后有人带来 Cassandra 或 ClickHouse 适配器)。
## 5. Layer 3 — Vector (placeholder, capability-negotiated)
## 5. 第 3 层 —— 向量(占位,按能力协商)
`pgvector` is installed but unused — no `vector` columns exist. When the Agentic Center adds embeddings, the decision
point arrives: vector search as a **PostgreSQL-only capability** (feature-flagged via capability negotiation, MySQL
profile degrades to external store or no vector search) or as a fourth pluggable dimension. Not designed now; recorded
so the choice is conscious when it happens.
`pgvector` 已安装但未使用——不存在任何 `vector` 列。当 Agentic Center 引入 embedding 时,决策点就会到来:
向量检索是作为 **PostgreSQL 专属能力**(通过能力协商加特性开关,MySQL profile 退化为外置存储或无向量检索),
还是作为第四个可插拔维度。现在不做设计;记录在此,是为了届时这是一个有意识的选择。
## 6. Configuration surface (unified across all pluggable dimensions)
## 6. 配置面(跨所有可插拔维度统一)
```yaml
dc3:
@@ -257,113 +244,101 @@ dc3:
type: rabbitmq # see mq-abstraction.md
```
One mechanism for all: `@ConditionalOnProperty` profile selection, capability matrix published per dimension, startup
log summarizing the negotiated combination. Compose stacks declare per-store services behind profiles so `make up`
assembles whatever combination is configured.
所有维度共用一个机制:`@ConditionalOnProperty` profile 选择、按维度发布能力矩阵、启动日志汇总协商出的组合。
Compose 栈在 profile 背后声明按库划分的服务,`make up` 即可组装出所配置的任意组合。
### 6.1 What "pluggable" means — and what it deliberately does not
### 6.1 "可插拔"意味着什么——以及它刻意不意味着什么
**Deploy-time selection is the goal.** Any combination of the three dimensions is chosen when the stack is assembled;
`dc3.*.type` is read once at startup. Switching an existing deployment means changing the property and restarting the
affected services — for a fresh deployment that is the whole story, no data moves.
**部署期选择就是目标。** 三个维度的任意组合都在组装栈时确定;
`dc3.*.type` 在启动时读取一次。切换既有部署意味着修改配置项并重启受影响的服务——对全新部署而言这就是全部,无需迁移任何数据。
**Runtime hot-swap is explicitly not a goal**, for three structural reasons:
**运行时热替换明确不是目标**,理由有三个结构性原因:
1. **Data gravity** — the engine holds the data. Swapping PostgreSQL for MySQL under live data without moving it yields
an empty store; moving the data is a *migration*, not a plug event. No abstraction layer can change that.
2. **Startup binding** — connection pools, `SqlSessionFactory` and `databaseId`
resolution (relational), TSDB clients and ingest-buffer flush points (time-series)
are all established at boot.
3. **Dialect correctness** — every forked statement is parsed and routed for the engine that will execute it; a runtime
dialect flip would bypass that guarantee.
1. **数据引力** — 数据在引擎手里。在真实数据之下把 PostgreSQL 换成 MySQL 而不搬数据,得到的只是一个空库;
搬数据是一次*迁移*,不是一次插拔动作。任何抽象层都改变不了这一点。
2. **启动期绑定** — 连接池、`SqlSessionFactory` `databaseId`
解析(关系库),TSDB 客户端与摄入缓冲刷写点(时序)
都在启动时建立。
3. **方言正确性** — 每条分支语句都是为将执行它的那个引擎解析和路由的;运行时翻转方言会绕开这一保证。
**What the port architecture does enable is online migration with cutover** ("warm swap"). For the time-series dimension
the `RepositoryStrategyFactory` can register two adapters simultaneously: dual-write window (fan out writes) → backfill
history from the old store → equivalence-check against the TCK fixtures → flip reads → drain → retire the old store. The
MQ-buffered ingest path (`PointValueReceiver` →
`PointValueIngestBuffer`) means no in-flight data is lost while a store is briefly unavailable. For the relational
dimension the equivalent is standard online-migration tooling (logical replication / CDC / pgloader) — supported by, but
outside, this design.
**port 架构真正能带来的是带切换(cutover)的在线迁移**("温替换")。对时序维度,
`RepositoryStrategyFactory` 可以同时注册两个适配器:双写窗口(写入扇出)→ 从旧库回填历史 → 用 TCK 夹具做等价校验
→ 切换读取 → 排空 → 退役旧库。MQ 缓冲的摄入路径(`PointValueReceiver` →
`PointValueIngestBuffer`)意味着存储短暂不可用时不会有在途数据丢失。对关系维度,对应物是标准的在线迁移工具
(逻辑复制 / CDC / pgloader)——得到本设计的支持,但在本设计范围之外。
**Adding a store is "hot" in the only sense that matters**: adapter modules are additive — new module + one config
property, zero core changes; the TCK is the bar.
**新增一个存储在唯一重要的意义上是"热"的**:适配器模块是纯增量的——新模块 + 一个配置项,核心零改动;
TCK 就是门槛。
## 7. Migration plan
## 7. 迁移计划
The two tracks are independent and can interleave:
两条轨道相互独立,可以交错进行:
- **R1 — relational hygiene (zero behavior change).** Portable SQL rewrites (`FILTER`→`SUM(CASE)` ×3, `generate_series`
recursive CTE, drop `::text`), split
`dc3-common-postgres` `dc3-common-jdbc` + `dc3-db-postgres`. *Gate: existing E2E and `make test-it` green,
unchanged.*
- **R2 — MySQL dialect.** Fork the 9 dialect-bound statements across 4 mappers via
`databaseId`; re-implement the seed trigger functions (`update_operate_time` →
`ON UPDATE CURRENT_TIMESTAMP`, driver-device revision triggers → application layer); write `initdb/mysql/` (expression
JSON defaults), add `dc3-db-mysql`, stand up the dual-dialect DAL TCK in CI.
- **T1 — consolidate the TSDB boundary.** Move `PostgresRepositoryServiceImpl` →
`dc3-repository-timescale`; fold the 10-statement dashboard read surface into the port (spike the read-primitives
shape first — open question §8.2); move
`dc3_point_value` DDL + `time_bucket` out of the relational seed. *Gate: no query outside the TSDB adapter references
`dc3_point_value`.*
- **T2 — port surface cleanup + TDengine adapter.** Cursor pagination, neutral aggregate enum; TDengine 3 adapter as the
first external-store proof, plus the time-series TCK.
- **T3 — community stores.** InfluxDB / IoTDB adapters, explicitly framed as community-sized tasks with the TCK as the
bar (same framing as MQ adapters).
- **R1 —— 关系库卫生(零行为变化)。** 可移植 SQL 改写(`FILTER`→`SUM(CASE)` ×3`generate_series`
递归 CTE、去掉 `::text`),把
`dc3-common-postgres` 拆分为 `dc3-common-jdbc` + `dc3-db-postgres`。*门槛:既有 E2E `make test-it` 保持绿色、结果不变。*
- **R2 —— MySQL 方言。** 通过 `databaseId` 对 4 个 mapper 中 9 条绑定方言的语句做分支;重新实现种子触发器函数
`update_operate_time` →
`ON UPDATE CURRENT_TIMESTAMP`,驱动设备修订触发器 → 应用层);编写 `initdb/mysql/`(表达式
JSON 默认值),新增 `dc3-db-mysql`,在 CI 中建立双方言 DAL TCK。
- **T1 —— 收拢 TSDB 边界。** 把 `PostgresRepositoryServiceImpl` 迁至
`dc3-repository-timescale`;把 10 条语句的看板读取面收编进 port(先做一次 spike 摸清读取原语的形状——开放问题 §8.2);把
`dc3_point_value` DDL + `time_bucket` 移出关系种子。*门槛:TSDB 适配器之外没有任何查询再引用
`dc3_point_value`。*
- **T2 —— port 表面清理 + TDengine 适配器。** 游标分页、中立聚合枚举;TDengine 3 适配器作为首个外置存储的验证,
外加时序 TCK。
- **T3 —— 社区存储。** InfluxDB / IoTDB 适配器,明确定位为社区体量的任务,以 TCK 为门槛
(与 MQ 适配器采用相同的定位方式)。
## 8. Open questions
## 8. 开放问题
1. **AGE** — installed and loaded at bootstrap, but no `cypher()` query exists in the codebase, and AGENTS.md describes
it as a pillar extension. Keep (documented as reserved for future graph features) or drop from the base image? Either
way the docs and the image should agree.
2. **Dashboard read-port home and shape** — the bypass surface is 10 statements (§4.2), several richer than bucketed
aggregation (latency histogram, silent sources, coverage gaps). Options: (a) extend `RepositoryService` with
composable read primitives (count-in-range, bucketed aggregate, latest-stream) and re-express the analytic dashboards
on top; (b) a separate read-only dashboard port carrying the full statement semantics. Leaning: (a) — a port that
encodes every dashboard shape would leak the current UI into the contract, and the primitives compose; but the
re-expression cost for `latencyHistogram` /
`silentSources` / `coverageGapItems` needs a spike before T1 commits to it.
3. **Pagination semantics** — pure time cursor, or cursor + optional offset for small-result UI pages? Affects the
frontend history view contract.
4. **Latest-value read path** — `PointValueLocalCacheService` already caches latest values; confirm whether port-level
`selectLatest*` is only a cold-start fallback (if so, adapters can implement it simply and optimize for write
throughput).
5. **InfluxDB version** — 3.x (SQL) is the natural target; whether a 1.8/2.x contributor adapter is worth accepting is a
community question.
6. **Phase ordering** — if T1/T2 land before R2, the MySQL fork shrinks (hypertable already gone); if R2 lands first,
MySQL ships with plain-table `dc3_point_value`
and T2 migrates it. Decide by community demand signals.
1. **AGE** —— 启动时已安装并加载,但代码库中不存在任何 `cypher()` 查询,而 AGENTS.md 将其描述为支柱性扩展。
保留(记录为预留给未来图功能)还是从基础镜像中移除?无论哪种,文档与镜像应当一致。
2. **看板读取 port 的归属与形状** —— 旁路面共 10 条语句(§4.2),其中几条比分桶聚合更复杂
(延迟直方图、静默源、覆盖缺口)。选项:(a) 用可组合的读取原语(范围计数、分桶聚合、最新流)扩展
`RepositoryService`,并在此基础上重新表达分析型看板;(b) 另设一个只读的看板 port,承载完整的语句语义。
倾向:(a) —— 一个把每种看板形状都编码进去的 port 会把当前 UI 泄漏进契约,而原语是可组合的;
但在 T1 承诺之前,`latencyHistogram` /
`silentSources` / `coverageGapItems` 的重新表达成本需要先做一次 spike。
3. **分页语义** —— 纯时间游标,还是游标 + 可选 offset(用于小结果集的 UI 分页)?影响前端历史视图契约。
4. **最新值读取路径** —— `PointValueLocalCacheService` 已经缓存最新值;需确认 port 层的
`selectLatest*` 是否只是冷启动回退(如果是,适配器可以简单实现它,并针对写吞吐做优化)。
5. **InfluxDB 版本** —— 3.xSQL)是自然的目标;1.8/2.x 的贡献者适配器是否值得接受,是社区问题。
6. **阶段顺序** —— 若 T1/T2 先于 R2 落地,MySQL 分支会缩小(超表已消失);若 R2 先落地,
MySQL 将随普通表形态的 `dc3_point_value`
一起发布,由 T2 完成迁移。根据社区需求信号决定。
## 9. Appendix — inventory (migration checklists)
## 9. 附录 —— 盘点(迁移清单)
PG-specific mapper statements (R1/R2 checklist; refreshed 2026-08-19):
PG 特有的 mapper 语句(R1/R2 清单;2026-08-19 刷新):
| Mapper | Statement | Action |
| Mapper | 语句 | 处理动作 |
|------------------------------|------------------------------------------------------------------------|----------------------------------------------------------------|
| `AlertMapper` ×3 | `COUNT(*) FILTER` | portable rewrite (`SUM(CASE)`) |
| `AlertMapper` | `generate_series` + `date_trunc` | recursive CTE (unify) |
| `OAuthMcpMapper` | `::text` casts | drop / rewrite |
| `EntityStateMapper` | `upsertEntityState` — `ON CONFLICT ... RETURNING` | **fork** (pg / mysql) |
| `EntityStateMapper` | `claimExpiredDevices` — `UPDATE ... FROM ... RETURNING` | **fork** (pg / mysql) |
| `PointValueMapper` | idempotent batch insert — `ON CONFLICT DO NOTHING ... RETURNING` | **fork** (pg / mysql); statement moves into TSDB adapter at T1 |
| `PointValueMapper` | latest-value upsert — `ON CONFLICT DO UPDATE` | **fork** (pg / mysql); moves into TSDB adapter at T1 |
| `DriverLeaseMapper` | `pg_advisory_xact_lock` acquire | **fork** (pg / mysql) |
| `DriverLeaseMapper` ×3 | lease renew / device-claim — `ON CONFLICT ... DO UPDATE ... RETURNING` | **fork** (pg / mysql) |
| `ResourceRegistryLockMapper` | `pg_advisory_xact_lock(hashtext())` | **fork** (pg / mysql) |
| `DashboardMapper` (data) | `time_bucket` + 8 further direct `dc3_point_value` reads | **T1**: fold into TSDB port |
| `DashboardMapper` (manager) | direct `dc3_history.dc3_point_value` read | **T1**: fold into TSDB port |
| `AlertMapper` ×3 | `COUNT(*) FILTER` | 可移植改写(`SUM(CASE)` |
| `AlertMapper` | `generate_series` + `date_trunc` | 递归 CTE(统一) |
| `OAuthMcpMapper` | `::text` 类型转换 | 去掉 / 改写 |
| `EntityStateMapper` | `upsertEntityState` — `ON CONFLICT ... RETURNING` | **方言分支**pg / mysql |
| `EntityStateMapper` | `claimExpiredDevices` — `UPDATE ... FROM ... RETURNING` | **方言分支**pg / mysql |
| `PointValueMapper` | 幂等批量插入 —— `ON CONFLICT DO NOTHING ... RETURNING` | **方言分支**pg / mysql);语句在 T1 移入 TSDB 适配器 |
| `PointValueMapper` | 最新值 upsert — `ON CONFLICT DO UPDATE` | **方言分支**pg / mysql);在 T1 移入 TSDB 适配器 |
| `DriverLeaseMapper` | `pg_advisory_xact_lock` 获取 | **方言分支**pg / mysql |
| `DriverLeaseMapper` ×3 | 租约续期 / 设备认领 —— `ON CONFLICT ... DO UPDATE ... RETURNING` | **方言分支**pg / mysql |
| `ResourceRegistryLockMapper` | `pg_advisory_xact_lock(hashtext())` | **方言分支**pg / mysql |
| `DashboardMapper`(数据侧) | `time_bucket` + 另有 8 条直读 `dc3_point_value` | **T1**:收编进 TSDB port |
| `DashboardMapper`manager 侧) | 直读 `dc3_history.dc3_point_value` | **T1**:收编进 TSDB port |
Seed-side R2 items: plpgsql `update_operate_time()` triggers (→ `ON UPDATE
CURRENT_TIMESTAMP`), 3× `track_driver_device_revision_*` statement-level triggers (→ application layer), `'{}'::JSON`
defaults (→ expression defaults).
种子侧 R2 事项:plpgsql `update_operate_time()` 触发器(→ `ON UPDATE
CURRENT_TIMESTAMP`)、3 个 `track_driver_device_revision_*` 语句级触发器(→ 应用层)、`'{}'::JSON`
默认值(→ 表达式默认值)。
TSDB boundary checklist (T1):
TSDB 边界清单(T1):
- `RepositoryService` / `RepositoryStrategyFactory` / `ActiveRepositoryProfileConfig` — keep, promote to shared
contract.
- `RepositoryService` / `RepositoryStrategyFactory` / `ActiveRepositoryProfileConfig` —— 保留,提升为共享
契约。
- `PostgresRepositoryServiceImpl` + `PointValueManager`/`PointValueMapper`
point-value statements → `dc3-repository-timescale`.
- `dc3_point_value` DDL, hypertable/compression DDL, seed data → timescale adapter.
- Verify zero remaining `dc3_point_value` references outside TSDB adapters (`grep -r dc3_point_value` gate).
的位值语句 → `dc3-repository-timescale`
- `dc3_point_value` DDL、超表/压缩 DDL、种子数据 → timescale 适配器。
- 验证 TSDB 适配器之外不再残留任何 `dc3_point_value` 引用(`grep -r dc3_point_value` 门槛)。
**R1/R2 实施记录(2026-08-24)**:关系轨道全部落地——R1 可移植改写 + 模块拆分 (中立基建 + 顶层 `dc3-db` 家族——后按家族一致性迁为
`dc3-db/dc3-db-core`,与 dc3-mq-core/dc3-tsdb-core 同构);R2 MySQL 方言(databaseId fork、RETURNING 解耦为
+63 -70
View File
@@ -1,88 +1,81 @@
# Message Broker Selection
# 消息 broker 选型指南
IoT DC3's internal async plane (point values, commands, states, alarms, events, notify tasks between the center services
and the drivers) runs behind a broker-neutral messaging port: the application code is identical on every broker, and the
deployment picks one with a single setting plus one dependency.
IoT DC3 的内部异步平面(中心服务与驱动之间的点位值、命令、状态、告警、事件、通知任务)运行在 broker 中立的消息
Port 后面:应用代码在每个 broker 上完全相同,部署侧只需一个配置项加一个依赖即可选定 broker。
> Design background: [`docs/design/mq-abstraction.md`](./design/mq-abstraction.md)
> 设计背景:[`docs/design/mq-abstraction.md`](./design/mq-abstraction.md)
## How to pick a broker
## 如何选型 broker
1. Set `DC3_MQ_TYPE` (maps to `dc3.mq.type`) — see the table below. Default is
`rabbitmq`, so existing deployments change nothing.
2. Provide the connection settings for that broker (table below).
3. That is all in terms of configuration — nothing else in the stack is broker-specific. Note that only the `rabbitmq`
adapter ships in the standard `dc3` images; running another broker additionally requires adding the corresponding
`dc3-mq-*` adapter dependency to the deployment (step 1 of the selection is still just the type + connection
settings).
1. 设置 `DC3_MQ_TYPE`(映射到 `dc3.mq.type`)——见下表。默认值为
`rabbitmq`,因此既有部署无需任何改动。
2. 提供该 broker 的连接配置(见下表)。
3. 配置层面仅此而已——技术栈中没有其他任何部分与特定 broker 相关。注意标准 `dc3` 镜像只随附
`rabbitmq` 适配器;运行其他 broker 还需在部署中额外加入对应的
`dc3-mq-*` 适配器依赖(选型第 1 步仍然只是类型 + 连接配置)。
The internal plane and the device-access plane are independent: switching the internal broker never affects the MQTT
drivers or the EMQX-based device access.
内部平面与南向设备面相互独立:更换内部 broker 绝不会影响 MQTT 驱动或基于 EMQX 的设备接入。
## Device-access plane (southbound MQTT)
## 南向设备面(MQTT 南向接入)
The device-access MQTT stack needs no adapter layer at all — MQTT is an open wire protocol and the driver uses a
standard Paho client, so the broker is selected by one URL (`dc3.driver.mqtt.url`): EMQX, Mosquitto, HiveMQ, NanoMQ,
VerneMQ ... are drop-in replacements with zero code change. The compose default (`dc3-emqx`) is a deployment choice, not
a dependency; vendor-specific features (management APIs, rule engines) must not leak into driver code, and
`MqttVendorNeutralityIT` keeps that property verified mechanically — the same client code round-trips against two
different broker vendors, differing only in the URL.
南向设备面的 MQTT 技术栈完全不需要适配器层——MQTT 是开放的线上协议,驱动使用标准 Paho 客户端,broker 只由一个
URL`dc3.driver.mqtt.url`)选定:EMQXMosquittoHiveMQNanoMQ、VerneMQ …… 都可以零代码改动直接替换。
Compose 默认值(`dc3-emqx`)是一种部署选择,而非依赖;厂商特有功能(管理 API、规则引擎)不得泄漏进驱动代码,
`MqttVendorNeutralityIT` 以机械化方式持续验证这一性质——同一份客户端代码对两家不同的 broker 厂商做往返验证,
唯一差异只是 URL。
One dialect note: the device-access client speaks MQTT 3.1.1, which every mainstream broker supports and which covers
device access (publish/subscribe, QoS, retained, TLS, X.509). MQTT 5-only features such as shared subscriptions are used
exclusively by the internal-plane adapter above.
一条方言说明:南向设备面客户端使用 MQTT 3.1.1——所有主流 broker 都支持该版本,且足以覆盖设备接入
(发布/订阅、QoS、保留消息、TLSX.509)。共享订阅等 MQTT 5 独有特性仅由上文的内部平面适配器使用。
## Certified brokers
## 已认证的 broker
Every adapter below except RocketMQ passes the same broker-neutral contract suite (`dc3-mq-tck`) against a live broker.
The RocketMQ adapter is **experimental / not yet certified**: its contract suite is opt-in (`TCK_ROCKETMQ_NAMESRV`) and
self-describes as not-yet-certified — run it against your own broker before relying on it.
下表中的适配器除 RocketMQ 外,均已在真实 broker 上通过同一套 broker 中立契约套件(`dc3-mq-tck`)。
RocketMQ 适配器为**实验性 / 尚未认证**:其契约套件需显式开启(`TCK_ROCKETMQ_NAMESRV`),并且自述为尚未认证——
在依赖它之前,请先针对你自己的 broker 运行一遍。
| Broker | `DC3_MQ_TYPE` | Connection settings | Notes |
|-------------------------------------------|---------------|---------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
| RabbitMQ (default) | `rabbitmq` | `RABBITMQ_HOST` / `RABBITMQ_PORT` / `RABBITMQ_USERNAME` / `RABBITMQ_PASSWORD` (+ SSL) | Wire-compatible with all pre-port deployments; publisher confirms + returns |
| Kafka | `kafka` | `spring.kafka.bootstrap-servers` | `acks=all`; new consumer groups start at latest; per-key ordering; plain connections only — the adapter configures no SASL/TLS |
| RocketMQ (experimental) | `rocketmq` | `dc3.mq.rocketmq.name-server-address` | CLUSTERING/BROADCASTING modes; sync-send confirmation; uncertified — see above |
| Pulsar | `pulsar` | `dc3.mq.pulsar.service-url` | Shared/exclusive subscriptions; native batch receive |
| ActiveMQ (Artemis / Classic) | `activemq` | `dc3.mq.activemq.url` (+ user/password) | JMS 2.0; shared durable subscriptions; scheduled delays; no publisher confirm — the driver outbox covers durability |
| MQTT 5 (EMQX / HiveMQ / NanoMQ / VerneMQ) | `mqtt` | `dc3.mq.mqtt.host` / `dc3.mq.mqtt.port` | Shared subscriptions (MQTT 5); QoS 1; lets one broker serve both the device plane and the internal plane |
| Broker | `DC3_MQ_TYPE` | 连接配置 | 说明 |
|--------------------------------------------|---------------|---------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
| RabbitMQ(默认) | `rabbitmq` | `RABBITMQ_HOST` / `RABBITMQ_PORT` / `RABBITMQ_USERNAME` / `RABBITMQ_PASSWORD`+ SSL | 与 Port 引入之前的所有部署线上兼容;发布者确认 + returns |
| Kafka | `kafka` | `spring.kafka.bootstrap-servers` | `acks=all`;新消费者组从 latest 起始;按 key 有序;仅明文连接——适配器不配置任何 SASL/TLS |
| RocketMQ(实验性) | `rocketmq` | `dc3.mq.rocketmq.name-server-address` | CLUSTERING/BROADCASTING 模式;同步发送确认;未认证——见上文 |
| Pulsar | `pulsar` | `dc3.mq.pulsar.service-url` | 共享/独占订阅;原生批量接收 |
| ActiveMQArtemis / Classic | `activemq` | `dc3.mq.activemq.url`+ user/password | JMS 2.0;共享持久订阅;定时延迟;无发布者确认——驱动发件箱(outbox)保障持久性 |
| MQTT 5EMQX / HiveMQ / NanoMQ / VerneMQ | `mqtt` | `dc3.mq.mqtt.host` / `dc3.mq.mqtt.port` | 共享订阅(MQTT 5);QoS 1;可让一个 broker 同时服务设备面与内部平面 |
## Capability matrix
## 能力矩阵
| Capability | RabbitMQ | Kafka | RocketMQ | Pulsar | ActiveMQ | MQTT 5 |
| 能力 | RabbitMQ | Kafka | RocketMQ | Pulsar | ActiveMQ | MQTT 5 |
|--------------------------|--------------------|-----------------------|------------------------------------|-----------------------------|------------------------------------------|-----------------------|
| Delayed message | fallback* | ❌ → local fallback | fallback (levels quantize) | fallback (native available) | ✅ JMS scheduled | ❌ → local fallback |
| Native DLQ | DLX + quarantine | adapter `.dlq` topic | adapter `-dlq` topic | adapter `.dlq` topic | adapter `.dlq` queue | adapter `/dlq` topic |
| Broadcast | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Per-message ack | ✅ | offset (approx) | ✅ | ✅ | ✅ | ✅ (QoS 1) |
| Publisher confirm | ✅ | ✅ (acks=all) | ✅ sync send | ✅ (message id) | ❌ best-effort (driver outbox covers it) | ✅ (PUBACK) |
| Batch delivery | ✅ native | ✅ native | ✅ native | ✅ native | ⚠️ synthesized | ⚠️ synthesized |
| Per-key ordering | ❌ | ✅ | ❌ (keys carried for routing only) | ❌ (shared sub) | ❌ | ❌ |
| Key-pattern routing | ✅ broker bindings | ✅ client-side router | ✅ client-side router | ✅ client-side router | ✅ client-side router | ✅ client-side router |
| Subscription expiry | ✅ x-expires | ❌ documented | ❌ documented | ❌ documented | ❌ documented | ⚠️ session expiry |
| Group durability offline | ✅ durable queue | ✅ log retention | ✅ offsets | ✅ durable subscription | ✅ durable subscription | ⚠️ broker-dependent |
| Retention | queue TTL | retention config | retention | retention | subscription retention | broker-dependent |
| 延迟消息 | 回退(fallback)* | ❌ → 本地回退(fallback) | 回退(fallback)(按级别量化) | 回退(fallback)(原生能力可用) | ✅ JMS 定时投递 | ❌ → 本地回退(fallback) |
| 原生死信队列 | DLX + 隔离区 | 适配器 `.dlq` 主题 | 适配器 `-dlq` 主题 | 适配器 `.dlq` 主题 | 适配器 `.dlq` 队列 | 适配器 `/dlq` 主题 |
| 广播 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 逐消息 ack | ✅ | offset(近似) | ✅ | ✅ | ✅ | ✅QoS 1 |
| 发布者确认 | ✅ | ✅ (acks=all) | ✅ 同步发送 | ✅message id | ❌ 尽力而为(驱动发件箱(outbox)兜底) | ✅ (PUBACK) |
| 批量投递 | ✅ 原生 | ✅ 原生 | ✅ 原生 | ✅ 原生 | ⚠️ 合成 | ⚠️ 合成 |
| 按 key 有序 | ❌ | ✅ | ❌key 仅用于路由) | ❌(共享订阅) | ❌ | ❌ |
| key 模式路由 | ✅ broker 绑定 | ✅ 客户端路由器 | ✅ 客户端路由器 | ✅ 客户端路由器 | ✅ 客户端路由器 | ✅ 客户端路由器 |
| 订阅过期 | ✅ x-expires | ❌ 文档注明不支持 | ❌ 文档注明不支持 | ❌ 文档注明不支持 | ❌ 文档注明不支持 | ⚠️ 会话过期 |
| 消费组离线持久性 | ✅ 持久化队列 | ✅ 日志保留 | ✅ offsets | ✅ 持久订阅 | ✅ 持久订阅 | ⚠️ 取决于 broker |
| 保留 | queue TTL | 保留配置 | 保留 | 保留 | 订阅保留 | 取决于 broker |
\* RabbitMQ's intrinsic TTL+DLX delays (driver lease timeout, device scan tick) work server-side exactly as before;
arbitrary per-message delays use the port's local scheduler fallback on every broker for uniform behavior.
\* RabbitMQ 固有的 TTL+DLX 延迟(驱动租约超时、设备扫描 tick)仍在服务端按原样工作;任意逐消息延迟则统一使用
Port 的本地调度器回退(fallback),以保证所有 broker 行为一致。
The full reasoning (semantics per broker, fallbacks, the TCK as the acceptance bar for community adapters) lives in the
design document linked above.
完整论证(各 broker 的语义、回退(fallback)策略、作为社区适配器验收门槛的 TCK)见上方链接的设计文档。
## Notes
## 说明
- **Key-pattern routing**: only RabbitMQ evaluates `SubscriptionSpec.keyPattern` broker-side (topic-exchange bindings).
Every other adapter runs a client-side topic router in front of one consumer per (topic, group): the message's
partition key is matched against each listener's pattern with Rabbit topic-binding wildcard semantics (`*` = one word,
`#` = zero or more words, blank pattern = everything; see `KeyMatcher` in `dc3-mq-core`), several matching listeners
in one JVM round-robin, and a key matching no listener in that JVM is acknowledged and skipped — Rabbit's
unroutable-drop semantics. Cross-JVM load balancing stays the broker's job via the shared consumer group.
- **Kafka security**: the adapter builds its producer/consumer with `acks=all` but no SASL/TLS settings — plain
connections only. Secured Kafka clusters need a user-provided `KafkaTemplate`/consumer config until the adapter grows
a security-properties bridge.
- **Driver outbox**: point-value durability is owned by the driver's SQLite outbox on every broker, so brokers without
publisher confirmation remain fully safe.
- **At-least-once**: the delivery contract is at-least-once with idempotent consumers on every broker; consumers must
not assume ordering (except where the matrix notes per-key ordering).
- **Community adapters**: pass the contract suite in `dc3-mq-tck` against your broker to certify a new adapter (Redis
Streams, SQS, ...).
- **key 模式路由**:只有 RabbitMQ 在 broker 侧求值 `SubscriptionSpec.keyPattern`topic 交换机绑定)。
其余所有适配器都在每个 (topic, group) 的单个消费者前面运行一个客户端主题路由器:消息的分区 key 按 Rabbit
topic 绑定通配符语义(`*` = 一个词,`#` = 零个或多个词,空白模式 = 全部;见 `dc3-mq-core` 中的
`KeyMatcher`)与各监听器的模式进行匹配;同一 JVM 内多个匹配的监听器以轮询方式分发;key 在该 JVM 中
匹配不到任何监听器时会被确认并跳过——即 Rabbit 的不可路由即丢弃语义。跨 JVM 负载均衡仍由 broker
通过共享消费者组承担。
- **Kafka 安全**:适配器构建生产者/消费者时带 `acks=all`,但不带任何 SASL/TLS 设置——仅明文连接。
在适配器具备安全属性桥接之前,启用了安全机制的 Kafka 集群需要用户自行提供 `KafkaTemplate`/消费者配置。
- **驱动发件箱(outbox)**:点位值持久性在每个 broker 上都由驱动的 SQLite 发件箱(outbox)负责,因此不支持
发布者确认的 broker 依然完全安全。
- **至少一次(at-least-once**:投递契约在每个 broker 上都是至少一次、消费方幂等;消费方不得假设
有序性(矩阵中注明按 key 有序的场景除外)。
- **社区适配器**:针对你的 broker 通过 `dc3-mq-tck` 中的契约套件,即可认证新的适配器
Redis Streams、SQS 等)。