Files
KSP_project/docs/superpowers/specs/2026-07-09-guanghan-topology-database-design.md

667 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 广寒基地 Wiki 时态拓扑数据库设计
日期:2026-07-09
状态:设计确认稿
适用范围:广寒基地拓扑图、模块详情和历史状态查询
## 1. 目标
为广寒基地 Wiki 拓扑页面建立独立的数据源,使页面能够:
- 根据明确时间点还原当时的完整基地拓扑与模块状态;
- 根据任务编号(如 `GHC-05`)定位该任务最后一条记录的时间,并展示该时刻的完整基地状态;
- 在页面内调整时间,查看拓扑、连接、模块信息和状态的变化;
- 保存任务的“已发生 / 未发生(已规划)”状态,为未来展示规划拓扑预留能力;
- 保证所有拓扑和状态变化都能追溯到具体任务及任务事件。
本数据库不包含“全局模拟时间”。查询时间只属于单次页面请求或页面当前视图,不会写回数据库,也不会影响其他访问者。
## 2. 系统边界
### 2.1 独立数据库
在现有 PostgreSQL 服务实例中创建独立数据库:
```text
建议数据库名:ksp_wiki_topology
建议 Schematopology
```
该数据库与以下系统保持逻辑和数据隔离:
- 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-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 的时间设置。