commit 12bd8c5ac86a4b547bd9cfbcce8f0be7624874fb Author: qsc Date: Sat Jul 11 08:19:19 2026 +0800 11 diff --git a/开发文档/code-standards.md b/开发文档/code-standards.md new file mode 100644 index 0000000..dcccde7 --- /dev/null +++ b/开发文档/code-standards.md @@ -0,0 +1,948 @@ +# 污水厂智能控制系统 — 代码规范 + +> 本文档适用于本平台所有模块(数据管理、历史数据、实时监控、智能控制、系统配置等)的代码开发工作。 +> 作为代码审查的依据标准,所有合入主分支的代码必须通过本规范的检查。 + +--- + +## 目录 + +- [1. 通用原则](#1-通用原则) +- [1.4 优先复用,禁止重复造轮子](#14-优先复用禁止重复造轮子) +- [2. 项目结构与工程规范](#2-项目结构与工程规范) +- [3. Go 后端规范](#3-go-后端规范) +- [4. 前端规范](#4-前端规范) +- [5. RESTful API 规范](#5-restful-api-规范) +- [6. 数据库规范](#6-数据库规范) +- [6.4 连接配置、初始化与重建](#64-连接配置初始化与重建) +- [7. 日志与错误处理规范](#7-日志与错误处理规范) +- [8. 安全规范](#8-安全规范) +- [9. 代码审核清单](#9-代码审核清单) +- [附录 A:模块间关系与命名约定](#附录-a模块间关系与命名约定) + +--- + +## 1. 通用原则 + +### 1.1 核心原则 + +| 原则 | 说明 | +|------|------| +| **可读性优先** | 代码是写给人类看的,其次才是给机器执行。优先清晰直白,避免晦涩技巧 | +| **一致性** | 同一概念在代码、数据库、API、前端中保持命名一致(如 `device_id` 在所有层统一) | +| **最小惊讶** | 函数的名称和签名应让调用者无需查看实现就能大致猜到行为 | +| **防御式编程** | 不信任外部输入,对所有外部输入做校验,但内部调用尽量减少冗余校验 | +| **模块自治** | 每个模块有清晰的边界,模块间通过 API 通信,不直接访问其他模块的内部数据 | +| **优先复用** | 先复用已有模块、成熟依赖和平台能力;仅在现有方案确实无法满足需求时才自行实现 | + +### 1.2 命名风格对照表 + +| 上下文 | 风格 | 示例 | +|--------|------|------| +| Go 源码 | `camelCase` / `CamelCase` | `deviceService`, `CreateDevice()` | +| 数据库(PG/TDengine) | `snake_case` | `device_id`, `protocol_type` | +| RESTful API(URL) | `kebab-case` | `/api/v1/collection-points` | +| RESTful API(JSON Body) | `snake_case` | `"device_id": "uuid"` | +| 前端 JS/TS 源码 | `camelCase` | `fetchDeviceList()` | +| 前端 CSS 类名 | `kebab-case` | `.device-table-container` | +| 前端组件/目录名 | `kebab-case` | `collection-points/` | +| 环境变量 | `UPPER_SNAKE_CASE` | `DB_CONNECTION_STRING` | +| Git 分支名 | `kebab-case` | `feat/aeration-model` | + +### 1.3 注释规范 + +- **Go 注释**:遵循 `// PackageName` 包注释和 `// FunctionName 功能描述` 的 Go 标准注释风格 +- **不要注释显而易见的事**:`// 递增 i` 是废话注释 +- **TODO 注释**:必须附带责任人,如 `// TODO(张三): 后续需要处理断线重连的边界情况` +- **FIXME 注释**:必须附带 issue 编号,如 `// FIXME(#123): 此处有并发安全问题` +- **代码删除**:不保留被注释掉的旧代码,直接删除,有需要从 git 历史查找 + +### 1.4 优先复用,禁止重复造轮子 + +- **先查后写**:新增功能前,必须先检查项目现有代码、已引入依赖、组件库和已封装的公共能力;优先复用 `internal/pkg/`、`web/src/components/`、`web/src/utils/` 与同类模块的实现。 +- **优先级**:项目既有能力 → 官方 SDK / 框架内置能力 → 维护活跃、许可证兼容的成熟开源库 → 自研实现。不得因“实现简单”绕过这一顺序。 +- **禁止重复实现**:禁止自行实现已有成熟方案已覆盖的通用能力,例如鉴权、参数校验、密码哈希、UUID、数据库迁移、HTTP 客户端重试、日期处理、图表、虚拟列表、CSV 编解码和协议编解码。 +- **允许自研的条件**:现有方案无法满足性能、可靠性、许可、安全、离线部署或工业协议兼容性要求时,方可自研;提交中必须说明已调研方案、未采用原因、维护边界和测试方案。 +- **封装边界**:对第三方库的调用应集中在适配层或公共封装中,业务代码不得散落依赖某个供应商的私有 API;不得为薄封装无理由再造通用组件。 +- **审查要求**:PR 必须说明“复用了什么”或“为何不能复用”;无法说明的重复实现不得合入。 + +--- + +## 2. 项目结构与工程规范 + +### 2.1 整体目录结构 + +``` +water-plant-control/ +├── cmd/ # 可执行程序入口 +│ ├── server/ # Web 服务端入口 +│ │ └── main.go +│ └── collector/ # 采集引擎入口 +│ └── main.go +├── internal/ # 私有应用代码(不对外暴露) +│ ├── api/ # HTTP handler 层 +│ │ ├── router.go # 路由注册 +│ │ ├── middleware/ # 中间件(鉴权、日志、CORS、恢复等) +│ │ ├── device/ # 设备管理 API +│ │ ├── collection/ # 采集点管理 API +│ │ ├── writepoint/ # 写入点管理 API +│ │ ├── history/ # 历史数据 API +│ │ └── system/ # 系统配置 API +│ ├── service/ # 业务逻辑层 +│ │ ├── device/ +│ │ ├── collection/ +│ │ ├── writepoint/ +│ │ ├── history/ +│ │ └── collector/ # 采集引擎业务逻辑 +│ ├── repository/ # 数据访问层 +│ │ ├── postgres/ # PostgreSQL 访问 +│ │ └── tdengine/ # TDengine 访问 +│ ├── model/ # 数据模型定义 +│ │ ├── device.go +│ │ ├── collection_point.go +│ │ ├── write_point.go +│ │ └── history.go +│ ├── engine/ # 运行时引擎 +│ │ ├── collector/ # 采集引擎 +│ │ │ ├── manager.go # 设备连接管理器 +│ │ │ ├── scheduler.go # 采集任务调度器 +│ │ │ └── runner.go # 采集点执行单元 +│ │ └── writer/ # 写入引擎 +│ │ └── writer.go +│ ├── protocol/ # 协议插件化 +│ │ ├── driver.go # ProtocolDriver 接口定义 +│ │ ├── registry.go # 注册表 +│ │ ├── s7/ # S7 协议实现 +│ │ └── modbus/ # Modbus TCP 协议实现 +│ └── pkg/ # 内部共享工具包 +│ ├── config/ # 配置管理 +│ ├── logger/ # 日志工具 +│ ├── response/ # HTTP 响应统一封装 +│ ├── validator/ # 自定义校验器 +│ └── csvutil/ # CSV 导入导出工具 +├── web/ # 前端代码 +│ ├── src/ +│ │ ├── api/ # API 调用层 +│ │ ├── components/ # 公共组件 +│ │ ├── views/ # 页面级组件 +│ │ │ ├── device/ # 设备管理 +│ │ │ ├── collection/ # 数据采集 +│ │ │ ├── write-point/ # 数据写入 +│ │ │ └── history/ # 历史数据 +│ │ ├── router/ # 前端路由 +│ │ ├── store/ # 状态管理 +│ │ └── utils/ # 工具函数 +│ └── ... +├── deploy/ # 部署相关 +├── docs/ # 文档 +└── Makefile # 构建脚本 +``` + +### 2.2 新增模块的目录规范 + +当新增模块(如未来开发"实时监控""智能控制""系统配置")时,遵循以下规范: + +``` +# 后端新增模块,在以下目录各增加一个子包 +internal/ +├── api/monitoring/ # 实时监控 API +├── service/monitoring/ # 实时监控业务逻辑 +├── repository/... # 数据访问 +└── model/ # 数据模型 + +# 前端新增模块,在以下目录增加 +web/src/views/ +└── monitoring/ # 实时监控页面 +``` + +#### 模块级命名约束 + +| 模块 | 后端 Go package | 前端目录 | API 路径前缀 | +|------|----------------|----------|-------------| +| 数据管理-设备 | `device` | `device/` | `/api/v1/devices` | +| 数据管理-采集点 | `collection` | `collection/` | `/api/v1/collection-points` | +| 数据管理-写入点 | `writepoint` | `write-point/` | `/api/v1/write-points` | +| 历史数据 | `history` | `history/` | `/api/v1/history` | + +> 上表中未列出的模块(实时监控、智能控制、系统配置)在开发时补充至此表。 + +### 2.3 依赖管理 + +- **Go**:使用 `go.mod` 管理依赖,定期执行 `go mod tidy` +- **前端**:使用 `package.json` + `pnpm-lock.yaml`(优先 pnpm,其次 npm) +- **禁止**:直接拷贝第三方库源码到项目中(除非 fork 修改,且需在 README 中注明) +- **新增依赖准入**:新增依赖前必须检索现有依赖和代码,确认没有等价能力;选择有明确维护状态、兼容许可证、稳定版本与安全更新渠道的库。 +- **版本锁定**:提交依赖变更时必须同时提交锁文件;禁止使用不受控的浮动版本或从未固定提交的 Git 分支安装依赖。 +- **最小化引入**:不得为单个小功能引入体积过大或功能重叠的依赖;优先按需导入,并删除不再使用的依赖。 + +--- + +## 3. Go 后端规范 + +### 3.1 代码风格 + +- 严格遵循 `gofmt` / `goimports` 格式,不允许任何格式化例外 +- 使用 `go vet` 和 `golangci-lint` 作为 CI 前置检查 +- 每行代码不超过 120 字符 + +### 3.2 分层架构规范 + +项目采用经典的三层架构(API Handler → Service → Repository),层间调用规则: + +``` +Handler (参数校验、请求/响应转换) + │ + ▼ +Service (业务逻辑、事务管理) + │ + ▼ +Repository (数据访问、ORM 查询) + +禁止: +✗ Handler 直接调用 Repository +✗ Service 层处理 HTTP 请求/响应 +✗ 循环依赖(A → B → A) +``` + +#### 3.2.1 Handler 层规范 + +```go +// ✅ 正确示例 +func (h *DeviceHandler) Create(c *gin.Context) { + var req CreateDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "无效的请求参数", err.Error()) + return + } + // Handler 只做参数校验和响应转换 + device, err := h.svc.Create(c.Request.Context(), &req) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, device) +} + +// ✗ 禁止:Handler 中写业务逻辑 +func (h *DeviceHandler) Create(c *gin.Context) { + // ... 校验参数 + // ... 自己查数据库判断是否重名 ✗ 应该调用 Service + // ... 自己拼 SQL 插入 ✗ 应该调用 Service + // ... 自己写日志 ✗ 应该由 Service 层处理 +} +``` + +#### 3.2.2 Service 层规范 + +```go +// ✅ 正确示例 +func (s *DeviceService) Create(ctx context.Context, req *CreateDeviceRequest) (*Device, error) { + // 1. 参数校验(复杂校验逻辑放在 Service 层) + if err := s.validateDevice(req); err != nil { + return nil, err + } + + // 2. 业务逻辑(如检查 name 唯一性) + existing, err := s.repo.FindByName(ctx, req.Name) + if err != nil { + return nil, fmt.Errorf("查询设备名称失败: %w", err) + } + if existing != nil { + return nil, ErrDeviceNameConflict + } + + // 3. 转换为模型 + device := &model.Device{ + Name: req.Name, + ProtocolType: req.ProtocolType, + Host: req.Host, + Port: req.Port, + // ... + } + + // 4. 持久化 + if err := s.repo.Create(ctx, device); err != nil { + return nil, fmt.Errorf("创建设备失败: %w", err) + } + + return device, nil +} +``` + +#### 3.2.3 Repository 层规范 + +```go +// ✅ 正确示例 +func (r *DeviceRepo) FindByProtocolType(ctx context.Context, protocolType string) ([]*model.Device, error) { + query := `SELECT id, name, protocol_type, host, port, enabled, + connect_timeout, reconnect_interval, protocol_config, + created_at, updated_at + FROM devices + WHERE protocol_type = $1 AND deleted = FALSE + ORDER BY name` + + rows, err := r.db.QueryContext(ctx, query, protocolType) + if err != nil { + return nil, fmt.Errorf("查询设备列表失败: %w", err) + } + defer rows.Close() + + var devices []*model.Device + for rows.Next() { + var d model.Device + if err := rows.Scan(&d.ID, &d.Name, &d.ProtocolType, &d.Host, &d.Port, + &d.Enabled, &d.ConnectTimeout, &d.ReconnectInterval, &d.ProtocolConfig, + &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, fmt.Errorf("扫描设备记录失败: %w", err) + } + devices = append(devices, &d) + } + return devices, rows.Err() +} + +// ✅ 建议:对于简单 CRUD 操作,统一封装 BaseRepo +// ✅ 建议:复杂查询使用 QueryBuilder,避免字符串拼接 +``` + +### 3.3 错误处理 + +```go +// 使用自定义错误类型,而非 magic string +var ( + ErrDeviceNotFound = errors.New("设备不存在") + ErrDeviceNameConflict = errors.New("设备名称已存在") + ErrProtocolNotSupport = errors.New("不支持的协议类型") +) + +// 错误包装:始终携带上下文 +if err != nil { + return nil, fmt.Errorf("创建采集点失败: %w", err) +} + +// 禁止: +// ✗ return errors.New("设备不存在") // 没有上下文 +// ✗ return nil, fmt.Errorf("err: %v", err) // "err: " 无意义前缀 +``` + +### 3.4 并发安全 + +- 采集引擎(运行时)涉及设备连接池、共享内存状态,必须使用 `sync.Mutex` 或 `sync.RWMutex` 保护 +- 避免裸 `sync.Map`,优先使用 `map + sync.RWMutex` +- 协程启动必须可控:使用 `context.Context` + `sync.WaitGroup` 管理生命周期 +- **禁止无限制启动 goroutine**,必须通过带有缓冲 channel 或协程池限制并发数 + +```go +// ✅ 正确示例:采集引擎任务管理 +type CollectorManager struct { + mu sync.RWMutex + connPool map[string]*Connection // 设备连接池 + runners map[string]*PointRunner // 采集点执行器 + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} +``` + +### 3.5 单元测试 + +| 层级 | 测试策略 | 覆盖率目标 | +|------|---------|-----------| +| Service | 使用 mock repository 进行纯逻辑测试 | ≥ 80% | +| Handler | 使用 httptest 进行 API 测试 | ≥ 60% | +| Repository | 使用 testcontainers 或内嵌数据库 | ≥ 50% | +| Engine | 使用 mock protocol driver 测试采集逻辑 | ≥ 70% | + +- 测试文件与源码同目录,命名 `*_test.go` +- Mock 统一使用 `github.com/stretchr/testify/mock` 或 `go.uber.org/mock` +- 测试数据使用 t.Helper() + t.Cleanup() 管理 + +--- + +## 4. 前端规范 + +### 4.1 框架与技术选型(约定) + +| 领域 | 选型 | +|------|------| +| UI 框架 | Vue 3 + Composition API | +| 构建工具 | Vite | +| 状态管理 | Pinia | +| 路由 | Vue Router | +| HTTP 请求 | Axios | +| 图表 | ECharts | +| CSS | Tailwind CSS(或 Less/Sass scoped) | +| 代码规范 | ESLint + Prettier | + +### 4.2 组件设计规范 + +```vue + + + + + + +``` + +### 4.3 API 调用层 + +```typescript +// src/api/device.ts +import request from '@/utils/request' + +// ✅ 每个模块一个 API 文件,所有请求集中在 api/ 目录 +export interface DeviceListParams { + page?: number + pageSize?: number + keyword?: string + protocolType?: string + enabled?: boolean +} + +export function fetchDeviceList(params: DeviceListParams) { + const { pageSize, ...rest } = params + return request.get('/api/v1/devices', { + params: { ...rest, page_size: pageSize }, + }) +} + +export function createDevice(data: Record) { + return request.post('/api/v1/devices', data) +} + +// ✗ 禁止:在组件中直接调用 axios +// ✗ 禁止:URL 路径写在组件内部 +``` + +### 4.4 ECharts 使用规范 + +- 图表组件统一封装在 `src/components/charts/` 目录下 +- ECharts 的 option 构造使用纯函数,方便测试 +- 图表容器使用 `ResizeObserver` 自动适应窗口变化 +- **曲线模式分段自适应 Y 轴**:实现为可复用的工具函数 `src/utils/axis-mapper.ts` + +```typescript +// ✅ 工具函数封装示例 +// src/utils/axis-mapper.ts +interface Segment { + start: number + end: number + heightRatio: number // 占Y轴高度的比例 +} + +export function buildSegments(dataValues: number[]): Segment[] { + // 根据数据分布自动分段 + // ... 实现分段自适应Y轴的映射逻辑 +} + +export function mapValueToPosition(value: number, segments: Segment[]): number { + // 将原始值映射为显示坐标 + // ... +} +``` + +### 4.5 状态与复选框勾选 + +- **勾选状态**:使用 Pinia store 统一管理,支持曲线/表格模式切换时保持状态 + +```typescript +// ✅ 正确示例 +// src/store/selected-points.ts +export const useSelectedPointsStore = defineStore('selected-points', () => { + const selectedIds = ref>(new Set()) + + // 最大勾选数 20 + const MAX_SELECTION = 20 + + function toggle(id: string) { + if (selectedIds.value.has(id)) { + selectedIds.value.delete(id) + } else if (selectedIds.value.size < MAX_SELECTION) { + selectedIds.value.add(id) + } + // 超过上限不做操作 + } + + return { selectedIds, toggle } +}) +``` + +--- + +## 5. RESTful API 规范 + +### 5.1 URL 设计 + +``` +格式:/api/v1/{资源名}[/{资源ID}][/{子资源}][/{动作}] + +约定: +- 资源名使用 kebab-case 复数形式(devices, collection-points, write-points) +- 子资源(如导出/导入)使用 POST 动词 + 路径 +- 批量操作使用 POST,非 GET(避免 URL 过长) +- 版本号 v1 写在路径中 +``` + +### 5.2 响应格式 + +所有 API 响应使用统一格式: + +```json +// ✅ 成功响应 +{ + "code": 0, + "message": "success", + "data": { ... } +} + +// ✅ 错误响应 +{ + "code": 40001, + "message": "设备名称已存在", + "data": null +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `code` | 是 | 0 表示成功,非 0 表示业务错误码 | +| `message` | 是 | 人类可读的描述信息 | +| `data` | 否 | 响应数据,可为 null | + +### 5.3 分页响应格式 + +```json +{ + "code": 0, + "message": "success", + "data": { + "total": 200, + "page": 1, + "page_size": 20, + "items": [ ... ] + } +} +``` + +### 5.4 错误码规范 + +| 错误码范围 | 类别 | 说明 | +|-----------|------|------| +| 0 | 成功 | 请求正常处理 | +| 400xx | 参数错误 | 请求参数校验失败 | +| 401xx | 鉴权错误 | 未登录或 token 过期 | +| 403xx | 权限错误 | 无操作权限 | +| 404xx | 未找到 | 请求的资源不存在 | +| 409xx | 冲突 | 唯一性冲突等 | +| 500xx | 服务端错误 | 内部错误 | + +各模块错误码分配: + +| 模块 | 范围 | +|------|------| +| 设备管理 | 40001~40999 | +| 采集点管理 | 41001~41999 | +| 写入点管理 | 42001~42999 | +| 历史数据 | 43001~43999 | +| 采集引擎 | 50001~50999 | +| 写入引擎 | 51001~51999 | + +### 5.5 路径参数命名规范 + +``` +✅ /api/v1/devices/{id} // 路径参数使用 {param} 风格 +✅ /api/v1/collection-points/groups // 固定子路径 +✅ /api/v1/write-points/{id}/write // 动作子路径 + +查询参数命名(与数据库字段一致): + ?page=1&page_size=20&keyword=XXX&enabled=true +``` + +--- + +## 6. 数据库规范 + +### 6.1 PostgreSQL 规范 + +| 规则 | 说明 | +|------|------| +| 表名 | 全部小写 `snake_case`,复数形式:`devices`, `collection_points`, `write_points` | +| 字段名 | 全部小写 `snake_case` | +| 主键 | 统一使用 `UUID` 类型,默认 `gen_random_uuid()` | +| 时间字段 | `created_at`, `updated_at` 使用 `TIMESTAMP WITH TIME ZONE` | +| 逻辑删除 | 统一使用 `deleted BOOLEAN NOT NULL DEFAULT FALSE` | +| 索引命名 | `idx_{表名}_{字段名}`:如 `idx_devices_name` | +| 唯一索引 | 逻辑删除的表使用 `WHERE deleted = FALSE` 的条件唯一索引 | +| 外键 | 显式声明 `REFERENCES`,但不启用级联(业务层处理级联逻辑) | +| 迁移 | 使用 golang-migrate 或类似工具管理,禁止手动修改库结构 | +| 更新时间 | 通过迁移创建触发器或由仓储层统一维护 `updated_at`,不得依赖调用方遗漏更新 | + +- 每次结构变更必须新增可重复执行的迁移文件,并在空库和包含历史数据的库上验证。 +- 破坏性变更采用“先兼容、再迁移、后删除”的至少两个发布周期策略;确需一次性变更时,必须附带回滚与数据备份方案。 + +```sql +-- ✅ 正确示例 +CREATE TABLE devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(128) NOT NULL, + protocol_type VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + host VARCHAR(256) NOT NULL, + port INTEGER NOT NULL, + connect_timeout INTEGER NOT NULL DEFAULT 5, + reconnect_interval INTEGER NOT NULL DEFAULT 10, + protocol_config JSONB NOT NULL DEFAULT '{}', + 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_devices_name ON devices(name) WHERE deleted = FALSE; +``` + +### 6.2 TDengine 规范 + +| 规则 | 说明 | +|------|------| +| 表名 | 超级表用 `snake_case`:`collection_data`, `computed_data` | +| 子表名 | 使用采集点 ID 去连字符后的 32 位小写字符串 | +| 时间戳 | 统一使用 `TIMESTAMP`,精度毫秒 | +| 质量戳 | `INT` 类型:`0=good`, `1=bad` | +| 值 | 统一使用 `DOUBLE`,BOOL 类型存 0/1 | +| 标签 | 元数据信息存为 TAGS,不做查询条件的有选择缓存 | +| KEEP 参数 | 通过系统配置动态调整,通过 `ALTER DATABASE` 执行 | + +### 6.3 SQL 书写规范 + +```sql +-- ✅ 关键字大写,字段名/表名小写 +SELECT id, name, protocol_type +FROM devices +WHERE enabled = TRUE AND deleted = FALSE +ORDER BY name +LIMIT 20 OFFSET 0; + +-- ✗ 禁止:SELECT *,必须显式指定字段列表 + +-- ✅ INSERT 语句 +INSERT INTO devices (name, protocol_type, host, port, protocol_config) +VALUES ($1, $2, $3, $4, $5) +RETURNING id; +``` + +### 6.4 连接配置、初始化与重建 + +数据库连接必须通过环境变量或密钥管理服务注入,应用仅读取配置并在启动时执行连通性检查。数据库名称不是默认值,必须由部署环境显式指定;严禁在代码、示例配置或本文档中写入真实密码。 + +```dotenv +# .env.example:可提交,仅包含非敏感连接端点与占位符 +POSTGRES_HOST=101.35.54.96 +POSTGRES_PORT=5432 +POSTGRES_USER=qsc20001102 +POSTGRES_PASSWORD=<通过密钥管理服务或本地未提交的 .env 注入> +POSTGRES_DB=<部署时指定的数据库名> +POSTGRES_SSLMODE=<部署时指定;生产环境使用 verify-full> + +TDENGINE_HOST=101.35.54.96 +TDENGINE_PORT=6041 +TDENGINE_USER=root +TDENGINE_PASSWORD=<通过密钥管理服务或本地未提交的 .env 注入> +TDENGINE_DB=<部署时指定的数据库名> +``` + +- **PostgreSQL DSN**:按 `postgres://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}?sslmode={POSTGRES_SSLMODE}` 组装。Go 后端使用连接池,并在应用启动时通过带超时的 `PingContext` 校验连接;不得记录完整 DSN 或密码。 +- **TDengine DSN**:6041 为 taosAdapter 服务端口。Go 后端优先使用 `github.com/taosdata/driver-go/v3` 的 WebSocket 驱动,DSN 格式为 `{TDENGINE_USER}:{TDENGINE_PASSWORD}@ws({TDENGINE_HOST}:{TDENGINE_PORT})/{TDENGINE_DB}`,驱动名为 `taosWS`。必须在 DSN 中指定数据库名,不依赖连接后的 `USE` 语句。 +- **网络与权限**:对公网地址必须配置最小网络访问范围;生产环境 PostgreSQL 必须启用证书校验,两个数据库账号均应按环境和最小权限拆分,禁止使用共享管理员账号作为长期应用账号。 +- **配置文件**:真实密码只允许位于部署平台密钥、CI Secret 或开发人员本地未提交的 `.env` 文件;`.env.example` 保留占位符,`.env` 必须在 `.gitignore` 中。 + +#### 6.4.1 开发/测试环境数据库重建 + +开发或测试环境允许清空并重建 PostgreSQL 与 TDengine 数据库,以确保初始结构一致;此操作会永久删除目标库中的全部数据。 + +- 仅允许由人工显式执行的初始化/重建脚本触发,**禁止**在应用启动、普通迁移或自动部署中隐式执行 `DROP DATABASE`。 +- 脚本必须要求显式确认标记(例如 `RESET_DATABASE=CONFIRM_DROP_AND_RECREATE`),并在执行前打印目标主机、端口、数据库名和环境;任一项缺失则立即失败。 +- PostgreSQL 重建顺序:连接维护库 → 断开目标库现有连接 → 删除目标库 → 创建目标库及所需扩展 → 从零执行全部迁移。 +- TDengine 重建顺序:删除目标库 → 创建目标库及保留策略 → 从零执行全部时序表/超级表初始化脚本。 +- 重建脚本必须只操作 `POSTGRES_DB` 和 `TDENGINE_DB` 指定的目标库,禁止对系统库、未指定库或生产环境执行;生产环境重建须另行书面审批、备份和恢复演练。 +- 每次重建完成后必须执行迁移状态检查和最小连通性/读写冒烟测试,并记录执行人、时间、目标环境和结果。 + +--- + +## 7. 日志与错误处理规范 + +### 7.1 日志级别 + +| 级别 | 使用场景 | 示例 | +|------|---------|------| +| DEBUG | 调试信息,仅开发环境 | `DEBUG 采集点曝气池DO_01 采集完成,值=2.35` | +| INFO | 正常运行信息 | `INFO 设备一期曝气柜PLC 连接成功` | +| WARN | 可恢复的异常 | `WARN 设备一期曝气柜PLC 重连第3次失败,将在10秒后重试` | +| ERROR | 需要关注的错误 | `ERROR 写入点加药泵频率 写入失败: 回读值不匹配` | + +### 7.2 日志规范 + +```go +// ✅ 正确示例 +logger.Info("设备连接成功", + "device_id", device.ID, + "device_name", device.Name, + "protocol", device.ProtocolType, +) + +logger.Error("写入操作失败", + "point_id", pointID, + "target_value", targetValue, + "error", err.Error(), +) + +// ✗ 禁止:没有上下文的日志 +// ✗ logger.Info("连接成功") +// ✗ logger.Error("报错了: " + err.Error()) +// ✗ fmt.Println() 或 fmt.Errorf() 替代日志库 +``` + +### 7.3 关键业务日志埋点 + +以下业务操作必须记录日志(作为审计追溯依据): + +| 操作 | 日志级别 | 说明 | +|------|---------|------| +| 设备创建/修改/删除 | INFO | 记录操作人和变更内容 | +| 采集点创建/修改/删除 | INFO | 同上 | +| 写入点创建/修改/删除 | INFO | 同上 | +| 写入操作 | INFO | 记录写入值、来源(manual/auto)、操作人、结果 | +| 采集引擎启停 | INFO | 记录启动/停止原因 | +| 设备连接/断开 | WARN | 记录连接耗时或断开原因 | +| 重连失败 | WARN | 记录失败次数 | +| 协议驱动异常 | ERROR | 记录异常堆栈 | + +--- + +## 8. 安全规范 + +### 8.1 SQL 注入防护 + +```go +// ✅ 正确:使用参数化查询,并显式声明返回字段 +db.QueryContext(ctx, "SELECT id, name FROM devices WHERE name = $1", name) + +// ✗ 禁止:字符串拼接 SQL +// db.QueryContext(ctx, "SELECT * FROM devices WHERE name = '" + name + "'") +``` + +### 8.2 输入校验 + +| 场景 | 校验规则 | +|------|---------| +| IP 地址 | 使用 `net.ParseIP()` 或正则校验合法格式 | +| 端口号 | 范围 1~65535 | +| 时间范围 | 结束时间必须晚于开始时间,最大跨度不超过 31 天(防止 TDengine 扫大量数据) | +| 分页 | page >= 1, page_size <= 100 | +| 文件名 | 导出的 CSV 文件名不含用户输入(防止路径穿越) | +| 布尔值 | 在 Go 中由 JSON 解析器自动校验,前端限制 true/false | + +### 8.3 写入安全 + +- **写入开关校验**:必须在 Service 层校验 `write_enabled=true`,Handler 层只做类型校验 +- **写入来源校验**:校验 `write_source` 与请求 `source` 的匹配关系(manual/auto/both) +- **回读验证**:写入后必须回读确认,实现位置在 Engine 层,Service 层调用 +- **写入权限**:人工写入(`source=manual`)需要登录鉴权;程序自动写入(`source=auto`)需要 API Token + +### 8.4 配置安全 + +- 数据库密码、API Key 等敏感信息通过环境变量或 vault 注入,**禁止硬编码在代码中** +- `.env` 文件加入 `.gitignore`,仅提供 `.env.example` 模板 +- `protocol_config` 中不存储凭据类信息 +- 本规范、README、Issue、日志、截图和错误响应中均不得记录真实密码、完整 DSN 或 Token;如发生泄露,立即轮换凭据并按安全事件处理 + +--- + +## 9. 代码审核清单 + +### 9.1 提交流程前置检查(开发者在提审前自行检查) + +``` +□ 代码通过 gofmt / goimports / eslint / prettier 格式化 +□ 本地 go vet 和 ESLint 检查通过 +□ 单元测试通过,且覆盖率满足要求 +□ 无被注释掉的旧代码 +□ 无 console.log / fmt.Println 等调试输出 +□ 无硬编码的敏感信息(密码、token、key) +□ 新增能力已复用既有实现或成熟依赖;如无法复用,已说明原因并完成评审 +□ 数据库变更已提供迁移,且未在应用启动流程中执行破坏性重建 +□ 新增 API 已添加至 API 文档 +□ 所有 TODO/FIXME 已确认或附带了责任人 +``` + +### 9.2 代码审查 Checklist + +#### 架构层面 + +| 检查项 | 说明 | +|--------|------| +| 模块边界是否清晰 | 是否有跨模块的内部依赖(如 Handler 直接调用其他模块的 Repo) | +| 分层是否遵守 | Handler → Service → Repository 单向依赖 | +| 是否引入循环依赖 | Package A 依赖 Package B,Package B 不应再依赖 Package A | +| 扩展点是否留好 | 协议驱动是否遵循 ProtocolDriver 接口而非直接调用具体实现 | +| 新增模块的目录是否符第 2 节规范 | 按照约定的目录结构添加 | +| 是否重复造轮子 | 是否已经检索并复用项目既有能力、官方 SDK 或成熟依赖;自研是否有充分理由 | + +#### 功能层面 + +| 检查项 | 说明 | +|--------|------| +| 业务规则是否覆盖 | 对照 spec 中的业务规则表(R001~R013, H001~H010)逐条确认 | +| 边界条件是否处理 | 空列表、分页边界、超长输入、字段缺失、逻辑删除记录等 | +| 并发安全 | 共享状态的读写是否有锁保护 | +| 事务管理 | 跨表操作的 Service 方法是否使用了数据库事务 | +| CSV 导入导出 | 编码是否为 UTF-8 with BOM,布尔值和 JSON 字段格式是否正确 | +| 写入流程完整性 | 是否执行了写入→回读验证→记录日志的完整流程 | + +#### 性能层面 + +| 检查项 | 说明 | +|--------|------| +| N+1 查询 | 列表查询时是否 batch 加载关联数据,而非循环查询 | +| 索引 | 新增查询条件是否添加了对应的数据库索引 | +| 分页 | 列表接口是否都有分页,且 page_size 有上限约束 | +| 连接池 | 数据库和 PLC 连接是否使用了连接池而非每次新建 | +| 迁移安全 | 结构变更是否可追踪、可验证;破坏性变更是否具备备份、回滚和发布兼容方案 | +| 采集周期最小限制 | collect_interval 是否校验 >= 1 秒 | + +#### 安全层面 + +| 检查项 | 说明 | +|--------|------| +| SQL 注入 | 全库搜索字符串拼接的 SQL 语句 | +| 参数校验 | 所有用户输入是否都通过了校验 | +| 写入权限校验 | `write_enabled` 和 `write_source` 是否在 Service 层校验 | +| 敏感信息泄露 | 错误信息是否直接返回给前端(如数据库密码、SQL 错误) | +| 路径穿越 | 文件操作的路径是否未使用用户输入构造 | + +#### 前端层面 + +| 检查项 | 说明 | +|--------|------| +| API 路径 | 路径是否统一在 `src/api/` 中管理 | +| 组件拆分 | 是否合理拆分为可复用组件,而非单文件过长 | +| 状态管理 | 跨组件共享状态是否使用 Pinia | +| 响应式 | 图表容器是否自适应窗口变化 | +| 错误处理 | API 调用是否有统一的错误处理(如失败提示、加载状态) | +| 勾选上限 | 历史数据点位勾选是否有 20 个上限 | + +--- + +## 附录 A:模块间关系与命名约定 + +### A.1 模块间依赖关系 + +``` +数据管理模块(基础层) +├── 提供:设备配置、采集点配置、写入点配置 +├── 提供:采集数据写入 TDengine +├── 依赖:PostgreSQL(配置)、TDengine(时序数据) +│ +├── 历史数据模块(消费层)依赖数据管理模块 +│ ├── 读取:采集点配置、设备配置 +│ ├── 读取:TDengine 历史数据 +│ └── 提供:历史数据查询、曲线/表格展示 +│ +├── 实时监控模块(待开发)依赖数据管理模块 +│ ├── 读取:采集点配置、设备配置 +│ ├── 读取:采集引擎内存中的最新值 +│ └── 订阅:MQTT 实时数据通道 +│ +├── 智能控制模块(待开发) +│ ├── 依赖:数据管理模块(设备信息、写入点) +│ ├── 依赖:历史数据模块(AI 训练数据来源) +│ └── 操作:通过写入点对 PLC 下发控制指令 +│ +└── 系统配置模块(待开发) + ├── 负责:全局参数管理,如历史保留天数、采集引擎参数 + └── 被所有模块引用 +``` + +### A.2 Go 模型命名与数据库字段映射 + +| 数据库表(snake_case) | Go 结构体(PascalCase) | 说明 | +|----------------------|-----------------------|------| +| `devices` | `Device` | 设备 | +| `collection_points` | `CollectionPoint` | 采集点 | +| `write_points` | `WritePoint` | 写入点 | +| `write_logs` | `WriteLog` | 写入日志 | + +Go 结构体字段映射规则: + +```go +type Device struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + ProtocolType string `json:"protocol_type" db:"protocol_type"` + Enabled bool `json:"enabled" db:"enabled"` + Deleted bool `json:"-" db:"deleted"` // 逻辑删除不返回前端 + Host string `json:"host" db:"host"` + Port int `json:"port" db:"port"` + ConnectTimeout int `json:"connect_timeout" db:"connect_timeout"` + ReconnectInterval int `json:"reconnect_interval" db:"reconnect_interval"` + ProtocolConfig json.RawMessage `json:"protocol_config" db:"protocol_config"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CreatedBy *string `json:"created_by,omitempty" db:"created_by"` + UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"` +} +``` + +规则: +- `json:"-"` 标记的字段不返回前端(如 `deleted`) +- `omitempty` 用于可空字段 +- 请求体对应的结构体以 `Request` 结尾(如 `CreateDeviceRequest`) +- 响应体结构体在 Handler 层组装,不直接暴露模型 + +### A.3 前端目录与路由命名 + +| 页面 | 路由路径 | 目录 | 说明 | +|------|---------|------|------| +| 设备管理 | `/data/device` | `views/device/` | 数据管理子模块 | +| 数据采集 | `/data/collection` | `views/collection/` | 数据管理子模块 | +| 数据写入 | `/data/write-point` | `views/write-point/` | 数据管理子模块 | +| 历史数据 | `/history` | `views/history/` | 独立模块 | +| 实时监控 | `/monitoring` | `views/monitoring/` | 待开发 | +| 智能控制 | `/control` | `views/control/` | 待开发 | +| 系统配置 | `/settings` | `views/settings/` | 待开发 | + +--- + +> 本文档版本:v1.0 +> 最后更新:2026-07-10 +> 适用范围:污水厂智能控制系统全部后端(Go)、前端(Vue 3)代码 +> +> **使用说明**: +> 1. 开发者开发新功能前阅读本文档,确保代码风格一致 +> 2. 提交 MR/PR 前依据 [第 9 章 代码审核清单](#9-代码审核清单) 逐条自查 +> 3. Code Review 时以本文档作为审核标准,不符合规范的要求修改后重新提审 +> 4. 本文档随项目推进持续更新,新增模块时补充对应章节 diff --git a/开发文档/spec-历史数据.md b/开发文档/spec-历史数据.md new file mode 100644 index 0000000..a330670 --- /dev/null +++ b/开发文档/spec-历史数据.md @@ -0,0 +1,762 @@ +# 历史数据模块 — 开发规格说明 + +> 本文档属于《污水厂智能控制平台》的一部分,详细描述历史数据模块的功能、数据模型、API接口和业务逻辑,细度可达直接开发级别。 +> 本模块与 [数据管理模块](./spec-数据管理.md) 紧密关联,依赖其中的设备、采集点、TDengine 超级表等设计。 + +--- + +## 目录 + +- [1. 概述](#1-概述) +- [2. 数据来源](#2-数据来源) +- [3. 历史点位树形结构](#3-历史点位树形结构) +- [4. 数据保留策略](#4-数据保留策略) +- [5. RESTful API](#5-restful-api) +- [6. 曲线模式(前端)](#6-曲线模式前端) +- [7. 表格模式(前端)](#7-表格模式前端) +- [8. 前端页面结构](#8-前端页面结构) + +--- + +## 1. 概述 + +### 1.1 模块定位 + +历史数据模块负责展示平台中所有已存储的历史时序数据,提供两种查看方式: + +- **曲线模式**:多条数据在同一时间轴上的变化趋势对比,支持游标查看 +- **表格模式**:按固定时间间隔展示数据,支持导出CSV + +### 1.2 模块边界 + +| 交互对象 | 方向 | 内容 | +|----------|------|------| +| Web前端 | 返回 | 历史点位树、历史数据查询结果、CSV文件 | +| TDengine | 查询 | 读取 collection_data 超级表下的子表数据 | +| PostgreSQL | 查询 | 读取设备、采集点配置,构建树形结构 | +| 数据管理模块 | 依赖 | 采集点配置、设备配置、分组信息 | + +### 1.3 页面层级 + +历史数据与数据管理在同一层级,位于左侧导航栏: + +``` +┌──────────┐ +│ ▽ 数据管理 │ +│ 设备管理 │ +│ 数据采集 │ +│ 数据写入 │ +│ │ +│ ○ 历史数据 │ ← 同层级,无子菜单 +│ │ +│ ○ 实时监控 │ +│ ○ 智能控制 │ +│ ○ 系统配置 │ +└──────────┘ +``` + +--- + +## 2. 数据来源 + +### 2.1 当前数据来源:采集点历史数据 + +来自数据管理模块中**启用了 store_history=true** 的采集点,数据存储在 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) +) TAGS ( + device_id VARCHAR(32), + device_name VARCHAR(128), + data_type VARCHAR(16), + unit VARCHAR(32) +); +``` + +### 2.2 预留数据来源:非采集点位(内部数据) + +未来的扩展数据(如AI模型推理的建议值、人工录入的补充数据等),统一归属为**内部数据**。 + +预留数据模型:新增 `computed_data` 超级表 + +```sql +-- 预留:非采集点位的时序数据 +CREATE STABLE IF NOT EXISTS computed_data ( + ts TIMESTAMP, + value DOUBLE, + quality INT, -- 0=good, 1=bad + point_id VARCHAR(32), + point_name VARCHAR(128), + source VARCHAR(32) -- 数据来源: 'ai_model', 'manual_input', 'external' 等 +) TAGS ( + category VARCHAR(32), -- 类别,如 'aeration', 'dosing', 'prediction' + unit VARCHAR(32) +); +``` + +**当前阶段只实现采集点历史数据展示,内部数据在树形结构中占位,不可勾选,显示"暂无数据"。** + +### 2.3 历史数据点位判定规则 + +**一个采集点是否出现在历史数据树形结构中**,取决于: + +``` +采集点出现在历史树中 ⇔ collection_points.enabled = true + AND collection_points.deleted = false + AND collection_points.store_history = true + AND 关联的 devices.enabled = true + AND 关联的 devices.deleted = false +``` + +--- + +## 3. 历史点位树形结构 + +### 3.1 树结构定义 + +``` +所有历史点位 + ├── 设备1 + │ ├── 分组A + │ │ ├── ☐ 采集点1(最新值,质量戳) + │ │ ├── ☐ 采集点2(最新值,质量戳) + │ │ └── ... + │ ├── 分组B + │ └── ... + ├── 设备2 + │ └── ... + └── 内部数据(预留) + └── (暂无数据) +``` + +### 3.2 构建规则 + +| 层级 | 数据来源 | 说明 | +|------|----------|------| +| 第一层:设备 | PostgreSQL `devices` 表 | 只有旗下有可用历史数据点的设备才显示 | +| 第二层:分组 | PostgreSQL `collection_points` 表的 group_name | 按设备分组,只有该设备下有历史数据点的分组才显示 | +| 第三层:点位 | PostgreSQL `collection_points` | 满足 store_history=true 且 enabled 的采集点 | +| 独立节点:内部数据 | 硬编码占位 | 当前阶段显示"暂无数据",后续扩展 | + +### 3.3 节点属性 + +每个采集点节点应包含以下信息(用于展示和查询): + +```json +{ + "id": "point-uuid", + "name": "曝气池DO_01", + "type": "collection", // 或 'computed'(预留) + "data_type": "REAL", + "unit": "mg/L", + "latest_value": 2.35, + "latest_quality": "good", + "latest_ts": "2026-07-09T10:00:01+08:00", + "device_id": "device-uuid", + "device_name": "一期曝气柜PLC", + "group_name": "曝气池" +} +``` + +### 3.4 复选框行为 + +| 操作 | 行为 | +|------|------| +| 勾选点位 | 该点位数据加入曲线/表格 | +| 取消勾选点位 | 该点位数据从曲线/表格中移除 | +| 勾选上限 | 建议最多同时勾选20个点位(前端友好性考虑) | +| 勾选状态 | 切换曲线/表格模式时,勾选状态保持不变 | + +--- + +## 4. 数据保留策略 + +### 4.1 配置方式 + +在系统配置中增加一个全局参数: + +| 参数 | 类型 | 范围 | 默认值 | 说明 | +|------|------|------|--------|------| +| history_retention_days | int | 1~730 | 365 | 历史数据保留天数 | + +### 4.2 实现方式 + +通过 TDengine 的数据库级 `KEEP` 参数控制: + +```sql +-- 创建数据库时指定保留天数 +CREATE DATABASE IF NOT EXISTS water_plant + KEEP 365 -- 数据保留天数(对应配置值) + DAYS 10 -- 每10天一个文件 + BLOCKS 100; + +-- 修改保留天数(当用户在系统配置中修改时执行) +ALTER DATABASE water_plant KEEP 730; +``` + +### 4.3 注意 + +- 修改保留天数后,TDengine 会自动清理超出保留期的数据 +- 保留天数对 `collection_data` 和 `computed_data` 两个超级表同时生效 +- 建议在系统配置页面提供"立即清理过期数据"按钮,但通常不需要手动操作 + +--- + +## 5. RESTful API + +### 5.1 接口列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/history/tree | 获取历史点位树形结构 | +| POST | /api/v1/history/query | 查询历史数据(曲线模式) | +| POST | /api/v1/history/query-table | 查询历史数据(表格模式) | +| POST | /api/v1/history/export | 导出历史数据为CSV | + +### 5.2 接口详细定义 + +#### 5.2.1 GET /api/v1/history/tree — 获取历史点位树 + +无需参数,从 PostgreSQL 查询所有符合条件的历史点位,按设备→分组→点位构建树。 + +Response (200): + +```json +{ + "tree": [ + { + "id": "device-uuid-1", + "name": "一期曝气柜PLC", + "type": "device", + "children": [ + { + "id": "group-aeration", + "name": "曝气池", + "type": "group", + "children": [ + { + "id": "point-uuid-1", + "name": "曝气池DO_01", + "type": "collection", + "data_type": "REAL", + "unit": "mg/L", + "latest_value": 2.35, + "latest_quality": "good", + "latest_ts": "2026-07-09T10:00:01+08:00" + }, + { + "id": "point-uuid-2", + "name": "曝气池温度", + "type": "collection", + "data_type": "REAL", + "unit": "℃", + "latest_value": 25.1, + "latest_quality": "good", + "latest_ts": "2026-07-09T10:00:01+08:00" + } + ] + } + ] + }, + { + "id": "internal-data", + "name": "内部数据", + "type": "reserved", + "children": [ + { + "id": "placeholder", + "name": "暂无数据", + "type": "placeholder", + "disabled": true + } + ] + } + ] +} +``` + +#### 5.2.2 POST /api/v1/history/query — 曲线模式查询 + +Request body: + +```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" +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| point_ids | string[] | 是 | 要查询的点位ID列表,最少1个,最多20个 | +| start_time | string | 是 | 开始时间,ISO 8601格式 | +| end_time | string | 是 | 结束时间,ISO 8601格式 | + +后端逻辑: + +``` +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. 聚合结果,返回 +``` + +Response (200): + +```json +{ + "series": [ + { + "point_id": "point-uuid-1", + "point_name": "曝气池DO_01", + "unit": "mg/L", + "data_type": "REAL", + "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": "曝气池温度", + "unit": "℃", + "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" } + ] + } + ] +} +``` + +#### 5.2.3 POST /api/v1/history/query-table — 表格模式查询 + +Request body: + +```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 +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| 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) | + +**后端逻辑(重点 — 最近邻匹配):** + +``` +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(显示—) | + +Response (200): + +```json +{ + "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" + ], + "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": "曝气池温度", + "unit": "℃", + "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" } + ] + } + ] +} +``` + +#### 5.2.4 POST /api/v1/history/export — 导出CSV + +Request body 与 query-table 一致: + +```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 +} +``` + +Response:CSV文件(Content-Type: text/csv; charset=utf-8-sig) + +CSV格式: + +```csv +时间,曝气池DO_01(mg/L),曝气池DO_01_质量,曝气池温度(℃),曝气池温度_质量 +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)时,数据列显示为 `—`,质量列显示 `—` + +--- + +## 6. 曲线模式(前端) + +### 6.1 布局 + +``` +┌───────────┬──────────────────────────────────────┐ +│ 点位树 │ 时间范围选择器 [过去1小时] [今天] [自定义] │ +│ (复选框) │ │ +│ │ ┌──────────────────────────────────┐ │ +│ ☑ 设备1 │ │ │ │ +│ ☑ 分组A │ │ 曲线图区域 │ │ +│ ☑ DO │ │ (ECharts 折线图) │ │ +│ ☐ 温度 │ │ │ │ +│ ☐ 分组B │ │ │ │ +│ ☐ 设备2 │ └──────────────────────────────────┘ │ +│ │ ┌──────────┬──────────┬──────────┐ │ +│ 内部数据 │ │ 点位名称 │ 时间 │ 数值 │ │ +│ ☐ (暂无) │ │ DO │ 00:00:05 │ 2.38 │ │ +│ │ │ 温度 │ 00:00:05 │ 25.2 │ │ +│ │ └──────────┴──────────┴──────────┘ │ +│ [曲线] [表格]│ │ +└───────────┴──────────────────────────────────────┘ +``` + +### 6.2 时间范围选择 + +提供快捷选择 + 自定义: + +| 选项 | 说明 | +|------|------| +| 过去1小时 | 快速查看最近1小时 | +| 过去6小时 | 快速查看最近6小时 | +| 过去24小时 | 快速查看最近1天 | +| 过去7天 | 快速查看最近1周 | +| 自定义 | 自由选择起止时间(精确到秒) | + +### 6.3 曲线图规则 + +#### 6.3.1 横轴(X轴) + +- 类型:时间轴 +- 自适应:根据选择的时间范围自动调整刻度 +- 刻度标签格式: + - 1小时内:`HH:mm:ss` + - 1小时~1天:`HH:mm` + - 1天以上:`MM-dd HH:mm` + +#### 6.3.2 纵轴(Y轴)— 单轴分段自适应 + +**核心需求**:一个Y轴,刻度不按数值均匀分布,而是根据数据分布自动调整疏密——数据密集的区域刻度变密(放大),数据稀疏的区域刻度变疏(压缩),让不同量级的多条曲线都能在同一个Y轴上看出变化趋势。 + +**场景举例**: + +同时展示两条曲线,一条 DO 值在 2.0~3.0 mg/L,一条温度在 20~30℃。如果Y轴从0均匀到30,DO曲线会被压缩成一条几乎水平的直线,完全看不出变化。 + +**实现方式 — 分段映射法**: + +``` +原始Y轴(均匀) 显示Y轴(分段自适应) +30 ─ 30 ─ + │ + │ 温度变化区域(20~30) + │ 该区域被适当压缩 +20 ─ 20 ─ + │ + │ ─ ─ ─ 中间区域(3~20) + │ 该区域数据稀疏,被大幅压缩 + │ + 3 ─ 3 ─ + │ + │ DO变化区域(2.0~3.0) + │ 该区域数据密集,被放大展开 + 2 ─ 2 ─ + │ + 0 ─ 0 ─ +``` + +**技术实现**: + +1. 后端(或前端)对查询到的所有曲线数据进行全局分析,找出数据分布 +2. 将Y轴划分为若干段,每段内使用线性映射,段间不等距 +3. 段划分规则: + - 找出所有曲线的数据值,按值聚类 + - 每个聚类形成一个"密度段",该段在Y轴上占据较大比例的高度 + - 聚类之间的空白区域形成一个"稀疏段",该段在Y轴上占据较小比例的高度 +4. 数据值 → 显示坐标的映射公式: + +``` +对于每个数据值 v,计算其在Y轴上的显示位置 p: + +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轴标题显示 `数值 (单位)`,如 `mg/L` | +| 分段视觉提示 | 在Y轴背景上用浅色横条标记不同分段区域,帮助用户识别分段边界 | + +#### 6.3.3 折线规则 + +| 数据质量 | 显示方式 | +|----------|----------| +| 连续 good 数据 | 实线折线,正常连接 | +| 连续 bad 数据 | 虚线连接(`--` 样式) | +| good → bad 过渡 | 实线连接到 bad 点,bad 点之后变为虚线 | +| 数据缺失(gap) | 断线,不连接 | + +#### 6.3.4 游标功能 + +**游标是一条垂直的十字线**,跟随鼠标移动。 + +行为: + +``` +鼠标在图表区域移动 + │ + ▼ +游标垂直线跟随鼠标位置 + │ + ▼ +对每条曲线,找到游标所在时刻最近的数据点 + │ + ├── 找到数据点 → 显示该点的值 + └── 未找到数据点 → 显示 "--" + │ + ▼ +游标信息显示在右侧表格中 +``` + +右侧游标信息表格: + +| 点位名称 | 时间 | 数值 | 质量 | +|----------|------|------|------| +| 曝气池DO_01 | 00:00:05 | 2.38 | good | +| 曝气池温度 | 00:00:05 | 25.2 | good | + +游标交互细节: +- 游标跟随鼠标移动,实时更新 +- 鼠标离开图表区域时,游标消失 +- 游标所在时刻精确到最近的数据点,不插值 +- 游标表格中,质量戳为 bad 时,数值显示为 `—` + +--- + +## 7. 表格模式(前端) + +### 7.1 布局 + +``` +┌───────────┬──────────────────────────────────────┐ +│ 点位树 │ 时间范围选择器 [自定义] │ +│ (复选框) │ 间隔: [10分钟] ▼ │ +│ │ │ +│ ☑ 设备1 │ ┌──────────────────────────────────┐ │ +│ ☑ 分组A │ │ 时间 │ DO(mg/L)│ 温度(℃)│ │ +│ ☑ DO │ │──────────────┼─────────┼────────│ │ +│ ☐ 温度 │ │ 00:00:00 │ 2.35 │ 25.1 │ │ +│ ☐ 分组B │ │ 00:10:00 │ 2.40 │ 25.0 │ │ +│ ☐ 设备2 │ │ 00:20:00 │ — │ 25.3 │ │ +│ │ │ 00:30:00 │ 2.38 │ — │ │ +│ 内部数据 │ │ ... │ ... │ ... │ │ +│ ☐ (暂无) │ └──────────────────────────────────┘ │ +│ │ [导出CSV] │ +│ [曲线] [表格]│ │ +└───────────┴──────────────────────────────────────┘ +``` + +### 7.2 时间间隔选择 + +| 参数 | 说明 | +|------|------| +| 间隔时间 | 下拉选择:1分钟、2分钟、5分钟、10分钟、15分钟、20分钟、30分钟、60分钟 | +| 默认值 | 10分钟 | + +### 7.3 表格数据规则 + +| 规则 | 说明 | +|------|------| +| 第一列 | 时间序列,从 start_time 开始,按 interval 递增 | +| 数据列 | 每个勾选的点位对应一列,列标题为 `点位名称(单位)` | +| 数据对齐 | 使用最近邻匹配(见 5.2.3 后端逻辑) | +| bad 质量 | 数据单元格显示 `—`(全角破折号) | +| 无匹配数据 | 数据单元格显示 `—`(全角破折号) | +| 空值处理 | 空白单元格统一显示 `—`,不显示空单元格 | + +### 7.4 导出CSV + +点击"导出CSV"按钮,触发 `POST /api/v1/history/export` 接口。 + +- 导出内容与当前表格显示内容一致(相同的时间范围、间隔、点位) +- 文件名格式:`历史数据_{start_time}_{end_time}_{interval}min.csv` +- 导出完成后浏览器自动下载 + +--- + +## 8. 前端页面结构 + +### 8.1 页面布局 + +``` +┌─────────────────────────────────────────────────────┐ +│ ┌──────────┐ ┌──────────────────────────────────┐ │ +│ │ ▽ 数据管理 │ │ │ │ +│ │ 设备管理 │ │ │ │ +│ │ 数据采集 │ │ 内容区域 │ │ +│ │ 数据写入 │ │ │ │ +│ │ │ │ ┌──────────────────────────────┐ │ │ +│ │ ○ 历史数据 │ │ │ ☐ 设备1 │ │ │ +│ │ │ │ │ ☐ 分组A │ │ │ +│ │ ○ 实时监控 │ │ │ ☑ 曝气池DO_01 2.35 │ │ │ +│ │ ○ 智能控制 │ │ │ ☐ 曝气池温度 25.1 │ │ │ +│ │ ○ 系统配置 │ │ │ ☐ 分组B │ │ │ +│ │ │ │ │ ☐ 设备2 │ │ │ +│ └──────────┘ │ │ ─────── │ │ │ +│ │ │ 内部数据 │ │ │ +│ │ │ ☐ (暂无数据) │ │ │ +│ │ └──────────────────────────────┘ │ │ +│ │ [曲线] [表格] 切换 │ │ +│ │ ┌──────────────────────────────┐ │ │ +│ │ │ 曲线图/表格内容区域 │ │ │ +│ │ │ │ │ │ +│ │ └──────────────────────────────┘ │ │ +│ └──────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +### 8.2 模式切换 + +- 曲线模式和表格模式通过 Tab 按钮切换 +- 切换时,**勾选的点位状态保持不变** +- 切换时,**时间范围保持不变** +- 切换时,不重复请求数据,按需加载(点击曲线Tab时请求曲线数据,点击表格Tab时请求表格数据) + +### 8.3 交互流程 + +``` +用户进入历史数据页面 + │ + ▼ +加载历史点位树(GET /api/v1/history/tree) + │ + ▼ +左侧树渲染,用户勾选点位 + │ + ▼ +用户选择时间范围 + │ + ├── 曲线模式 → 点击查询 → POST /api/v1/history/query → 渲染曲线图 + └── 表格模式 → 选择间隔 → 点击查询 → POST /api/v1/history/query-table → 渲染表格 + │ + ▼ +用户勾选/取消勾选点位 → 自动重新查询(保持时间范围不变) +用户切换模式 → 自动重新查询(保持点位和时间范围不变) +``` + +--- + +## 附录A:关键业务规则一览 + +| 编号 | 规则 | 说明 | +|------|------|------| +| H001 | 历史数据来源 | 仅展示 store_history=true 且设备/点位均启用的采集点数据 | +| H002 | 树形结构 | 按设备→分组→点位三层组织,外加"内部数据"预留节点 | +| H003 | 最大勾选数 | 同时勾选的点位不超过20个 | +| H004 | 数据保留 | 可配置1~730天,通过TDengine KEEP参数实现 | +| H005 | 表格时间对齐 | 最近邻匹配,匹配窗口 = interval/2 | +| H006 | 曲线Y轴 | 单Y轴分段自适应,数据密集区放大,稀疏区压缩 | +| H007 | bad质量显示 | 曲线:虚线;表格:`—`;游标:`—` | +| H008 | 数据缺失 | 曲线:断线;表格:`—` | +| H009 | 模式切换 | 保持点位勾选状态和时间范围不变 | +| H010 | CSV导出 | 使用UTF-8 with BOM编码,与表格显示内容一致 | + +--- + +> 本文档版本:v1.0 +> 最后更新:2026-07-09 \ No newline at end of file diff --git a/开发文档/spec-数据管理.md b/开发文档/spec-数据管理.md new file mode 100644 index 0000000..ff621a6 --- /dev/null +++ b/开发文档/spec-数据管理.md @@ -0,0 +1,1128 @@ +# 数据管理模块 — 开发规格说明 + +> 本文档属于《污水厂智能控制平台》的一部分,详细描述数据管理模块的功能、数据模型、API接口和业务逻辑,细度可达直接开发级别。 + +--- + +## 目录 + +- [1. 概述](#1-概述) +- [2. 设备管理](#2-设备管理) +- [3. 数据采集点管理](#3-数据采集点管理) +- [4. 数据写入点管理](#4-数据写入点管理) +- [5. 采集引擎(运行时)](#5-采集引擎运行时) +- [6. 写入引擎(运行时)](#6-写入引擎运行时) +- [7. 协议插件化架构](#7-协议插件化架构) + +--- + +## 1. 概述 + +### 1.1 模块定位 + +本模块是平台的数据基础层,负责: +- 管理PLC设备及其连接配置 +- 定义数据采集点位(从PLC读取数据) +- 定义数据写入点位(向PLC写入控制指令) +- 运行时执行数据采集任务,将数据写入TDengine +- 运行时执行控制指令下发任务 + +### 1.2 模块边界 + +| 交互对象 | 方向 | 内容 | +|----------|------|------| +| Web前端 | 双向 | 设备/点位CRUD、实时数据展示、控制指令下发 | +| TDengine | 单向写入 | 采集到的时序数据 | +| PostgreSQL | 双向 | 设备配置、点位配置、操作日志 | +| PLC设备 | 双向 | 读取寄存器、写入寄存器 | + +### 1.3 核心概念关系 + +``` +设备 (Device) ← 一个物理PLC或Modbus设备 + ├── 采集点 (CollectPoint) ← 从该设备读取的测点(N个) + └── 写入点 (WritePoint) ← 向该设备写入的测点(M个) + +采集点 ≠ 写入点,两者分开管理,独立配置。 +``` + +--- + +## 2. 设备管理 + +### 2.1 数据模型 + +#### 2.1.1 数据库表:devices + +```sql +CREATE TABLE devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(128) NOT NULL, + protocol_type VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + + -- 网络连接参数 + host VARCHAR(256) NOT NULL, + port INTEGER NOT NULL, + connect_timeout INTEGER NOT NULL DEFAULT 5, -- 连接超时,单位秒 + reconnect_interval INTEGER NOT NULL DEFAULT 10, -- 断线重连间隔,单位秒 + + -- 协议级配置(JSON格式,由协议驱动自行解析) + protocol_config JSONB NOT NULL DEFAULT '{}', + + 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_devices_name ON devices(name) WHERE deleted = FALSE; +``` + +#### 2.1.2 协议类型枚举 + +| 协议类型标识 | 说明 | 当前状态 | +|-------------|------|---------| +| `S7` | 西门子S7协议 | 已支持 | +| `MODBUS_TCP` | Modbus TCP协议 | 已支持 | +| (后续扩展) | 新增协议通过插件机制添加 | 待扩展 | + +#### 2.1.3 各协议 protocol_config 定义 + +**S7协议**: + +```json +{ + "rack": 0, + "slot": 1 +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| rack | int | 是 | PLC机架号,默认0 | +| slot | int | 是 | PLC插槽号,默认1 | + +**Modbus TCP协议**: + +```json +{ + "unit_id": 1, + "byte_order": "ABCD", + "word_order": "AB" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| unit_id | int | 是 | Modbus从站地址(站号),范围1~247 | +| byte_order | string | 是 | 32位浮点数(REAL)的字节序,详见下文 | +| word_order | string | 是 | 32位浮点数(REAL)的字序,详见下文 | + +**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` 决定每个字内的字节顺序。 + +#### 2.1.4 设备的业务状态 + +| 状态 | 说明 | +|------|------| +| enabled=true | 设备启用,采集引擎会为该设备建立连接并执行采集任务 | +| enabled=false | 设备禁用,采集引擎跳过该设备,已有连接断开 | +| deleted=true | 逻辑删除,数据保留但不再显示和使用 | + +### 2.2 RESTful API + +#### 2.2.1 接口列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/devices | 获取设备列表(支持分页、搜索、按协议筛选) | +| POST | /api/v1/devices | 新增设备 | +| GET | /api/v1/devices/{id} | 获取单个设备详情 | +| PUT | /api/v1/devices/{id} | 修改设备 | +| DELETE | /api/v1/devices/{id} | 逻辑删除设备 | +| POST | /api/v1/devices/export | 导出设备列表为CSV | +| POST | /api/v1/devices/import | 从CSV文件导入设备 | +| GET | /api/v1/devices/protocols | 获取当前支持的所有协议类型列表 | + +#### 2.2.2 接口详细定义 + +**POST /api/v1/devices** — 新增设备 + +Request body: + +```json +{ + "name": "一期曝气柜PLC", + "protocol_type": "S7", + "host": "192.168.1.100", + "port": 102, + "connect_timeout": 5, + "reconnect_interval": 10, + "protocol_config": { + "rack": 0, + "slot": 1 + } +} +``` + +Response (201): + +```json +{ + "id": "uuid-string", + "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 + }, + "created_at": "2026-07-09T10:00:00+08:00", + "updated_at": "2026-07-09T10:00:00+08:00" +} +``` + +校验规则: +- name:必填,1~128字符,不可重复(逻辑删除的记录不参与唯一性校验) +- protocol_type:必填,必须是当前系统支持的协议类型 +- host:必填,合法的IP地址或域名 +- port:必填,1~65535 +- connect_timeout:可选,默认5,范围1~60 +- reconnect_interval:可选,默认10,范围1~3600 +- protocol_config:必填,根据protocol_type不同,校验规则不同 + +**PUT /api/v1/devices/{id}** — 修改设备 + +- 与新增使用相同的请求体结构 +- 修改后,如果设备正在运行中,采集引擎应自动重新连接 + +**DELETE /api/v1/devices/{id}** — 逻辑删除 + +- 将该设备的 deleted 字段设为 true +- 如果设备正在运行中,采集引擎应立即停止该设备的所有采集任务并断开连接 +- 同时该设备下所有采集点和写入点也一并逻辑删除 + +**POST /api/v1/devices/export** — 导出设备列表为CSV + +Request body: + +```json +{ + "ids": ["uuid1", "uuid2"] // 可选,不传则导出全部 +} +``` + +Response:CSV文件(Content-Type: text/csv; charset=utf-8-sig) + +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 +``` + +注意: +- CSV使用UTF-8 with BOM编码 +- 首行为标题行 +- 布尔值导出为 TRUE/FALSE +- JSON字段导出为转义后的JSON字符串 + +**POST /api/v1/devices/import** — 从CSV导入设备 + +- Content-Type: multipart/form-data,文件字段名:file +- 导入逻辑: + - 以name为唯一标识:如果CSV中的设备名称已存在,则更新该设备;不存在则新增 + - 导入完成后返回处理结果:成功数、失败数、失败原因列表 + - 校验规则与新增接口一致,校验失败的行跳过并记录原因 + +**GET /api/v1/devices** — 获取设备列表 + +Query parameters: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 页码,默认1 | +| page_size | int | 否 | 每页数量,默认20,最大100 | +| keyword | string | 否 | 搜索关键词(匹配name) | +| protocol_type | string | 否 | 按协议筛选 | +| enabled | bool | 否 | 按启用状态筛选 | + +Response (200): + +```json +{ + "total": 50, + "page": 1, + "page_size": 20, + "items": [ + { + "id": "uuid", + "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 }, + "created_at": "...", + "updated_at": "..." + } + ] +} +``` + +--- + +## 3. 数据采集点管理 + +### 3.1 数据模型 + +#### 3.1.1 数据库表:collection_points + +```sql +CREATE TABLE collection_points ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(128) NOT NULL, + group_name VARCHAR(64) NOT NULL DEFAULT 'default', + 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 + store_history BOOLEAN NOT NULL DEFAULT TRUE, + history_interval INTEGER NOT NULL DEFAULT 1, -- 单位:分钟,最小值1 + + 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_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; +``` + +#### 3.1.2 各协议的地址格式 + +**S7协议地址格式**: + +地址由协议类型决定解析方式,统一存储在 address 字段中: + +| 地址类型 | 格式 | 示例 | 说明 | +|----------|------|------|------| +| DB | `DB{db_number}.{byte_offset}.{bit}` | `DB2.10.0` | 数据块,DB号2,字节偏移10,第0位 | +| M | `M{byte_offset}.{bit}` | `M4.0` | 中间寄存器,字节偏移4,第0位 | +| I | `I{byte_offset}.{bit}` | `I1.0` | 输入映像区,字节偏移1,第0位 | +| Q | `Q{byte_offset}.{bit}` | `Q3.0` | 输出映像区,字节偏移3,第0位 | + +对于 INT 和 REAL 类型,address 不需要 .bit 部分: + +| 数据类型 | 示例地址 | 说明 | +|----------|----------|------| +| BOOL | `DB2.10.0` | 读DB2的第10字节的第0位 | +| INT | `DB2.10` | 读DB2的第10字节开始的2字节(16位整数) | +| REAL | `DB2.10` | 读DB2的第10字节开始的4字节(32位浮点数) | + +解析规则: +- 地址格式:`{type_prefix}{numbers}`,其中 numbers 以 `.` 分隔 +- 对于 BOOL 类型:必须有3段(或2段,M/I/Q类型),如 `DB2.10.0` 或 `M4.0` +- 对于 INT/REAL 类型:BOOL类型的地址去掉最后的 .bit 部分,如 `DB2.10`、`M4` + +**Modbus TCP协议地址格式**: + +| 地址范围 | 功能码 | 对应Modbus寄存器类型 | 示例 | +|----------|--------|---------------------|------| +| `00001`~`09999` | 01/05/15 | 线圈(Coil),可读可写,位类型 | `00001` | +| `10001`~`19999` | 02 | 离散输入(Discrete Input),只读,位类型 | `10001` | +| `30001`~`39999` | 04 | 输入寄存器(Input Register),只读,16位 | `30001` | +| `40001`~`49999` | 03/06/16 | 保持寄存器(Holding Register),可读可写,16位 | `40001` | + +地址与功能码的映射规则: + +| 地址范围 | 读取功能码 | 写入功能码 | 数据类型限制 | +|----------|-----------|-----------|-------------| +| 00001~09999 | 01 | 05(写单个)/15(写多个) | 仅BOOL | +| 10001~19999 | 02 | 不支持 | 仅BOOL | +| 30001~39999 | 04 | 不支持 | INT/REAL | +| 40001~49999 | 03 | 06(写单个)/16(写多个) | INT/REAL | + +地址与寄存器地址的换算: +- 地址 `00001` → 协议中的寄存器地址 0 +- 地址 `40001` → 协议中的寄存器地址 0 +- 地址 `40011` → 协议中的寄存器地址 10 +- 公式:`register_address = modbus_address - (address_prefix * 10000 + 1)` + +对于 BOOL 类型,每个地址对应一个位(线圈/离散输入)。 +对于 INT 类型,每个地址对应1个寄存器(16位)。 +对于 REAL 类型,每个地址对应2个连续寄存器(32位),读取时从指定地址连续读2个寄存器,按 device 中配置的 byte_order 和 word_order 解析。 + +#### 3.1.3 分组机制 + +分组是扁平结构,group_name 字段即分组名称。 + +- 默认分组名:`default` +- 分组名支持任意字符串(建议不超过64字符) +- 一个采集点只能属于一个分组 +- 分组的增删改通过修改采集点的 group_name 字段实现 +- 查询时支持按分组名筛选 + +#### 3.1.4 实时数据 + +实时数据不是存储在数据库中的字段,而是采集引擎运行时维护在内存中的最新值。 + +API 返回采集点时,应附带该点的最新采集值(如果存在): + +```json +{ + "id": "uuid", + "name": "曝气池DO_01", + "latest_value": { + "value": 2.35, + "quality": "good", + "ts": "2026-07-09T10:00:01+08:00" + } +} +``` + +### 3.2 RESTful API + +#### 3.2.1 接口列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/collection-points | 获取采集点列表(支持分页、搜索、按设备/分组/数据类型筛选) | +| POST | /api/v1/collection-points | 新增采集点 | +| GET | /api/v1/collection-points/{id} | 获取单个采集点详情(含实时数据) | +| PUT | /api/v1/collection-points/{id} | 修改采集点 | +| DELETE | /api/v1/collection-points/{id} | 逻辑删除采集点 | +| POST | /api/v1/collection-points/export | 导出采集点列表为CSV | +| POST | /api/v1/collection-points/import | 从CSV文件导入采集点 | +| GET | /api/v1/collection-points/groups | 获取所有分组列表 | + +#### 3.2.2 接口详细定义 + +**POST /api/v1/collection-points** — 新增采集点 + +Request body: + +```json +{ + "name": "曝气池DO_01", + "group_name": "曝气池", + "device_id": "uuid-of-device", + "address": "DB2.10", + "data_type": "REAL", + "collect_interval": 1, + "store_history": true, + "history_interval": 1 +} +``` + +校验规则: +- name:必填,全局唯一,1~128字符 +- group_name:可选,默认"default",1~64字符 +- device_id:必填,必须引用一个已存在的设备(且deleted=false) +- address:必填,1~256字符,根据设备协议类型校验格式 +- data_type:必填,枚举值:`BOOL`、`INT`、`REAL` +- collect_interval:必填,最小值1(秒) +- store_history:可选,默认true +- history_interval:当store_history=true时必填,最小值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}** — 修改采集点 + +- 修改后,如果采集引擎正在运行中,需要动态更新采集任务配置 + +**DELETE /api/v1/collection-points/{id}** — 逻辑删除 + +- 将该采集点的 deleted 字段设为 true +- 采集引擎应立即停止该点的采集任务 + +**GET /api/v1/collection-points** — 获取采集点列表 + +Query parameters: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| page | int | 否 | 默认1 | +| page_size | int | 否 | 默认20,最大100 | +| keyword | string | 否 | 搜索name、group_name | +| device_id | string | 否 | 按设备筛选 | +| group_name | string | 否 | 按分组筛选 | +| data_type | string | 否 | 按数据类型筛选 | +| enabled | bool | 否 | 按启用状态筛选 | + +Response (200): + +```json +{ + "total": 200, + "page": 1, + "page_size": 20, + "items": [ + { + "id": "uuid", + "name": "曝气池DO_01", + "group_name": "曝气池", + "device_id": "uuid", + "device_name": "一期曝气柜PLC", + "protocol_type": "S7", + "address": "DB2.10", + "data_type": "REAL", + "collect_interval": 1, + "store_history": true, + "history_interval": 1, + "enabled": true, + "latest_value": { + "value": 2.35, + "quality": "good", + "ts": "2026-07-09T10:00:01+08:00" + }, + "created_at": "...", + "updated_at": "..." + } + ] +} +``` + +**GET /api/v1/collection-points/groups** — 获取分组列表 + +Response (200): + +```json +{ + "groups": [ + { + "name": "default", + "count": 10 + }, + { + "name": "曝气池", + "count": 25 + }, + { + "name": "加药间", + "count": 15 + } + ] +} +``` + +**POST /api/v1/collection-points/export** — 导出CSV + +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 +``` + +注意: +- device_name 列关联设备名称,导入时根据设备名称查找对应的 device_id +- 如果导入时 device_name 找不到对应的设备,则该行导入失败 +- 其他规则与设备CSV导入一致 + +**POST /api/v1/collection-points/import** — 从CSV导入 + +- 导入逻辑:以 name 为唯一标识,存在则更新,不存在则新增 +- 需先校验 device_name 是否有效 + +--- + +## 4. 数据写入点管理 + +### 4.1 数据模型 + +#### 4.1.1 数据库表:write_points + +```sql +CREATE TABLE write_points ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(128) NOT NULL, + 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'(两者均可) + deleted BOOLEAN NOT NULL DEFAULT FALSE, + + -- 写入地址(协议驱动自行解析,格式与采集点相同) + address VARCHAR(256) NOT NULL, + + -- 数据类型 + data_type VARCHAR(16) NOT NULL, -- 'BOOL', 'INT', 'REAL' + + 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; +``` + +#### 4.1.2 与采集点的区别 + +| 维度 | 采集点 | 写入点 | +|------|--------|--------| +| 方向 | 从PLC读取 | 向PLC写入 | +| 存储历史 | 支持(可配置写入TDengine) | 不支持(只记录操作日志) | +| 采集周期 | 有 | 无(指令触发,非周期执行) | +| 写入开关 | 无 | 有(write_enabled + write_source) | +| 写入来源 | 无 | 支持人工(manual)和程序自动(auto)两种来源 | +| 采集引擎 | 周期性调度 | 无(等待API触发) | + +#### 4.1.3 地址格式 + +与采集点完全一致,参考 [3.1.2](#312-各协议的地址格式)。 + +注意:写入点必须使用可写的地址类型: +- S7协议:DB、M、Q 类型可写(I类型为输入,不可写) +- Modbus协议:00001(线圈)和 40001(保持寄存器)可写;10001(离散输入)和 30001(输入寄存器)不可写 + +### 4.2 RESTful API + +#### 4.2.1 接口列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/v1/write-points | 获取写入点列表 | +| POST | /api/v1/write-points | 新增写入点 | +| GET | /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 | 执行写入操作(人工/程序自动均使用此接口) | + +#### 4.2.2 写入接口详细定义 + +**POST /api/v1/write-points/{id}/write** — 执行写入 + +Request body(人工写入): + +```json +{ + "value": 20.5, + "source": "manual", + "operator": "张三", + "reason": "AI推荐加药量调整" +} +``` + +Request body(程序自动写入): + +```json +{ + "value": 25.0, + "source": "auto", + "operator": "ai-engine:aeration-model-v1", + "reason": "曝气模型推理结果:进水负荷上升,需要增加风量" +} +``` + +校验规则: +- 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. 校验写入点是否存在且 write_enabled=true +2. 校验请求中的 source 是否在写入点 write_source 允许的范围内 +3. 校验值类型与 data_type 匹配 +4. 连接PLC(如果已连接则复用) +5. 写入地址(调用协议驱动的 Write 方法) +6. 回读验证(读取刚写入的地址,确认值一致) + - 如果回读值与写入值一致:写入成功 + - 如果回读值与写入值不一致:重试一次,仍不一致则标记为失败 +7. 记录操作日志到 write_logs 表 +8. 返回写入结果 +``` + +Response (200): + +```json +{ + "id": "uuid", + "point_name": "加药泵频率", + "value": 20.5, + "result": "success", + "readback_value": 20.5, + "ts": "2026-07-09T10:00:00+08:00" +} +``` + +写入失败时: + +```json +{ + "id": "uuid", + "point_name": "加药泵频率", + "value": 20.5, + "result": "failed", + "error": "回读值不匹配: 期望20.5, 实际18.3", + "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(), + point_id UUID NOT NULL REFERENCES write_points(id), + point_name VARCHAR(128) NOT NULL, + device_id UUID NOT NULL REFERENCES devices(id), + 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' + error_message TEXT, + + operator VARCHAR(64) NOT NULL, + reason TEXT, + + 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); +``` + +--- + +## 5. 采集引擎(运行时) + +### 5.1 架构概述 + +采集引擎是后台常驻服务,负责执行实际的数据采集任务。 + +``` +┌─────────────────────────────────────────────────────┐ +│ 采集引擎(Collector Engine) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 设备连接管理器 │ │ 采集任务调度器 │ │ +│ │ (Connection │ │ (Scheduler) │ │ +│ │ Manager) │ └──────┬───────┘ │ +│ └──────┬───────┘ │ │ +│ │ │ 每个采集点的独立协程/任务 │ +│ │ ┌───────▼────────┐ │ +│ │ │ 采集点执行单元 │ │ +│ │ │ (Point Runner) │ │ +│ │ └───────┬────────┘ │ +│ │ │ │ +│ │ ┌───────▼────────┐ │ +│ │ │ 数据后处理 │ │ +│ │ │ - 质量戳判定 │ │ +│ │ │ - 更新内存缓存 │ │ +│ │ │ - 写入TDengine │ │ +│ │ └────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +### 5.2 启动流程 + +``` +1. 采集引擎启动 +2. 从数据库加载所有 enabled=true AND deleted=false 的设备 +3. 从数据库加载所有 enabled=true AND deleted=false 的采集点,按 device_id 分组 +4. 对每个设备,尝试建立连接: + a. 连接成功 → 标记为"已连接",启动该设备下所有采集点的采集任务 + b. 连接失败 → 标记为"断开",启动重连计时器 +5. 每个采集点按 collect_interval 周期性执行 +``` + +### 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 } +``` + +### 5.4 质量戳判定规则 + +| 条件 | 质量戳 | +|------|--------| +| 协议读取成功,返回有效值 | `good` | +| 协议读取成功,但值为 null 或解析异常 | `bad` | +| 协议读取失败(超时/无响应/异常) | `bad` | +| 设备连接断开 | `bad` | +| 设备连接断开后,断线期间所有采集点都标记为 `bad` | `bad` | + +### 5.5 断线重连机制 + +``` +1. 采集引擎检测到设备连接断开(读取失败或连接异常断开) +2. 立即标记该设备状态为"断开" +3. 该设备下所有采集点标记为 bad +4. 启动重连计时器(间隔 = 设备配置的 reconnect_interval) +5. 每次重连尝试: + a. 连接成功 → 标记设备为"已连接",恢复采集 + b. 连接失败 → 继续等待下一次重连 +6. 重连成功后,自动恢复所有采集点的正常采集 +7. 重连无次数限制,持续尝试直到成功或设备被禁用 +``` + +### 5.6 动态配置更新 + +当用户通过API修改设备或采集点配置时,采集引擎需要动态响应: + +| 用户操作 | 采集引擎响应 | +|----------|-------------| +| 新增设备 | 立即建立连接,启动采集任务 | +| 修改设备参数(IP/端口等) | 断开旧连接,使用新参数重新连接 | +| 删除设备 | 断开连接,停止所有采集任务 | +| 启用/禁用设备 | 禁用则断开;启用则连接并启动 | +| 新增采集点 | 立即启动该点的采集任务 | +| 修改采集点参数(地址/周期等) | 停止旧任务,按新参数启动 | +| 删除采集点 | 停止该点的采集任务 | +| 启用/禁用采集点 | 禁用则停止,启用则启动 | + +实现方式:采集引擎启动一个配置变更监听协程,定期(每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) -- 采集点名称(冗余字段,方便查询) +) TAGS ( + device_id VARCHAR(32), -- 设备ID + device_name VARCHAR(128), -- 设备名称 + data_type VARCHAR(16), -- 原始数据类型 + unit VARCHAR(32) -- 单位(可选,由采集点配置扩展) +); +``` + +#### 5.7.2 子表创建策略 + +每个采集点对应一个子表,子表名使用采集点ID去掉连字符后的字符串: + +```sql +-- 假设采集点ID为: a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d +-- 子表名: a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d +CREATE TABLE IF NOT EXISTS a1b2c3d4e5f64a7b8c9d0e1f2a3b4c5d +USING collection_data TAGS ( + 'device-uuid', '一期曝气柜PLC', 'REAL', 'mg/L' +); +``` + +#### 5.7.3 写入策略 + +- 写入时使用 `INSERT INTO ... USING ... TAGS` 语法,自动创建子表 +- 批量写入:每1秒或每100条数据攒一批写入,提高写入效率 +- 如果 store_history=false,不写入TDengine +- 写入频率由 history_interval 控制(例如 history_interval=5,则每5分钟写入一条) + +--- + +## 6. 写入引擎(运行时) + +### 6.1 架构概述 + +写入引擎负责接收写入请求(来自人工Web操作或AI引擎自动调用),执行写入操作并返回结果。两种写入来源共用同一个执行通道,区别仅在于 source 字段和 operator 字段不同。 + +``` +写入请求 + │ + ├── source=manual(来自Web前端人工操作) + └── source=auto(来自AI引擎程序自动调用) + │ + ▼ +┌─────────────────────┐ +│ 请求校验 │ +│ - 写入点是否存在 │ +│ - write_enabled=true │ +│ - source是否允许 │ +│ - 值类型校验 │ +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ 指令执行 │ +│ - 获取设备连接 │ +│ - 调用协议驱动写入 │ +│ - 回读验证 │ +└─────────┬───────────┘ + │ + ▼ +┌─────────────────────┐ +│ 结果记录 │ +│ - 写入 write_logs │ +│ - 返回结果给调用方 │ +└─────────────────────┘ +``` + +### 6.2 写入安全策略 + +1. **write_enabled 开关**:写入点必须显式开启 write_enabled=true 才能写入,防止误操作 +2. **写入来源校验**:写入点的 write_source 字段控制该点允许 manual/auto/both 哪种来源 +3. **回读验证**:每次写入后必须回读确认,值一致才算成功 +4. **操作审计**:所有写入操作记录到 write_logs,可追溯 +5. **权限控制**:写入操作需要用户登录权限(由Web后端统一管理) + +--- + +## 7. 协议插件化架构 + +### 7.1 接口定义 + +所有协议驱动必须实现以下接口: + +```go +// ProtocolDriver 协议驱动接口 +type ProtocolDriver interface { + // 协议类型标识,如 "S7", "MODBUS_TCP" + 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{} +} +``` + +### 7.2 注册机制 + +```go +// 全局协议驱动注册表 +var protocolDrivers = make(map[string]ProtocolDriver) + +// 注册协议驱动 +func RegisterProtocol(driver ProtocolDriver) { + protocolDrivers[driver.ProtocolType()] = driver +} + +// 获取协议驱动 +func GetProtocol(protocolType string) (ProtocolDriver, error) { + driver, ok := protocolDrivers[protocolType] + if !ok { + return nil, fmt.Errorf("不支持的协议类型: %s", protocolType) + } + return driver, nil +} +``` + +### 7.3 扩展新协议的步骤 + +1. 创建新的协议驱动文件,实现 `ProtocolDriver` 接口 +2. 在 `init()` 函数中调用 `RegisterProtocol()` 注册 +3. 在 `protocol_config` 中定义该协议特有的配置参数 +4. 实现 `ConfigSchema()` 返回配置参数的JSON Schema,供前端动态渲染配置表单 + +### 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 } }` | + +#### 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" } }` | + +--- + +## 附录A:前端页面结构参考 + +### A.1 页面布局(左侧导航栏) + +``` +┌─────────────────────────────────────────────────────┐ +│ ┌──────────┐ ┌──────────────────────────────────┐ │ +│ │ ▽ 数据管理 │ │ │ │ +│ │ 设备管理 │ │ 内容区域 │ │ +│ │ 数据采集 │ │ │ │ +│ │ 数据写入 │ │ │ │ +│ │ │ │ │ │ +│ │ ○ 实时监控 │ │ │ │ +│ │ ○ 智能控制 │ │ │ │ +│ │ ○ 历史分析 │ │ │ │ +│ │ ○ 系统配置 │ │ │ │ +│ └──────────┘ └──────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +左侧导航栏说明: +- 导航栏为树形结构,主菜单可展开/收起 +- 当前展开的主菜单:**数据管理** +- 展开后显示三个子菜单项:**设备管理**、**数据采集**、**数据写入** +- 点击子菜单项,右侧内容区域切换对应页面 +- 其他主菜单(实时监控、智能控制、历史分析、系统配置)为后续模块占位,当前不可展开 + +### A.2 子页面:设备管理 + +- 设备列表表格(ID | 名称 | 协议类型 | IP | 端口 | 状态 | 操作) +- 新增/编辑设备弹窗(动态表单,协议类型切换时protocol_config区域联动变化) +- 删除确认(二次确认,提示"同时会删除该设备下所有采集点和写入点") +- 启用/禁用开关 +- 导入/导出CSV按钮 + +### A.3 子页面:数据采集 + +- 分组筛选器(横向标签或下拉菜单) +- 采集点列表表格(名称 | 分组 | 所属设备 | 地址 | 数据类型 | 采集周期 | 存储历史 | 最新值 | 质量戳 | 操作) +- 新增/编辑采集点弹窗(设备选择后,地址格式提示跟随设备协议变化) +- 拖拽修改分组(或通过编辑弹窗修改) +- 导入/导出CSV按钮 + +### A.4 子页面:数据写入 + +- 写入点列表表格(名称 | 分组 | 所属设备 | 地址 | 数据类型 | 允许写入 | 写入来源 | 操作) +- 新增/编辑写入点弹窗(写入来源下拉选项:仅人工/仅程序自动/两者均可) +- 执行写入操作弹窗:区分人工写入和程序自动写入 + - 人工写入:输入值、选择操作人、原因,点击确认后执行并显示结果 + - 程序自动写入:显示调用方标识(如 AI 模型名称) +- 写入操作日志查看(可筛选 source=manual 或 source=auto) +- 导入/导出CSV按钮 + +--- + +## 附录B:关键业务规则一览 + +| 编号 | 规则 | 说明 | +|------|------|------| +| R001 | 设备名称全局唯一 | 逻辑删除的记录不参与唯一性校验 | +| R002 | 采集点名称全局唯一 | 同上 | +| R003 | 写入点名称全局唯一 | 同上 | +| R004 | 逻辑删除级联 | 删除设备时,该设备下所有采集点和写入点也逻辑删除 | +| R005 | 采集地址格式校验 | 根据设备协议类型校验地址格式,创建设备时即确定协议 | +| R006 | 采集周期最小1秒 | 不可低于1秒 | +| R007 | 历史存储间隔最小1分钟 | 当store_history=true时有效 | +| R008 | 写入来源校验 | 写入点的 write_source 控制允许 manual/auto/both | +| R009 | 写入回读验证 | 每次写入后必须回读确认,不一致则重试1次 | +| R010 | 写入点地址类型限制 | 必须使用可写地址类型 | +| R011 | 质量戳自动判定 | 连接断开/读取失败/解析异常 → bad,正常 → good | +| R012 | 断线无限重连 | 持续按配置间隔重连,直到成功或设备被禁用 | +| R013 | 配置变更动态生效 | 修改设备/采集点配置后,采集引擎自动更新,无需重启 | + +--- + +> 本文档版本:v1.1 +> 最后更新:2026-07-09 \ No newline at end of file diff --git a/系统设计图/历史数据趋势分析-深色科技风.png b/系统设计图/历史数据趋势分析-深色科技风.png new file mode 100644 index 0000000..336978e Binary files /dev/null and b/系统设计图/历史数据趋势分析-深色科技风.png differ