diff --git a/开发文档/code-standards.md b/开发文档/code-standards.md index 3b41c89..cd3977d 100644 --- a/开发文档/code-standards.md +++ b/开发文档/code-standards.md @@ -22,6 +22,8 @@ ## 1. 通用原则 +> 跨文档契约发生冲突时,必须遵循《[一致性决策基线](一致性决策基线.md)》;该文件的已采纳决策优先于本文旧表述。 + ### 1.1 核心原则 | 原则 | 说明 | @@ -30,7 +32,7 @@ | **一致性** | 同一概念在代码、数据库、API、前端中保持命名一致(如 `device_id` 在所有层统一) | | **最小惊讶** | 函数的名称和签名应让调用者无需查看实现就能大致猜到行为 | | **防御式编程** | 不信任外部输入,对所有外部输入做校验,但内部调用尽量减少冗余校验 | -| **模块自治** | 每个模块有清晰的边界,模块间通过 API 通信,不直接访问其他模块的内部数据 | +| **模块自治** | 每个模块有清晰的边界。模块间通过公开的 Service/领域接口通信;同一进程内不得直接访问其他模块的 Repository、ORM 模型或数据库表 | ### 1.2 命名风格对照表 @@ -70,9 +72,7 @@ ``` water-plant-control/ ├── cmd/ # 可执行程序入口 -│ ├── server/ # Web 服务端入口 -│ │ └── main.go -│ └── collector/ # 采集引擎入口 +│ └── server/ # Web 服务端入口(嵌入采集引擎) │ └── main.go ├── internal/ # 私有应用代码(不对外暴露) │ ├── api/ # HTTP handler 层 @@ -105,7 +105,7 @@ water-plant-control/ │ │ └── writer/ # 写入引擎 │ │ └── writer.go │ ├── protocol/ # 协议插件化 -│ │ ├── driver.go # ProtocolDriver 接口定义 +│ │ ├── driver.go # ProtocolDriverFactory / ProtocolConnection 接口定义 │ │ ├── registry.go # 注册表 │ │ ├── s7/ # S7 协议实现 │ │ └── modbus/ # Modbus TCP 协议实现 @@ -133,6 +133,8 @@ water-plant-control/ └── Makefile # 构建脚本 ``` +运行模型采用**单进程嵌入式**:`cmd/server/main.go` 是唯一的生产可执行入口,负责初始化依赖、创建并启动采集引擎、启动 Web API,并在服务关闭时按序停止采集引擎。`internal/engine/collector` 是进程内运行时组件,不提供独立的 `cmd/collector` 进程。 + ### 2.2 模块目录与命名约束 新增模块时,后端在 `internal/` 下各层同步添加子包,前端在 `web/src/views/` 下添加页面目录。 @@ -188,7 +190,8 @@ Repository (数据访问、ORM 查询) func (h *DeviceHandler) Create(c *gin.Context) { var req CreateDeviceRequest if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "无效的请求参数", err.Error()) + logger.Warn("请求参数绑定失败", "error", err) + response.BadRequest(c, "无效的请求参数", nil) return } device, err := h.svc.Create(c.Request.Context(), &req) @@ -221,10 +224,20 @@ func (s *DeviceService) Create(ctx context.Context, req *CreateDeviceRequest) (* #### 3.2.3 Repository 层规范 -- 使用参数化查询,禁止字符串拼接 SQL +- 所有用户值使用参数化查询,禁止拼接用户输入 - 显式指定查询字段,禁止 `SELECT *` -- 复杂查询使用 QueryBuilder,避免字符串拼接 +- 复杂查询使用 QueryBuilder - 简单 CRUD 操作统一封装 BaseRepo +- 动态表名仅允许用于 TDengine 子表:必须由已验证 UUID 派生为 `p_`、通过 `^p_[0-9a-f]{32}$` 白名单并使用安全标识符引用;请求不得直接传入表名 + +#### 3.2.4 模块间调用规范 + +本项目采用模块化单体:模块间调用使用进程内公开的 Service/领域接口,不通过 HTTP 调用本应用自身。 + +- 数据访问权归属数据所有模块;只有该模块的 Service 可以调用其 Repository。 +- 调用模块依赖数据所有模块公开的只读接口,不得导入、调用或复用其 Repository、ORM 模型及表名。 +- 公开接口返回稳定的领域 DTO,不暴露数据库实体;接口的实现与依赖装配由 `cmd/server/main.go` 负责。 +- 需要未来拆分为独立服务时,保持接口语义不变,再以 HTTP/gRPC 适配器替换进程内实现。 ### 3.3 错误处理 @@ -250,7 +263,9 @@ if err != nil { - 采集引擎涉及设备连接池、共享内存状态,必须使用 `sync.Mutex` 或 `sync.RWMutex` 保护 - 避免裸 `sync.Map`,优先使用 `map + sync.RWMutex` - 协程启动必须可控:使用 `context.Context` + `sync.WaitGroup` 管理生命周期 -- **禁止无限制启动 goroutine**,必须通过缓冲 channel 或协程池限制并发数 +- **禁止无限制启动 goroutine**,采集调度必须使用有界 worker pool;每个点位是调度任务,不默认占用一个长期 goroutine +- 协议注册表只保存无状态工厂;每个设备持有独立连接实例 +- 同一设备连接默认串行读写,采集与人工写入通过设备级互斥或单线程命令队列协调 ### 3.5 单元测试 @@ -359,7 +374,7 @@ export function fetchDeviceList(params: Record) { ### 5.2 响应格式 -所有 API 响应使用统一格式: +JSON API 使用统一格式: ```json { @@ -371,10 +386,14 @@ export function fetchDeviceList(params: Record) { | 字段 | 必填 | 说明 | |------|------|------| -| `code` | 是 | 0 表示成功,非 0 表示业务错误码 | -| `message` | 是 | 人类可读的描述信息 | +| `code` | 是 | 0 表示成功,非 0 表示模块业务错误码 | +| `message` | 是 | 稳定、可展示的人类可读描述;不得暴露原始驱动或数据库错误 | | `data` | 否 | 响应数据,可为 null | +**流式响应例外**:CSV/其他文件下载、SSE 和 WebSocket 成功时直接返回对应的文件或数据流,不使用 JSON 包装;发生错误时仍返回统一 JSON 错误。 + +同步执行设备操作时,只有实际执行与回读均成功才可返回 HTTP 200 和 `code=0`。设备执行失败或超时必须返回非 2xx HTTP 状态和非零业务码;如已生成操作日志,`data` 返回 `write_log_id`。 + ### 5.3 分页响应格式 ```json @@ -392,20 +411,26 @@ export function fetchDeviceList(params: Record) { ### 5.4 错误码规范 -| 错误码范围 | 类别 | 说明 | -|-----------|------|------| -| 0 | 成功 | 请求正常处理 | -| 400xx | 参数错误 | 请求参数校验失败 | -| 401xx | 鉴权错误 | 未登录或 token 过期 | -| 403xx | 权限错误 | 无操作权限 | -| 404xx | 未找到 | 请求的资源不存在 | -| 409xx | 冲突 | 唯一性冲突等 | -| 500xx | 服务端错误 | 内部错误 | +HTTP 状态码表达错误类别;响应体 `code` 表达模块内具体原因。不得根据业务码前缀推断 HTTP 状态。 -各模块错误码分配: +| HTTP 状态 | 类别 | 典型场景 | +|---|---|---| +| 400 | 请求参数错误 | 格式、范围、必填项或时间范围不合法 | +| 401 | 未认证 | token 缺失或失效 | +| 403 | 无权限 | 已认证但无权执行操作 | +| 404 | 资源不存在 | 设备、点位或历史元数据不存在 | +| 409 | 业务冲突 | 名称冲突、不可变字段修改、状态冲突 | +| 413 | 请求或导出结果过大 | 超出行数或文件限制 | +| 422 | 业务校验失败 | 值类型或设备状态不满足执行条件 | +| 502 | 下游设备/数据库执行失败 | PLC 写入失败、TDengine 查询失败 | +| 504 | 下游超时 | PLC 或数据库超时 | +| 500 | 未预期内部错误 | 未分类异常 | + +业务码按模块分配: | 模块 | 范围 | |------|------| +| 通用、认证与权限 | 10001~10999 | | 设备管理 | 40001~40999 | | 采集点管理 | 41001~41999 | | 写入点管理 | 42001~42999 | @@ -413,6 +438,8 @@ export function fetchDeviceList(params: Record) { | 采集引擎 | 50001~50999 | | 写入引擎 | 51001~51999 | +每个模块必须维护错误码清单;同一业务原因不得复用多个错误码。 + ### 5.5 路径参数命名规范 ``` @@ -467,14 +494,16 @@ CREATE UNIQUE INDEX idx_devices_name ON devices(name) WHERE deleted = FALSE; | 规则 | 说明 | |------|------| -| 表名 | 超级表用 `snake_case`:`collection_data`, `computed_data` | -| 子表名 | 使用采集点 ID 去连字符后的 32 位小写字符串 | +| 超级表名 | 使用 `snake_case`:`collection_data`, `computed_data` | +| 子表名 | 固定为 `p_`,必须匹配 `^p_[0-9a-f]{32}$` | +| UUID 字段/TAG | 使用带连字符的 36 位标准 UUID | | 时间戳 | 统一使用 `TIMESTAMP`,精度毫秒 | -| 质量戳 | `INT` 类型:`0=good`, `1=bad` | -| 值 | 统一使用 `DOUBLE`,BOOL 类型存 0/1 | -| 标签 | 元数据信息存为 TAGS,不做查询条件的有选择缓存 | +| 质量戳 | `INT`:`0=good`, `1=bad`;`none` 不落库 | +| 值 | 使用可空 `DOUBLE`,BOOL 存 0/1;读取失败可存 NULL | +| 质量原因 | 可空 `VARCHAR(32)` 稳定代码,例如 `timeout`, `parse_error`, `out_of_range` | +| 标签 | `device_name`、`data_type` 等为创建子表时的快照,不作为当前配置的权威来源 | -> **质量戳转换约定**:TDengine 存储 INT 值(0=good, 1=bad),后端 API 层在返回响应时统一转换为字符串格式。前端始终处理字符串格式的质量戳。`none` 状态仅在查询无匹配记录时由 API 层返回,不会出现在 TDengine 存储中。 +> API 质量枚举统一为 `good | bad | none`。无匹配记录返回固定对象 `{value:null, quality:"none", quality_reason:null, matched_ts:null}`。 ### 6.3 SQL 书写规范 @@ -496,24 +525,40 @@ RETURNING id; ### 6.4 数据库连接规范 -- 数据库连接参数通过环境变量注入,禁止硬编码在代码中 -- 连接 DSN 按 `postgres://USER:PASS@HOST:PORT/DB?sslmode=require` 格式组装 -- Go 后端使用连接池,并在应用启动时通过带超时的 `PingContext` 校验连接 -- 数据库连接配置仅由 `.env.example` 提供占位符模板,`.env` 文件必须加入 `.gitignore` +- PostgreSQL 和 TDengine 的主机、端口、用户、密码和数据库名均通过环境变量注入,禁止硬编码 +- PostgreSQL DSN 按 `postgres://USER:PASS@HOST:PORT/DB?sslmode=require` 格式组装 +- TDengine 数据库名使用 `TDENGINE_DATABASE`,默认值可为 `aquacontrolai` +- Go 后端使用连接池,并在应用启动时通过带超时的健康检查校验连接 +- `.env.example` 只提供占位符;`.env` 必须加入 `.gitignore` ### 6.5 CSV 导入导出格式规范 +CSV 分为两类: + +#### 6.5.1 配置型 CSV + +用于设备、采集点和写入点导入导出,必须可再次导入。 + | 规则 | 说明 | |------|------| -| 编码 | 统一使用 `UTF-8 with BOM`(`charset=utf-8-sig`) | -| 首行 | 标题行,字段名使用 `snake_case` | -| 布尔值 | 导出为 `TRUE` / `FALSE`(大写) | +| 编码 | `UTF-8 with BOM`(`charset=utf-8-sig`) | +| 标题 | 固定字段,使用 `snake_case` | +| 布尔值 | `TRUE` / `FALSE` | | 数字 | 整数无小数位,浮点数按实际精度输出 | -| JSON 字段 | 导出为转义后的 JSON 字符串 | -| 空值 | 空字段(无匹配数据)统一使用 `—`(全角破折号) | -| 日期时间 | 格式 `YYYY-MM-DD HH:mm:ss`(24小时制) | -| 文件扩展名 | `.csv` | -| 导入校验 | 与对应 API 的校验规则一致,校验失败的行跳过并记录失败原因 | +| JSON 字段 | RFC 4180 转义后的紧凑 JSON 字符串 | +| 空值 | 空字段;导入时按字段规则解释 | +| 日期时间 | `YYYY-MM-DD HH:mm:ss` | +| 导入校验 | 与对应 API 一致,失败行跳过并返回行号与稳定原因 | + +#### 6.5.2 报表型 CSV + +用于历史数据等面向用户的下载,不要求再次导入。 + +- 允许本地化标题和动态点位列。 +- 重复点位名称追加 UUID 前 8 位,保证列名唯一。 +- 缺失或不展示的值使用 `—`,质量列使用 `good | bad | —`。 +- 文件名由服务端使用已解析参数重新格式化,只允许 ASCII 字母、数字、下划线、短横线和点。 +- 文件扩展名统一为 `.csv`。 --- @@ -551,7 +596,8 @@ logger.Info("设备连接成功", | 采集点/写入点创建/修改/删除 | INFO | | 写入操作 | INFO | | 采集引擎启停 | INFO | -| 设备连接/断开 | WARN | +| 设备连接成功 | INFO | +| 设备断开或连接失败 | WARN | | 重连失败 | WARN | | 协议驱动异常 | ERROR | @@ -562,12 +608,20 @@ logger.Info("设备连接成功", ### 8.1 SQL 注入防护 ```go -// ✅ 正确:使用参数化查询,并显式声明返回字段 +// ✅ 用户值使用参数化查询 db.QueryContext(ctx, "SELECT id, name FROM devices WHERE name = $1", name) - -// ✗ 禁止:字符串拼接 SQL ``` +TDengine 动态子表名按以下流程处理: + +1. 解析并验证标准 UUID; +2. 转为小写、移除连字符并加 `p_`; +3. 校验 `^p_[0-9a-f]{32}$`; +4. 使用驱动的安全标识符引用能力构造 SQL; +5. 时间和值条件继续使用参数绑定。 + +禁止把请求参数、点位名称、分组名称或任意未验证字符串拼入 SQL。 + ### 8.2 输入校验 | 场景 | 校验规则 | @@ -575,15 +629,16 @@ db.QueryContext(ctx, "SELECT id, name FROM devices WHERE name = $1", name) | IP 地址 | 使用 `net.ParseIP()` 或正则校验合法格式 | | 端口号 | 范围 1~65535 | | 时间范围 | 结束时间必须晚于开始时间,最大跨度不超过 31 天 | +| 曲线结果 | `max_samples` 范围 100~10000,默认 2000 | | 分页 | page >= 1, page_size <= 100 | -| 文件名 | 导出的 CSV 文件名不含用户输入(防止路径穿越) | +| 文件名 | 不直接拼接原始用户输入;解析后由服务端重新格式化为安全文件名 | ### 8.3 写入安全 - **写入开关校验**:在 Service 层校验 `write_enabled=true` -- **写入来源校验**:校验 `write_source` 与请求 `source` 的匹配关系 - **回读验证**:写入后必须执行回读确认 -- **写入权限**:人工写入需登录鉴权;程序自动写入需 API Token +- **人工写入记录**:当前阶段写入接口仅支持人工写入;Service 固定记录 `source=manual`、`operator=null`,请求体不得传入这两个字段 +- **访问边界**:未实施身份认证前,写入 API 只允许部署在受信任的内部网络;接入身份认证后再补充权限与操作者归属 ### 8.4 配置安全 @@ -615,10 +670,10 @@ db.QueryContext(ctx, "SELECT id, name FROM devices WHERE name = $1", name) | 检查项 | 说明 | |--------|------| -| 模块边界是否清晰 | 是否有 Handler 直接调用其他模块的 Repo | +| 模块边界是否清晰 | 是否有跨模块直接调用 Repository、ORM 模型或访问其他模块的数据表 | | 分层是否遵守 | Handler → Service → Repository 单向依赖 | | 是否引入循环依赖 | 包间依赖不能成环 | -| 扩展点是否留好 | 协议驱动是否遵循 ProtocolDriver 接口 | +| 扩展点是否留好 | 协议驱动是否使用 Factory/Connection 模型,且每设备连接隔离 | #### 功能层面 @@ -634,14 +689,14 @@ db.QueryContext(ctx, "SELECT id, name FROM devices WHERE name = $1", name) |--------|------| | N+1 查询 | 列表查询时是否 batch 加载关联数据 | | 索引 | 新增查询条件是否添加了对应的数据库索引 | -| 分页 | 列表接口是否都有分页,且 page_size 有上限约束 | +| 分页/采样 | 列表是否分页;历史曲线是否有 max_samples 和降采样 | | 连接池 | 数据库连接是否使用了连接池 | #### 安全层面 | 检查项 | 说明 | |--------|------| -| SQL 注入 | 全库搜索字符串拼接的 SQL 语句 | +| SQL 注入 | 用户值是否参数化;动态子表名是否只由 UUID 派生并通过白名单 | | 参数校验 | 所有用户输入是否都通过了校验 | | 敏感信息泄露 | 错误信息是否直接返回给前端 | | 路径穿越 | 文件操作的路径是否使用用户输入构造 | @@ -703,7 +758,7 @@ type Device struct { --- -> 本文档版本:v1.1 +> 本文档版本:v1.2 > 最后更新:2026-07-11 > 适用范围:污水厂智能控制系统全部后端(Go)、前端(Vue 3)代码 > @@ -711,4 +766,4 @@ type Device struct { > 1. 开发者开发新功能前阅读本文档,确保代码风格一致 > 2. 提交 MR/PR 前依据 [第 9 章 代码审核清单](#9-代码审核清单) 逐条自查 > 3. Code Review 时以本文档作为审核标准,不符合规范的要求修改后重新提审 -> 4. 本文档随项目推进持续更新,新增模块时补充对应章节 \ No newline at end of file +> 4. 本文档随项目推进持续更新,新增模块时补充对应章节 diff --git a/开发文档/spec-历史数据.md b/开发文档/spec-历史数据.md index 7c03e8c..9862dca 100644 --- a/开发文档/spec-历史数据.md +++ b/开发文档/spec-历史数据.md @@ -1,7 +1,9 @@ # 历史数据模块 — 开发规格说明 > 本文档属于《污水厂智能控制平台》的一部分,详细描述历史数据模块的功能、数据模型、API接口和业务逻辑,细度可达直接开发级别。 -> 本模块与 [数据管理模块](./spec-数据管理.md) 紧密关联,依赖其中的设备、采集点、TDengine 超级表等设计。 +> 本模块与 [数据管理模块](./spec-数据管理.md) 紧密关联,依赖其中公开的历史元数据领域接口和 TDengine 超级表设计。 + +> 跨文档契约发生冲突时,必须遵循《[一致性决策基线](一致性决策基线.md)》;该文件的已采纳决策优先于本文旧表述。 --- @@ -33,8 +35,7 @@ |----------|------|------| | Web前端 | 返回 | 历史点位树、历史数据查询结果、CSV文件 | | TDengine | 查询 | 读取 collection_data 超级表下的子表数据 | -| PostgreSQL | 查询 | 读取设备、采集点配置,构建树形结构 | -| 数据管理模块 | 依赖 | 采集点配置、设备配置、分组信息 | +| 数据管理模块 | 调用进程内只读领域接口 | 获取采集点、设备和分组元数据;不直接查询其 PostgreSQL 表或 Repository | ### 1.3 页面层级 @@ -57,23 +58,30 @@ ### 2.1 当前数据来源:采集点历史数据 -来自数据管理模块中**启用了 store_history=true** 的采集点,数据存储在 TDengine 的 `collection_data` 超级表中。 +历史树包含两类点位: + +- **活跃点位**:设备和点位均启用、未删除,且当前 `store_history=true`;即使尚无历史记录也显示,并标记 `has_history_data=false`。 +- **归档点位**:设备或点位已禁用/逻辑删除,或当前 `store_history=false`,但 TDengine 中仍有保留期内记录;归档点位只读、可查询。 + +数据存储在 TDengine 的 `collection_data` 超级表: ```sql --- 已在数据管理模块中定义的超级表 CREATE STABLE IF NOT EXISTS collection_data ( - ts TIMESTAMP, - value DOUBLE, - quality INT, -- 0=good, 1=bad - point_id VARCHAR(32), - point_name VARCHAR(128) + ts TIMESTAMP, + value DOUBLE, + quality INT, -- 0=good, 1=bad + quality_reason VARCHAR(32), + point_id VARCHAR(36), + point_name VARCHAR(128) ) TAGS ( - device_id VARCHAR(32), - device_name VARCHAR(128), - data_type VARCHAR(16) + device_id VARCHAR(36), + device_name VARCHAR(128), + data_type VARCHAR(16) ); ``` +`value` 可为 NULL。读取失败或断线时使用 `quality=bad`、`value=NULL`;超出有效范围时保留实际值并使用 `quality=bad`。 + ### 2.2 预留数据来源:非采集点位(内部数据) 未来的扩展数据(如AI模型推理的建议值、人工录入的补充数据等),统一归属为**内部数据**。 @@ -86,7 +94,8 @@ CREATE STABLE IF NOT EXISTS computed_data ( ts TIMESTAMP, value DOUBLE, quality INT, -- 0=good, 1=bad - point_id VARCHAR(32), + quality_reason VARCHAR(32), + point_id VARCHAR(36), point_name VARCHAR(128), source VARCHAR(32) -- 数据来源: 'ai_model', 'manual_input', 'external' 等 ) TAGS ( @@ -94,19 +103,23 @@ CREATE STABLE IF NOT EXISTS computed_data ( ); ``` +`point_id`、`device_id` 在 API、PostgreSQL 与 TDengine 中统一使用带连字符的 36 位标准 UUID 字符串。仅子表名使用 `p_` 格式:对已校验 UUID 规范化为小写、移除连字符并加 `p_` 前缀;历史模块不得使用请求中传入的表名。 + **当前阶段只实现采集点历史数据展示,内部数据在树形结构中占位,不可勾选,显示"暂无数据"。** ### 2.3 历史数据点位判定规则 -**一个采集点是否出现在历史数据树形结构中**,取决于: +```text +active ⇔ point.enabled AND NOT point.deleted + AND device.enabled AND NOT device.deleted + AND point.store_history +archived ⇔ NOT active AND TDengine 中仍存在保留期内记录 + +出现在树中 ⇔ active OR archived ``` -采集点出现在历史树中 ⇔ collection_points.enabled = true - AND collection_points.deleted = false - AND collection_points.store_history = true - AND 关联的 devices.enabled = true - AND 关联的 devices.deleted = false -``` + +历史模块调用数据管理模块的只读元数据接口取得包括逻辑删除记录在内的配置,再通过批量 TDengine 元数据查询判断 `has_history_data`。查询接口不得仅因点位当前禁用或逻辑删除而拒绝其归档历史。 --- @@ -114,61 +127,66 @@ CREATE STABLE IF NOT EXISTS computed_data ( ### 3.1 树结构定义 -``` +```text 所有历史点位 ├── 分组A - │ ├── ☐ 采集点1(最新值,质量戳) - │ ├── ☐ 采集点2(最新值,质量戳) - │ └── ... + │ ├── ☐ 活跃点位1(最新值,质量戳) + │ └── ☐ 归档点位2(归档标识) ├── 分组B - │ ├── ☐ 采集点3(最新值,质量戳) │ └── ... - ├── ...(其他分组) └── 内部数据(预留) - └── (暂无数据) + └── 暂无数据 ``` -分组名称直接取自数据采集点配置中的 `group_name`,同一分组下的点位可来自不同设备。 +树保持“分组→点位”两层结构;活跃和归档点位可在同一分组中,通过 `lifecycle_status`、图标和样式区分。分组和点位按 `name` 的 Unicode 升序稳定排序。 ### 3.2 构建规则 | 层级 | 数据来源 | 说明 | |------|----------|------| -| 第一层:分组 | PostgreSQL `collection_points` 表的 `group_name` | 去重后作为树的顶层节点,只有包含可用历史数据点的分组才显示 | -| 第二层:点位 | PostgreSQL `collection_points` | 满足 store_history=true 且 enabled 的采集点,按 group_name 归属到对应分组下 | -| 独立节点:内部数据 | 硬编码占位 | 当前阶段显示"暂无数据",后续扩展 | +| 分组 | `ListHistoryPointMetadata(IncludeArchived=true)` 的 `group_name` | 去重后生成稳定分组节点 | +| 点位 | 数据管理元数据 + TDengine 批量存在性检查 | active 总是显示;archived 仅在有历史数据时显示 | +| 内部数据 | 硬编码占位 | 当前不可勾选 | + +分组 ID 使用 `group_` 加 `SHA-256(group_name)` 的前 16 个十六进制字符,不把任意分组名称直接作为 DOM/API 标识。 ### 3.3 节点属性 -每个采集点节点应包含以下信息(用于展示和查询): - ```json { "id": "point-uuid", "name": "曝气池DO_01", - "type": "collection", // 或 'computed'(预留) + "type": "collection", "data_type": "REAL", + "unit": "mg/L", + "history_interval": 5, "device_id": "device-uuid", "device_name": "一期曝气柜PLC", "group_name": "曝气池", + "lifecycle_status": "active", + "has_history_data": true, "latest_value": { "value": 2.35, "quality": "good", + "quality_reason": null, "ts": "2026-07-09T10:00:01+08:00" } } ``` -> 由于树结构按分组平铺,点位节点需额外携带 `device_id` 和 `device_name` 以便前端识别点位所属设备。 +- `lifecycle_status`:`active | archived`。 +- `latest_value` 来自数据管理模块实时缓存;无缓存时固定为 `null`,不得用 TDengine 最后一条记录冒充实时值。 +- 归档点位的 `latest_value` 通常为 null,但仍可勾选查询历史。 ### 3.4 复选框行为 | 操作 | 行为 | |------|------| -| 勾选点位 | 该点位数据加入曲线/表格 | -| 取消勾选点位 | 该点位数据从曲线/表格中移除 | -| 勾选上限 | 建议最多同时勾选20个点位(前端友好性考虑) | -| 勾选状态 | 切换曲线/表格模式时,勾选状态保持不变 | +| 勾选点位 | 加入当前曲线/表格查询 | +| 取消勾选 | 从查询中移除 | +| 勾选上限 | **强制最多20个**;前端阻止第21个,后端再次校验 | +| 模式切换 | 保持勾选状态和时间范围 | +| 归档点位 | 可勾选,只读查询 | --- @@ -188,20 +206,21 @@ CREATE STABLE IF NOT EXISTS computed_data ( ```sql -- 创建数据库时指定保留天数 -CREATE DATABASE IF NOT EXISTS aquacontrolai +CREATE DATABASE IF NOT EXISTS ${TDENGINE_DATABASE} KEEP 365 -- 数据保留天数(对应配置值) DAYS 10 -- 每10天一个文件 BLOCKS 100; -- 修改保留天数(当用户在系统配置中修改时执行) -ALTER DATABASE aquacontrolai KEEP 730; +ALTER DATABASE ${TDENGINE_DATABASE} KEEP 730; ``` ### 4.3 注意 - 修改保留天数后,TDengine 会自动清理超出保留期的数据 - 保留天数对 `collection_data` 和 `computed_data` 两个超级表同时生效 -- 建议在系统配置页面提供"立即清理过期数据"按钮,但通常不需要手动操作 +- 数据库名由 `TDENGINE_DATABASE` 配置;系统配置 Service 执行 ALTER DATABASE 并记录审计日志 +- 配置更新失败时不修改 PostgreSQL 中的已生效配置值;TDengine 自动执行过期清理,不提供伪同步的“立即清理”按钮 --- @@ -220,7 +239,13 @@ ALTER DATABASE aquacontrolai KEEP 730; #### 5.2.1 GET /api/v1/history/tree — 获取历史点位树 -无需参数,从 PostgreSQL 查询所有符合条件的历史点位,按分组→点位构建树。 +Query parameters: + +| 参数 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| include_archived | bool | 否 | true | 是否包含仍有历史数据的归档点位 | + +历史模块调用 `ListHistoryPointMetadata(IncludeArchived=true)`,批量检查 TDengine 历史存在性并构建树;不得直接查询数据管理 PostgreSQL 表。 Response (200): @@ -231,7 +256,7 @@ Response (200): "data": { "tree": [ { - "id": "group-aeration", + "id": "group_a1b2c3d4e5f60708", "name": "曝气池", "type": "group", "children": [ @@ -240,26 +265,33 @@ Response (200): "name": "曝气池DO_01", "type": "collection", "data_type": "REAL", + "unit": "mg/L", + "history_interval": 5, "device_id": "device-uuid-1", "device_name": "一期曝气柜PLC", + "group_name": "曝气池", + "lifecycle_status": "active", + "has_history_data": true, "latest_value": { "value": 2.35, "quality": "good", + "quality_reason": null, "ts": "2026-07-09T10:00:01+08:00" } }, { - "id": "point-uuid-2", - "name": "曝气池温度", + "id": "point-uuid-archived", + "name": "旧DO点位", "type": "collection", "data_type": "REAL", - "device_id": "device-uuid-1", - "device_name": "一期曝气柜PLC", - "latest_value": { - "value": 25.1, - "quality": "good", - "ts": "2026-07-09T10:00:01+08:00" - } + "unit": "mg/L", + "history_interval": 5, + "device_id": "device-uuid-old", + "device_name": "旧PLC", + "group_name": "曝气池", + "lifecycle_status": "archived", + "has_history_data": true, + "latest_value": null } ] }, @@ -267,14 +299,7 @@ Response (200): "id": "internal-data", "name": "内部数据", "type": "reserved", - "children": [ - { - "id": "placeholder", - "name": "暂无数据", - "type": "placeholder", - "disabled": true - } - ] + "children": [{"id":"placeholder","name":"暂无数据","type":"placeholder","disabled":true}] } ] } @@ -289,27 +314,25 @@ Request body: { "point_ids": ["point-uuid-1", "point-uuid-2"], "start_time": "2026-07-09T00:00:00+08:00", - "end_time": "2026-07-09T01:00:00+08:00" + "end_time": "2026-07-09T01:00:00+08:00", + "max_samples": 2000 } ``` | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| point_ids | string[] | 是 | 要查询的点位ID列表,最少1个,最多20个 | -| start_time | string | 是 | 开始时间,ISO 8601格式 | -| end_time | string | 是 | 结束时间,ISO 8601格式 | +| point_ids | UUID[] | 是 | 1~20个,允许活跃或归档点位 | +| start_time | string | 是 | ISO 8601 | +| end_time | string | 是 | 必须晚于开始时间,跨度不超过31天 | +| max_samples | int | 否 | 每序列100~10000,默认2000 | 后端逻辑: -``` -1. 根据 point_ids 获取每个点位的子表名(从 point_id 映射) -2. 对每个点位,查询 TDengine: - SELECT ts, value, quality - FROM {subtable_name} - WHERE ts >= start_time AND ts <= end_time - ORDER BY ts ASC -3. 聚合结果,返回 -``` +1. 调用 `GetHistoryPointMetadata(..., includeArchived=true)` 校验并批量取得当前名称、单位和类型; +2. 由 UUID 派生并白名单校验 `p_` 子表名; +3. 查询 `ts, value, quality, quality_reason`; +4. 原始点数超过 `max_samples` 时执行自适应 min-max 降采样,保留首尾、极值、质量变化和 gap 边界; +5. 返回每条序列的采样统计。 Response (200): @@ -323,22 +346,16 @@ Response (200): "point_id": "point-uuid-1", "point_name": "曝气池DO_01", "data_type": "REAL", + "unit": "mg/L", + "sampled": false, + "raw_count": 5, + "sample_count": 5, "data": [ - { "ts": "2026-07-09T00:00:01+08:00", "value": 2.35, "quality": "good" }, - { "ts": "2026-07-09T00:00:02+08:00", "value": 2.36, "quality": "good" }, - { "ts": "2026-07-09T00:00:05+08:00", "value": 2.38, "quality": "good" }, - { "ts": "2026-07-09T00:00:08+08:00", "value": 2.30, "quality": "bad" }, - { "ts": "2026-07-09T00:00:10+08:00", "value": 2.40, "quality": "good" } - ] - }, - { - "point_id": "point-uuid-2", - "point_name": "曝气池温度", - "data_type": "REAL", - "data": [ - { "ts": "2026-07-09T00:00:01+08:00", "value": 25.1, "quality": "good" }, - { "ts": "2026-07-09T00:00:05+08:00", "value": 25.2, "quality": "good" }, - { "ts": "2026-07-09T00:00:10+08:00", "value": 25.0, "quality": "good" } + {"ts":"2026-07-09T00:00:01+08:00","value":2.35,"quality":"good","quality_reason":null}, + {"ts":"2026-07-09T00:00:02+08:00","value":2.36,"quality":"good","quality_reason":null}, + {"ts":"2026-07-09T00:00:05+08:00","value":2.38,"quality":"bad","quality_reason":"out_of_range"}, + {"ts":"2026-07-09T00:00:08+08:00","value":null,"quality":"bad","quality_reason":"timeout"}, + {"ts":"2026-07-09T00:00:10+08:00","value":2.40,"quality":"good","quality_reason":null} ] } ] @@ -361,35 +378,21 @@ Request body: | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| -| point_ids | string[] | 是 | 要查询的点位ID列表,最少1个,最多20个 | -| start_time | string | 是 | 开始时间 | -| end_time | string | 是 | 结束时间 | -| interval_minutes | int | 是 | 间隔分钟数,范围1~1440,必须能被60整除(建议限制为1, 2, 5, 10, 15, 20, 30, 60) | +| point_ids | UUID[] | 是 | 1~20个 | +| start_time | string | 是 | ISO 8601 | +| end_time | string | 是 | 晚于开始时间,跨度不超过31天 | +| interval_minutes | int | 是 | 1~1440任意整数 | -> **注意**:`interval_minutes` 是表格展示的对齐间隔,与采集点配置的 `history_interval`(存储间隔,见数据管理模块 §3.1.1)相互独立。如果 `interval_minutes` 小于选中点位中最大的 `history_interval`,大部分目标时间点将因无匹配数据而显示为 `—`。建议前端在表格模式下默认将 `interval_minutes` 的最小可选值限制为不小于所有已勾选点位中最大的 `history_interval`,或至少在用户选择过小间隔时给出提示。 +`interval_minutes` 与存储间隔独立。小于已选点位最大 `history_interval` 时前端提示,但不禁止查询。 -**后端逻辑(重点 — 最近邻匹配):** +最近邻算法: -``` -1. 根据 point_ids 获取每个点位的子表名 -2. 对每个点位,查询 TDengine 获取 start_time ~ end_time 范围内的所有原始数据 -3. 生成目标时间序列:从 start_time 开始,每隔 interval_minutes 生成一个目标时间点 - - 例如:00:00, 00:10, 00:20, ..., 01:00 -4. 对每个目标时间点 T,执行最近邻匹配: - a. 定义匹配窗口 = interval_minutes / 2(向上取整,单位分钟) - b. 在原始数据中,找时间戳距离 T 最近的数据点 - c. 如果最近距离 ≤ 匹配窗口 → 匹配成功,使用该数据点的 value 和 quality - d. 如果最近距离 > 匹配窗口 → 匹配失败,标记为 null -5. 返回按目标时间序列对齐的多条数据 -``` - -**举例:** interval_minutes=10,匹配窗口=5分钟 - -| 目标时间 | 原始数据 | 最近距离 | 匹配结果 | -|----------|---------|---------|---------| -| 00:00:00 | 有 00:00:01 的数据 | 1秒 | 2.35 (good) | -| 00:10:00 | 有 00:00:08 和 00:10:02 的数据 | 2秒(00:10:02) | 2.40 (good) | -| 00:20:00 | 最近数据在 00:14:00,距离6分钟 | 6分钟 > 5分钟 | null(显示—) | +1. `window = interval_minutes / 2` 的精确时长,例如1分钟间隔对应30秒窗口; +2. 查询范围扩大为 `[start_time-window, end_time+window]`; +3. 目标时间为 `start_time + n*interval` 且 `<= end_time`,不强制追加未对齐的 end_time; +4. 每个目标时间独立选择绝对距离最小的记录;距离相同时选择较早时间戳; +5. 同一原始记录允许匹配相邻目标时间,响应通过 `matched_ts` 明示; +6. 无匹配时返回固定 `none` 对象,数组长度始终与 `time_column` 相同。 Response (200): @@ -401,37 +404,17 @@ Response (200): "time_column": [ "2026-07-09T00:00:00+08:00", "2026-07-09T00:10:00+08:00", - "2026-07-09T00:20:00+08:00", - "2026-07-09T00:30:00+08:00", - "2026-07-09T00:40:00+08:00", - "2026-07-09T00:50:00+08:00", - "2026-07-09T01:00:00+08:00" + "2026-07-09T00:20:00+08:00" ], "columns": [ { "point_id": "point-uuid-1", "point_name": "曝气池DO_01", + "unit": "mg/L", "data": [ - { "value": 2.35, "quality": "good" }, - { "value": 2.40, "quality": "good" }, - null, - { "value": 2.38, "quality": "good" }, - { "value": 2.42, "quality": "good" }, - null, - { "value": 2.36, "quality": "good" } - ] - }, - { - "point_id": "point-uuid-2", - "point_name": "曝气池温度", - "data": [ - { "value": 25.1, "quality": "good" }, - { "value": 25.0, "quality": "good" }, - { "value": 25.3, "quality": "good" }, - null, - { "value": 25.1, "quality": "good" }, - { "value": 25.2, "quality": "good" }, - { "value": 25.0, "quality": "good" } + {"value":2.35,"quality":"good","quality_reason":null,"matched_ts":"2026-07-09T00:00:01+08:00"}, + {"value":2.40,"quality":"good","quality_reason":null,"matched_ts":"2026-07-09T00:10:02+08:00"}, + {"value":null,"quality":"none","quality_reason":null,"matched_ts":null} ] } ] @@ -441,34 +424,34 @@ Response (200): #### 5.2.4 POST /api/v1/history/export — 导出CSV -Request body 与 query-table 一致: +请求体与 query-table 一致。成功响应直接返回 CSV 文件流,不使用 JSON envelope;失败时返回统一 JSON 错误。 -```json -{ - "point_ids": ["point-uuid-1", "point-uuid-2"], - "start_time": "2026-07-09T00:00:00+08:00", - "end_time": "2026-07-09T01:00:00+08:00", - "interval_minutes": 10 -} -``` +- Content-Type:`text/csv; charset=utf-8-sig` +- Content-Disposition:`attachment; filename="history_20260709T000000_20260709T010000_10m.csv"` +- 文件名由服务端解析时间后重新格式化,不直接拼接请求字符串。 +- 最大输出 50,000 行;超出时返回 HTTP 413、`code=43006`。 -Response:CSV文件(Content-Type: text/csv; charset=utf-8-sig) - -CSV格式: +报表 CSV 允许本地化动态标题: ```csv -时间,曝气池DO_01,曝气池DO_01_质量,曝气池温度,曝气池温度_质量 +时间,曝气池DO_01[mg/L],曝气池DO_01[mg/L]_质量,曝气池温度[℃],曝气池温度[℃]_质量 2026-07-09 00:00:00,2.35,good,25.1,good 2026-07-09 00:10:00,2.40,good,25.0,good 2026-07-09 00:20:00,—,—,25.3,good -2026-07-09 00:30:00,2.38,good,—,— ``` -注意: -- CSV使用UTF-8 with BOM编码 -- 首行为标题行,包含时间、每个点位的数据列和质量列 -- 质量戳为 bad 时,数据列显示为 `—`(全角破折号),质量列显示 `bad` -- 未匹配到数据(null)时,数据列显示为 `—`,质量列显示 `—` +重复点位名称追加 UUID 前8位。bad 时数值列显示 `—`、质量列显示 `bad`;none 时两列均显示 `—`。 + +### 5.3 错误码 + +| code | HTTP | 场景 | +|---:|---:|---| +| 43001 | 400 | 参数格式或范围错误 | +| 43002 | 400 | 点位数量不在1~20 | +| 43003 | 400 | 时间范围无效或超过31天 | +| 43004 | 404 | 点位元数据不存在或历史记录已过保留期 | +| 43005 | 502 | TDengine 查询失败 | +| 43006 | 413 | 导出行数超过限制 | --- @@ -550,50 +533,24 @@ CSV格式: **技术实现**: -1. 后端(或前端)对查询到的所有曲线数据进行全局分析,找出数据分布 -2. 将Y轴划分为若干段,每段内使用线性映射,段间不等距 -3. 段划分规则: - - 找出所有曲线的数据值,按值聚类 - - 每个聚类形成一个"密度段",该段在Y轴上占据较大比例的高度 - - 聚类之间的空白区域形成一个"稀疏段",该段在Y轴上占据较小比例的高度 -4. 数据值 → 显示坐标的映射公式: +分段自适应映射全部由前端统一图表组件完成;历史 API 只返回原始值,不返回映射后的坐标。 -``` -对于每个数据值 v,计算其在Y轴上的显示位置 p: +1. 组件对当前可绘制的非空数值分析分布并生成分段映射; +2. 映射函数必须单调,刻度标签和 tooltip 始终显示原始值; +3. 图表显著显示“非线性分段轴”标识和分段边界,避免把视觉距离误解为等比例数值差; +4. 用户可切换到标准线性轴;导出数据始终使用原始值; +5. 不同单位同时展示时,在图例和游标表格中显示单位,并给出“不同单位仅用于趋势对比”的提示。 -p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segment_start) * segment_height - -其中 segment_height 根据该段的数据密度分配: -- 密度段(包含大量数据点):分配较高比例(如60%的Y轴高度) -- 稀疏段(几乎无数据点):分配较低比例(如10%的Y轴高度) -``` - -5. Y轴刻度标签显示原始值,位置按映射后的坐标放置 - -**ECharts 实现方案**: - -方案一(推荐 — 数据变换):后端将数据值按分段映射关系转换为显示坐标,前端使用线性Y轴绘制,Y轴通过 `axisLabel.formatter` 显示原始值标签,`tooltip.formatter` 显示原始值。 - -方案二(纯前端):前端获取原始数据后,使用 ECharts 的 `custom` 系列或数据变换插件,在前端完成映射计算。 - -**Y轴标签规则**: - -| 规则 | 说明 | -|------|------| -| 单Y轴 | 物理上只有一个Y轴,位于图表左侧 | -| 数值标签 | 显示原始数值,不显示映射后的坐标值 | -| 刻度位置 | 刻度线在Y轴上的物理位置由映射函数决定,不是均匀分布 | -| 刻度数量 | 自动控制,保持5~10个刻度标签,避免过密 | -| 标题标注 | Y轴标题显示 `数值` | -| 分段视觉提示 | 在Y轴背景上用浅色横条标记不同分段区域,帮助用户识别分段边界 | +ECharts 使用封装在 `src/components/charts/` 的纯函数生成映射和 option;业务页面不得复制映射算法。 #### 6.3.3 折线规则 | 数据质量 | 显示方式 | |----------|----------| | 连续 good 数据 | 实线折线,正常连接 | -| 连续 bad 数据 | 虚线连接(`--` 样式) | -| good → bad 过渡 | 实线连接到 bad 点,bad 点之后变为虚线 | +| 连续 bad 且 value 非空 | 虚线连接,并在 tooltip 显示 quality_reason | +| good → bad 且 value 非空 | 实线连接到 bad 点,之后使用虚线 | +| bad 且 value=null | 断线,不绘制数值点 | | 数据缺失(gap) | 断线,不连接 | #### 6.3.4 游标功能 @@ -629,7 +586,7 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme - 游标跟随鼠标移动,实时更新 - 鼠标离开图表区域时,游标消失 - 游标所在时刻精确到最近的数据点,不插值 -- 游标表格中,质量戳为 bad 时,数值显示为 `—` +- 游标表格中,bad 且 value 非空时显示数值并标红;bad 且 value=null 时显示 `—` --- @@ -659,7 +616,7 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme | 参数 | 说明 | |------|------| -| 间隔时间 | 下拉选择:1分钟、2分钟、5分钟、10分钟、15分钟、20分钟、30分钟、60分钟 | +| 间隔时间 | 数值输入,单位分钟,允许 1~1440 的任意整数;可提供 1、5、10、15、30、60 等快捷值,但快捷值不构成限制 | | 默认值 | 10分钟 | ### 7.3 表格数据规则 @@ -667,7 +624,7 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme | 规则 | 说明 | |------|------| | 第一列 | 时间序列,从 start_time 开始,按 interval 递增 | -| 数据列 | 每个勾选的点位对应一列,列标题为 `点位名称(单位)` | +| 数据列 | 每个点位一列,标题为 `点位名称[单位]`;单位为空时只显示名称 | | 数据对齐 | 使用最近邻匹配(见 5.2.3 后端逻辑) | | bad 质量 | 数据单元格显示 `—`(全角破折号) | | 无匹配数据 | 数据单元格显示 `—`(全角破折号) | @@ -678,7 +635,7 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme 点击"导出CSV"按钮,触发 `POST /api/v1/history/export` 接口。 - 导出内容与当前表格显示内容一致(相同的时间范围、间隔、点位) -- 文件名格式:`历史数据_{start_time}_{end_time}_{interval}min.csv` +- 文件名由服务端生成:`history_YYYYMMDDTHHMMSS_YYYYMMDDTHHMMSS_{interval}m.csv` - 导出完成后浏览器自动下载 --- @@ -717,7 +674,7 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme - 曲线模式和表格模式通过 Tab 按钮切换 - 切换时,**勾选的点位状态保持不变** - 切换时,**时间范围保持不变** -- 切换时,不重复请求数据,按需加载(点击曲线Tab时请求曲线数据,点击表格Tab时请求表格数据) +- 使用缓存键 `mode + point_ids + start_time + end_time + interval/max_samples`;缓存存在且参数未变化时不重复请求,否则按需请求 ### 8.3 交互流程 @@ -737,8 +694,8 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme └── 表格模式 → 选择间隔 → 点击查询 → POST /api/v1/history/query-table → 渲染表格 │ ▼ -用户勾选/取消勾选点位 → 自动重新查询(保持时间范围不变) -用户切换模式 → 自动重新查询(保持点位和时间范围不变) +用户勾选/取消勾选点位 → 标记当前模式缓存失效,由用户点击查询或启用防抖自动查询 +用户切换模式 → 命中该模式缓存则直接展示;未命中时自动查询 ``` --- @@ -747,19 +704,21 @@ p = sum( previous_segment_heights ) + (v - segment_start) / (segment_end - segme | 编号 | 规则 | 说明 | |------|------|------| -| H001 | 历史数据来源 | 仅展示 store_history=true 且设备/点位均启用的采集点数据 | +| H001 | 历史数据来源 | 展示活跃点位,以及仍有保留期内数据的归档点位 | | H002 | 树形结构 | 按分组→点位两层组织,分组名称取自采集点配置的 group_name,外加"内部数据"预留节点 | -| H003 | 最大勾选数 | 同时勾选的点位不超过20个 | +| H003 | 最大勾选数 | 前后端强制不超过20个 | | H004 | 数据保留 | 可配置1~730天,通过TDengine KEEP参数实现 | -| H005 | 表格时间对齐 | 最近邻匹配,匹配窗口 = interval/2 | -| H006 | 曲线Y轴 | 单Y轴分段自适应,数据密集区放大,稀疏区压缩 | -| H007 | bad质量显示 | 曲线:虚线;表格:`—`;游标:`—` | +| H005 | 表格时间对齐 | 精确半间隔窗口;扩展首尾查询范围;距离相同选较早记录 | +| H006 | 曲线Y轴 | 前端实现可切换的非线性分段轴,API始终返回原始值 | +| H007 | bad质量显示 | 有值bad画虚线;无值bad断线;表格bad显示— | | H008 | 数据缺失 | 曲线:断线;表格:`—` | | H009 | 模式切换 | 保持点位勾选状态和时间范围不变 | -| H010 | CSV导出 | 使用UTF-8 with BOM编码,与表格显示内容一致 | -| H011 | 查询间隔建议 | 表格查询的 `interval_minutes` 建议不小于选中点位的 `history_interval`,否则可能大量无匹配数据 | +| H010 | CSV导出 | 报表型动态标题,安全服务端文件名,最多50,000行 | +| H011 | 查询间隔建议 | 小于最大history_interval时仅提示,不禁止;查询最大31天 | +| H012 | 曲线采样 | 每序列默认最多2000点,超限执行自适应min-max降采样 | +| H013 | 缺失值协议 | 固定返回value=null、quality=none、matched_ts=null对象 | --- -> 本文档版本:v1.0 -> 最后更新:2026-07-09 +> 本文档版本:v1.1 +> 最后更新:2026-07-11 diff --git a/开发文档/spec-数据管理.md b/开发文档/spec-数据管理.md index 191c985..29ee985 100644 --- a/开发文档/spec-数据管理.md +++ b/开发文档/spec-数据管理.md @@ -2,6 +2,8 @@ > 本文档属于《污水厂智能控制平台》的一部分,详细描述数据管理模块的功能、数据模型、API接口和业务逻辑,细度可达直接开发级别。 +> 跨文档契约发生冲突时,必须遵循《[一致性决策基线](一致性决策基线.md)》;该文件的已采纳决策优先于本文旧表述。 + --- ## 目录 @@ -40,7 +42,7 @@ ``` 设备 (Device) ← 一个物理PLC或Modbus设备 - ├── 采集点 (CollectPoint) ← 从该设备读取的测点(N个) + ├── 采集点 (CollectionPoint) ← 从该设备读取的测点(N个) └── 写入点 (WritePoint) ← 向该设备写入的测点(M个) 采集点 ≠ 写入点,两者分开管理,独立配置。 @@ -110,34 +112,16 @@ CREATE UNIQUE INDEX idx_devices_name ON devices(name) WHERE deleted = FALSE; ```json { "unit_id": 1, - "byte_order": "ABCD", - "word_order": "AB" + "float32_order": "ABCD" } ``` | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| unit_id | int | 是 | Modbus从站地址(站号),范围1~247 | -| byte_order | string | 是 | 32位浮点数(REAL)的字节序,详见下文 | -| word_order | string | 是 | 32位浮点数(REAL)的字序,详见下文 | +| unit_id | int | 是 | Modbus从站地址,范围1~247 | +| float32_order | string | 是 | REAL 占用两个寄存器时的四字节排列:`ABCD`、`BADC`、`CDAB`、`DCBA`;默认 `ABCD` | -**byte_order 和 word_order 说明**: - -对于Modbus中占用2个寄存器的REAL类型(32位浮点数),解析时需指定字节顺序: - -| byte_order | 说明 | 示例(4字节: 0x41 0xA0 0x00 0x00) | -|------------|------|--------------------------------------| -| `ABCD` | 大端序(默认) | 41A00000 → 20.0 | -| `BADC` | 字节交换 | A0410000 → 乱码(通常不推荐) | -| `CDAB` | 字内字节交换 | 000041A0 → 20.0(某些PLC的格式) | -| `DCBA` | 小端序 | 0000A041 → 乱码(通常不推荐) | - -| word_order | 说明 | 示例(4字节: 0x41 0xA0 0x00 0x00) | -|------------|------|--------------------------------------| -| `AB` | 正常字序 | 寄存器1=41A0, 寄存器2=0000 → 20.0 | -| `BA` | 字交换 | 寄存器1=0000, 寄存器2=41A0 → 20.0(某些PLC的格式) | - -**最终解析公式**:按照 `word_order` 决定字的排列,再按 `byte_order` 决定每个字内的字节顺序。 +`float32_order` 直接描述从两个寄存器读取到的四个字节如何重排为 IEEE-754 32 位浮点数,不再叠加第二个 `word_order` 字段,避免字节交换与字交换职责重叠。INT 类型按标准 16 位寄存器顺序解析。 #### 2.1.4 设备的业务状态 @@ -147,7 +131,7 @@ CREATE UNIQUE INDEX idx_devices_name ON devices(name) WHERE deleted = FALSE; |------|------| | enabled=true | 设备启用,采集引擎会为该设备建立连接并执行采集任务 | | enabled=false | 设备禁用,采集引擎跳过该设备,已有连接断开 | -| deleted=true | 逻辑删除,数据保留但不再显示和使用 | +| deleted=true | 逻辑删除;不再出现在数据管理活动列表或运行时任务中,但保留期内历史数据仍可在历史模块只读查询 | **运行时连接状态(采集引擎内存维护,不落库)**: @@ -234,17 +218,34 @@ Response (201): **PUT /api/v1/devices/{id}** — 修改设备 -- 与新增使用相同的请求体结构 -- 修改后,如果设备正在运行中,采集引擎应自动重新连接 -- 如果修改了 enabled 字段(启用/禁用),响应中的 `connection_status` 同步变化: - - 从 enabled=false 改为 true → 采集引擎尝试连接,`connection_status` 在异步连接完成前为 `disconnected` - - 从 enabled=true 改为 false → `connection_status` 立即变为 `disabled` +PUT 使用完整替换语义,以下字段全部必填: + +```json +{ + "name": "一期曝气柜PLC", + "protocol_type": "S7", + "enabled": true, + "host": "192.168.1.100", + "port": 102, + "connect_timeout": 5, + "reconnect_interval": 10, + "protocol_config": { + "rack": 0, + "slot": 1 + } +} +``` + +- 校验规则与新增一致,额外要求 `enabled` 为布尔值。 +- 修改连接参数或协议配置后,Service 在事务提交后发布配置变更事件;采集引擎断开旧连接并使用新参数重连。 +- `enabled=false` 时运行时状态立即进入 `disabled`;`enabled=true` 后先返回 `disconnected`,连接成功后变为 `connected`。 +- 配置变更正常情况下 1 秒内生效,5 秒全量校对保证最终一致。 **DELETE /api/v1/devices/{id}** — 逻辑删除 - 将该设备的 deleted 字段设为 true - 如果设备正在运行中,采集引擎应立即停止该设备的所有采集任务并断开连接 -- 同时该设备下所有采集点和写入点也一并逻辑删除 +- 同时该设备下所有采集点和写入点也一并逻辑删除;已有 TDengine 历史数据不删除,作为归档数据继续只读查询 **POST /api/v1/devices/export** — 导出设备列表为CSV @@ -252,10 +253,12 @@ Request body: ```json { - "ids": ["uuid1", "uuid2"] // 可选,不传则导出全部 + "ids": ["uuid1", "uuid2"] } ``` +`ids` 可选;省略时导出全部符合当前权限和逻辑删除规则的设备。 + Response:CSV文件(Content-Type: text/csv; charset=utf-8-sig) CSV格式定义: @@ -263,7 +266,7 @@ CSV格式定义: ```csv name,protocol_type,host,port,connect_timeout,reconnect_interval,protocol_config,enabled 一期曝气柜PLC,S7,192.168.1.100,102,5,10,"{""rack"":0,""slot"":1}",TRUE -二期加药间PLC,MODBUS_TCP,192.168.2.50,502,5,10,"{""unit_id"":1,""byte_order"":""ABCD"",""word_order"":""AB""}",TRUE +二期加药间PLC,MODBUS_TCP,192.168.2.50,502,5,10,"{""unit_id"":1,""float32_order"":""ABCD""}",TRUE ``` 注意: @@ -323,11 +326,46 @@ Response (200): ``` `connection_status` 字段说明: -- 由 API Handler 从采集引擎运行时状态中获取后组装到响应中 +- 由设备 Service 通过 `DeviceRuntimeStatusProvider` 获取运行时状态后组装;Handler 不直接访问采集引擎 - 枚举值:`connected`、`disconnected`、`disabled` - enabled=false 时始终为 `disabled`,无需查询引擎运行时状态 - enabled=true 时,如果采集引擎未运行或查询不到该设备状态,统一返回 `disconnected` +**GET /api/v1/devices/{id}** 返回与列表项相同的完整设备对象;不存在或已逻辑删除时返回 HTTP 404、`code=40004`。 + +**GET /api/v1/devices/protocols** 返回注册表中的协议工厂元数据: + +```json +{ + "code": 0, + "message": "success", + "data": { + "items": [ + {"protocol_type":"S7","default_port":102,"config_schema":{"type":"object"}}, + {"protocol_type":"MODBUS_TCP","default_port":502,"config_schema":{"type":"object"}} + ] + } +} +``` + +设备导入结果使用统一结构: + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 10, + "created": 6, + "updated": 3, + "failed": 1, + "errors": [{"row":8,"field":"host","message":"无效的IP地址或域名"}] + } +} +``` + +设备 CSV 成功时直接返回文件流,不使用 JSON envelope;失败时返回统一 JSON 错误。 + --- ## 3. 数据采集点管理 @@ -344,34 +382,60 @@ CREATE TABLE collection_points ( device_id UUID NOT NULL REFERENCES devices(id), enabled BOOLEAN NOT NULL DEFAULT TRUE, deleted BOOLEAN NOT NULL DEFAULT FALSE, - - -- 采集地址(协议驱动自行解析) + address VARCHAR(256) NOT NULL, - - -- 数据类型 data_type VARCHAR(16) NOT NULL, -- 'BOOL', 'INT', 'REAL' - - -- 采集参数 - collect_interval INTEGER NOT NULL DEFAULT 1, -- 单位:秒,最小值1 + unit VARCHAR(32), + valid_min DOUBLE PRECISION, + valid_max DOUBLE PRECISION, + + collect_interval INTEGER NOT NULL DEFAULT 1 CHECK (collect_interval >= 1), store_history BOOLEAN NOT NULL DEFAULT TRUE, - history_interval INTEGER NOT NULL DEFAULT 1, -- 单位:分钟,最小值1 - + history_interval INTEGER NOT NULL DEFAULT 1 CHECK (history_interval BETWEEN 1 AND 1440), + history_started_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), created_by VARCHAR(64), - updated_by VARCHAR(64) + updated_by VARCHAR(64), + + CHECK (valid_min IS NULL OR valid_max IS NULL OR valid_min <= valid_max) ); --- 全局唯一名称(逻辑删除的记录不参与) -CREATE UNIQUE INDEX idx_collection_points_name ON collection_points(name) WHERE deleted = FALSE; - --- 按设备查询 -CREATE INDEX idx_collection_points_device ON collection_points(device_id) WHERE deleted = FALSE; - --- 按分组查询 -CREATE INDEX idx_collection_points_group ON collection_points(group_name) WHERE deleted = FALSE; +CREATE UNIQUE INDEX idx_collection_points_name + ON collection_points(name) WHERE deleted = FALSE; +CREATE INDEX idx_collection_points_device_id + ON collection_points(device_id) WHERE deleted = FALSE; +CREATE INDEX idx_collection_points_group_name + ON collection_points(group_name) WHERE deleted = FALSE; ``` +`history_started_at` 在点位首次准备写入 TDengine 前设置。一旦非空,`device_id` 和 `data_type` 不可修改;需要改变设备归属或数据类型时必须新建点位,防止同一历史子表混入不同语义的数据。 + +#### 3.1.1.1 历史模块只读元数据接口 + +设备与采集点配置由数据管理模块拥有。历史模块必须调用公开的进程内只读领域接口,不得直接查询 `devices`、`collection_points` 表或调用数据管理 Repository。 + +接口能力: + +```go +type HistoryPointMetadataOptions struct { + IncludeArchived bool +} + +ListHistoryPointMetadata(ctx context.Context, opts HistoryPointMetadataOptions) ([]HistoryPointMetadata, error) +GetHistoryPointMetadata(ctx context.Context, pointIDs []uuid.UUID, includeArchived bool) ([]HistoryPointMetadata, error) +``` + +稳定 DTO 至少包含: + +- 点位 ID、名称、分组、单位、设备 ID/名称、数据类型; +- `enabled`、`deleted`、设备启用/删除状态、`store_history`、`history_interval`; +- `history_started_at`; +- 可空 `latest_value`,结构为 `{value, quality, quality_reason, ts}`。 + +`IncludeArchived=true` 时必须返回逻辑删除或禁用但配置记录仍保留的点位。历史模块再结合 TDengine 是否存在记录确定 `has_history_data` 和 `lifecycle_status`。 + #### 3.1.2 各协议的地址格式 **S7协议地址格式**: @@ -424,7 +488,7 @@ CREATE INDEX idx_collection_points_group ON collection_points(group_name) WHERE 对于 BOOL 类型,每个地址对应一个位(线圈/离散输入)。 对于 INT 类型,每个地址对应1个寄存器(16位)。 -对于 REAL 类型,每个地址对应2个连续寄存器(32位),读取时从指定地址连续读2个寄存器,按 device 中配置的 byte_order 和 word_order 解析。 +对于 REAL 类型,每个地址对应2个连续寄存器(32位),读取时从指定地址连续读2个寄存器,按 device 中配置的 float32_order 解析。 #### 3.1.3 分组机制 @@ -440,7 +504,7 @@ CREATE INDEX idx_collection_points_group ON collection_points(group_name) WHERE 实时数据不是存储在数据库中的字段,而是采集引擎运行时维护在内存中的最新值。 -API 返回采集点时,应附带该点的最新采集值(如果存在): +API 返回采集点时应附带最新采集值;没有缓存时 `latest_value` 固定为 `null`: ```json { @@ -449,6 +513,7 @@ API 返回采集点时,应附带该点的最新采集值(如果存在): "latest_value": { "value": 2.35, "quality": "good", + "quality_reason": null, "ts": "2026-07-09T10:00:01+08:00" } } @@ -482,6 +547,9 @@ Request body: "device_id": "uuid-of-device", "address": "DB2.10", "data_type": "REAL", + "unit": "mg/L", + "valid_min": 0, + "valid_max": 20, "collect_interval": 1, "store_history": true, "history_interval": 1 @@ -494,16 +562,39 @@ Request body: - device_id:必填,必须引用一个已存在的设备(且deleted=false) - address:必填,1~256字符,根据设备协议类型校验格式 - data_type:必填,枚举值:`BOOL`、`INT`、`REAL` +- unit:可选,0~32字符;用于历史表格和图表展示 +- valid_min / valid_max:可选;同时提供时必须 valid_min <= valid_max,超范围值记为 bad 但保留原始值 - collect_interval:必填,最小值1(秒) - store_history:可选,默认true -- history_interval:当store_history=true时必填,最小值1(分钟) +- history_interval:当 store_history=true 时必填,必须为 1~1440 的整数(分钟);store_history=false 时可省略并使用默认值 1,若提供仍须符合该范围 - address 和 data_type 的兼容性校验: - S7协议:BOOL类型必须包含bit位(如 `DB2.10.0`),INT/REAL类型不能包含bit位 - Modbus协议:00001/10001 地址只能使用BOOL类型,30001/40001 地址只能使用INT/REAL类型 **PUT /api/v1/collection-points/{id}** — 修改采集点 -- 修改后,如果采集引擎正在运行中,需要动态更新采集任务配置 +PUT 使用完整替换语义,请求字段与新增一致,并额外要求 `enabled`: + +```json +{ + "name": "曝气池DO_01", + "group_name": "曝气池", + "device_id": "uuid-of-device", + "enabled": true, + "address": "DB2.10", + "data_type": "REAL", + "unit": "mg/L", + "valid_min": 0, + "valid_max": 20, + "collect_interval": 1, + "store_history": true, + "history_interval": 5 +} +``` + +- `history_started_at` 非空时,修改 `device_id` 或 `data_type` 返回 HTTP 409、`code=41009`。 +- 名称、分组、地址、单位、有效范围、采集周期和历史间隔可以修改。 +- Service 在事务提交后发布配置变更事件,采集引擎停止旧任务并按新配置启动;5 秒内保证最终生效。 **DELETE /api/v1/collection-points/{id}** — 逻辑删除 @@ -544,6 +635,9 @@ Response (200): "protocol_type": "S7", "address": "DB2.10", "data_type": "REAL", + "unit": "mg/L", + "valid_min": 0, + "valid_max": 20, "collect_interval": 1, "store_history": true, "history_interval": 1, @@ -551,6 +645,7 @@ Response (200): "latest_value": { "value": 2.35, "quality": "good", + "quality_reason": null, "ts": "2026-07-09T10:00:01+08:00" }, "created_at": "...", @@ -593,10 +688,10 @@ Response (200): CSV格式定义: ```csv -name,group_name,device_name,address,data_type,collect_interval,store_history,history_interval,enabled -曝气池DO_01,曝气池,一期曝气柜PLC,DB2.10,REAL,1,TRUE,1,TRUE -进水pH,进水仪表,进水仪表柜PLC,DB1.0,REAL,5,TRUE,5,TRUE -风机运行状态,曝气池,一期曝气柜PLC,M4.0,BOOL,1,TRUE,1,TRUE +name,group_name,device_name,address,data_type,unit,valid_min,valid_max,collect_interval,store_history,history_interval,enabled +曝气池DO_01,曝气池,一期曝气柜PLC,DB2.10,REAL,mg/L,0,20,1,TRUE,1,TRUE +进水pH,进水仪表,进水仪表柜PLC,DB1.0,REAL,pH,0,14,5,TRUE,5,TRUE +风机运行状态,曝气池,一期曝气柜PLC,M4.0,BOOL,,,,1,TRUE,1,TRUE ``` 注意: @@ -606,8 +701,14 @@ name,group_name,device_name,address,data_type,collect_interval,store_history,his **POST /api/v1/collection-points/import** — 从CSV导入 -- 导入逻辑:以 name 为唯一标识,存在则更新,不存在则新增 -- 需先校验 device_name 是否有效 +- 导入逻辑:以 name 为唯一标识,存在则更新,不存在则新增。 +- 先校验 `device_name`,再按对应协议校验地址和数据类型。 +- 若更新已有点位且 `history_started_at` 非空,CSV 不得改变 `device_name` 对应的设备或 `data_type`。 +- 响应沿用设备导入的 `{total,created,updated,failed,errors}` 结构。 + +**GET /api/v1/collection-points/{id}** 返回列表项的完整对象,并包含 `history_started_at`;无实时缓存时 `latest_value=null`。 + +采集点 CSV 成功时直接返回文件流,不使用 JSON envelope;失败时返回统一 JSON 错误。 --- @@ -624,25 +725,27 @@ CREATE TABLE write_points ( group_name VARCHAR(64) NOT NULL DEFAULT 'default', device_id UUID NOT NULL REFERENCES devices(id), enabled BOOLEAN NOT NULL DEFAULT TRUE, - write_enabled BOOLEAN NOT NULL DEFAULT FALSE, -- 是否允许写入 - write_source VARCHAR(16) NOT NULL DEFAULT 'manual', -- 写入来源: 'manual'(仅人工), 'auto'(仅程序自动), 'both'(两者均可) + write_enabled BOOLEAN NOT NULL DEFAULT FALSE, deleted BOOLEAN NOT NULL DEFAULT FALSE, - - -- 写入地址(协议驱动自行解析,格式与采集点相同) + address VARCHAR(256) NOT NULL, - - -- 数据类型 data_type VARCHAR(16) NOT NULL, -- 'BOOL', 'INT', 'REAL' - + unit VARCHAR(32), + readback_tolerance DOUBLE PRECISION NOT NULL DEFAULT 0.0001 + CHECK (readback_tolerance >= 0), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), created_by VARCHAR(64), updated_by VARCHAR(64) ); -CREATE UNIQUE INDEX idx_write_points_name ON write_points(name) WHERE deleted = FALSE; -CREATE INDEX idx_write_points_device ON write_points(device_id) WHERE deleted = FALSE; -CREATE INDEX idx_write_points_group ON write_points(group_name) WHERE deleted = FALSE; +CREATE UNIQUE INDEX idx_write_points_name + ON write_points(name) WHERE deleted = FALSE; +CREATE INDEX idx_write_points_device_id + ON write_points(device_id) WHERE deleted = FALSE; +CREATE INDEX idx_write_points_group_name + ON write_points(group_name) WHERE deleted = FALSE; ``` #### 4.1.2 与采集点的区别 @@ -650,19 +753,18 @@ CREATE INDEX idx_write_points_group ON write_points(group_name) WHERE deleted = | 维度 | 采集点 | 写入点 | |------|--------|--------| | 方向 | 从PLC读取 | 向PLC写入 | -| 存储历史 | 支持(可配置写入TDengine) | 不支持(只记录操作日志) | -| 采集周期 | 有 | 无(指令触发,非周期执行) | -| 写入开关 | 无 | 有(write_enabled + write_source) | -| 写入来源 | 无 | 支持人工(manual)和程序自动(auto)两种来源 | -| 采集引擎 | 周期性调度 | 无(等待API触发) | +| 存储历史 | 支持 | 不支持,只记录操作日志 | +| 周期 | 周期采集 | API 指令触发 | +| 写入开关 | 无 | `write_enabled` | +| 写入来源 | 无 | 当前阶段仅人工写入,服务端固定为 `manual` | +| 回读 | 无 | 写入后必须回读验证 | -#### 4.1.3 地址格式 +#### 4.1.3 地址与回读规则 -与采集点完全一致,参考 [3.1.2](#312-各协议的地址格式)。 - -注意:写入点必须使用可写的地址类型: -- S7协议:DB、M、Q 类型可写(I类型为输入,不可写) -- Modbus协议:00001(线圈)和 40001(保持寄存器)可写;10001(离散输入)和 30001(输入寄存器)不可写 +- 地址格式与采集点一致,但只允许可写区域:S7 的 DB/M/Q,Modbus 的 Coil/保持寄存器。 +- BOOL、INT 回读必须严格相等。 +- REAL 使用 `abs(readback-target) <= readback_tolerance`;默认容差 0.0001。 +- 同一设备的采集、写入和回读通过设备连接命令队列串行执行,避免协议客户端并发冲突。 ### 4.2 RESTful API @@ -673,171 +775,129 @@ CREATE INDEX idx_write_points_group ON write_points(group_name) WHERE deleted = | GET | /api/v1/write-points | 获取写入点列表 | | POST | /api/v1/write-points | 新增写入点 | | GET | /api/v1/write-points/{id} | 获取单个写入点详情 | -| PUT | /api/v1/write-points/{id} | 修改写入点 | +| PUT | /api/v1/write-points/{id} | 完整修改写入点 | | DELETE | /api/v1/write-points/{id} | 逻辑删除 | -| POST | /api/v1/write-points/export | 导出CSV | -| POST | /api/v1/write-points/import | 从CSV导入 | -| POST | /api/v1/write-points/{id}/write | 执行写入操作(人工/程序自动均使用此接口) | +| POST | /api/v1/write-points/export | 导出配置 CSV | +| POST | /api/v1/write-points/import | 从配置 CSV 导入 | +| POST | /api/v1/write-points/{id}/write | 执行人工写入 | | GET | /api/v1/write-logs | 查询写入操作日志 | -**GET /api/v1/write-logs** — 查询写入操作日志 +#### 4.2.2 写入点 CRUD -Query parameters: - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| page | int | 否 | 默认1 | -| page_size | int | 否 | 默认20,最大100 | -| point_id | string | 否 | 按写入点筛选 | -| device_id | string | 否 | 按设备筛选 | -| source | string | 否 | 筛选 manual/auto | -| result | string | 否 | 筛选 success/failed | -| start_time | string | 否 | 开始时间 | -| end_time | string | 否 | 结束时间 | -| keyword | string | 否 | 搜索 operator 或 point_name | - -Response (200): +新增请求: ```json { - "code": 0, - "message": "success", - "data": { - "total": 500, - "page": 1, - "page_size": 20, - "items": [ - { - "id": "uuid", - "point_id": "uuid", - "point_name": "加药泵频率", - "device_id": "uuid", - "device_name": "一期加药间PLC", - "address": "DB2.10", - "data_type": "REAL", - "source": "manual", - "target_value": "20.5", - "readback_value": "20.5", - "result": "success", - "error_message": null, - "operator": "张三", - "reason": "AI推荐加药量调整", - "created_at": "2026-07-09T10:00:00+08:00" - } - ] - } + "name": "加药泵频率", + "group_name": "加药间", + "device_id": "uuid-of-device", + "enabled": true, + "write_enabled": false, + "address": "DB2.10", + "data_type": "REAL", + "unit": "Hz", + "readback_tolerance": 0.01 } ``` -#### 4.2.2 写入接口详细定义 +- POST 中 `enabled` 可省略,默认 true;PUT 中全部字段必填。 +- 名称全局唯一;设备必须存在且未删除;地址必须可写并与数据类型兼容。 +- `readback_tolerance` 仅对 REAL 生效,范围 0~1,000,000。 +- GET 列表支持 `page`、`page_size`、`keyword`、`device_id`、`group_name`、`data_type`、`enabled`、`write_enabled`。 +- DELETE 设置 `deleted=true` 并立即拒绝新的写入请求。 -**POST /api/v1/write-points/{id}/write** — 执行写入 +成功响应中的写入点对象包含上述字段及 `id`、`device_name`、`protocol_type`、`created_at`、`updated_at`。 -Request body(人工写入): +配置 CSV: + +```csv +name,group_name,device_name,address,data_type,unit,enabled,write_enabled,readback_tolerance +加药泵频率,加药间,一期加药间PLC,DB2.10,REAL,Hz,TRUE,FALSE,0.01 +``` + +导入以 `name` 为唯一标识,存在则更新;失败结果返回行号、字段和稳定错误原因。写入点 CSV 成功时直接返回文件流,不使用 JSON envelope;失败时返回统一 JSON 错误。 + +#### 4.2.3 执行写入 + +**POST /api/v1/write-points/{id}/write** ```json { "value": 20.5, - "source": "manual", - "operator": "张三", - "reason": "AI推荐加药量调整" + "reason": "工艺调整" } ``` -Request body(程序自动写入): +校验: -```json -{ - "value": 25.0, - "source": "auto", - "operator": "ai-engine:aeration-model-v1", - "reason": "曝气模型推理结果:进水负荷上升,需要增加风量" -} -``` +- BOOL 只接受 JSON boolean;INT 只接受整数;REAL 接受 JSON number。 +- `reason` 可选,最多 500 字符。 +- 请求体不得包含 `source` 或 `operator`;服务端固定记录 `source=manual`、`operator=null`。 -校验规则: -- value:必填,类型必须与写入点的 data_type 匹配 - - BOOL:true/false - - INT:整数 - - REAL:浮点数 -- source:必填,枚举值:`manual`(人工)、`auto`(程序自动) -- operator:必填 - - 当 source=manual 时,操作人标识(如 "张三") - - 当 source=auto 时,调用方标识(如 "ai-engine:aeration-model-v1") -- reason:可选,操作原因 -- 写入来源校验:写入点的 write_source 字段必须与请求中的 source 匹配 - - write_source=manual:仅允许 source=manual - - write_source=auto:仅允许 source=auto - - write_source=both:manual 和 auto 都允许 +执行流程: -写入流程: +1. 校验点位、设备、`enabled` 和 `write_enabled`; +2. 通过设备级连接队列写入; +3. 回读并按数据类型验证;不一致时重试一次; +4. 无论成功、失败或超时都写入 `write_logs`; +5. 返回稳定业务结果,原始协议错误只写服务端日志。 -``` -1. 校验写入点是否存在且 write_enabled=true -2. 校验请求中的 source 是否在写入点 write_source 允许的范围内 -3. 校验值类型与 data_type 匹配 -4. 连接PLC(如果已连接则复用) -5. 写入地址(调用协议驱动的 Write 方法) -6. 回读验证(读取刚写入的地址,确认值一致) - - 如果回读值与写入值一致:写入成功 - - 如果回读值与写入值不一致:重试一次,仍不一致则标记为失败 -7. 记录操作日志到 write_logs 表 -8. 返回写入结果 -``` - -Response (200): +成功:HTTP 200、`code=0`。 ```json { "code": 0, "message": "success", "data": { - "id": "uuid", + "write_log_id": "uuid", "point_name": "加药泵频率", + "data_type": "REAL", "value": 20.5, + "readback_value": 20.50001, "result": "success", - "readback_value": 20.5, "ts": "2026-07-09T10:00:00+08:00" } } ``` -写入失败时: +失败:HTTP 502、`code=51001`;超时:HTTP 504、`code=51002`。失败响应 `data` 至少包含 `write_log_id`、`result` 和稳定 `error_message`。 + +#### 4.2.4 写入日志 + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 默认1,最大 page_size=100 | +| point_id | UUID | 否 | 按写入点筛选 | +| device_id | UUID | 否 | 按设备筛选 | +| result | string | 否 | `success | failed | timeout` | +| start_time / end_time | string | 否 | ISO 8601,最大跨度31天 | +| keyword | string | 否 | 搜索 point_name | + +API 根据 `data_type` 把数据库 TEXT 转换为 JSON boolean/number: ```json { - "code": 0, - "message": "success", - "data": { - "id": "uuid", - "point_name": "加药泵频率", - "value": 20.5, - "result": "failed", - "error": "回读值不匹配: 期望20.5, 实际18.3", - "ts": "2026-07-09T10:00:00+08:00" - } + "id": "uuid", + "point_id": "uuid", + "point_name": "加药泵频率", + "device_id": "uuid", + "device_name": "一期加药间PLC", + "address": "DB2.10", + "data_type": "REAL", + "unit": "Hz", + "source": "manual", + "target_value": 20.5, + "readback_value": 20.50001, + "result": "success", + "error_message": null, + "operator": null, + "reason": "工艺调整", + "created_at": "2026-07-09T10:00:00+08:00" } ``` -写入超时: - -```json -{ - "code": 0, - "message": "success", - "data": { - "id": "uuid", - "point_name": "加药泵频率", - "value": 20.5, - "result": "timeout", - "error": "写入操作超时: 设备无响应超过30秒", - "ts": "2026-07-09T10:00:00+08:00" - } -} -``` - -#### 4.2.3 写入操作日志表:write_logs - ```sql CREATE TABLE write_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -847,23 +907,21 @@ CREATE TABLE write_logs ( device_name VARCHAR(128) NOT NULL, address VARCHAR(256) NOT NULL, data_type VARCHAR(16) NOT NULL, - - source VARCHAR(16) NOT NULL, -- 'manual' 或 'auto' - target_value TEXT NOT NULL, -- 目标值(统一存为字符串) - readback_value TEXT, -- 回读值 - result VARCHAR(16) NOT NULL, -- 'success', 'failed', 'timeout' + unit VARCHAR(32), + + source VARCHAR(16) NOT NULL DEFAULT 'manual' CHECK (source = 'manual'), + target_value TEXT NOT NULL, + readback_value TEXT, + result VARCHAR(16) NOT NULL CHECK (result IN ('success', 'failed', 'timeout')), error_message TEXT, - - operator VARCHAR(64) NOT NULL, - reason TEXT, - + operator VARCHAR(64), + reason VARCHAR(500), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -CREATE INDEX idx_write_logs_point ON write_logs(point_id); -CREATE INDEX idx_write_logs_device ON write_logs(device_id); -CREATE INDEX idx_write_logs_source ON write_logs(source); -CREATE INDEX idx_write_logs_time ON write_logs(created_at DESC); +CREATE INDEX idx_write_logs_point_id ON write_logs(point_id); +CREATE INDEX idx_write_logs_device_id ON write_logs(device_id); +CREATE INDEX idx_write_logs_created_at ON write_logs(created_at DESC); ``` --- @@ -872,7 +930,9 @@ CREATE INDEX idx_write_logs_time ON write_logs(created_at DESC); ### 5.1 架构概述 -采集引擎是后台常驻服务,负责执行实际的数据采集任务。 +采集引擎是嵌入 Web 服务进程的后台常驻组件,负责执行实际的数据采集任务。 + +运行模型采用**单进程嵌入式**:`cmd/server/main.go` 是唯一的生产可执行入口。服务启动时初始化采集引擎并启动其后台协程;服务关闭时先停止接收新请求,再停止采集引擎并释放设备连接。采集引擎不作为独立的 `cmd/collector` 进程运行,因此 API 服务可通过进程内依赖访问其运行时状态。 ``` ┌─────────────────────────────────────────────────────┐ @@ -883,7 +943,7 @@ CREATE INDEX idx_write_logs_time ON write_logs(created_at DESC); │ │ (Connection │ │ (Scheduler) │ │ │ │ Manager) │ └──────┬───────┘ │ │ └──────┬───────┘ │ │ -│ │ │ 每个采集点的独立协程/任务 │ +│ │ │ 有界 worker pool 中的采集任务 │ │ │ ┌───────▼────────┐ │ │ │ │ 采集点执行单元 │ │ │ │ │ (Point Runner) │ │ @@ -901,55 +961,44 @@ CREATE INDEX idx_write_logs_time ON write_logs(created_at DESC); ### 5.2 启动流程 ``` -1. 采集引擎启动 +1. Web 服务完成依赖初始化后启动采集引擎 2. 从数据库加载所有 enabled=true AND deleted=false 的设备 3. 从数据库加载所有 enabled=true AND deleted=false 的采集点,按 device_id 分组 4. 对每个设备,尝试建立连接: a. 连接成功 → 标记为"已连接",启动该设备下所有采集点的采集任务 b. 连接失败 → 标记为"断开",启动重连计时器 -5. 每个采集点按 collect_interval 周期性执行 +5. 调度器按 collect_interval 生成点位任务并投递到有界 worker pool;同一设备的协议操作串行执行 ``` ### 5.3 采集任务执行流程 -``` -每个采集点周期执行: -1. 检查设备连接状态: - - 已连接 → 执行采集 - - 断开中 → 标记质量戳为 bad,跳过本次采集 -2. 调用协议驱动读取数据: - - 成功 → 获取原始值 - - 失败 → 递增失败计数,标记质量戳为 bad -3. 数据解析: - - 根据 data_type 解析原始字节为对应类型值 - - 应用 byte_order/word_order(Modbus REAL类型) -4. 质量戳判定: - - 读取成功 + 值在合理范围内 → good - - 读取失败 → bad - - 读取成功但值为 null/异常 → bad -5. 更新内存缓存(latest_values): - - key: 采集点ID - - value: { value, quality, ts } -6. 写入TDengine(如果 store_history=true): - - 根据 history_interval 判断是否需要写入 - - 写入时使用质量戳标记 -7. 发送MQTT消息(如果启用了MQTT): - - 主题:plc/{device_id}/{point_id}/realtime - - 载荷:{ ts, value, quality } +```text +每个点位到期时: +1. 调度器生成任务并投递到有界 worker pool;队列满时记录告警,不无限创建 goroutine。 +2. 获取该设备的独立 ProtocolConnection,并进入设备级串行命令队列。 +3. 读取和解析: + - 成功且值在有效范围内(或未配置范围)→ value=实际值, quality=good; + - 成功但超出 valid_min/valid_max → value=实际值, quality=bad, quality_reason=out_of_range; + - 超时/断线/读取失败/解析失败 → value=null, quality=bad,并填写稳定 quality_reason。 +4. 更新内存 latest_values:{value, quality, quality_reason, ts}。 +5. store_history=true 且到达 history_interval 时写入 TDengine。 +6. 首次准备写入历史数据前设置 history_started_at;设置成功后即禁止修改 device_id/data_type。 +7. 如配置 MQTT,由独立发布队列异步发送,MQTT 失败不得阻塞采集任务。 ``` ### 5.4 质量戳判定规则 -| 条件 | 质量戳(API层) | 质量戳(存储层 INT) | -|------|----------------|---------------------| -| 协议读取成功,返回有效值 | `good` | `0` | -| 协议读取成功,但值为 null 或解析异常 | `bad` | `1` | -| 协议读取失败(超时/无响应/异常) | `bad` | `1` | -| 设备连接断开 | `bad` | `1` | -| 设备连接断开后,断线期间所有采集点都标记为 `bad` | `bad` | `1` | -| 查询时间范围内无对应数据记录 | `none` | 不存在(无记录) | +| 条件 | value | quality | quality_reason | TDengine quality | +|---|---:|---|---|---:| +| 读取、解析成功且在有效范围内 | 实际值 | `good` | null | 0 | +| 读取成功但超出有效范围 | 实际值 | `bad` | `out_of_range` | 1 | +| 协议超时 | null | `bad` | `timeout` | 1 | +| 设备断开 | null | `bad` | `disconnected` | 1 | +| 读取异常 | null | `bad` | `read_error` | 1 | +| 解析异常 | null | `bad` | `parse_error` | 1 | +| 查询目标时间无记录 | null | `none` | null | 不落库 | -> **质量戳转换约定**:TDengine 存储 INT 值(0=good, 1=bad),后端 API 层在返回响应时统一转换为字符串格式。前端始终处理字符串格式的质量戳。`none` 状态仅在查询无匹配记录时由 API 层返回,不会出现在 TDengine 存储中。 +API 始终返回字符串质量戳。`none` 只表示查询没有匹配记录;不得用 `bad` 代替缺失,也不得用 0 或上次值填充失败读取。 ### 5.5 断线重连机制 @@ -980,75 +1029,64 @@ CREATE INDEX idx_write_logs_time ON write_logs(created_at DESC); | 删除采集点 | 停止该点的采集任务 | | 启用/禁用采集点 | 禁用则停止,启用则启动 | -实现方式:采集引擎启动一个配置变更监听协程,定期(每5秒)或通过数据库通知监听配置变更。 +实现方式:Service 在配置事务提交后发布进程内变更事件,采集引擎正常情况下 1 秒内处理;同时每 5 秒按配置版本执行一次全量校对,作为丢事件后的兜底,因此对外保证 5 秒内最终生效。 ### 5.7 TDengine 数据写入 #### 5.7.1 超级表定义 ```sql --- 采集点数据超级表 CREATE STABLE IF NOT EXISTS collection_data ( - ts TIMESTAMP, -- 采集时间戳 - value DOUBLE, -- 采集值(所有类型统一转DOUBLE,BOOL转0/1) - quality INT, -- 质量戳:0=good, 1=bad - point_id VARCHAR(32), -- 采集点ID(加速查询冗余字段) - point_name VARCHAR(128) -- 采集点名称(冗余字段,方便查询) + ts TIMESTAMP, + value DOUBLE, -- 可空;BOOL 使用 0/1 + quality INT, -- 0=good, 1=bad + quality_reason VARCHAR(32), -- 可空稳定原因代码 + point_id VARCHAR(36), + point_name VARCHAR(128) ) TAGS ( - device_id VARCHAR(32), -- 设备ID - device_name VARCHAR(128), -- 设备名称 - data_type VARCHAR(16) -- 原始数据类型 + device_id VARCHAR(36), + device_name VARCHAR(128), + data_type VARCHAR(16) ); ``` +`point_id`、`device_id` 使用 36 位标准 UUID。`point_name`、`device_name` 和 `data_type` 是写入/建表时快照;历史 API 的当前显示名称、单位和生命周期状态以 PostgreSQL 元数据为准。 + #### 5.7.2 子表创建策略 -每个采集点对应一个子表,子表名使用采集点ID去掉连字符后的字符串: +每个采集点对应一个子表。子表名由已校验 UUID 的规范化小写形式派生:固定前缀 `p_` 加上去除连字符后的 32 位十六进制字符串,即 `p_`。不得接受或使用请求中直接传入的表名。 ```sql -- 假设采集点ID为: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d --- 子表名: a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d -CREATE TABLE IF NOT EXISTS a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d +-- 子表名: p_a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d +CREATE TABLE IF NOT EXISTS p_a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d USING collection_data TAGS ( - 'device-uuid', '一期曝气柜PLC', 'REAL' + 'd2e3f4a5-b6c7-4d8e-9f01-a2b3c4d5e6f7', '一期曝气柜PLC', 'REAL' ); ``` #### 5.7.3 写入策略 -- 写入时使用 `INSERT INTO ... USING ... TAGS` 语法,自动创建子表 -- 批量写入:每1秒或每100条数据攒一批写入,提高写入效率 -- 如果 store_history=false,不写入TDengine -- 写入频率由 history_interval 控制(例如 history_interval=5,则每5分钟写入一条) +- 首次准备历史写入前设置 `history_started_at`,然后使用 `INSERT INTO ... USING ... TAGS` 创建/写入子表 +- 批量写入:每1秒或每100条数据一批;服务关闭前尽力刷新 +- `store_history=false` 时停止新增历史记录,但已存在的数据仍可在历史模块中查询 +- 写入频率由 `history_interval` 控制 +- 动态子表名只由已验证 UUID 派生并通过 `^p_[0-9a-f]{32}$` 校验;时间和值参数仍使用参数绑定 ### 5.8 运行时状态查询接口 -采集引擎需要对外提供设备连接状态的查询能力,供 API Handler 层在组装设备列表/详情响应时获取。 +采集引擎实现只读 `DeviceRuntimeStatusProvider`,由设备 Service 注入使用;Handler 不直接访问引擎。 -``` -采集引擎内部维护: - deviceConnections map[string]ConnectionState // key: device_id - - type ConnectionState struct { - Status string // "connected" | "disconnected" - Since time.Time // 状态持续起始时间 - } - -对外暴露的查询方法(在采集引擎实例上): - func (m *CollectorManager) GetDeviceStatus(deviceID string) string - func (m *CollectorManager) GetDeviceStatusMap() map[string]string +```go +type DeviceRuntimeStatusProvider interface { + GetDeviceStatus(ctx context.Context, deviceID uuid.UUID) ConnectionStatus + GetDeviceStatuses(ctx context.Context, deviceIDs []uuid.UUID) map[uuid.UUID]ConnectionStatus +} ``` -状态查询规则: +状态枚举:`connected | disconnected | disabled`。设备 Service 根据持久化 `enabled/deleted` 与运行时状态统一计算响应:禁用或删除始终为 `disabled`;启用但引擎无状态时为 `disconnected`。 -| 条件 | 返回状态 | -|------|---------| -| 设备不在映射表中(引擎未初始化该设备) | `disconnected` | -| 设备在映射表中且连接成功 | `connected` | -| 设备在映射表中但连接已断开 | `disconnected` | -| 引擎未启动或不可用 | `disconnected` | - -> 采集引擎以单例模式运行在服务端进程中,API Handler 通过依赖注入获取引擎实例的引用,直接调用 `GetDeviceStatusMap()` 查询状态。 +采集引擎和最新值缓存均为进程内组件,通过公开接口注入相应 Service;不得由 Handler 获取单例或读取内部 map。 --- @@ -1056,20 +1094,16 @@ USING collection_data TAGS ( ### 6.1 架构概述 -写入引擎负责接收写入请求(来自人工Web操作或AI引擎自动调用),执行写入操作并返回结果。两种写入来源共用同一个执行通道,区别仅在于 source 字段和 operator 字段不同。 +写入引擎负责接收人工写入请求、执行写入操作并返回结果。当前阶段未接入身份认证或自动写入;服务端将每次请求记录为 `source=manual`、`operator=null`,表示发生了未认证的人工写入请求,而非已识别具体操作者。 ``` 写入请求 - │ - ├── source=manual(来自Web前端人工操作) - └── source=auto(来自AI引擎程序自动调用) │ ▼ ┌─────────────────────┐ │ 请求校验 │ │ - 写入点是否存在 │ │ - write_enabled=true │ -│ - source是否允许 │ │ - 值类型校验 │ └─────────┬───────────┘ │ @@ -1092,10 +1126,9 @@ USING collection_data TAGS ( ### 6.2 写入安全策略 1. **write_enabled 开关**:写入点必须显式开启 write_enabled=true 才能写入,防止误操作 -2. **写入来源校验**:写入点的 write_source 字段控制该点允许 manual/auto/both 哪种来源 -3. **回读验证**:每次写入后必须回读确认,值一致才算成功 -4. **操作审计**:所有写入操作记录到 write_logs,可追溯 -5. **权限控制**:写入操作需要用户登录权限(由Web后端统一管理) +2. **回读验证**:BOOL/INT 严格相等;REAL 满足 `abs(actual-target) <= readback_tolerance` 才算成功 +3. **操作记录**:所有写入操作记录到 write_logs,包含日志 ID、时间、点位、目标值、回读值、结果、失败原因和原因;当前阶段不记录具体操作者 +4. **访问边界**:当前阶段未实施身份认证,写入 API 仅应部署在受信任的内部网络;接入身份认证后再补充操作者归属与权限控制 --- @@ -1103,96 +1136,109 @@ USING collection_data TAGS ( ### 7.1 接口定义 -所有协议驱动必须实现以下接口: +协议注册表保存无状态工厂;每个设备创建独立连接实例。 ```go -// ProtocolDriver 协议驱动接口 -type ProtocolDriver interface { - // 协议类型标识,如 "S7", "MODBUS_TCP" +type DataType string + +const ( + DataTypeBool DataType = "BOOL" + DataTypeInt DataType = "INT" + DataTypeReal DataType = "REAL" +) + +type DeviceConnectionConfig struct { + DeviceID uuid.UUID + Host string + Port int + ConnectTimeout time.Duration + ProtocolConfig map[string]any +} + +type ProtocolDriverFactory interface { ProtocolType() string - - // 创建连接 - // config: 设备配置中的 protocol_config(JSONB解析后的map) - // host: 设备IP - // port: 设备端口 - // timeout: 连接超时(秒) - Connect(config map[string]interface{}, host string, port int, timeout int) error - - // 断开连接 - Disconnect() error - - // 读取数据 - // address: 采集点地址字符串(如 "DB2.10", "M4.0", "40001") - // dataType: 数据类型(BOOL/INT/REAL) - // 返回: 读取到的值(统一使用float64返回,BOOL返回0或1),错误 - Read(address string, dataType string) (float64, error) - - // 写入数据 - // address: 写入点地址字符串 - // dataType: 数据类型 - // value: 要写入的值(float64,BOOL类型时0=false,1=true) - // 返回: 错误 - Write(address string, dataType string, value float64) error - - // 校验地址格式是否合法 - ValidateAddress(address string, dataType string) error - - // 获取协议配置的JSON Schema(用于前端动态渲染配置表单) - ConfigSchema() map[string]interface{} + ValidateConfig(config map[string]any) error + ValidateAddress(address string, dataType DataType, writable bool) error + ConfigSchema() map[string]any + NewConnection(ctx context.Context, cfg DeviceConnectionConfig) (ProtocolConnection, error) +} + +type ProtocolConnection interface { + Read(ctx context.Context, address string, dataType DataType) (float64, error) + Write(ctx context.Context, address string, dataType DataType, value float64) error + Close() error } ``` +约束: + +- Factory 必须无连接状态、可并发调用。 +- 每个设备持有一个独立 `ProtocolConnection`。 +- Connection 默认不要求并发安全;ConnectionManager 必须按设备串行调度 Read/Write。 +- 所有调用接收 `context.Context`,超时和取消由上层控制。 +- `Close` 必须幂等。 + ### 7.2 注册机制 ```go -// 全局协议驱动注册表 -var protocolDrivers = make(map[string]ProtocolDriver) - -// 注册协议驱动 -func RegisterProtocol(driver ProtocolDriver) { - protocolDrivers[driver.ProtocolType()] = driver +type ProtocolRegistry struct { + mu sync.RWMutex + factories map[string]ProtocolDriverFactory } -// 获取协议驱动 -func GetProtocol(protocolType string) (ProtocolDriver, error) { - driver, ok := protocolDrivers[protocolType] - if !ok { - return nil, fmt.Errorf("不支持的协议类型: %s", protocolType) +func (r *ProtocolRegistry) Register(factory ProtocolDriverFactory) error { + protocolType := factory.ProtocolType() + r.mu.Lock() + defer r.mu.Unlock() + if _, exists := r.factories[protocolType]; exists { + return fmt.Errorf("协议已注册: %s", protocolType) } - return driver, nil + r.factories[protocolType] = factory + return nil +} + +func (r *ProtocolRegistry) Get(protocolType string) (ProtocolDriverFactory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + factory, ok := r.factories[protocolType] + if !ok { + return nil, ErrUnsupportedProtocol + } + return factory, nil } ``` -### 7.3 扩展新协议的步骤 +注册错误必须在应用启动阶段暴露并终止启动,不使用隐藏失败的包级可变全局变量。 -1. 创建新的协议驱动文件,实现 `ProtocolDriver` 接口 -2. 在 `init()` 函数中调用 `RegisterProtocol()` 注册 -3. 在 `protocol_config` 中定义该协议特有的配置参数 -4. 实现 `ConfigSchema()` 返回配置参数的JSON Schema,供前端动态渲染配置表单 +### 7.3 扩展新协议 -### 7.4 目前支持的协议驱动 +1. 实现 `ProtocolDriverFactory` 和设备级 `ProtocolConnection`; +2. 为配置、地址、读写、超时、取消和并发隔离编写测试; +3. 在应用装配阶段显式注册; +4. 前端通过 `ConfigSchema()` 渲染协议配置表单; +5. 更新协议列表和配置导入校验。 -#### 7.4.1 S7 协议驱动 +### 7.4 当前协议 + +#### 7.4.1 S7 | 属性 | 说明 | -|------|------| +|---|---| | ProtocolType | `S7` | | 默认端口 | 102 | -| 依赖库 | `github.com/robinson/gos7` 或等效实现 | -| 连接方式 | 基于ISO TCP(RFC 1006) | -| 地址解析 | 见 [3.1.2 S7协议地址格式](#312-各协议的地址格式) | -| ConfigSchema | `{ "rack": { "type": "integer", "default": 0 }, "slot": { "type": "integer", "default": 1 } }` | +| 连接粒度 | 每设备一个连接实例 | +| 地址解析 | 见 3.1.2 | +| 配置 | `rack`、`slot` | -#### 7.4.2 Modbus TCP 协议驱动 +#### 7.4.2 Modbus TCP | 属性 | 说明 | -|------|------| +|---|---| | ProtocolType | `MODBUS_TCP` | | 默认端口 | 502 | -| 依赖库 | `github.com/goburrow/modbus` 或等效实现 | -| 连接方式 | Modbus TCP直接连接 | -| 地址解析 | 见 [3.1.2 Modbus TCP协议地址格式](#312-各协议的地址格式) | -| ConfigSchema | `{ "unit_id": { "type": "integer", "default": 1, "min": 1, "max": 247 }, "byte_order": { "type": "string", "enum": ["ABCD", "BADC", "CDAB", "DCBA"], "default": "ABCD" }, "word_order": { "type": "string", "enum": ["AB", "BA"], "default": "AB" } }` | +| 连接粒度 | 每设备一个连接实例 | +| 地址解析 | 见 3.1.2 | +| 配置 | `unit_id`、`float32_order`,其中 `float32_order ∈ {ABCD,BADC,CDAB,DCBA}` | --- @@ -1235,12 +1281,10 @@ func GetProtocol(protocolType string) (ProtocolDriver, error) { ### A.4 子页面:数据写入 -- 写入点列表表格(名称 | 分组 | 所属设备 | 地址 | 数据类型 | 允许写入 | 写入来源 | 操作) -- 新增/编辑写入点弹窗(写入来源下拉选项:仅人工/仅程序自动/两者均可) -- 执行写入操作弹窗:区分人工写入和程序自动写入 - - 人工写入:输入值、选择操作人、原因,点击确认后执行并显示结果 - - 程序自动写入:显示调用方标识(如 AI 模型名称) -- 写入操作日志查看(可筛选 source=manual 或 source=auto) +- 写入点列表表格(名称 | 分组 | 所属设备 | 地址 | 数据类型 | 单位 | 回读容差 | 允许写入 | 操作) +- 新增/编辑写入点弹窗(配置是否允许写入) +- 执行写入操作弹窗:输入值和可选原因,点击确认后执行并显示结果 +- 写入操作日志查看(展示未认证人工写入的时间、点位、值、结果和原因) - 导入/导出CSV按钮 --- @@ -1255,15 +1299,17 @@ func GetProtocol(protocolType string) (ProtocolDriver, error) { | R004 | 逻辑删除级联 | 删除设备时,该设备下所有采集点和写入点也逻辑删除 | | R005 | 采集地址格式校验 | 根据设备协议类型校验地址格式,创建设备时即确定协议 | | R006 | 采集周期最小1秒 | 不可低于1秒 | -| R007 | 历史存储间隔最小1分钟 | 当store_history=true时有效 | -| R008 | 写入来源校验 | 写入点的 write_source 控制允许 manual/auto/both | -| R009 | 写入回读验证 | 每次写入后必须回读确认,不一致则重试1次 | +| R007 | 历史存储间隔范围 | 1~1440 分钟;当store_history=true时必填 | +| R008 | 人工写入记录 | 服务端固定记录 source=manual、operator=null;当前阶段不支持自动写入 | +| R009 | 写入回读验证 | BOOL/INT严格相等;REAL按readback_tolerance判断,不一致重试1次 | | R010 | 写入点地址类型限制 | 必须使用可写地址类型 | -| R011 | 质量戳自动判定 | 连接断开/读取失败/解析异常 → bad,正常 → good | +| R011 | 质量戳自动判定 | 失败时value=null且bad;越界时保留value并标记bad;无匹配记录为none | | R012 | 断线无限重连 | 持续按配置间隔重连,直到成功或设备被禁用 | -| R013 | 配置变更动态生效 | 修改设备/采集点配置后,采集引擎自动更新,无需重启 | +| R013 | 配置变更动态生效 | 事务后事件正常1秒内生效,5秒全量校对保证最终一致 | +| R014 | 历史语义不可变 | history_started_at非空后不可修改device_id或data_type | +| R015 | 协议连接隔离 | 注册Factory,每个设备独立Connection,同设备读写串行 | --- -> 本文档版本:v1.1 -> 最后更新:2026-07-09 +> 本文档版本:v1.2 +> 最后更新:2026-07-11 diff --git a/开发文档/三文档一致性与冲突审查报告.md b/开发文档/三文档一致性与冲突审查报告.md deleted file mode 100644 index 54d9a49..0000000 --- a/开发文档/三文档一致性与冲突审查报告.md +++ /dev/null @@ -1,981 +0,0 @@ -# 三文档一致性与冲突审查报告 - -## 1. 审查对象 - -- `spec-数据管理.md`:v1.1,最后更新 2026-07-09 -- `spec-历史数据.md`:v1.0,最后更新 2026-07-09 -- `code-standards.md`:v1.1,最后更新 2026-07-11 - -本报告按本地文件实际行号定位。严重级别定义: - -- **阻断**:按现有文档无法得到唯一、可运行或安全的实现,应在开发前解决。 -- **高**:会造成接口不兼容、数据错误、安全问题或大范围返工。 -- **中**:实现可以继续,但不同团队很可能产生不同解释。 -- **低**:主要影响命名、示例准确性或长期维护。 - -## 2. 总体结论 - -共发现 **38 项**需要协调的问题,其中: - -- 阻断:4 项 -- 高:12 项 -- 中:18 项 -- 低:4 项 - -最需要优先解决的主题是: - -1. 服务进程模型与运行时内存状态的访问方式不一致。 -2. 历史模块直接读取数据管理模块数据库,违反模块自治要求。 -3. TDengine 中 UUID 字段长度与 API/PG UUID 表达不兼容。 -4. 表格查询间隔规则自相矛盾,且缺少实现所需的 `history_interval`。 -5. 写入接口允许客户端自报操作者,和鉴权规范冲突。 -6. CSV、统一响应包、错误码、质量戳存在多套互不兼容的约定。 -7. 历史数据对禁用/删除点位不可访问,与“展示所有已存储历史数据”冲突。 - ---- - -## 3. 冲突明细 - -### A. 架构与模块边界 - -#### C-01|阻断|历史模块直接访问数据管理内部数据,违反模块自治 - -**位置** - -- `spec-历史数据.md` L30-L37:历史模块直接查询 PostgreSQL 设备、采集点配置。 -- `spec-历史数据.md` L133-L139、L221-L223:直接读取 `collection_points` 构建树。 -- `code-standards.md` L29-L33:模块间通过 API 通信,不直接访问其他模块内部数据。 -- `code-standards.md` L614-L620:代码审查要求检查跨模块 Repo 调用。 - -**冲突内容** - -历史规格把 `collection_points`、`devices` 当作历史模块可直接查询的数据源;代码规范则要求模块之间只能通过 API 通信。 - -**影响** - -- 历史模块会与数据管理表结构强耦合。 -- 数据管理字段或逻辑删除规则变化会直接破坏历史模块。 -- 无法明确历史 Service 应调用数据管理 API、共享领域服务,还是共享 Repository。 - -**建议** - -二选一并写入架构决策: - -1. **模块化单体方案**:允许 Service 通过共享只读领域接口访问配置数据,并删除“模块间只能通过 API”的绝对要求。 -2. **服务化方案**:历史模块通过数据管理内部 API/客户端获取点位元数据,禁止直接读表。 - ---- - -#### C-02|阻断|`server`/`collector` 分进程结构与“直接共享内存单例”冲突 - -**位置** - -- `code-standards.md` L71-L76:存在独立的 `cmd/server` 和 `cmd/collector` 可执行程序。 -- `spec-数据管理.md` L439-L443:最新值维护在采集引擎内存。 -- `spec-数据管理.md` L1024-L1051:采集引擎单例运行在服务端进程中,Handler 直接获得实例引用。 -- `spec-数据管理.md` L325-L329:设备 API 从采集引擎运行时状态组装 `connection_status`。 - -**冲突内容** - -目录规范表明 Web 服务和采集器是两个独立进程;数据管理规格却要求 API Handler 直接引用采集引擎内存对象。独立进程不能直接共享 Go 内存。 - -**影响** - -`latest_value`、`connection_status` 等接口无法按文档实现,除非实际部署结构与目录规范不同。 - -**建议** - -明确唯一运行模型: - -- 若采集器嵌入 Web 服务:删除独立 `cmd/collector`,或说明它仅用于离线/独立部署。 -- 若保持双进程:引入 Redis、MQTT、NATS、数据库状态表或内部 RPC;Web 服务不得直接引用 CollectorManager。 - ---- - -#### C-03|高|Handler 直接访问采集引擎,违反三层架构 - -**位置** - -- `spec-数据管理.md` L325-L329、L1024-L1051:API Handler 直接查询引擎实例并组装状态。 -- `code-standards.md` L165-L180:Handler → Service → Repository 单向调用。 -- `code-standards.md` L184-L203:Handler 仅做参数校验和响应转换。 - -**冲突内容** - -运行时状态查询属于业务聚合逻辑,应由 Service 完成;规格明确要求 Handler 直接访问 Engine。 - -**影响** - -Handler 与运行时实现耦合,单元测试和未来进程拆分困难。 - -**建议** - -定义 `RuntimeStatusProvider` 接口,由 Service 注入;Handler 只调用 Service。 - ---- - -#### C-04|高|历史“最新值”的数据源和语义未统一 - -**位置** - -- `spec-数据管理.md` L439-L454:`latest_value` 明确定义为采集引擎内存中的实时值。 -- `spec-历史数据.md` L141-L159、L221-L273:历史树也返回同名 `latest_value`。 -- `spec-历史数据.md` L32-L37:历史模块声明的数据源只有 PostgreSQL 和 TDengine。 - -**冲突内容** - -历史模块没有说明如何取得实时内存值。若从 TDengine 读取,它受 `history_interval` 影响,不再是数据管理接口定义的“实时最新值”;若从采集引擎读取,又违反当前模块边界和分进程结构。 - -**影响** - -同名字段在两个接口中可能代表不同时间新鲜度。 - -**建议** - -明确命名和来源: - -- 实时缓存值:`realtime_value`,由运行时状态服务提供。 -- TDengine 最新存储值:`latest_stored_value`,并返回 `stored_at`/延迟说明。 - ---- - -### B. 历史数据可见性与业务规则 - -#### C-05|高|“展示所有已存储历史数据”与禁用/删除过滤冲突 - -**位置** - -- `spec-历史数据.md` L23-L28:模块负责展示所有已存储历史时序数据。 -- `spec-历史数据.md` L99-L109:树仅显示点位和设备均启用、未删除的点。 -- `spec-数据管理.md` L142-L150:逻辑删除时数据保留。 -- `spec-数据管理.md` L243-L247:删除设备会逻辑删除其点位,但 TDengine 历史数据未删除。 - -**冲突内容** - -禁用或逻辑删除后,历史数据仍保留,但点位从树中消失,用户无法再查询这些已存储数据。 - -**影响** - -历史追溯、事故审计和删除前数据查看不可实现。 - -**建议** - -历史树应区分: - -- 活跃点位; -- 已禁用点位; -- 已删除/归档点位。 - -至少提供“包含归档点位”筛选,并禁止采集但允许只读历史查询。 - ---- - -#### C-06|中|树声明“可用历史数据点”,实际只检查配置,不检查是否存在历史数据 - -**位置** - -- `spec-历史数据.md` L133-L139:只显示包含“可用历史数据点”的分组。 -- `spec-历史数据.md` L99-L109:判定条件仅为 enabled/deleted/store_history。 -- `spec-数据管理.md` L1017-L1022:子表和数据只有实际写入时才产生。 - -**冲突内容** - -刚启用 `store_history`、尚未首次写入的点位也会进入树,但并不存在历史数据。 - -**影响** - -“可用”含义不一致,用户勾选后可能得到空结果。 - -**建议** - -将术语改为“已配置历史存储的点位”,或额外检查 TDengine 子表/首条数据是否存在,并返回 `has_history_data`。 - ---- - -#### C-07|中|树过滤规则与查询接口校验规则不一致 - -**位置** - -- `spec-历史数据.md` L99-L109:树过滤 enabled/deleted/store_history。 -- `spec-历史数据.md` L302-L311、L371-L383:查询仅根据 `point_ids` 映射子表,没有说明重复校验业务状态。 - -**冲突内容** - -客户端可绕过树,直接传入禁用、删除或 `store_history=false` 的旧点位 ID。 - -**影响** - -UI 和 API 的业务边界不一致;可能访问本应隐藏的数据。 - -**建议** - -明确查询权限策略:是否允许归档历史查询。Service 必须根据该策略重新校验点位,不信任前端树。 - ---- - -### C. 数据模型与数据格式 - -#### C-08|阻断|TDengine UUID 字段长度与 PostgreSQL/API UUID 不兼容 - -**位置** - -- `spec-数据管理.md` L58-L63、L337-L346:主键为 PostgreSQL `UUID`。 -- `spec-历史数据.md` L64-L74:`point_id`、`device_id` 为 `VARCHAR(32)`。 -- `spec-数据管理.md` L989-L1001:同样使用 `VARCHAR(32)`。 -- `spec-数据管理.md` L1006-L1014:仅“子表名”明确去除 UUID 连字符。 -- API 示例普遍使用带连字符的 UUID/UUID 语义。 - -**冲突内容** - -标准 UUID 字符串带连字符时长度为 36;`VARCHAR(32)` 只能保存去连字符格式。文档只规定子表名去连字符,没有规定普通字段和 TAG 也去连字符。 - -**影响** - -插入失败、截断、查询无法匹配 PostgreSQL ID,属于数据完整性阻断问题。 - -**建议** - -统一一种方案: - -- 推荐 TDengine `point_id`、`device_id` 改为 `VARCHAR(36)`,API/PG/TD 全部使用标准 UUID 字符串; -- 子表名使用固定安全前缀加 32 位无连字符形式,例如 `p_`。 - ---- - -#### C-09|高|可变名称/设备/数据类型与 TDengine 冗余字段、TAGS 更新规则缺失 - -**位置** - -- `spec-数据管理.md` L235-L241、L504-L506:允许修改设备和采集点配置。 -- `spec-数据管理.md` L991-L1001:TDengine 保存 `point_name`、`device_name`、`data_type`。 -- `spec-数据管理.md` L1004-L1014:子表按点位 ID 创建,TAGS 在创建时写入。 -- `spec-历史数据.md` L141-L159、L314-L344:历史接口返回名称和数据类型。 - -**冲突内容** - -点位改名、换设备、改数据类型后,PostgreSQL 当前配置与 TDengine 历史行/TAGS 可能不同;文档没有规定更新 TAG、保留历史名称还是使用当前名称。 - -**影响** - -同一条曲线可能显示错误设备名或数据类型;历史审计失去“当时配置”。 - -**建议** - -明确元数据版本策略: - -- 历史响应默认使用当前配置,但另存 `recorded_point_name`; -- 或配置变更生成新点位 ID/新子表; -- 若允许更新 TAG,写明 TDengine 更新流程及失败补偿。 - ---- - -#### C-10|高|表格要求显示“单位”,但数据模型和 API 没有单位字段 - -**位置** - -- `spec-历史数据.md` L665-L674:列标题为 `点位名称(单位)`。 -- `spec-数据管理.md` L337-L363:`collection_points` 无 `unit`。 -- `spec-历史数据.md` L141-L159、L314-L439:树、曲线和表格响应均无 `unit`。 - -**冲突内容** - -前端要求无法由任何已定义数据源实现。 - -**影响** - -不同前端可能硬编码单位、忽略单位或自行推断,产生错误展示。 - -**建议** - -在 `collection_points` 增加 `unit VARCHAR(...)`,并在树、曲线、表格 API 元数据中返回;或删除单位要求。 - ---- - -#### C-11|中|树节点属性定义与实际 API 响应不一致 - -**位置** - -- `spec-历史数据.md` L141-L159:点位节点要求包含 `group_name`。 -- `spec-历史数据.md` L225-L273:`GET /history/tree` 的点位对象没有 `group_name`。 - -**冲突内容** - -同一文档对同一对象给出两种结构。 - -**影响** - -前后端类型定义不一致。 - -**建议** - -补齐 `group_name`,或从节点属性要求中删除并说明可由父节点推导。 - ---- - -#### C-12|中|质量缺失状态 `none` 与历史 API 的 `null` 表达冲突 - -**位置** - -- `spec-数据管理.md` L943-L952:无记录时 API 质量为 `none`,前端始终处理字符串质量戳。 -- `code-standards.md` L466-L477:重复同一约定。 -- `spec-历史数据.md` L378-L383、L394-L439:未匹配数据直接返回数组元素 `null`。 -- `spec-历史数据.md` L467-L471:CSV 中用 `—`。 - -**冲突内容** - -缺失数据到底是: - -- `{ "value": null, "quality": "none" }`; -- 整个元素为 `null`; -- 或完全没有点; - -文档没有统一。 - -**影响** - -前端类型、图表 gap 处理、CSV 转换会出现分支差异。 - -**建议** - -统一为显式对象,例如: - -```json -{ "value": null, "quality": "none", "matched_ts": null } -``` - -曲线原始序列可继续用“无记录即无点”,但表格对齐结果应保持固定对象结构。 - ---- - -#### C-13|中|坏质量数据是否必须有数值未定义 - -**位置** - -- `spec-数据管理.md` L917-L935、L943-L949:断线、读取失败、null、解析异常均为 bad。 -- `spec-历史数据.md` L327-L331:bad 示例仍有数值。 -- `spec-历史数据.md` L590-L597:连续 bad 数据要求画虚线。 - -**冲突内容** - -读取失败或 null 时通常没有可绘制数值;历史曲线规则却假设 bad 点有数值并可连接。 - -**影响** - -实现者可能使用上次值、0、null 或丢点,曲线结果完全不同。 - -**建议** - -定义 bad 子类型或值策略: - -- `bad_with_value`:有可疑数值,可虚线绘制; -- `bad_no_value`:值为 null,形成断线; -- 禁止用 0 或上次值隐式补齐,除非响应显式标注。 - ---- - -### D. 历史查询接口与算法 - -#### C-14|阻断|`interval_minutes` 约束自相矛盾 - -**位置** - -- `spec-历史数据.md` L362-L367:范围 1~1440,“必须能被60整除”,同时建议 1、2、5、10、15、20、30、60。 -- `spec-历史数据.md` L658-L663:前端选项仅上述 8 个值。 - -**冲突内容** - -中文“必须能被 60 整除”通常表示 `interval % 60 == 0`,则 1、2、5、10 等均不满足。文档实际可能想表达“必须是 60 的约数”。 - -**影响** - -后端校验会产生两种完全不同的实现。 - -**建议** - -直接定义枚举,避免自然语言: - -```text -interval_minutes ∈ {1, 2, 5, 10, 15, 20, 30, 60} -``` - -若确需支持到 1440,应给出完整规则和前端选项。 - ---- - -#### C-15|高|前端需要 `history_interval`,树接口却不返回 - -**位置** - -- `spec-历史数据.md` L369:前端应根据已选点位最大的 `history_interval` 限制或提示。 -- `spec-历史数据.md` L141-L159、L225-L273:树节点不含 `history_interval`。 -- `spec-数据管理.md` L355-L357:该字段只存在于采集点配置。 - -**冲突内容** - -历史页面没有已定义接口可获得每个勾选点位的 `history_interval`。 - -**影响** - -前端只能忽略要求,或逐点调用数据管理详情接口,造成跨模块依赖和 N+1 请求。 - -**建议** - -在历史树节点中增加 `history_interval`,或增加批量元数据接口。 - ---- - -#### C-16|高|`history_interval` 无上限,与历史表格可选范围不兼容 - -**位置** - -- `spec-数据管理.md` L355-L357、L491-L499:仅规定最小值 1 分钟,没有上限。 -- `spec-历史数据.md` L367:表格 API 最大 1440 分钟。 -- `spec-历史数据.md` L662:前端最大选项仅 60 分钟。 -- `spec-历史数据.md` L369:建议表格间隔不小于最大 `history_interval`。 - -**冲突内容** - -若点位 `history_interval=120`,前端没有可选值满足建议;若大于 1440,连 API 都无法满足。 - -**影响** - -合法的数据管理配置会产生无法正确展示的历史点位。 - -**建议** - -统一约束。推荐: - -- `history_interval` 枚举与历史表格可选间隔共享; -- 或前端动态生成可选值并允许到 1440; -- 数据管理 API 增加最大值校验。 - ---- - -#### C-17|高|代码规范要求最大 31 天,历史接口未定义该限制 - -**位置** - -- `code-standards.md` L571-L579:时间范围最大跨度不超过 31 天。 -- `spec-历史数据.md` L284-L300、L349-L367:只要求起止时间,未规定顺序和最大跨度。 -- `spec-历史数据.md` L498-L508:自定义时间范围未规定上限。 - -**冲突内容** - -规格可被理解为允许任意时间跨度,代码规范要求拒绝超过 31 天。 - -**影响** - -前后端校验不一致;大查询可能导致内存和网络压力。 - -**建议** - -在三个历史接口中明确: - -- `end_time > start_time`; -- 最大 31 天; -- 超限错误码; -- 导出是否允许更长范围以及异步策略。 - ---- - -#### C-18|高|动态子表 SQL 与“禁止字符串拼接 SQL”冲突 - -**位置** - -- `spec-历史数据.md` L302-L311:`FROM {subtable_name}` 动态替换表名。 -- `code-standards.md` L222-L226、L562-L569:必须参数化,禁止字符串拼接 SQL。 - -**冲突内容** - -表名通常不能作为普通参数绑定;历史规格又要求动态表名。 - -**影响** - -开发者可能直接拼接用户可影响的标识,形成 SQL 注入风险,或违反代码审查规则。 - -**建议** - -为动态标识符增加明确例外和安全实现: - -1. 只接受数据库查询得到的 UUID; -2. 服务端派生固定格式 `p_`; -3. 使用严格正则白名单; -4. 值条件仍使用参数绑定; -5. 禁止直接使用请求中的表名。 - ---- - -#### C-19|中|最近邻窗口与原始查询边界不一致 - -**位置** - -- `spec-历史数据.md` L375:只查询 `start_time ~ end_time`。 -- `spec-历史数据.md` L378-L382:每个目标点在 `± interval/2` 窗口内寻找最近值。 - -**冲突内容** - -第一个目标点可能需要 `start_time` 之前的数据,最后一个目标点可能需要 `end_time` 之后的数据;当前查询范围把这些候选值排除。 - -**影响** - -边界行会出现不必要的 `null`,与“最近邻窗口”定义不符。 - -**建议** - -原始查询范围扩展为: - -```text -[start_time - window, end_time + window] -``` - -最终输出仍只保留目标时间序列。 - ---- - -#### C-20|中|最近邻算法缺少并列、复用和非整除终点规则 - -**位置** - -- `spec-历史数据.md` L373-L383。 - -**缺失内容** - -- 前后两个点距离相同,选前还是选后; -- 同一原始点能否匹配两个目标时间; -- `end_time-start_time` 不是 interval 整数倍时是否追加 `end_time`; -- bad 与 good 距离相同时是否优先 good。 - -**影响** - -不同后端实现会返回不同表格和 CSV。 - -**建议** - -补充确定性规则,并建立算法测试样例。 - ---- - -#### C-21|高|曲线原始查询缺少降采样/结果上限,难以满足性能规范 - -**位置** - -- `spec-历史数据.md` L298-L311:最多 20 点,但返回范围内全部原始数据。 -- `spec-数据管理.md` L355:采集周期最小 1 秒。 -- `code-standards.md` L631-L638:要求审查分页、索引和连接池等性能问题。 -- `code-standards.md` L577:最大跨度仍可达 31 天。 - -**问题** - -单个 1 秒点位 31 天约 267 万条;20 点可能超过 5300 万条。API 没有 `max_points`、降采样或服务端聚合。 - -**影响** - -内存、TDengine 查询、JSON 序列化、浏览器 ECharts 都可能失控。 - -**建议** - -根据像素宽度/最大点数服务端降采样,或要求客户端传 `max_samples`;原始明细导出采用流式响应。 - ---- - -#### C-22|中|Y 轴“后端转换”方案与 API 响应结构不匹配 - -**位置** - -- `spec-历史数据.md` L553-L577:推荐后端把原始值转换为显示坐标。 -- `spec-历史数据.md` L314-L344:曲线 API 只返回原始 `value`,没有 `display_value` 或映射元数据。 - -**冲突内容** - -推荐方案没有数据合同支持,前端无法同时绘制映射值和显示原始 tooltip。 - -**建议** - -确定唯一职责: - -- 推荐纯前端映射;或 -- API 返回 `display_value`、原始 `value` 和分段映射定义。 - ---- - -### E. REST API、错误和安全 - -#### C-23|高|所有 API 统一 JSON 包装与 CSV 文件响应冲突 - -**位置** - -- `code-standards.md` L360-L376:“所有 API 响应”统一为 `{code,message,data}`。 -- `spec-数据管理.md` L249-L273、L591-L605:导出直接返回 CSV。 -- `spec-历史数据.md` L442-L471:导出直接返回 CSV。 - -**冲突内容** - -文件下载不可能同时是原始 CSV 和 JSON 包装。 - -**建议** - -在代码规范中增加明确例外:文件流、SSE、WebSocket 不使用统一 JSON 包装;错误响应仍返回统一 JSON。 - ---- - -#### C-24|高|写入失败/超时仍返回 `code=0`、`message=success` - -**位置** - -- `spec-数据管理.md` L805-L836:`result=failed/timeout`,但 envelope 仍为成功。 -- `code-standards.md` L372-L376、L393-L403:`code=0` 表示成功,非 0 表示业务错误。 - -**冲突内容** - -同一响应同时表示“成功”和“写入失败”。 - -**影响** - -通用请求层可能把失败当作成功;告警和重试逻辑不可靠。 - -**建议** - -区分两类情况: - -- 请求成功且设备写入成功:`code=0`。 -- 请求已处理但设备写入失败:使用明确业务错误码,HTTP 409/422/502/504 按原因选择,同时 `data` 可附审计记录 ID。 - ---- - -#### C-25|高|写入请求允许客户端指定 operator,与鉴权规范冲突 - -**位置** - -- `spec-数据管理.md` L736-L767:客户端提交 `source`、`operator`。 -- `spec-数据管理.md` L1092-L1098:写入需要登录权限。 -- `code-standards.md` L581-L586:人工写入需登录,自动写入需 API Token。 - -**冲突内容** - -已鉴权身份应由服务端从登录会话或 Token 推导;允许请求体任意填写 `operator` 会导致身份伪造。`source` 同样不应完全信任客户端声明。 - -**影响** - -写入审计日志不可可信,存在严重审计与安全风险。 - -**建议** - -- 从认证上下文生成 `operator` 和调用方类型; -- 请求体删除 `operator`,必要时仅保留 `operator_display_name` 作为非权威备注; -- manual/auto 使用不同凭证或不同内部路由。 - ---- - -#### C-26|中|写入日志错误字段命名不一致 - -**位置** - -- `spec-数据管理.md` L719-L723:日志列表返回 `error_message`。 -- `spec-数据管理.md` L805-L836:写入结果返回 `error`。 -- `spec-数据管理.md` L851-L855:数据库字段为 `error_message`。 -- `code-standards.md` L29-L30:同一概念跨层命名一致。 - -**影响** - -前端需要两套字段,Go DTO 和模型映射易混乱。 - -**建议** - -统一为 `error_message`,或明确 `error` 是标准错误对象而不是字符串。 - ---- - -#### C-27|中|写入值在不同 API 中类型不一致 - -**位置** - -- `spec-数据管理.md` L719-L720:日志列表的 `target_value`、`readback_value` 是字符串。 -- `spec-数据管理.md` L790-L800:写入响应 `value`、`readback_value` 是数字。 -- `spec-数据管理.md` L851-L853:数据库统一存 TEXT。 - -**影响** - -同一业务值在接口间不能复用类型,BOOL/INT/REAL 转换规则不清楚。 - -**建议** - -日志 API 返回: - -```json -{ - "data_type": "REAL", - "target_value": 20.5, - "readback_value": 20.5, - "raw_target_value": "20.5" -} -``` - -或统一使用可辨别联合类型。 - ---- - -#### C-28|中|日志查询筛选值遗漏 `timeout` - -**位置** - -- `spec-数据管理.md` L693-L695:`result` 仅说明 success/failed。 -- `spec-数据管理.md` L822-L835:存在 timeout。 -- `spec-数据管理.md` L854:数据库枚举包含 timeout。 - -**建议** - -筛选枚举统一为 `success | failed | timeout`。 - ---- - -#### C-29|高|错误码分类和模块分段无法同时成立 - -**位置** - -- `code-standards.md` L395-L403:400xx=参数、401xx=鉴权、403xx=权限、404xx=未找到、409xx=冲突。 -- `code-standards.md` L405-L414:采集点=41001~41999,写入点=42001~42999,历史=43001~43999。 - -**冲突内容** - -例如历史数据参数错误按类型应是 400xx,但按模块必须是 430xx;历史数据未找到按类型应是 404xx,但又必须是 430xx。 - -**影响** - -无法设计一致的错误码,前端不能按前缀分类。 - -**建议** - -采用一种二维编码方式,例如: - -- HTTP 状态表达错误类别; -- 业务码按模块分段; -- 或业务码格式 `模块两位 + 类别两位 + 序号`,并给出算法。 - ---- - -#### C-30|中|代码规范示例直接返回 `err.Error()`,与安全审查项冲突 - -**位置** - -- `code-standards.md` L184-L199:绑定失败时把 `err.Error()` 返回前端。 -- `code-standards.md` L640-L647:禁止直接向前端泄露错误信息。 - -**影响** - -开发者会复制示例,可能暴露内部字段、解析器或库错误。 - -**建议** - -对外仅返回稳定校验消息;原始错误写结构化日志。 - ---- - -### F. CSV 与文件规范 - -#### C-31|高|历史 CSV 表头不符合统一 `snake_case` - -**位置** - -- `spec-历史数据.md` L457-L470:表头为中文“时间”、动态点位名和“_质量”。 -- `code-standards.md` L504-L515:CSV 标题字段名统一使用 `snake_case`。 -- `spec-数据管理.md` L263-L267、L595-L600:配置 CSV 使用 snake_case。 - -**冲突内容** - -历史展示型 CSV 和配置导入型 CSV 实际需求不同,但代码规范没有区分。 - -**建议** - -在规范中分为: - -1. **机器可导入配置 CSV**:固定 snake_case。 -2. **用户展示/报表 CSV**:允许本地化和动态列名,但需定义转义和重复点名处理。 - ---- - -#### C-32|高|历史 CSV 文件名使用用户输入,违反路径安全规范 - -**位置** - -- `spec-历史数据.md` L676-L682:文件名包含 `{start_time}`、`{end_time}`、`{interval}`。 -- `code-standards.md` L571-L579:导出文件名不得包含用户输入。 - -**影响** - -除路径穿越外,ISO 时间中的冒号、加号在部分系统不适合作为文件名。 - -**建议** - -服务端解析并重新格式化为安全值,例如: - -```text -history_20260709T000000_20260709T010000_10m.csv -``` - -只使用数字、ASCII 字母、下划线和短横线。 - ---- - -### G. 前端交互和接口契约 - -#### C-33|中|模式切换“不重复请求”与“自动重新查询”冲突 - -**位置** - -- `spec-历史数据.md` L715-L720:切换模式“不重复请求数据,按需加载”。 -- `spec-历史数据.md` L740-L741:切换模式自动重新查询。 - -**冲突内容** - -无法判断是每次切换都请求、首次进入某模式请求一次,还是使用缓存。 - -**建议** - -明确缓存键:`mode + point_ids + start_time + end_time + interval`。只有缓存不存在或参数变化时请求。 - ---- - -#### C-34|中|20 点上限同时被描述为建议和强制 - -**位置** - -- `spec-历史数据.md` L164-L171:建议最多 20。 -- `spec-历史数据.md` L296-L300、L362-L367、L748-L753:API/附录要求最多 20。 - -**建议** - -统一为强制规则;前端在第 21 个点时阻止并提示,后端仍校验。 - ---- - -#### C-35|中|设备 PUT 声称与新增同结构,但新增结构没有 `enabled` - -**位置** - -- `spec-数据管理.md` L182-L198:新增请求体无 `enabled`。 -- `spec-数据管理.md` L235-L241:PUT 与新增相同,同时说明可修改 `enabled`。 - -**影响** - -无法按请求合同执行启用/禁用。 - -**建议** - -使用独立 `UpdateDeviceRequest`,明确字段可选性;或增加专用动作接口 `/devices/{id}/enable`、`/disable`。 - ---- - -#### C-36|中|“立即生效”与 5 秒轮询实现不一致 - -**位置** - -- `spec-数据管理.md` L243-L247、L504-L511、L970-L981:删除/变更后立即停止或动态更新。 -- `spec-数据管理.md` L983:可每 5 秒轮询数据库。 - -**冲突内容** - -轮询方案最多延迟约 5 秒,不是“立即”。 - -**建议** - -定义 SLA,例如 5 秒内生效;若必须立即,使用事务后事件、LISTEN/NOTIFY 或消息总线。 - ---- - -### H. 命名、代码风格和内部标准 - -#### C-37|中|索引命名不符合 `idx_{表名}_{字段名}` - -**位置** - -- `code-standards.md` L432-L442:索引命名要求包含实际字段名。 -- `spec-数据管理.md` L368-L372:`idx_collection_points_device/group`,实际字段为 `device_id/group_name`。 -- `spec-数据管理.md` L643-L645:`idx_write_points_device/group`。 -- `spec-数据管理.md` L863-L866:`idx_write_logs_point/device/time`,实际字段为 `point_id/device_id/created_at`。 - -**建议** - -改为: - -- `idx_collection_points_device_id` -- `idx_collection_points_group_name` -- `idx_write_logs_created_at` -- 其他同理 - -或放宽规范,明确允许语义化简称。 - ---- - -#### C-38|低|核心类型命名 `CollectPoint` 与 `CollectionPoint` 不一致 - -**位置** - -- `spec-数据管理.md` L39-L46:概念图使用 `CollectPoint`。 -- `code-standards.md` L664-L669:Go 模型使用 `CollectionPoint`。 -- API/table/package 也使用 `collection`。 - -**建议** - -统一为 `CollectionPoint`,避免出现第三种命名。 - ---- - -## 4. 其他需要补充但尚不足以判定为直接冲突的事项 - -以下问题属于规格缺失或高风险歧义,建议一并处理: - -1. `spec-数据管理.md` L926-L929 使用“值在合理范围内”判定 good,但数据模型没有合理范围、量程或校验表达式字段。 -2. Modbus `byte_order` 与 `word_order` 的职责存在重叠;`CDAB` 已涉及字交换,同时又提供 `word_order=BA`,组合语义需要重新定义。 -3. 历史查询同一原始点是否可匹配多个表格时间点未规定。 -4. 历史树、分组、点位的排序规则未规定。 -5. 最新值不存在、采集器未运行或缓存过期时,`latest_value` 是 `null`、省略还是返回 `quality=none` 未规定。 -6. 导出大文件是否流式传输、是否设置 Content-Disposition、超时和最大行数未规定。 -7. `history_retention_days` 只有参数说明,没有系统配置存储模型、API、权限和变更失败补偿。 -8. `CREATE/ALTER DATABASE aquacontrolai` 使用固定数据库名,而代码规范要求连接参数环境化;需明确数据库名是否也是环境配置。 -9. 数据管理导出请求示例含 `// 可选` 注释,不是合法 JSON。 -10. 代码规范日志示例用 INFO 记录“连接成功”,但日志埋点表把“设备连接/断开”统一定为 WARN;应拆分成功 INFO、断开/失败 WARN。 - ---- - -## 5. 建议的协调优先级 - -### 第一批:开发前必须定稿 - -1. C-02 服务进程模型和运行时状态传输。 -2. C-01 模块边界和历史元数据访问方式。 -3. C-08 UUID 存储格式。 -4. C-14/C-15/C-16 表格间隔与 `history_interval` 合同。 -5. C-25 写入身份来源与鉴权。 -6. C-24/C-29 错误语义和错误码体系。 -7. C-05 归档历史数据可见性。 - -### 第二批:接口冻结前完成 - -1. C-10 单位字段。 -2. C-12 质量缺失表示。 -3. C-17/C-21 时间范围、降采样、结果上限。 -4. C-23 文件响应例外。 -5. C-31/C-32 CSV 两类规范和安全文件名。 -6. C-09 TDengine 元数据变更策略。 - -### 第三批:编码规范和文档清理 - -1. C-33 至 C-38。 -2. 补齐排序、空值、超时、流式导出和配置变更 SLA。 -3. 为关键算法增加契约测试样例。 - ---- - -## 6. 推荐的统一决策摘要 - -建议最终统一为以下基线: - -- Web API 与 Collector 保持双进程,通过内部状态服务或消息/缓存同步状态。 -- 历史模块通过只读领域接口取得点位元数据,不直接依赖数据管理 Repo。 -- UUID 在 API、PG、TDengine 字段中统一使用 36 字符标准形式;仅表名使用 `p_`。 -- 历史树返回 `history_interval`、`unit`、`archived`、`has_history_data`。 -- 表格间隔采用明确枚举;缺失值统一 `{value:null, quality:"none"}`。 -- 历史查询最大 31 天并强制降采样/最大点数。 -- 写入操作者从认证上下文生成,失败使用非零业务码。 -- JSON API 使用统一 envelope;CSV 文件流为明确例外。 -- 配置 CSV 使用固定 snake_case;历史报表 CSV 允许本地化动态表头。