From dac7457c60e3eed37d731bb4d6ae935afa9d0ee6 Mon Sep 17 00:00:00 2001 From: qsc20001102 Date: Mon, 13 Jul 2026 11:13:00 +0800 Subject: [PATCH] OK --- README.md | 149 ++++++++++++++++++++- docs/development-log.md | 38 ++++++ internal/api/router.go | 10 +- internal/repository/tdengine/store.go | 17 ++- internal/repository/tdengine/store_test.go | 8 ++ internal/service/platform/history.go | 43 ++++-- internal/service/platform/history_test.go | 47 +++++++ pnpm-workspace.yaml | 2 +- web/src/components/charts/HistoryChart.vue | 11 ++ 9 files changed, 297 insertions(+), 28 deletions(-) create mode 100644 internal/service/platform/history_test.go diff --git a/README.md b/README.md index b1bf0d2..2c42dd4 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,152 @@ ## 本地启动 -1. 复制 `.env.example` 为 `.env`,通过环境变量注入数据库配置(应用不会自动读取文件)。 -2. `cd web && pnpm install && pnpm build` -3. `go run ./cmd/server` +当前项目采用前后端分离运行: -服务启动时执行幂等迁移并对 PostgreSQL、TDengine 进行带超时健康检查。生产环境应通过进程管理器注入环境变量,并仅在受信任内网暴露写入 API。 +- 后端:Go HTTP 服务,默认监听 `APP_PORT=8080`。 +- 前端:Vue 3 + Vite 开发服务,默认监听 `5173`。 +- 数据库:PostgreSQL 存储配置数据,TDengine 存储历史数据。 + +注意:应用不会自动读取 `.env` 文件。启动后端前,必须先把 `.env` 中的配置注入当前 PowerShell 进程环境变量。 + +### 1. 准备 `.env` + +复制 `.env.example` 为 `.env`,并填写实际数据库连接信息: + +```env +APP_PORT=8080 +POSTGRES_HOST=数据库地址 +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=实际密码 +POSTGRES_DATABASE=aquacontrolai +POSTGRES_SSLMODE=disable +TDENGINE_HOST=数据库地址 +TDENGINE_PORT=6041 +TDENGINE_USER=root +TDENGINE_PASSWORD=实际密码 +TDENGINE_DATABASE=aquacontrolai +HISTORY_RETENTION_DAYS=365 +COLLECTOR_WORKERS=8 +``` + +如果数据库部署在云服务器上,需要确认本机可以访问云服务器的 PostgreSQL `5432` 和 TDengine REST `6041` 端口。 + +### 2. 安装前端依赖 + +首次启动前执行: + +```powershell +Set-Location "E:\Vibe Coding\AquaControlAI\web" +pnpm install +``` + +如遇到 `esbuild` 构建脚本审批问题,执行: + +```powershell +Set-Location "E:\Vibe Coding\AquaControlAI" +pnpm approve-builds --all +``` + +### 3. 启动后端 + +打开一个 PowerShell 窗口,执行: + +```powershell +Set-Location "E:\Vibe Coding\AquaControlAI" + +Get-Content .env -Encoding UTF8 | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith("#") -and $line -match "^([^=]+)=(.*)$") { + [Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process") + } +} + +go run ./cmd/server +``` + +后端启动时会执行幂等迁移,并对 PostgreSQL、TDengine 进行带超时健康检查。如果数据库连接失败,后端会直接退出。 + +### 4. 启动前端 + +另打开一个 PowerShell 窗口,执行: + +```powershell +Set-Location "E:\Vibe Coding\AquaControlAI\web" +pnpm dev +``` + +浏览器访问: + +```text +http://127.0.0.1:5173 +``` + +Vite 已配置 `/api` 代理到 `http://127.0.0.1:8080`,所以前端页面会通过本地后端访问接口。 + +### 5. 验证服务 + +验证后端健康检查: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8080/api/v1/health" +``` + +验证前端代理到后端是否正常: + +```powershell +Invoke-RestMethod "http://127.0.0.1:5173/api/v1/health" +``` + +正常返回应包含: + +```json +{ + "code": 0, + "message": "success", + "data": { + "status": "ok" + } +} +``` + +## 停止服务 + +如果后端和前端是在两个 PowerShell 窗口中直接启动的,分别在对应窗口按: + +```text +Ctrl + C +``` + +如果服务在后台运行,或窗口已经关闭,可以按端口查找并停止进程。 + +停止后端 `8080` 端口: + +```powershell +$conn = Get-NetTCPConnection -LocalPort 8080 -ErrorAction SilentlyContinue +if ($conn) { + Stop-Process -Id $conn.OwningProcess -Force +} +``` + +停止前端 `5173` 端口: + +```powershell +$conn = Get-NetTCPConnection -LocalPort 5173 -ErrorAction SilentlyContinue +if ($conn) { + Stop-Process -Id $conn.OwningProcess -Force +} +``` + +确认端口已释放: + +```powershell +Get-NetTCPConnection -LocalPort 8080,5173 -ErrorAction SilentlyContinue +``` + +没有输出表示端口已经释放。 + +生产环境应通过进程管理器注入环境变量,并仅在受信任内网暴露写入 API。 ## 安全说明 diff --git a/docs/development-log.md b/docs/development-log.md index 6843473..6186507 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -158,3 +158,41 @@ - 根因:采集读失败后 `Manager` 将设备状态置为 `disconnected`;缓存连接后续读取成功时此前只更新点位最新数据,没有恢复设备状态,因此设备管理页持续显示离线。 - 修复:采集成功后调用 `Manager.MarkConnected` 恢复在线状态;`MarkConnected` 与 `MarkDisconnected` 仅在状态发生变化时更新时间戳,避免每个采集周期反复刷新最近在线/离线时间。 - 验证:真实环境中 PLC1、PLC2、PLC3 均显示 `connected`,40 个采集点的 `latest_value.ts` 持续更新;新增设备状态恢复及时间戳稳定性单元测试。 + +## 2026-07-13 · 启停流程文档与历史曲线断档修复 + +- 维护约定:从本次起,后续每次代码、配置、部署流程或验证方式调整,都必须同步记录到 `docs/development-log.md`。 +- README 启停流程补充:明确项目按前后端分离运行;后端默认 `8080`,前端 Vite 默认 `5173`;应用不会自动读取 `.env`,启动后端前必须先在 PowerShell 中注入环境变量;补充后端、前端启动命令、健康检查命令和按端口停止服务命令。 +- pnpm 构建脚本审批:将 `pnpm-workspace.yaml` 中 `allowBuilds.esbuild` 从占位值修正为 `true`,解决 `esbuild@0.25.12` postinstall 被拦截导致前端无法构建的问题。 +- 启动验证:`.env` 已指向云端数据库,PostgreSQL `101.35.54.96:5432` 与 TDengine REST `101.35.54.96:6041` TCP 连通;`GET http://127.0.0.1:8080/api/v1/health` 与 `GET http://127.0.0.1:5173/api/v1/health` 均返回 `code=0`。 +- 服务停止验证:停止后端 Go 进程与前端 Vite 进程后,`8080` 和 `5173` 均无监听进程。 +- 历史曲线断档根因:曲线查询只返回真实采样点,停机或未采集时段没有显式空值断点;ECharts 在相邻有效点之间会直接连线,因此会把 2026-07-13 02:00 到 08:00 这类无数据时段渲染成连续曲线。 +- 历史曲线修复:后端 `history/query` 按点位 `history_interval` 判断断档,相邻样本间隔超过 `3 × history_interval` 时插入 `value=null, quality=none` 断点;前端游标插值遇到空值或非 good 质量断点时不再跨断档估算,避免提示表显示伪造的连续值。 +- 新增测试:`internal/service/platform/history_test.go` 覆盖长时间无数据插入断点、短时间采集抖动不误判断档。 +- 验证:`go test ./...` 通过;`pnpm build` 通过,仅保留既有 ECharts chunk 体积提示;`pnpm lint` 未通过,原因为项目当前缺少 ESLint 9 所需的 `eslint.config.js`,不是本次变更引入。 + +## 2026-07-13 · TDengine 时间戳时区修复 + +- 现象:TDengine 工具中最新历史数据时间显示为 `2026-07-13 17:50:53.026`,比现场当前时间快 8 小时;页面实时数据时间显示正常。 +- 根因:历史写入使用 `2006-01-02 15:04:05.000` 无时区格式,TDengine REST 链路按 UTC instant 存储;数据库工具按本地时区显示后形成 `+8h` 偏移。历史查询代码此前又把读回时间按上海墙上时间重建,导致页面显示正常但库内物理时间不一致。 +- 验证:临时 TDengine 探测表写入同一时间的两种格式,`plain` 无时区读回为 `2026-07-13T10:10:10.123Z`,`rfc3339` 带 `+08:00` 读回为 `2026-07-13T02:10:10.123Z`,证明无时区写入会偏移 8 小时。 +- 修复:TDengine 写入和查询时间字面量统一改为 RFC3339 样式 `yyyy-MM-ddTHH:mm:ss.SSS+08:00`;读回时间按真实 instant 转换为 `Asia/Shanghai`,不再重建墙上时间。 +- 测试:新增 `TestTDTimeLiteralUsesShanghaiOffset`,锁定 TDengine 时间字面量必须带 `+08:00`。 +- 验证:`go test ./...` 通过;`pnpm build` 通过,仅保留既有 ECharts chunk 体积提示。 +- 运行验证:重启后端后,`8080` 后端健康检查与 `5173` 前端代理健康检查均返回 `code=0`;对点位 `1dd3c3c4-bb27-4b57-bd10-039a6e2a81d9` 查询 `2026-07-13T10:00:00+08:00` 到 `10:30:00+08:00` 区间,TDengine 返回新记录 `2026-07-13 10:05:41.002 +08:00`,证明新写入时间已按本地真实时间入库。 +- 注意:修复只保证后续新写入数据时间正确;已经写入 TDengine 的旧历史行物理时间戳已偏移到未来,如需修正,需要在停采后执行一次受控数据回拨/重写操作,不能自动盲改。 + +## 2026-07-13 · TDengine 旧历史数据时间回拨 + +- 操作前先停止后端采集进程,确认 `8080` 无监听,避免迁移期间继续写入历史数据。 +- 迁移范围:枚举 TDengine `collection_data` 下 40 张 `p_*` 点位子表,共 1562 行历史数据;dry-run 统计 1526 行为旧无时区写入数据,需要整体回拨 8 小时,36 行位于 `2026-07-13 10:00~10:30 +08:00` 修复后新写入窗口,保留原时间不动。 +- 备份:执行前导出 JSONL 本地备份 `C:\Users\ADMINI~1\AppData\Local\Temp\aquacontrolai-tdengine-history-backup-20260713-101548.jsonl`,记录原始时间、修正后时间、点位、设备、质量和值。 +- 修复方式:因时间戳为 TDengine 时序主键,不直接执行盲目 `UPDATE`;脚本逐表读取全量行、计算修正时间、检查修正后同表时间戳冲突,然后 `DROP TABLE` 子表并按原稳定表 `collection_data`、原标签和原数据列重建写回。 +- 结果:实际执行后 `repair_complete rows=1562`,迁移前后总行数一致。 +- 验证:全库最新时间由未来 `18:00` 左右恢复为 `2026-07-13 10:05:46.506 +08:00`;示例点位 `1dd3c3c4-bb27-4b57-bd10-039a6e2a81d9` 最新 5 条为 `10:05:41`、`10:00:53`、`09:50:53`、`09:48:52`、`09:43:55`;该点位 `2026-07-13 02:00~08:00 +08:00` 区间查询结果为 0 条。 + +## 2026-07-13 · 历史数据 CSV 导出列调整 + +- 需求:历史数据表格导出 CSV 时,每个测点只导出一列实际值,不再为每个测点额外导出“质量”列。 +- 修复:`POST /api/v1/history/export` 表头从“时间 + 点位值列 + 点位质量列”改为“时间 + 每个点位一个值列”;行数据仅写入 `value`,无值时留空,不再输出 `good/bad/—` 质量文本。 +- 验证:`go test ./...` 通过;重启后端后调用 `POST /api/v1/history/export` 导出点位 `1dd3c3c4-bb27-4b57-bd10-039a6e2a81d9` 的 `2026-07-13 09:40~10:10 +08:00` 数据,CSV 表头为 `时间,2区PAC投加流量[L/h]`,列数为 2,无质量列。 diff --git a/internal/api/router.go b/internal/api/router.go index d010d52..1bfa5fb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -711,19 +711,17 @@ func (h *Handler) exportHistory(c *gin.Context) { n += "_" + col.PointID.String()[:8] } used[n] = true - head = append(head, n, n+"_质量") + head = append(head, n) } w.Write(head) for i, t := range data.TimeColumn { row := []string{t.Format("2006-01-02 15:04:05")} for _, col := range data.Columns { x := col.Data[i] - if x.Quality == "good" && x.Value != nil { - row = append(row, strconv.FormatFloat(*x.Value, 'f', -1, 64), "good") - } else if x.Quality == "bad" { - row = append(row, "—", "bad") + if x.Value != nil { + row = append(row, strconv.FormatFloat(*x.Value, 'f', -1, 64)) } else { - row = append(row, "—", "—") + row = append(row, "") } } w.Write(row) diff --git a/internal/repository/tdengine/store.go b/internal/repository/tdengine/store.go index ff4c44e..e59a08d 100644 --- a/internal/repository/tdengine/store.go +++ b/internal/repository/tdengine/store.go @@ -28,11 +28,18 @@ func (s *Store) Insert(ctx context.Context, p pg.PointRow, d model.Device, value if reason != nil { reasonLiteral = sqlString(*reason) } - q := fmt.Sprintf("INSERT INTO `%s`.`%s` USING `%s`.`collection_data` TAGS(%s,%s,%s) VALUES(%s,%s,%d,%s,%s,%s)", s.Database, TableName(p.ID), s.Database, sqlString(d.ID.String()), sqlString(d.Name), sqlString(p.DataType), sqlString(ts.Format("2006-01-02 15:04:05.000")), valueLiteral, quality, reasonLiteral, sqlString(p.ID.String()), sqlString(p.Name)) + q := fmt.Sprintf("INSERT INTO `%s`.`%s` USING `%s`.`collection_data` TAGS(%s,%s,%s) VALUES(%s,%s,%d,%s,%s,%s)", s.Database, TableName(p.ID), s.Database, sqlString(d.ID.String()), sqlString(d.Name), sqlString(p.DataType), sqlString(tdTimeLiteral(ts)), valueLiteral, quality, reasonLiteral, sqlString(p.ID.String()), sqlString(p.Name)) _, e := s.DB.ExecContext(ctx, q) return e } func sqlString(v string) string { return "'" + strings.ReplaceAll(v, "'", "''") + "'" } + +var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60) + +func tdTimeLiteral(t time.Time) string { + return t.In(shanghai).Format("2006-01-02T15:04:05.000-07:00") +} + func (s *Store) SetRetention(ctx context.Context, days int) error { if days < 1 || days > 730 { return fmt.Errorf("保留天数超出范围") @@ -95,8 +102,7 @@ func (s *Store) DropTables(ctx context.Context, ids []uuid.UUID) (int, error) { return removed, nil } func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ([]Sample, error) { - shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) - q := fmt.Sprintf("SELECT ts,`value`,quality,quality_reason FROM `%s`.`%s` WHERE ts >= %s AND ts <= %s ORDER BY ts", s.Database, TableName(id), sqlString(start.In(shanghai).Format("2006-01-02 15:04:05.000")), sqlString(end.In(shanghai).Format("2006-01-02 15:04:05.000"))) + q := fmt.Sprintf("SELECT ts,`value`,quality,quality_reason FROM `%s`.`%s` WHERE ts >= %s AND ts <= %s ORDER BY ts", s.Database, TableName(id), sqlString(tdTimeLiteral(start)), sqlString(tdTimeLiteral(end))) rows, e := s.DB.QueryContext(ctx, q) if e != nil { return nil, e @@ -109,10 +115,7 @@ func (s *Store) Query(ctx context.Context, id uuid.UUID, start, end time.Time) ( if e = rows.Scan(&x.TS, &x.Value, &quality, &x.QualityReason); e != nil { return nil, e } - // The REST driver returns TDengine's local wall-clock timestamp with a - // UTC location. Rebuild it in Asia/Shanghai instead of converting the - // instant, otherwise an eight-hour offset would be applied twice. - x.TS = time.Date(x.TS.Year(), x.TS.Month(), x.TS.Day(), x.TS.Hour(), x.TS.Minute(), x.TS.Second(), x.TS.Nanosecond(), shanghai) + x.TS = x.TS.In(shanghai) if quality == 0 { x.Quality = "good" } else { diff --git a/internal/repository/tdengine/store_test.go b/internal/repository/tdengine/store_test.go index c68c1f6..cf9e7ce 100644 --- a/internal/repository/tdengine/store_test.go +++ b/internal/repository/tdengine/store_test.go @@ -3,6 +3,7 @@ package tdengine import ( "github.com/google/uuid" "testing" + "time" ) func TestTableName(t *testing.T) { @@ -16,3 +17,10 @@ func TestSQLStringEscapesQuote(t *testing.T) { t.Fatalf("unexpected literal: %s", got) } } + +func TestTDTimeLiteralUsesShanghaiOffset(t *testing.T) { + ts := time.Date(2026, 7, 13, 2, 10, 10, 123000000, time.UTC) + if got := tdTimeLiteral(ts); got != "2026-07-13T10:10:10.123+08:00" { + t.Fatalf("unexpected TDengine timestamp literal: %s", got) + } +} diff --git a/internal/service/platform/history.go b/internal/service/platform/history.go index d34f2aa..75d1672 100644 --- a/internal/service/platform/history.go +++ b/internal/service/platform/history.go @@ -21,14 +21,15 @@ type History struct { } } type Series struct { - PointID uuid.UUID `json:"point_id"` - PointName string `json:"point_name"` - DataType string `json:"data_type"` - Unit *string `json:"unit"` - Sampled bool `json:"sampled"` - RawCount int `json:"raw_count"` - SampleCount int `json:"sample_count"` - Data []td.Sample `json:"data"` + PointID uuid.UUID `json:"point_id"` + PointName string `json:"point_name"` + DataType string `json:"data_type"` + Unit *string `json:"unit"` + HistoryInterval int `json:"history_interval"` + Sampled bool `json:"sampled"` + RawCount int `json:"raw_count"` + SampleCount int `json:"sample_count"` + Data []td.Sample `json:"data"` } func (h *History) Tree(ctx context.Context) ([]map[string]any, error) { @@ -108,13 +109,35 @@ func (h *History) Query(ctx context.Context, ids []uuid.UUID, start, end time.Ti return nil, e } raw := len(data) - if raw > max { + data = withHistoryGaps(data, time.Duration(p.HistoryInterval)*time.Minute) + sampled := len(data) > max + if sampled { data = minMax(data, max) } - out = append(out, Series{id, p.Name, p.DataType, p.Unit, raw > len(data), raw, len(data), data}) + out = append(out, Series{id, p.Name, p.DataType, p.Unit, p.HistoryInterval, sampled, raw, len(data), data}) } return out, nil } + +func withHistoryGaps(data []td.Sample, interval time.Duration) []td.Sample { + if len(data) < 2 || interval <= 0 { + return data + } + threshold := interval * 3 + out := make([]td.Sample, 0, len(data)) + out = append(out, data[0]) + for i := 1; i < len(data); i++ { + prev := data[i-1] + current := data[i] + gap := current.TS.Sub(prev.TS) + if gap > threshold { + out = append(out, td.Sample{TS: prev.TS.Add(gap / 2), Quality: "none"}) + } + out = append(out, current) + } + return out +} + func minMax(data []td.Sample, max int) []td.Sample { if len(data) <= max { return data diff --git a/internal/service/platform/history_test.go b/internal/service/platform/history_test.go new file mode 100644 index 0000000..1b1ad47 --- /dev/null +++ b/internal/service/platform/history_test.go @@ -0,0 +1,47 @@ +package platform + +import ( + td "aquacontrolai/internal/repository/tdengine" + "testing" + "time" +) + +func sampleAt(ts time.Time, value float64) td.Sample { + return td.Sample{TS: ts, Value: &value, Quality: "good"} +} + +func TestWithHistoryGapsInsertsNullBreakpointForLongOutage(t *testing.T) { + start := time.Date(2026, 7, 13, 1, 59, 0, 0, time.UTC) + data := []td.Sample{ + sampleAt(start, 1), + sampleAt(start.Add(time.Minute), 2), + sampleAt(start.Add(6*time.Hour), 3), + } + + got := withHistoryGaps(data, time.Minute) + + if len(got) != 4 { + t.Fatalf("expected gap breakpoint, got %d rows", len(got)) + } + if got[2].Value != nil || got[2].Quality != "none" { + t.Fatalf("expected null gap marker, got value=%v quality=%q", got[2].Value, got[2].Quality) + } + if !got[2].TS.After(got[1].TS) || !got[2].TS.Before(got[3].TS) { + t.Fatalf("gap marker should be inside outage: marker=%v before=%v after=%v", got[2].TS, got[1].TS, got[3].TS) + } +} + +func TestWithHistoryGapsAllowsExpectedIntervalJitter(t *testing.T) { + start := time.Date(2026, 7, 13, 1, 59, 0, 0, time.UTC) + data := []td.Sample{ + sampleAt(start, 1), + sampleAt(start.Add(2*time.Minute), 2), + sampleAt(start.Add(3*time.Minute), 3), + } + + got := withHistoryGaps(data, time.Minute) + + if len(got) != len(data) { + t.Fatalf("expected no gap breakpoint for short jitter, got %d rows", len(got)) + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b354ef3..374bc75 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - web allowBuilds: - esbuild: set this to true or false + esbuild: true onlyBuiltDependencies: - esbuild diff --git a/web/src/components/charts/HistoryChart.vue b/web/src/components/charts/HistoryChart.vue index aa312a3..817a590 100644 --- a/web/src/components/charts/HistoryChart.vue +++ b/web/src/components/charts/HistoryChart.vue @@ -140,6 +140,17 @@ function cursorRow(s: any, target: number) { } } if (before && after && before.time !== after.time) { + const blockedBetween = points.some( + (point: any) => + point.time > before.time && + point.time < after.time && + (point.value === null || + point.value === undefined || + point.quality !== "good"), + ); + if (blockedBetween) { + return base; + } const ratio = (target - before.time) / (after.time - before.time); const badBetween = points.some( (point: any) =>