Files
MyGoNavi/internal/app/explain_parse_common.go
Syngnat 1a8effb51f 🐛 fix(export,ssh,secret,explain): 修复 SQL 导出注入与六处静默数据丢失/资源泄漏
- SQL 导出:formatSQLValue 按方言转义反斜杠,修复 MySQL 系 dump 还原时静默改写数据、
  且以反斜杠结尾的值吞掉闭合引号致源库恶意行可执行任意 SQL 的问题;非 MySQL 方言保持原样
- 明文凭据:daily_secrets.json 由 0o644 改为 0o600、目录改 0o700,并对历史文件显式 Chmod
- SSH 隧道:RegisterSSHNetwork 改为确定性 network 名并复用缓存客户端,消除驱动全局 dialer
  表随重连线性增长、永久钉住 ssh.Client 的连接与 goroutine 泄漏
- xlsx 导入:单元格 r 属性列号增加 OOXML 16384 上限并提前熔断,避免篡改文件放大分配致 OOM
- 数据导出:三处非 SQL 导出入口捕获 file.Close 错误,不再在落盘失败时返回“导出成功”
- 数据根迁移:copyFile 捕获 Close 错误并补 Sync,避免被截断的副本被判定为迁移成功
- 执行计划:节点 ID 改为按 ExplainResult 派生,移除进程级全局计数器与 reset,
  修复并发诊断互相踩踏编号导致的重复 node ID 与错挂父子边
- 补充 5 个回归测试文件,含“导出→切分”闭环断言与未转义时的反向对照
2026-07-26 19:40:33 +08:00

163 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// SQL 诊断工作台:方言解析器公共工具。
//
// 本文件只放跨方言共享的辅助函数;每方言解析器在 explain_parse_<db>.go。
// nextExplainNodeIDFor 返回该 result 内的下一个节点 ID"n1"、"n2"……)。
//
// 编号从 result.Nodes 的长度派生而不是用包级计数器DiagnoseQuery 是 Wails 绑定方法,
// 每个前端调用都在独立 goroutine 中派发,两次并发诊断会互相踩踏共享计数器,导致同一份
// ExplainResult 里出现重复 node ID、Edges 的 From/To 指向歧义节点、前端计划图渲染错乱。
// 由于 result.Nodes 只在 appendExplainChild 内追加、且调用方从不预设 node.ID
// len(result.Nodes)+1 与原先的递增序列完全等价,同时天然做到每次解析相互隔离。
func nextExplainNodeIDFor(result *connection.ExplainResult) string {
return fmt.Sprintf("n%d", len(result.Nodes)+1)
}
// appendExplainChild 把子节点追加到 result.Nodes并生成对应的 ExplainEdge。
// parentID 为空时不生成 Edge根节点
func appendExplainChild(result *connection.ExplainResult, parentID string, node connection.ExplainNode) (nodeID string) {
if node.ID == "" {
node.ID = nextExplainNodeIDFor(result)
}
if parentID != "" {
node.ParentID = parentID
result.Edges = append(result.Edges, connection.ExplainEdge{From: parentID, To: node.ID})
}
result.Nodes = append(result.Nodes, node)
return node.ID
}
// finalizeExplainStats 遍历所有节点,计算聚合统计并写入 Stats 字段。
// 在解析器返回前调用。
//
// 注意TotalDurationMs 在 PG/MySQL 8.0 中由解析器直接从 Execution Time 写入,
// 这里只在解析器未设置时(=0才用节点累加值兜底避免覆盖更精确的实例值。
func finalizeExplainStats(result *connection.ExplainResult) {
if result == nil || len(result.Nodes) == 0 {
return
}
var totalCost, accumulatedDuration float64
var rowsRead, maxRows int64
var bufferHitSum float64
var bufferHitCount int
for _, n := range result.Nodes {
if n.Cost > 0 {
totalCost += n.Cost
}
if n.DurationMs > 0 {
accumulatedDuration += n.DurationMs
}
if n.OpType == connection.ExplainOpScan || n.OpType == connection.ExplainOpIndexScan || n.OpType == connection.ExplainOpIndexOnly {
rowsRead += n.EstRows
}
if n.EstRows > maxRows {
maxRows = n.EstRows
}
if n.BufferHit > 0 {
bufferHitSum += n.BufferHit
bufferHitCount++
}
for _, flag := range n.Flags {
switch flag {
case connection.ExplainFlagFullScan:
result.Stats.HasFullScan = true
case connection.ExplainFlagFilesort:
result.Stats.HasFilesort = true
case connection.ExplainFlagTempTable:
result.Stats.HasTempTable = true
}
}
}
if result.Stats.TotalCost == 0 {
result.Stats.TotalCost = totalCost
}
if result.Stats.TotalDurationMs == 0 && accumulatedDuration > 0 {
result.Stats.TotalDurationMs = accumulatedDuration
}
result.Stats.RowsRead = rowsRead
result.Stats.MaxEstRows = maxRows
if bufferHitCount > 0 {
result.Stats.BufferHitRate = bufferHitSum / float64(bufferHitCount)
}
}
// parseExplainTSVRows 把 collectExplainRaw 生成的 TSV 原文重新切分为行(每行 []string 按列拆)。
// 第一行视为列头;空行跳过。
func parseExplainTSVRows(raw string) (header []string, rows [][]string) {
lines := strings.Split(strings.TrimSpace(raw), "\n")
if len(lines) == 0 {
return nil, nil
}
header = strings.Split(lines[0], "\t")
for i := 1; i < len(lines); i++ {
line := strings.TrimRight(lines[i], "\r")
if strings.TrimSpace(line) == "" {
continue
}
rows = append(rows, strings.Split(line, "\t"))
}
return header, rows
}
// lookupTSVColumn 在 header 中按列名查找索引(大小写不敏感);未找到返回 -1。
func lookupTSVColumn(header []string, names ...string) int {
if len(header) == 0 || len(names) == 0 {
return -1
}
for _, name := range names {
target := strings.ToLower(strings.TrimSpace(name))
if target == "" {
continue
}
for i, h := range header {
if strings.ToLower(strings.TrimSpace(h)) == target {
return i
}
}
}
return -1
}
// parseExplainInt64 容错地把字符串解析为 int64空/非法返回 0
func parseExplainInt64(s string) int64 {
s = strings.TrimSpace(s)
if s == "" || s == "NULL" || s == "<nil>" || s == "null" {
return 0
}
var n int64
for _, ch := range s {
if ch < '0' || ch > '9' {
if ch == '-' || ch == '+' {
continue
}
break
}
n = n*10 + int64(ch-'0')
}
return n
}
// parseExplainFloat64 容错地把字符串解析为 float64空/非法返回 0
// 支持形如 "100.00"、"1.5e3" 的简单浮点格式。
func parseExplainFloat64(s string) float64 {
s = strings.TrimSpace(s)
if s == "" || s == "NULL" || s == "<nil>" || s == "null" {
return 0
}
var f float64
_, err := fmt.Sscanf(s, "%f", &f)
if err != nil {
return 0
}
return f
}