diff --git a/apps/docs/public/figures/endor-architecture.svg b/apps/docs/public/figures/endor-architecture.svg index 04ae775..dd12af7 100644 --- a/apps/docs/public/figures/endor-architecture.svg +++ b/apps/docs/public/figures/endor-architecture.svg @@ -1,106 +1,119 @@ + width="2000" height="1860" viewBox="0 0 2000 1860"> -Endor 概念架构草案:全球入口、区域网关与运行时、分片和跨区恢复 - -ENDOR / SYSTEM ATLAS -一个全球入口,多个区域,一组可恢复的对象。 - -概念设计 · 草案 -01 全球接入 - - -动态 API / 对象访问 - - -静态资源 / 可缓存内容 - - -登录 / 换取凭证 - +Endor 架构:全球入口、区域网关、独立存储分片与统一备份 + +ENDOR / SYSTEM ATLAS +01 全球接入 + + +动态 API / 对象访问 + + +静态资源 / 可缓存内容 + + +登录 / 换取凭证 + -全球客户端 - +全球客户端 + -endorphin API -全球流量入口 · 按健康与策略选区 - +endorphin API +全球流量入口 · 按健康与策略选区 + -CDN -命中直接返回 - +CDN +命中直接返回 + -身份服务 -登录 / 签发凭证 -独立控制服务 · 建议 - -北美区域 -NA / 独立故障域 - -区域入口 - - - +身份服务 +登录 / 签发凭证 + + + +… / etc +其他区域 +按需扩展 + +北美区域 +NA / 独立故障域 + +区域入口 + + + -区域负载均衡 -健康检查 / TLS 路由 - - - - -TRAEFIK -生产冗余 · 可选 - - - - - - - +区域负载均衡 +健康检查 / TLS 路由 + + + + +TRAEFIK +生产冗余 · 可选 + + + + + + + + + + -Traefik 1 - - - - - - - +Traefik 1 + + + + + + + + + + + + + + + + -Traefik 2 - -HA 副本 - - - - - - - - - - +Traefik 2 + +HA 副本 + + + + + + +K₂,₅ 全连接 + + @@ -110,8 +123,9 @@ -Nexus Runtime 1 - +Nexus Runtime 01 + + @@ -126,10 +140,10 @@ -Aster VM -1 Nexus : 1 VM - - +Aster VM + + + @@ -139,8 +153,9 @@ -Nexus Runtime 2 - +Nexus Runtime 02 + + @@ -155,10 +170,10 @@ -Aster VM -1 Nexus : 1 VM - - +Aster VM + + + @@ -168,8 +183,9 @@ -Nexus Runtime 3 - +Nexus Runtime 03 + + @@ -184,34 +200,120 @@ -Aster VM -1 Nexus : 1 VM -K₂,₃ 全连接 -选择健康节点 - - - - -分片定位 / 对象读写 - - - -NA-01 / 数据分片 - -主 + 区域 HA - +Aster VM + + + + + + + + + + + + +Nexus Runtime 04 + + + + + + + + + + + + + + + + +Aster VM + + + + + + + + + + + + +Nexus Runtime 05 + + + + + + + + + + + + + + + + +Aster VM + + + + +分片定位 / 对象访问 +02 独立存储分片群 + +PostgreSQL +独立分片群 + -PostgreSQL - - +NA-PG-01 + + - +区域 HA + + + + + +NA-PG-02 + + + + + + +区域 HA + + + + + +NA-PG-03 + + + + + + +区域 HA + + +Aerarium +Object data pool + @@ -219,9 +321,9 @@ -Aerarium - - +NA-AO-01 + + @@ -229,44 +331,8 @@ - - - - - -S3 - - - - - - - - - - -Object data pool -区域内副本 · 跨故障域放置 - - - - -NA-02 / 数据分片 - -主 + 区域 HA - - - - - -PostgreSQL - - - - - - - +区域 HA + @@ -274,9 +340,9 @@ -Aerarium - - +NA-AO-02 + + @@ -284,77 +350,182 @@ - +区域 HA + + + + + + + + +NA-AO-03 + + + + + + + + + +区域 HA + + +S3 +独立分片群 + -S3 - - +NA-S3-01 + + - - - - -Object data pool -区域内副本 · 跨故障域放置 - - -亚洲区域 -AS / 独立故障域 - -区域入口 - - - +区域 HA + + + + + +NA-S3-02 + + + + + + +区域 HA + + + + + +NA-S3-03 + + + + + + +区域 HA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Aerarium ↔ PG / S3:各分片群全连接,按归属与映射定位数据 + +亚洲区域 +AS / 独立故障域 + +区域入口 + + + -区域负载均衡 -健康检查 / TLS 路由 - - - - -TRAEFIK -生产冗余 · 可选 - - - - - - - +区域负载均衡 +健康检查 / TLS 路由 + + + + +TRAEFIK +生产冗余 · 可选 + + + + + + + + + + -Traefik 1 - - - - - - - +Traefik 1 + + + + + + + + + + + + + + + + -Traefik 2 - -HA 副本 - - - - - - - - - - +Traefik 2 + +HA 副本 + + + + + + +K₂,₅ 全连接 + + @@ -364,8 +535,9 @@ -Nexus Runtime 1 - +Nexus Runtime 01 + + @@ -380,10 +552,10 @@ -Aster VM -1 Nexus : 1 VM - - +Aster VM + + + @@ -393,8 +565,9 @@ -Nexus Runtime 2 - +Nexus Runtime 02 + + @@ -409,10 +582,10 @@ -Aster VM -1 Nexus : 1 VM - - +Aster VM + + + @@ -422,8 +595,9 @@ -Nexus Runtime 3 - +Nexus Runtime 03 + + @@ -438,34 +612,120 @@ -Aster VM -1 Nexus : 1 VM -K₂,₃ 全连接 -选择健康节点 - - - - -分片定位 / 对象读写 - - - -AS-01 / 数据分片 - -主 + 区域 HA - +Aster VM + + + + + + + + + + + + +Nexus Runtime 04 + + + + + + + + + + + + + + + + +Aster VM + + + + + + + + + + + + +Nexus Runtime 05 + + + + + + + + + + + + + + + + +Aster VM + + + + +分片定位 / 对象访问 +02 独立存储分片群 + +PostgreSQL +独立分片群 + -PostgreSQL - - +AS-PG-01 + + - +区域 HA + + + + + +AS-PG-02 + + + + + + +区域 HA + + + + + +AS-PG-03 + + + + + + +区域 HA + + +Aerarium +Object data pool + @@ -473,9 +733,9 @@ -Aerarium - - +AS-AO-01 + + @@ -483,155 +743,204 @@ - +区域 HA + + + + + + + + +AS-AO-02 + + + + + + + + + +区域 HA + + + + + + + + +AS-AO-03 + + + + + + + + + +区域 HA + + +S3 +独立分片群 + -S3 - - +AS-S3-01 + + - - - - -Object data pool -区域内副本 · 跨故障域放置 - - - - -AS-02 / 数据分片 - -主 + 区域 HA - +区域 HA + + + + + +AS-S3-02 + + + + + + +区域 HA + + + + + +AS-S3-03 + + + + + + +区域 HA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Aerarium ↔ PG / S3:各分片群全连接,按归属与映射定位数据 + + + + + + +JWKS / 凭证验证 +Nexus 本地验证 + + + +CDN +回源 + + + + + + + + + + + + + +03 统一备份系统 +全区域 / 全部分片 · 统一编排、持久化、校验与恢复 + -PostgreSQL - - - - - - - +PostgreSQL 备份 +基础备份 + WAL / 恢复点 +各区域 PostgreSQL 分片的事务恢复材料 + - + - + - + -Aerarium - - - - - - - - - - +Aerarium 备份 +对象池状态 + 索引 / 版本映射 +关联对象、PG 恢复点与 S3 对象版本 + -S3 - - - - - - - - - - -Object data pool -区域内副本 · 跨故障域放置 - - - - -CDN -S3 回源 - - - - - - - -JWKS / 凭证验证 -Nexus 本地验证 -02 跨区域恢复层 -每个源分片都有一个异地目标;正常读取优先本区域,故障时恢复或转至已追平的副本。 - -亚洲分片的异地备份 · 位于北美 - - - - - - - - -AS-01 备份集 -PG + Aerarium + S3 -恢复点 / 清单校验 / 重试 - - - - - - - - -AS-02 备份集 -PG + Aerarium + S3 -恢复点 / 清单校验 / 重试 - -北美分片的异地备份 · 位于亚洲 - - - - - - - - -NA-01 备份集 -PG + Aerarium + S3 -恢复点 / 清单校验 / 重试 - - - - - - - - -NA-02 备份集 -PG + Aerarium + S3 -恢复点 / 清单校验 / 重试 - -异步复制 / 版本化备份 - - -请求与数据依赖 - -身份控制 / 公钥 - -缓存回源 - -跨区复制 / 备份 -示意数量:2 区域 × 2 网关 × 3 Runtime × 2 分片;数量不构成部署要求。 -可恢复性以已持久化且已校验的恢复点为边界;异步 ≠ 零 RPO。 +S3 备份 +对象版本 + Blob / 快照 +各区域 S3 分片的实际对象与文件数据 + +统一恢复目录:区域 / 存储类型 / 分片 ID / 恢复清单 / 校验和 / 重试状态 + + +请求 / 存储依赖 + +身份控制 / 公钥 + +缓存回源 + +统一备份采集 +实线为逻辑可达关系;全连接不表示广播,也不表示任意分片拥有相同数据。 +备份保证以已持久化且已校验的恢复点为边界。 \ No newline at end of file diff --git a/apps/docs/src/components/ArchitectureFigure.tsx b/apps/docs/src/components/ArchitectureFigure.tsx index bd563f1..88dc88c 100644 --- a/apps/docs/src/components/ArchitectureFigure.tsx +++ b/apps/docs/src/components/ArchitectureFigure.tsx @@ -1,6 +1,7 @@ import { useRef, useState } from "react"; const source = "/figures/endor-architecture.svg"; +const figureWidth = 2000; export function ArchitectureFigure({ alt }: { alt: string }) { const dialog = useRef(null); @@ -15,7 +16,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) { } function changeZoom(delta: number) { const current = fit - ? ((pan.current?.clientWidth ?? 1776) - 16) / 1760 + ? ((pan.current?.clientWidth ?? figureWidth + 16) - 16) / figureWidth : zoom; setFit(false); setZoom( @@ -25,7 +26,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) { return (
- ENDOR / 概念架构草案 + ENDOR / 架构拓扑 @@ -39,7 +40,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) { {alt} - 两区域拓扑 · 点击放大查看全连接与复制路径 + 多区域拓扑 · 独立分片群与统一备份 · 点击放大查看 trigger.current?.focus()} >
- - Endor 概念架构 草案 - + Endor 架构拓扑

实线:请求 / - 依赖 · 紫色:身份 · 蓝色点线:回源 · 金色:跨区备份。放大后可横向、纵向滚动,Esc + 依赖 · 紫色:身份 · 蓝色点线:回源 · 金色:统一备份。放大后可横向、纵向滚动,Esc 关闭。

diff --git a/content/docs/00-endor-concepts-draft.md b/content/docs/00-endor-concepts-draft.md index 234ddfa..6ce3367 100644 --- a/content/docs/00-endor-concepts-draft.md +++ b/content/docs/00-endor-concepts-draft.md @@ -1,46 +1,50 @@ # 理解 Endor:从全球入口到对象数据 -Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。数据以分片组织,在区域内保持高可用,并为每个分片安排跨区域恢复目标。 +Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。PostgreSQL、Aerarium 和 S3 各自维护独立的分片群,在区域内保持高可用,并由统一备份系统追踪所有区域、所有存储类型的分片与恢复材料。 > **概念设计草案**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。 ## 架构总览 -![Endor 概念拓扑:全球入口连接北美与亚洲,各区 Traefik 与 Nexus Runtime 全连接,每个 Nexus 配一个 Aster VM;数据分片包含 PostgreSQL、Aerarium 和 S3,并具有区域内副本和跨区备份](/figures/endor-architecture.svg) +![Endor 架构:全球入口连接北美、亚洲及其他区域;各区两个 Traefik 与五个 Nexus Runtime 全连接,每个 Nexus 侧下方配一个 Aster VM;Aerarium 分片群分别与 PostgreSQL、S3 分片群全连接,所有分片汇入统一备份系统](/figures/endor-architecture.svg) -图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示跨区复制和备份。连线表示可达或依赖关系,**不表示请求向所有节点广播**。 - -图的编排由 Python 生成 SVG:节点图标、二分全连接和复制线路都能复现和编辑。Python 更适合这张静态架构图;页面查看、缩放与下载由 TypeScript/React 负责。 +图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示向统一备份系统采集数据。连线表示可达或依赖关系,**不表示请求向所有节点广播**。 ## 全球入口与区域入口 客户端包括浏览器、Tessera、SDK 和 CLI。`endorphin API` 是逻辑上的全球流量入口,尚未在本文规定一个真实的域名。全球调度根据健康、延迟、项目归属和数据放置策略选择区域;不能只选择网络最近的区域而忽略对象在哪个分片。 -示例使用北美和亚洲两个区域。每个区域有自己的负载均衡入口,下面是 Traefik 网关,负责按配置进行 TLS、主机名或路径路由。图中的第二个 Traefik 标记为 **HA 副本**:多副本是生产冗余选项,单实例概念部署不要求两个网关。 +图中展开北美和亚洲两个区域,并用“ … / etc · 其他区域”表示可以继续扩展。每个区域有自己的负载均衡入口,下面是 Traefik 网关,负责按配置进行 TLS、主机名或路径路由。图中的第二个 Traefik 标记为 **HA 副本**:多副本是生产冗余选项,单实例概念部署不要求两个网关。 ### 为什么画成二分全连接 -每个区域示例中有两个 Traefik 和三个 Nexus Runtime,画出全部六条边,即二分图 **K₂,₃**。每个网关都能把请求路由到任意一个符合条件的健康 Runtime,避免把一个网关固定绑定到一个运行时。 +每个区域示例中有两个 Traefik 和五个 Nexus Runtime,画出全部十条边,即二分图 **K₂,₅**。网关使用独立的路由分流图标,与 Nexus 的服务器图标区分。每个网关都能把请求路由到任意一个符合条件的健康 Runtime,避免把一个网关固定绑定到一个运行时。 这是逻辑可达关系。实际转发仍需考虑服务发现、健康检查、项目和对象归属,以及有状态连接的路由策略;Runtime 之间并没有因此形成全连接网状网络。 ## Nexus Runtime 与 Aster VM -此处采用 **一个 Nexus Runtime 节点配一个 Aster VM** 的概念约定。Nexus 表示对外接入与运行时承载边界,Aster VM 执行对象相关逻辑。图中每台服务器下面都单独画出一个 VM 芯片,避免把多个 VM 误读成另一个共享池。 +此处采用 **一个 Nexus Runtime 节点配一个 Aster VM** 的概念约定。Nexus 表示对外接入与运行时承载边界,Aster VM 执行对象相关逻辑。图中每台服务器侧下方单独画出一个 VM 芯片,用点线连接到对应 Nexus;节点与 VM 采用错位排布,保持一对一归属。 请求经过区域入口和 Traefik 到达 Nexus,再定位目标分片。图中运行时下方的连接汇入分片路由层,表示节点可以按归属访问区域分片,**不是**「Runtime 1 只能访问分片 1」。对象身份、远程引用、生命周期与调用语义仍需结合[节点与对象挂载草案](/docs/02-mounting-draft)继续定义。 -## 三类存储如何组成一个分片 +## 三类存储各自独立分片 -示例中每个区域都有两个逻辑数据分片。每个分片展示三个协作部分,它们是逻辑数据边界,不要求一分片对应三台独占物理机器。 +PostgreSQL、Aerarium Object data pool 和 S3 是三组独立的分片群。图中每组各展开三个分片,数量可以独立增长,分片边界也可以不同;不能将一个 PG 分片、一个 Aerarium 分片和一个 S3 分片打包成固定“三件套”。 -| 部分 | 概念职责 | 依赖关系 | -| ------------------------- | ------------------------------------------------------------ | ------------------------- | -| PostgreSQL | 结构化元数据、对象索引及需要事务约束的状态;具体数据模型待定 | 提供关系数据与事务能力 | -| Aerarium Object data pool | 对象数据的逻辑组织与访问层;不是把 S3 换一个名字 | 同时依赖 PostgreSQL 与 S3 | -| S3 兼容存储 | Blob、文件、快照和适合对象存储的数据;具体格式待定 | 提供对象持久化能力 | +| 分片群 | 概念职责 | 分片标识示例 | +| ------------------------- | ------------------------------------------------------------ | ---------------------------- | +| PostgreSQL | 结构化元数据、对象索引及需要事务约束的状态;具体数据模型待定 | NA-PG-01、NA-PG-02、NA-PG-03 | +| Aerarium Object data pool | 对象数据的逻辑组织、对象池状态和访问层;同时依赖 PG 与 S3 | NA-AO-01、NA-AO-02、NA-AO-03 | +| S3 兼容存储 | Blob、文件、快照和适合对象存储的数据;具体格式待定 | NA-S3-01、NA-S3-02、NA-S3-03 | -图中 Aerarium 向左右各画一条依赖箭头,分别连接 PostgreSQL 和 S3。它们**不是**三个互不相关的平行数据池。 +### 分片群之间的全连接 + +每个 Aerarium 分片都能按映射访问任意一个 PostgreSQL 分片,也能访问任意一个 S3 分片。图中左右各画一组 **K₃,₃**:Aerarium ↔ PostgreSQL 有九条边,Aerarium ↔ S3 也有九条边;每区合计十八条存储关系边。 + +这些线表示逻辑可达关系,实际读写仍根据对象归属、分片映射、授权与健康状态选择目标。它们不表示广播,也不表示所有分片保存相同数据。PostgreSQL 和 S3 之间没有凭空增加直接全连接;两种存储通过 Aerarium 的对象模型与版本映射协作。 + +运行时访问先进入对象池的分片定位层,再由 Aerarium 按映射访问 PG 和 S3。因此不同层可以采用不同的分片数量和容量规划,而无需一一绑定。 ### 区域内高可用 @@ -48,16 +52,23 @@ Endor 的概念模型把全球访问、区域运行时和对象数据连接起 这是逻辑 HA 要求,各层的实现不同:PostgreSQL 可以使用主备复制,Aerarium 需要自己的状态协调和故障恢复设计,S3 的耐久性与副本由选定的存储系统提供。图不强制三者采用同一种同步复制机制,也不把单纯增加副本等同于已经实现故障自动切换。 -## 每个分片都要有跨区恢复目标 +## 一个统一的备份系统 -区域内副本解决本地节点或故障域失效;跨区备份解决整个区域不可用。四个源分片均有独立的异地目标: +备份是覆盖所有区域的总体系统,不按“北美备份亚洲、亚洲备份北美”分成两个固定系统。它统一编排各区域、各存储类型的备份采集、持久化、校验、保留与恢复。物理副本应隔离源区域的故障风险,实际存放位置由备份策略决定;图中不将该系统固定放进任何业务区域。 -| 源分片 | 异地备份位置 | 覆盖内容 | -| ------------ | ------------ | -------------------------------------------- | -| NA-01、NA-02 | 亚洲区域 | 各分片的 PostgreSQL、Aerarium 状态和 S3 数据 | -| AS-01、AS-02 | 北美区域 | 各分片的 PostgreSQL、Aerarium 状态和 S3 数据 | +图中金色线路将每个区域的三类分片群接入同一备份边界,代表采集组内全部分片。统一恢复目录按“区域 + 存储类型 + 分片 ID”追踪每个分片,避免遗漏或将不同层的编号误当作同一个分片。 -每个目标应保留可校验的恢复清单,将关系数据恢复点、对象池元数据版本与 S3 对象版本关联起来;不能只复制其中一种存储就宣称整个分片可恢复。图中的金色线路明确跨越区域,备份集按源分片编号标识。 +### 备份中的 PG、Aerarium、S3 分别是什么 + +| 备份内容 | 保存什么 | 恢复时的用途 | +| --------------- | -------------------------------------------------------------------- | ------------------------------------------ | +| PostgreSQL 备份 | 各 PG 分片的基础备份、WAL 与可恢复时间点等事务恢复材料 | 恢复结构化元数据与事务状态 | +| Aerarium 备份 | 各对象池分片的状态、索引、对象身份和版本映射等恢复材料,具体格式待定 | 重建对象池,并关联 PG 恢复点与 S3 对象版本 | +| S3 备份 | 各 S3 分片的实际对象版本、Blob、文件及快照 | 恢复被对象池引用的实际字节数据 | + +这里展示的是**统一系统内三类不同的备份内容与采集能力**,不是每个备份槽里又放一套在线 PG + Aerarium + S3 服务。系统还要维护统一恢复清单、校验和、重试状态,以及各层的版本对应关系。 + +恢复某个对象或项目时,清单应能定位所需的 PG 分片恢复点、Aerarium 分片状态与 S3 对象版本。因为三类存储独立分片,恢复集合也可能涉及多个不同编号的分片;不能按固定三件套恢复,也不能只备份其中一种存储就宣称整个对象可恢复。 ### “最终一定拿到数据”需要怎样的保证 diff --git a/design/verification/endor-concepts-v2/README.md b/design/verification/endor-concepts-v2/README.md new file mode 100644 index 0000000..f330461 --- /dev/null +++ b/design/verification/endor-concepts-v2/README.md @@ -0,0 +1,27 @@ +# Endor 拓扑修订验收 · 2026-10-27 + +本轮根据用户反馈修正第一版概念图。上一轮截图保留于 `../endor-concepts`,仅作为历史记录;本目录及当前 SVG 才反映本轮模型。 + +## 变更核对 + +- Traefik 使用路由菱形与分流箭头,Nexus 使用机架服务器,VM 使用芯片,三者图形不同。 +- 每区五个 Nexus、两个 Traefik,形成完整 K₂,₅;两个区域合计10个 VM、20条网关到 Runtime 边。VM 位于各自 Nexus 侧下方,通过点线连接,Runtime 按高低错位排布。 +- PostgreSQL、Aerarium 和 S3 各有自己的三个分片,不再以“三件套”作为一个分片。每区 Aerarium ↔ PG、Aerarium ↔ S3 各为 K₃,₃,两个区域合计36条存储关系边、18个独立存储分片及其区域HA示意。 +- 各分片群接入同一个统一备份系统。PG 备份解释为基础备份/WAL/恢复点;Aerarium 备份解释为对象池状态/索引/版本映射;S3 备份解释为对象版本/Blob/快照。统一目录按区域、存储类型和分片ID追踪所有分片。 +- 全局路由加入“… / etc · 其他区域”分支;删除图中的“概念设计 · 草案”角标及“一个全球入口,多个区域,一组可恢复的对象。”大标题。 +- 图形查看器删除草案小标签,画布原始宽度更新为2000px;正文仍明确概念与未确定的实现边界。 + +## 检查 + +- Python拓扑与可复现生成测试通过:覆盖节点数、K₂,₅和K₃,₃边数、独立分片标识、统一备份唯一性、其他区域入口及删除的标签。 +- `pnpm lint`、`pnpm typecheck`、`pnpm figures` 通过。 +- `pnpm --filter @endor/docs run build --webpack` 通过;使用已有本机验证方式,没有变更生产构建、部署、缓存或回退配置。 +- `python3 scripts/smoke.py http://127.0.0.1:3016` 检查健康、全部文档、JS/CSS、SVG及404。 +- 浏览器实际检查1280px与390px宽度。桌面放大至86%时图宽1720px、容器宽1227px,页面仍宽1280px;手机放大至43%时容器宽372px、内容宽876px,页面仍宽390px。 +- Escape关闭正常;图形可适应宽度、放大并在容器中滚动。浏览器捕获的error/warn日志为空。 + +## 当前截图 + +- `architecture.png`:2000×1860的完整SVG浏览器渲染。 +- `viewer-desktop.png`、`viewer-zoom.png`:桌面适应及放大。 +- `viewer-mobile.png`、`viewer-mobile-zoom.png`:手机适应及放大。 diff --git a/design/verification/endor-concepts-v2/architecture.png b/design/verification/endor-concepts-v2/architecture.png new file mode 100644 index 0000000..80cdd8d Binary files /dev/null and b/design/verification/endor-concepts-v2/architecture.png differ diff --git a/design/verification/endor-concepts-v2/viewer-desktop.png b/design/verification/endor-concepts-v2/viewer-desktop.png new file mode 100644 index 0000000..35360df Binary files /dev/null and b/design/verification/endor-concepts-v2/viewer-desktop.png differ diff --git a/design/verification/endor-concepts-v2/viewer-mobile-zoom.png b/design/verification/endor-concepts-v2/viewer-mobile-zoom.png new file mode 100644 index 0000000..c3f835a Binary files /dev/null and b/design/verification/endor-concepts-v2/viewer-mobile-zoom.png differ diff --git a/design/verification/endor-concepts-v2/viewer-mobile.png b/design/verification/endor-concepts-v2/viewer-mobile.png new file mode 100644 index 0000000..79c4ae6 Binary files /dev/null and b/design/verification/endor-concepts-v2/viewer-mobile.png differ diff --git a/design/verification/endor-concepts-v2/viewer-zoom.png b/design/verification/endor-concepts-v2/viewer-zoom.png new file mode 100644 index 0000000..619e7ad Binary files /dev/null and b/design/verification/endor-concepts-v2/viewer-zoom.png differ diff --git a/tools/figures/src/endor_figures/architecture.py b/tools/figures/src/endor_figures/architecture.py index 6aef519..30c9818 100644 --- a/tools/figures/src/endor_figures/architecture.py +++ b/tools/figures/src/endor_figures/architecture.py @@ -13,9 +13,9 @@ FONT = 'ui-sans-serif, system-ui, -apple-system, PingFang SC, Microsoft YaHei, s def build_architecture(output: Path) -> None: - svg = d.Drawing(1760, 1640) - svg.append_title('Endor 概念架构草案:全球入口、区域网关与运行时、分片和跨区恢复') - svg.append(d.Rectangle(0, 0, 1760, 1640, fill='#fcfdfb')) + svg = d.Drawing(2000, 1860) + svg.append_title('Endor 架构:全球入口、区域网关、独立存储分片与统一备份') + svg.append(d.Rectangle(0, 0, 2000, 1860, fill='#fcfdfb')) def rect(x, y, w, h, fill='white', stroke=LINE, radius=12, dash=None): svg.append(d.Rectangle(x, y, w, h, rx=radius, fill=fill, stroke=stroke, @@ -68,7 +68,14 @@ def build_architecture(output: Path) -> None: elif kind == 'lb': g.append(d.Circle(0,-17,7)); g.append(d.Circle(-22,19,7)); g.append(d.Circle(22,19,7)) g.append(d.Lines(0,-10,0,1,-22,1,-22,12)); g.append(d.Lines(0,1,22,1,22,12)) - elif kind in ('gateway','runtime'): + elif kind == 'gateway': + # Routing diamond: deliberately distinct from a Runtime server. + g.append(d.Lines(0,-23,23,0,0,23,-23,0,0,-23)) + g.append(d.Line(-34,0,-12,0)); g.append(d.Lines(-17,-5,-12,0,-17,5)) + for yy in [-12,0,12]: + g.append(d.Lines(0,0,9,yy,34,yy)) + g.append(d.Lines(29,yy-4,34,yy,29,yy+4)) + elif kind == 'runtime': for dy in [-14,2]: g.append(d.Rectangle(-25,dy,50,12,rx=3)) g.append(d.Circle(-16,dy+6,1.3,fill=color)); g.append(d.Line(-8,dy+6,16,dy+6)) @@ -100,139 +107,151 @@ def build_architecture(output: Path) -> None: rect(x, y-17, w, 25, bg, 'none', 5) text(s, x+w/2, y, 12, color, '500', 'middle') - text('ENDOR / SYSTEM ATLAS', 45, 48, 13, GREEN, '600') - text('一个全球入口,多个区域,一组可恢复的对象。', 45, 92, 31, INK, '600') - badge('概念设计 · 草案', 1520, 57, AMBER, '#f8f2e6', 192) - text('01 全球接入', 45, 142, 13, MUTED, '600') + text('ENDOR / SYSTEM ATLAS', 40, 42, 14, GREEN, '600') + text('01 全球接入', 40, 83, 13, MUTED, '600') + path([(160,143),(550,143)], arrow=True) + text('动态 API / 对象访问',280,131,13,GREEN) + path([(110,113),(110,100),(1300,100),(1300,114)],BLUE,arrow=True) + text('静态资源 / 可缓存内容',1000,91,13,BLUE) + path([(110,179),(110,214),(1750,214),(1750,179)],PURPLE,'5 6',arrow=True) + text('登录 / 换取凭证',1470,204,12,PURPLE) + icon('client',110,143) + text('全球客户端',110,191,17,INK,'600','middle') + icon('globe',600,143) + text('endorphin API',654,138,24,INK,'600') + text('全球流量入口 · 按健康与策略选区',654,165,14,MUTED) + icon('edge',1300,143,BLUE) + text('CDN',1340,137,21,INK,'600') + text('命中直接返回',1340,162,13,MUTED) + icon('shield',1750,143,PURPLE) + text('身份服务',1790,138,18,INK,'600') + text('登录 / 签发凭证',1790,162,12,MUTED) - # Request routes first, icons and labels above them. - path([(170,214),(500,214)], arrow=True) - text('动态 API / 对象访问', 280, 201, 13, GREEN) - path([(120,185),(120,163),(1160,163),(1160,184)], BLUE, arrow=True) - text('静态资源 / 可缓存内容', 815, 155, 13, BLUE) - path([(110,250),(110,300),(1540,300),(1540,253)], PURPLE, '5 6', arrow=True) - text('登录 / 换取凭证', 1140, 290, 13, PURPLE) - icon('client',120,214) - text('全球客户端',120,268,17,INK,'600','middle') - icon('globe',550,214) - text('endorphin API',620,208,23,INK,'600') - text('全球流量入口 · 按健康与策略选区',620,237,14,MUTED) - icon('edge',1160,214,BLUE) - text('CDN',1200,208,21,INK,'600') - text('命中直接返回',1200,234,13,MUTED) - icon('shield',1540,214,PURPLE) - text('身份服务',1580,207,17,INK,'600') - text('登录 / 签发凭证',1580,232,12,MUTED) - text('独立控制服务 · 建议',1580,256,11,PURPLE) + # A real fan-out includes an open-ended third branch, not just two regions. + path([(835,180),(835,243),(1900,243),(1900,306)],GREEN,arrow=True) + rect(1855,315,110,132,'#f8faf7',LINE,12,'3 5') + text('… / etc',1910,354,23,GREEN,'500','middle') + text('其他区域',1910,388,15,INK,'600','middle') + text('按需扩展',1910,418,12,MUTED,'400','middle') - # Two equally detailed regions. Solid edges mean eligible request routes, - # not broadcast execution or a requirement for inter-runtime mesh links. - regions = [('北美', 'NA', 40, GREEN), ('亚洲', 'AS', 910, BLUE)] - for name, prefix, ox, accent in regions: - center = ox+390 - rect(ox,337,780,815,'#ffffff',LINE,18) - text(f'{name}区域',ox+28,375,23,INK,'600') - text(f'{prefix} / 独立故障域',ox+28,401,12,MUTED) - badge('区域入口',ox+623,376,accent,'#f0f6f3',126) - # Region's own load balancer; global routing fans into these, not nodes. - path([(800,252),(800,318),(center,318),(center,419)], GREEN, arrow=True) - icon('lb',center,446,accent,.72) - text('区域负载均衡',center+42,442,16,INK,'600') - text('健康检查 / TLS 路由',center+42,464,12,MUTED) - gateways = [ox+235, ox+545] - runtime_x = [ox+135, ox+390, ox+645] + regions = [('北美','NA',40,GREEN),('亚洲','AS',960,BLUE)] + # Each storage family owns its own shard set. No PG/Aerarium/S3 triplets. + families = [('PostgreSQL','PG','db',150), + ('Aerarium','AO','pool',435), + ('S3','S3','bucket',720)] + backup_ports = [] + for name,prefix,ox,accent in regions: + center = ox+435 + rect(ox,275,870,1025,'#ffffff',LINE,18) + text(f'{name}区域',ox+28,314,24,INK,'600') + text(f'{prefix} / 独立故障域',ox+28,340,12,MUTED) + badge('区域入口',ox+708,316,accent,'#eff5f0',132) + path([(835,180),(835,243),(center,243),(center,352)],GREEN,arrow=True) + icon('lb',center,377,accent,.72) + text('区域负载均衡',center+42,372,16,INK,'600') + text('健康检查 / TLS 路由',center+42,396,12,MUTED) + gateways = [ox+265,ox+605] + runtime_x = [ox+105+i*165 for i in range(5)] + runtime_y = [605,619,605,619,605] for gx in gateways: - path([(center,470),(center,488),(gx,488),(gx,514)],accent,arrow=True) - text('TRAEFIK',ox+27,528,12,MUTED,'600') - text('生产冗余 · 可选',ox+27,550,12,MUTED) - for gi,gx in enumerate(gateways): - icon('gateway',gx,536,accent,.9) - text(f'Traefik {gi+1}',gx,579,14,INK,'500','middle') - if gi == 1: - badge('HA 副本',gx+42,539,accent,'#edf5f0',78) - # Explicit K2,3 complete bipartite graph. Every edge is drawn. - for gx in gateways: - for nx in runtime_x: - path([(gx,592),(nx,665)],accent,width=1.35,opacity=.65) - dot(gx,592,accent) - for ni,nx in enumerate(runtime_x): - dot(nx,665,accent) - icon('runtime',nx,701,accent) - text(f'Nexus Runtime {ni+1}',nx,746,15,INK,'600','middle') - icon('vm',nx-49,779,accent,.65) - text('Aster VM',nx-21,785,14,INK) - text('1 Nexus : 1 VM',nx,813,11,MUTED,'400','middle') - text('K₂,₃ 全连接',ox+22,621,11,accent) - text('选择健康节点',ox+22,640,11,MUTED) - # Shared regional shard routing bus, not a fixed node-to-shard assignment. - for nx in runtime_x: - path([(nx,829),(nx,850)],accent,opacity=.8) - path([(runtime_x[0],850),(runtime_x[-1],850)],accent,opacity=.8) - text('分片定位 / 对象读写',center,874,12,MUTED,'400','middle') - for si,sx in enumerate([ox+24,ox+406]): - mid=sx+175 - path([(mid,850),(mid,905)],accent,arrow=True) - rect(sx,912,350,208,'#f8faf7', '#e2eae1',10) - text(f'{prefix}-{si+1:02} / 数据分片',sx+15,939,14,INK,'600') - badge('主 + 区域 HA',sx+212,938,accent,'#edf3ec',124) - for kind,dx,label in [('db',62,'PostgreSQL'),('pool',175,'Aerarium'),('bucket',288,'S3')]: - icon(kind,sx+dx,989,accent,.8) - text(label,sx+dx,1033,13,INK,'500','middle') - # Every tier has an explicit local standby, linked by dots. - path([(sx+dx,1041),(sx+dx,1062)],accent,'2 4',width=1.1) - icon(kind,sx+dx,1080,accent,.38) - # Aerarium depends on both PostgreSQL and S3. - path([(sx+150,986),(sx+87,986)],accent,arrow=True) - path([(sx+200,986),(sx+263,986)],accent,arrow=True) - text('Object data pool',mid,1052,10,MUTED,'400','middle') - text('区域内副本 · 跨故障域放置',mid,1108,11,MUTED,'400','middle') - # Each shard gets its own, separately routed off-region copy. - dot(mid,1120,AMBER) + path([(center,403),(center,423),(gx,423),(gx,444)],accent,arrow=True) + text('TRAEFIK',ox+26,467,12,MUTED,'600') + text('生产冗余 · 可选',ox+26,491,12,MUTED) + for i,gx in enumerate(gateways): + icon('gateway',gx,467,accent,.85) + text(f'Traefik {i+1}',gx,510,14,INK,'500','middle') + if i == 1: + badge('HA 副本',gx+49,469,accent,'#edf5f0',85) + for nx,ny in zip(runtime_x,runtime_y): + path([(gx,523),(nx,ny-30)],accent,width=1.35,opacity=.65) + dot(gx,523,accent) + text('K₂,₅ 全连接',ox+23,556,11,accent) + for i,(nx,ny) in enumerate(zip(runtime_x,runtime_y)): + dot(nx,ny-30,accent) + icon('runtime',nx,ny,accent) + text(f'Nexus Runtime {i+1:02}',nx,ny+39,13,INK,'600','middle') + # VM deliberately offset to the lower right of its host. + path([(nx+26,ny+5),(nx+64,ny+5),(nx+64,ny+70),(nx+50,ny+70)],accent,'2 4',width=1) + icon('vm',nx+35,ny+70,accent,.55) + text('Aster VM',nx+35,ny+100,11,INK,'400','middle') + # Route storage traffic outside the label/VM, not through them. + path([(nx-26,ny),(nx-61,ny),(nx-61,747)],accent,width=1.1,opacity=.75) + path([(runtime_x[0]-61,747),(runtime_x[-1]-61,747)],accent,width=1.2) + path([(center,747),(center,793)],accent,arrow=True) + text('分片定位 / 对象访问',center+16,775,12,MUTED) + text('02 独立存储分片群',ox+26,775,13,INK,'600') + ys = [880,1020,1160] + for label,code,kind,dx in families: + cx=ox+dx + rect(cx-95,802,190,468,'#f8faf7','#e3ebe2',10) + text(label,cx,829,17,INK,'600','middle') + text('Object data pool' if code=='AO' else '独立分片群',cx,850,11,MUTED,'400','middle') + for i,sy in enumerate(ys): + icon(kind,cx,sy,accent,.8) + text(f'{prefix}-{code}-{i+1:02}',cx,sy+36,12,INK,'500','middle') + path([(cx,sy+43),(cx,sy+54)],accent,'2 4',width=1) + icon(kind,cx,sy+69,accent,.32) + text('区域 HA',cx+23,sy+73,10,MUTED) + dot(cx,1270,AMBER) + backup_ports.append((prefix,code,cx)) + # K3,3 on each side: every Aerarium shard can address every PG/S3 shard. + # The two storage families do not gain a fictitious direct PG-to-S3 edge. + pg,ao,s3 = ox+150,ox+435,ox+720 + for ay in ys: + path([(ao-25,ay),(ao-75,ay)],accent,width=1.1) + path([(ao+25,ay),(ao+75,ay)],accent,width=1.1) + for by in ys: + path([(ao-75,ay),(pg+75,by)],accent,width=1.15,opacity=.55) + path([(ao+75,ay),(s3-75,by)],accent,width=1.15,opacity=.55) + dot(ao-75,ay,accent,2.5); dot(ao+75,ay,accent,2.5) + for sy in ys: + path([(pg+75,sy),(pg+25,sy)],accent,width=1.1,arrow=True) + path([(s3-75,sy),(s3-25,sy)],accent,width=1.1,arrow=True) + dot(pg+75,sy,accent,2.5); dot(s3-75,sy,accent,2.5) + text('Aerarium ↔ PG / S3:各分片群全连接,按归属与映射定位数据',center,1290,12,MUTED,'400','middle') - # Cross-region origin fill. CDN is intentionally outside private dynamic API. - path([(1160,253),(1160,278),(1718,278),(1718,883),(1680,883),(1680,989),(1627,989)],BLUE,'2 6',arrow=True) - rect(1698,582,58,46,'#fcfdfb','none',4) - text('CDN',1727,600,12,BLUE,anchor='middle') - text('S3 回源',1727,621,11,MUTED,anchor='middle') - # Control-plane key retrieval, deliberately not a runtime hosting dependency. - path([(1540,245),(1540,309),(869,309),(869,705)],PURPLE,'5 6',arrow=True) - path([(869,705),(710,705)],PURPLE,'5 6',arrow=True) - path([(869,705),(1020,705)],PURPLE,'5 6',arrow=True) - rect(791,682,156,39,'#fcfdfb','none',4) - text('JWKS / 凭证验证',869,699,12,PURPLE,anchor='middle') - text('Nexus 本地验证',869,716,11,PURPLE,anchor='middle') + # Identity is a control-plane relation; representative endpoints are labeled. + path([(1750,180),(1750,227),(935,227),(935,653)],PURPLE,'5 6') + path([(935,653),(900,653),(900,605),(871,605)],PURPLE,'5 6',arrow=True) + path([(935,653),(985,653),(985,605),(1038,605)],PURPLE,'5 6',arrow=True) + rect(870,667,130,49,'#fcfdfb','none',4) + text('JWKS / 凭证验证',935,686,11,PURPLE,anchor='middle') + text('Nexus 本地验证',935,705,10,PURPLE,anchor='middle') + # CDN fills from an S3 shard, without crossing other storage labels. + path([(1300,180),(1300,207),(1975,207),(1975,885),(1808,885),(1808,880),(1705,880)],BLUE,'2 6',arrow=True) + rect(1948,559,49,51,'#fcfdfb','none',3) + text('CDN',1972,580,11,BLUE,anchor='middle') + text('回源',1972,601,11,BLUE,anchor='middle') - text('02 跨区域恢复层',45,1486,16,INK,'600') - text('每个源分片都有一个异地目标;正常读取优先本区域,故障时恢复或转至已追平的副本。',45,1514,14,MUTED) - # Source routes are independent, with a visible crossing gap at the center. - # Backups physically hosted in the opposite region are explicitly labeled. - backup_specs = [ - ('亚洲分片的异地备份 · 位于北美', 40, 'AS', [1109,1491], [239,621]), - ('北美分片的异地备份 · 位于亚洲', 910, 'NA', [239,621], [1109,1491]), - ] - for bi, (label, bx, source, sources, targets) in enumerate(backup_specs): - rect(bx,1280,780,174,'#fbf8f1','#e9dec8',14) - text(label,bx+25,1310,17,INK,'600') - for i,(origin,target) in enumerate(zip(sources,targets)): - lane=1180+bi*40+i*20 - path([(origin,1120),(origin+12,1134),(origin+12,lane),(target-12,lane),(target-12,1280)],AMBER,'6 6',arrow=True) - icon('backup',target-77,1376,AMBER,.85) - text(f'{source}-{i+1:02} 备份集',target-38,1369,15,INK,'600') - text('PG + Aerarium + S3',target-38,1393,12,MUTED) - text('恢复点 / 清单校验 / 重试',target-38,1415,11,MUTED) - # A caption is painted over empty whitespace, no connectivity is implied. - rect(724,1148,312,25,'#fcfdfb','none',4) - text('异步复制 / 版本化备份',880,1166,13,AMBER,'500','middle') - path([(50,1555),(105,1555)],GREEN,arrow=True) - text('请求与数据依赖',118,1560,12,MUTED) - path([(355,1555),(410,1555)],PURPLE,'5 6') - text('身份控制 / 公钥',423,1560,12,MUTED) - path([(670,1555),(725,1555)],BLUE,'2 6') - text('缓存回源',738,1560,12,MUTED) - path([(950,1555),(1005,1555)],AMBER,'6 6') - text('跨区复制 / 备份',1018,1560,12,MUTED) - text('示意数量:2 区域 × 2 网关 × 3 Runtime × 2 分片;数量不构成部署要求。',45,1608,12,MUTED) - text('可恢复性以已持久化且已校验的恢复点为边界;异步 ≠ 零 RPO。',1715,1608,12,AMBER,anchor='end') + # One system, not a North America / Asia pair of backup silos. + for i,(prefix,code,cx) in enumerate(backup_ports): + dest={'PG':370,'AO':980,'S3':1580}[code] + lane=1325+i*18 + path([(cx,1270),(cx,lane),(dest,lane),(dest,1460)],AMBER,'6 6',arrow=True) + rect(40,1460,1790,280,'#fbf8f1','#e9dec8',16) + text('03 统一备份系统',68,1502,23,INK,'600') + text('全区域 / 全部分片 · 统一编排、持久化、校验与恢复',68,1531,14,MUTED) + for kind,cx,label,line1,line2 in [ + ('db',370,'PostgreSQL 备份','基础备份 + WAL / 恢复点','各区域 PostgreSQL 分片的事务恢复材料'), + ('pool',980,'Aerarium 备份','对象池状态 + 索引 / 版本映射','关联对象、PG 恢复点与 S3 对象版本'), + ('bucket',1580,'S3 备份','对象版本 + Blob / 快照','各区域 S3 分片的实际对象与文件数据'), + ]: + icon(kind,cx-145,1596,AMBER,1.05) + text(label,cx-95,1587,18,INK,'600') + text(line1,cx-95,1615,14,MUTED) + text(line2,cx-95,1641,12,MUTED) + path([(90,1670),(1780,1670)],'#e3d5b9',width=1) + text('统一恢复目录:区域 / 存储类型 / 分片 ID / 恢复清单 / 校验和 / 重试状态',935,1709,14,AMBER,'500','middle') + path([(50,1790),(105,1790)],GREEN,arrow=True) + text('请求 / 存储依赖',118,1795,12,MUTED) + path([(365,1790),(420,1790)],PURPLE,'5 6') + text('身份控制 / 公钥',433,1795,12,MUTED) + path([(680,1790),(735,1790)],BLUE,'2 6') + text('缓存回源',748,1795,12,MUTED) + path([(965,1790),(1020,1790)],AMBER,'6 6') + text('统一备份采集',1033,1795,12,MUTED) + text('实线为逻辑可达关系;全连接不表示广播,也不表示任意分片拥有相同数据。',40,1837,12,MUTED) + text('备份保证以已持久化且已校验的恢复点为边界。',1830,1837,12,AMBER,anchor='end') output.parent.mkdir(parents=True, exist_ok=True) svg.save_svg(str(output)) diff --git a/tools/figures/tests/test_architecture.py b/tools/figures/tests/test_architecture.py index 338f279..24ea438 100644 --- a/tools/figures/tests/test_architecture.py +++ b/tools/figures/tests/test_architecture.py @@ -1,4 +1,4 @@ -"""Protect the relationships that make this a topology rather than a block chart.""" +"""Protect the topology, independent shard sets and unified backup boundary.""" from pathlib import Path import tempfile import unittest @@ -17,21 +17,32 @@ class ArchitectureTest(unittest.TestCase): root = ET.parse(first).getroot() ns = {'s': 'http://www.w3.org/2000/svg'} texts = [''.join(e.itertext()) for e in root.findall('.//s:text', ns)] - self.assertEqual(texts.count('Aster VM'), 6) - for i in range(1, 4): - self.assertEqual(texts.count(f'Nexus Runtime {i}'), 2) + self.assertEqual(texts.count('Aster VM'), 10) + for i in range(1, 6): + self.assertEqual(texts.count(f'Nexus Runtime {i:02}'), 2) for i in range(1, 3): self.assertEqual(texts.count(f'Traefik {i}'), 2) for prefix in ('NA', 'AS'): - for i in range(1, 3): - self.assertIn(f'{prefix}-{i:02} / 数据分片', texts) - self.assertIn(f'{prefix}-{i:02} 备份集', texts) - mesh_edges = [e for e in root.findall('.//s:path', ns) - if e.get('stroke-width') == '1.35' and e.get('opacity') == '0.65'] - self.assertEqual(len(mesh_edges), 12, 'Each region must retain the full K2,3 mesh') - # The figure must remain self-contained and editable. + for family in ('PG', 'AO', 'S3'): + for i in range(1, 4): + self.assertIn(f'{prefix}-{family}-{i:02}', texts) + paths = root.findall('.//s:path', ns) + runtime_mesh = [e for e in paths if e.get('stroke-width') == '1.35' + and e.get('opacity') == '0.65'] + self.assertEqual(len(runtime_mesh), 20, 'Two complete K2,5 gateway meshes') + storage_mesh = [e for e in paths if e.get('stroke-width') == '1.15' + and e.get('opacity') == '0.55'] + self.assertEqual(len(storage_mesh), 36, 'Each region has two K3,3 storage meshes') + self.assertEqual(texts.count('03 统一备份系统'), 1) + for family in ('PostgreSQL', 'Aerarium', 'S3'): + self.assertIn(f'{family} 备份', texts) + self.assertIn('… / etc', texts) + self.assertIn('其他区域', texts) + self.assertNotIn('PG + Aerarium + S3', texts) + self.assertNotIn('概念设计 · 草案', texts) + self.assertNotIn('一个全球入口,多个区域,一组可恢复的对象。', texts) + self.assertFalse(any('位于北美' in t or '位于亚洲' in t for t in texts)) self.assertEqual(root.findall('.//s:image', ns), []) - self.assertIn('异步复制 / 版本化备份', texts) if __name__ == '__main__':