11
This commit is contained in:
@@ -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
|
||||
<!-- ✅ 正确示例 -->
|
||||
<script setup lang="ts">
|
||||
// 1. props 和 emit 使用类型定义
|
||||
interface Props {
|
||||
deviceId: string
|
||||
loading?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', id: string): void
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
// 2. 组合式函数提取可复用逻辑
|
||||
const { data, fetchData } = useDeviceDetail(props.deviceId)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-detail">
|
||||
<!-- 模板保持简洁,复杂条件逻辑使用计算属性 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 使用 scoped style,避免全局污染 */
|
||||
</style>
|
||||
```
|
||||
|
||||
### 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<string, unknown>) {
|
||||
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<Set<string>>(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. 本文档随项目推进持续更新,新增模块时补充对应章节
|
||||
@@ -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
|
||||
+1128
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Reference in New Issue
Block a user