This commit is contained in:
2026-07-13 11:13:00 +08:00
parent ef96af7f21
commit dac7457c60
9 changed files with 297 additions and 28 deletions
+145 -4
View File
@@ -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。
## 安全说明
+38
View File
@@ -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,无质量列。
+4 -6
View File
@@ -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)
+10 -7
View File
@@ -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 {
@@ -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)
}
}
+33 -10
View File
@@ -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
+47
View File
@@ -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))
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
packages:
- web
allowBuilds:
esbuild: set this to true or false
esbuild: true
onlyBuiltDependencies:
- esbuild
@@ -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) =>