# 广寒基地 Wiki 时态拓扑数据库设计 日期:2026-07-09 状态:设计确认稿 适用范围:广寒基地拓扑图、模块详情和历史状态查询 ## 1. 目标 为广寒基地 Wiki 拓扑页面建立独立的数据源,使页面能够: - 根据明确时间点还原当时的完整基地拓扑与模块状态; - 根据任务编号(如 `GHC-05`)定位该任务最后一条记录的时间,并展示该时刻的完整基地状态; - 在页面内调整时间,查看拓扑、连接、模块信息和状态的变化; - 保存任务的“已发生 / 未发生(已规划)”状态,为未来展示规划拓扑预留能力; - 保证所有拓扑和状态变化都能追溯到具体任务及任务事件。 本数据库不包含“全局模拟时间”。查询时间只属于单次页面请求或页面当前视图,不会写回数据库,也不会影响其他访问者。 ## 2. 系统边界 ### 2.1 独立数据库 在现有 PostgreSQL 服务实例中创建独立数据库: ```text 建议数据库名:ksp_wiki_topology 建议 Schema:topology ``` 该数据库与以下系统保持逻辑和数据隔离: - Wiki.js 自身数据库; - Operation Hangar 业务数据库; - Operation Hangar 的当前时间或模拟时间设置。 PostgreSQL 服务地址、端口和基础设施可以复用,但数据库、用户权限、迁移记录和备份策略应独立。 ### 2.2 访问方式 Wiki 页面不直接连接 PostgreSQL,而是通过只读 HTTP API 获取快照: ```text Wiki 页面 → 拓扑只读 API → ksp_wiki_topology ``` 数据库写入应由独立的导入、管理或迁移流程完成。API 使用只读数据库账号。 ## 3. 核心设计选择 采用“稳定实体 + 时效区间 + 任务事件”的时态图模型。 - 稳定实体:基地、模块、设备、车辆、安装口等长期身份; - 时效区间:名称、尺寸、状态、安装关系和连接关系在什么时间有效; - 任务事件:哪一个任务在什么时间造成了变化; - 可选缓存:未来可以物化完整快照,但缓存不是事实来源。 不采用“每个任务保存一份完整快照”,以免大量重复和历史修正不一致;也不采用“查询时从头重放全部事件”,以免页面查询复杂且性能不可预测。 ### 3.1 时间区间约定 所有有效期统一采用左闭右开区间: ```text [valid_from, valid_to) ``` 例如某连接在 `2035-01-01T08:00:00Z` 建立,在 `2035-02-01T08:00:00Z` 拆除,则它在起始时刻有效,在拆除时刻不再有效。 `valid_to IS NULL` 表示尚未失效。 所有业务时间使用 PostgreSQL `timestamptz`,API 使用带时区的 ISO 8601 字符串。建议以 UTC 存储和交换,前端按显示需要转换。 ## 4. 查询语义 ### 4.1 参数优先级 建议快照接口: ```http GET /api/v1/bases/guanghan/snapshot GET /api/v1/bases/guanghan/snapshot?mission=GHC-05 GET /api/v1/bases/guanghan/snapshot?at=2035-01-01T08:00:00Z GET /api/v1/bases/guanghan/snapshot?mission=GHC-05&at=2035-02-01T08:00:00Z ``` 时间解析规则: | 请求参数 | 快照时间 | 任务编号的作用 | |---|---|---| | `at` + `mission` | 严格采用 `at` | 页面上下文、标注和导航,不限制快照数据 | | 仅 `at` | 严格采用 `at` | 无 | | 仅 `mission` | 该任务最后一条有效事件的时间 | 决定默认快照时间 | | 均未提供 | 所有已发生任务中最新一条有效事件的时间 | 无 | 关键约束: - 指定任务时,快照仍是该时刻的完整基地状态,不是“只展示该任务产生的数据”; - 同时指定任务和时间时,即使时间早于或晚于该任务,仍按时间展示; - Wiki 页面生成链接或请求时应尽可能携带任务编号; - 不存在任何可由页面读取或修改的全局当前时间。 ### 4.2 已发生与规划任务 第一阶段仅录入和查询已发生任务。 任务状态预留: ```text occurred 已发生 planned 未发生 / 已规划 ``` 默认查询只纳入 `occurred`。未来启用规划展示时,应增加显式参数,例如: ```http GET /api/v1/bases/guanghan/snapshot?mission=GHC-12&view=planned ``` 规划数据不得在默认查询中混入已发生事实。若未来需要多套互斥规划,应在后续版本增加规划场景表,而不是让冲突规划共用同一时间线。 在规划场景投影功能实现前,可以保存 `planned` 任务及其事件,但这些事件不得关闭或改写已发生时间线中的有效区间。未来开始保存规划拓扑时,必须先为时态事实增加 `scenario_id`,以“已发生时间线 + 指定规划场景增量”的方式查询;不能直接用规划事件填写实际记录的 `valid_to`。 ## 5. 概念模型 ```mermaid erDiagram BASES ||--o{ COMPONENTS : contains BASES ||--o{ MISSIONS : scopes MISSIONS ||--|{ MISSION_EVENTS : contains COMPONENTS ||--o{ COMPONENT_VERSIONS : describes COMPONENTS ||--o{ COMPONENT_STATES : has COMPONENTS ||--|{ PORTS : exposes PORTS ||--o{ TOPOLOGY_CONNECTIONS : endpoint_a PORTS ||--o{ TOPOLOGY_CONNECTIONS : endpoint_b MISSION_EVENTS ||--o{ COMPONENTS : introduces MISSION_EVENTS ||--o{ COMPONENT_VERSIONS : starts MISSION_EVENTS ||--o{ COMPONENT_STATES : starts MISSION_EVENTS ||--o{ TOPOLOGY_CONNECTIONS : establishes DIAGRAM_LAYOUTS ||--o{ DIAGRAM_NODES : contains COMPONENTS ||--o{ DIAGRAM_NODES : renders BASES { uuid id PK text code UK text name } MISSIONS { uuid id PK uuid base_id FK citext code UK text occurrence_status text title } MISSION_EVENTS { uuid id PK uuid mission_id FK timestamptz effective_at integer event_order text event_type } COMPONENTS { uuid id PK uuid base_id FK text component_key UK text component_type } PORTS { uuid id PK uuid component_id FK text port_key text direction } TOPOLOGY_CONNECTIONS { uuid id PK uuid port_a_id FK uuid port_b_id FK text connection_type timestamptz valid_from timestamptz valid_to } ``` ## 6. 表设计 以下字段为第一阶段必须字段。所有表建议包含 `created_at` 和 `updated_at`;写入账号负责维护,API 不得修改。 ### 6.1 `bases` 基地身份。虽然首期只有广寒基地,仍保留基地层级,避免所有表写死单一基地。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键,默认 `gen_random_uuid()` | | `code` | `text` | 唯一,如 `guanghan` | | `name` | `text` | 如“广寒基地” | | `description` | `text` | 可空 | ### 6.2 `missions` 任务主表。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `base_id` | `uuid` | 外键 `bases.id` | | `code` | `citext` | 任务编号,如 `GHC-05` | | `title` | `text` | 任务名称 | | `occurrence_status` | `text` | `occurred` 或 `planned` | | `description` | `text` | 可空 | | `source_ref` | `text` | Timeline、Wiki 或其他来源标识 | 约束: - `UNIQUE (base_id, code)`; - `occurrence_status` 使用检查约束或枚举; - 不在任务表中保存“当前时间”; - 不要求额外的“正式完成时间”。任务默认时间由其事件的最大业务时间计算。 ### 6.3 `mission_events` 任务内具有业务时间的原子事件。所有模块、状态和连接变化必须追溯到事件,事件必须归属任务。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `mission_id` | `uuid` | 非空,外键 `missions.id` | | `effective_at` | `timestamptz` | 事件实际生效时间 | | `event_order` | `integer` | 同一时刻内的稳定排序,从 0 开始 | | `event_type` | `text` | 如 `component_installed`、`connection_established` | | `title` | `text` | 简短说明 | | `details` | `jsonb` | 可选的结构化补充信息 | | `source_ref` | `text` | 原始记录位置或文档引用 | | `recorded_at` | `timestamptz` | 数据录入时间,不参与历史快照判断 | 约束: - `UNIQUE (mission_id, effective_at, event_order)`; - 任务“最后一条记录时间”为 `MAX(effective_at)`; - `recorded_at` 只表达何时录入,不能替代 `effective_at`。 如果历史资料只能精确到日期,可在 `details.time_precision` 中记录 `date`,同时将 `effective_at` 统一落在约定时刻。不得用无时区时间或模糊字符串参与排序。 ### 6.4 `components` 基地中可独立识别和点击的对象,包括舱段、结构节点、设备、着陆器、漫游车等。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `base_id` | `uuid` | 非空,外键 `bases.id` | | `component_key` | `text` | 稳定机器标识,如 `core_cabin` | | `component_type` | `text` | `module`、`node`、`vehicle`、`equipment` 等 | | `introduced_by_event_id` | `uuid` | 非空,首次出现事件 | | `retired_by_event_id` | `uuid` | 可空,移除或退役事件 | | `metadata` | `jsonb` | 不参与核心查询的扩展字段 | 约束: - `UNIQUE (base_id, component_key)`; - `component_key` 一旦发布不得因改名而变化; - 对象是否存在由引入和退役事件的 `effective_at` 判断; - 首期所有组件必须由已发生任务事件引入。 ### 6.5 `component_versions` 模块信息的时态版本。名称、简介、尺寸或详情链接发生变化时新增版本,不覆盖历史。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `component_id` | `uuid` | 非空,外键 `components.id` | | `display_name` | `text` | 页面显示名称 | | `summary` | `text` | 弹窗摘要 | | `description` | `text` | 详细信息 | | `length_m` | `numeric(10,3)` | 可空 | | `width_m` | `numeric(10,3)` | 可空 | | `height_m` | `numeric(10,3)` | 可空 | | `diameter_m` | `numeric(10,3)` | 可空 | | `mass_t` | `numeric(12,3)` | 可空 | | `detail_url` | `text` | Wiki 详情页地址,可空 | | `properties` | `jsonb` | 特有参数,如乘员、功率、容量 | | `valid_from` | `timestamptz` | 非空 | | `valid_to` | `timestamptz` | 可空 | | `started_by_event_id` | `uuid` | 非空,版本开始事件 | | `ended_by_event_id` | `uuid` | 可空,版本结束事件 | 同一组件的版本有效期不得重叠。尺寸字段使用 SI 单位;页面负责格式化,不在数据库中保存“约 6 米”之类不可计算文本。 ### 6.6 `component_states` 模块状态按维度分别保存,避免任一小状态变化都复制整份模块信息。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `component_id` | `uuid` | 非空 | | `state_kind` | `text` | 状态维度 | | `state_value` | `text` | 状态值 | | `details` | `jsonb` | 可选读数、原因或备注 | | `valid_from` | `timestamptz` | 非空 | | `valid_to` | `timestamptz` | 可空 | | `started_by_event_id` | `uuid` | 非空 | | `ended_by_event_id` | `uuid` | 可空 | 建议首期状态维度: | `state_kind` | 示例值 | |---|---| | `lifecycle` | `installed`、`retired` | | `operation` | `operational`、`standby`、`offline`、`fault` | | `occupancy` | `uncrewed`、`crewed` | | `construction` | `complete`、`under_construction` | 同一组件、同一状态维度的有效期不得重叠。允许某个维度没有记录,此时 API 返回 `unknown`,不得擅自推断正常。 ### 6.7 `ports` 组件安装口或逻辑连接端点。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `component_id` | `uuid` | 非空 | | `port_key` | `text` | 组件内稳定标识,如 `north` | | `display_name` | `text` | 如“北部安装口” | | `direction` | `text` | `north`、`east`、`south`、`west` 或自定义 | | `interface_type` | `text` | 安装、对接、管线等接口类型 | | `properties` | `jsonb` | 可空 | 约束: - `UNIQUE (component_id, port_key)`; - 十字结构必须明确建立四个端口,不能把“温室模块”等普通舱段误建为四向节点; - 没有物理安装口但需要连接的对象,可以建立命名明确的逻辑端口。 ### 6.8 `topology_connections` 拓扑图中的边。结构直连、加压走道、燃料/供电连接等都使用同一表,通过类型区分。 | 字段 | 类型 | 约束 / 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `base_id` | `uuid` | 非空 | | `port_a_id` | `uuid` | 非空,外键 `ports.id` | | `port_b_id` | `uuid` | 非空,外键 `ports.id` | | `connection_type` | `text` | 连接类型 | | `properties` | `jsonb` | 方向、容量等扩展信息 | | `valid_from` | `timestamptz` | 非空 | | `valid_to` | `timestamptz` | 可空 | | `established_by_event_id` | `uuid` | 非空 | | `ended_by_event_id` | `uuid` | 可空 | 建议连接类型: | 类型 | 页面语义 | |---|---| | `structural_mount` | 结构直连 | | `pressurized_passage` | 加压走道,渲染为绿色实线 | | `docking` | 临时或长期对接 | | `power` | 供电关系 | | `fuel` | 燃料关系 | | `cooling` | 冷却回路 | 同一端口在同一时间能否存在多个连接,应由 `interface_type` 决定。结构安装口默认只允许一个有效结构连接;逻辑管线端口可以允许多个。 ### 6.9 `diagram_layouts` 与 `diagram_nodes` 拓扑事实和视觉排版分离。数据库可以保存页面布局,但坐标不是物理位置,也不改变基地历史。 `diagram_layouts`: | 字段 | 类型 | 含义 | |---|---|---| | `id` | `uuid` | 主键 | | `base_id` | `uuid` | 基地 | | `layout_key` | `text` | 如 `engineering_overview_v1` | | `name` | `text` | 布局名称 | | `canvas_width` | `numeric` | 逻辑画布宽度 | | `canvas_height` | `numeric` | 逻辑画布高度 | | `is_default` | `boolean` | 默认布局 | `diagram_nodes`: | 字段 | 类型 | 含义 | |---|---|---| | `layout_id` | `uuid` | 布局 | | `component_id` | `uuid` | 对象 | | `x`、`y` | `numeric` | 中心坐标 | | `width`、`height` | `numeric` | 图形尺寸 | | `rotation_deg` | `numeric` | 旋转角 | | `style_key` | `text` | 样式类别 | | `label_override` | `text` | 可空,原则上使用版本名称 | | `z_index` | `integer` | 层级 | 连接线由端口和组件坐标计算。加压走道不作为独立外形组件,仅作为 `pressurized_passage` 类型连接渲染;顶部图例负责说明,无需在线上重复标签。 布局中不显示未来扩展标记。未来模块真正录入并在所选时间有效后,才会出现在快照中。 ## 7. 数据完整性 ### 7.1 推荐 PostgreSQL 扩展 ```sql CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE EXTENSION IF NOT EXISTS citext; CREATE EXTENSION IF NOT EXISTS btree_gist; ``` ### 7.2 有效期 建议在时态表中增加生成列: ```sql valid_period tstzrange GENERATED ALWAYS AS ( tstzrange(valid_from, valid_to, '[)') ) STORED ``` 以模块信息版本为例,禁止同一组件的版本重叠: ```sql EXCLUDE USING gist ( component_id WITH =, valid_period WITH && ); ``` `component_states` 使用 `(component_id, state_kind, valid_period)` 排斥重叠;结构连接根据端口占用规则设置部分排斥约束。 ### 7.3 事件一致性 写入服务必须验证: - `started_by_event_id` 的时间等于对应 `valid_from`; - `ended_by_event_id` 的时间等于对应 `valid_to`; - 结束事件不得早于开始事件; - 引用的任务与组件属于同一基地; - 所有变化都有任务事件,不允许“无来源修改”; - 已发生事实默认不得引用 `planned` 任务事件。 复杂的跨表、跨事件验证建议通过事务写入服务实现,并辅以延迟约束触发器。不要仅依赖前端表单。 ### 7.4 历史纠错 业务时间与录入时间必须分开: - `effective_at` / `valid_from`:事情何时发生; - `recorded_at` / `created_at`:何时写入数据库。 历史资料纠错不应直接静默覆盖。建议保留审计表,记录表名、记录 ID、旧值、新值、操作者、原因和修改时间。第一阶段不必实现完整双时态查询,但必须具备审计能力。 ## 8. 快照查询 ### 8.1 确定锚点时间 伪代码: ```text if at is provided: snapshot_at = parse(at) anchor_source = "explicit_time" else if mission is provided: assert mission exists snapshot_at = max(effective_at of events in that mission) anchor_source = "mission_latest_event" else: snapshot_at = max(effective_at of events whose mission is occurred) anchor_source = "latest_recorded_state" ``` 若指定任务不存在,返回 `404 mission_not_found`。 若任务存在但没有事件,返回 `409 mission_has_no_timed_event`。 若数据库完全没有有效事件,返回空基地快照,而不是服务器当前时间。 ### 8.2 选择有效数据 对每张时态表采用同一条件: ```sql valid_from <= :snapshot_at AND (valid_to IS NULL OR :snapshot_at < valid_to) ``` 组件存在条件由引入、退役事件时间计算。查询结果必须包含: - 在该时刻存在的组件及有效信息版本; - 每个组件当时有效的各维度状态; - 两端组件均存在且当时有效的连接; - 默认布局中的节点信息; - 任务上下文和实际采用的快照时间。 ### 8.3 任务筛选不是数据过滤 例如: ```http GET /snapshot?mission=GHC-05 ``` 先取得 GHC-05 最后事件时间,再查询截至该时间的完整历史结果。因此快照会包含 GHC-01 至 GHC-05 留存下来的全部有效设施。 例如: ```http GET /snapshot?mission=GHC-05&at= ``` 仍按明确时间展示 GHC-06 之后的基地;`GHC-05` 仅作为页面当前任务上下文。 ## 9. API 返回建议 ```json { "base": { "code": "guanghan", "name": "广寒基地" }, "query": { "requestedAt": null, "requestedMission": "GHC-05", "snapshotAt": "2035-01-01T08:00:00Z", "anchorSource": "mission_latest_event", "view": "occurred" }, "components": [ { "key": "core_cabin", "type": "module", "name": "广寒核心舱", "dimensions": { "lengthM": 8.0, "widthM": 4.0, "heightM": 4.0 }, "states": { "lifecycle": "installed", "operation": "operational" }, "properties": {}, "ports": [] } ], "connections": [ { "type": "pressurized_passage", "fromPort": "habitat_1.south", "toPort": "power_north.north" } ], "layout": { "key": "engineering_overview_v1", "nodes": [] } } ``` 响应应返回最终 `snapshotAt`,使页面时间控件准确定位。页面切换时间只重新请求快照,不修改服务端状态。 ## 10. 索引 第一阶段至少建立: ```text missions(base_id, code) UNIQUE missions(base_id, occurrence_status) mission_events(mission_id, effective_at DESC, event_order DESC) mission_events(effective_at DESC) components(base_id, component_key) UNIQUE component_versions(component_id, valid_from DESC) component_states(component_id, state_kind, valid_from DESC) topology_connections(base_id, valid_from DESC) ports(component_id, port_key) UNIQUE diagram_nodes(layout_id, component_id) UNIQUE ``` 时态表的 GiST 排斥约束会同时提供区间索引能力。数据量增长后,可针对“当前仍有效”的记录增加 `WHERE valid_to IS NULL` 部分索引。 ## 11. 权限、部署与备份 建议角色: | 角色 | 权限 | |---|---| | `topology_owner` | 数据库与迁移所有者,不供应用日常使用 | | `topology_writer` | 管理和导入流程读写业务表 | | `topology_reader` | API 只读访问 | 部署要求: - 使用独立连接串,例如 `WIKI_TOPOLOGY_DATABASE_URL`; - Wiki.js 不持有数据库密码,只访问 API; - API 只向允许的 Wiki 来源开放 CORS; - 数据库迁移使用独立版本表; - 数据库备份可复用现有 PostgreSQL 基础设施,但必须能够单独恢复; - 日志不得输出完整连接串或密码。 ## 12. 首期数据导入 建议按以下顺序导入: 1. 创建广寒基地; 2. 创建已发生任务及其事件,至少覆盖现有 Timeline 中的 GHC 系列记录; 3. 创建稳定组件和端口; 4. 写入组件信息版本与尺寸; 5. 写入组件状态区间; 6. 写入结构直连、加压走道和其他连接; 7. 写入当前工程总图布局; 8. 对每个任务编号生成快照,与 Timeline 和现有拓扑图逐项核对。 GHC-05 验收快照至少应验证: - 核心舱是四向安装节点; - 居住舱 #2 位于核心舱北部,居住舱 #1 位于南部; - 居住舱 #1 通过加压走道连接电力模块北部舱段; - 温室模块通过加压走道连接吴刚二号; - 电力模块为独立十字结构,其北、南、东、西模块关系正确; - 电力模块相关四个大型模块均显示散热器信息; - 加压走道在页面渲染为绿色实线,不显示重复标签; - 页面不显示尚未发生的未来扩展标记。 ## 13. 测试与验收 ### 13.1 数据库约束测试 - 同一组件同一状态维度的重叠区间必须写入失败; - 同一组件的重叠信息版本必须写入失败; - 无任务事件来源的变化必须写入失败; - 结束时间早于开始时间必须写入失败; - 重复任务编号、组件键和端口键必须写入失败。 ### 13.2 查询测试 - 仅任务编号:采用该任务最大事件时间; - 仅明确时间:采用明确时间; - 时间与任务同时存在:采用明确时间,任务不裁剪数据; - 无参数:采用已发生任务的全库最大事件时间; - 明确时间早于首个任务:返回空拓扑; - 规划任务默认不进入事实快照; - 任意历史时间点查询不得依赖服务器当前时间。 ### 13.3 页面验收 - URL 可携带任务编号和时间; - 页面调整时间后,拓扑和模块弹窗信息同步变化; - 刷新或复制 URL 能还原同一视图; - 两位用户选择不同时间互不影响; - API 异常时页面显示明确错误,不使用过期数据冒充当前结果。 ## 14. 明确排除项 本阶段只形成数据库与查询契约设计,不实施: - PostgreSQL 建库和迁移脚本; - 数据导入程序; - 拓扑 API; - Wiki 页面动态加载和时间控件; - 规划场景分支系统; - 完整双时态历史查询; - 快照物化缓存。 这些功能应以本文档的数据语义为后续实现依据,尤其不得重新引入全局模拟时间,或直接复用 Operation Hangar 的时间设置。