feat: 执行结果结构化-每条SQL记录status/affected_rows/duration_ms + 去掉工单详情优先级字段
后端: - 新增 SqlExecItem/SqlExecResult 结构体 - MySQL/Redis/gh-ost 三条执行路径全部改为 JSON 结构化结果 - 每条 SQL 记录完整语句、状态、影响行数、耗时(ms)、错误信息 - 总耗时/开始结束时间一并记录 前端: - ExecuteResult 接口匹配后端 SqlExecResult - 表格展示每条 SQL 执行结果(序号/SQL/状态/影响行数/耗时/错误) - 汇总卡片: 状态/总耗时/SQL数量 - 去掉工单详情中的优先级和变更原因字段 - 保留 plain text fallback 兼容旧数据 E2E验证: INSERT+UPDATE+ALTER 三语句全部成功, 结构化结果正确
This commit is contained in:
1 parent
1364c737ef
commit
bec2cafec2
2 files changed
+133
-66
No files matched your search
@@ -16,6 +16,24 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SqlExecItem records the execution result of a single SQL statement
|
||||||
|
type SqlExecItem struct {
|
||||||
|
SQL string `json:"sql"`
|
||||||
|
Status string `json:"status"` // success / fail
|
||||||
|
AffectedRows int64 `json:"affected_rows"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SqlExecResult is the structured execution result stored as JSON
|
||||||
|
type SqlExecResult struct {
|
||||||
|
Status string `json:"status"` // success / failed
|
||||||
|
TotalMs int64 `json:"total_ms"`
|
||||||
|
Items []SqlExecItem `json:"items"`
|
||||||
|
StartTime string `json:"start_time,omitempty"`
|
||||||
|
EndTime string `json:"end_time,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/v1/tickets/statistics
|
// GET /api/v1/tickets/statistics
|
||||||
func TicketStatistics(c *gin.Context) {
|
func TicketStatistics(c *gin.Context) {
|
||||||
userID, _, role := middleware.GetCurrentUser(c)
|
userID, _, role := middleware.GetCurrentUser(c)
|
||||||
@@ -547,16 +565,34 @@ func ExecuteTicket(c *gin.Context) {
|
|||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
execErr = "empty command"
|
execErr = "empty command"
|
||||||
} else {
|
} else {
|
||||||
cmd := strings.ToUpper(parts[0])
|
|
||||||
args := make([]interface{}, len(parts))
|
args := make([]interface{}, len(parts))
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
args[i] = p
|
args[i] = p
|
||||||
}
|
}
|
||||||
|
execStart := time.Now()
|
||||||
result, err := client.Do(ctx, args...).Result()
|
result, err := client.Do(ctx, args...).Result()
|
||||||
|
stmtMs := time.Since(execStart).Milliseconds()
|
||||||
|
execRes := SqlExecResult{
|
||||||
|
Status: "success",
|
||||||
|
TotalMs: stmtMs,
|
||||||
|
StartTime: execStart.UTC().Format(time.RFC3339),
|
||||||
|
EndTime: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
execErr = fmt.Sprintf("%s failed: %v", cmd, err)
|
execRes.Status = "failed"
|
||||||
|
execRes.Items = []SqlExecItem{{
|
||||||
|
SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs, Error: err.Error(),
|
||||||
|
}}
|
||||||
|
execErr = err.Error()
|
||||||
} else {
|
} else {
|
||||||
execResult = fmt.Sprintf("[OK] %s → %v", ticket.SQLContent, result)
|
resultStr := fmt.Sprintf("%v", result)
|
||||||
|
execRes.Items = []SqlExecItem{{
|
||||||
|
SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs,
|
||||||
|
Error: resultStr,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if jsonBytes, jsonErr := json.Marshal(execRes); jsonErr == nil {
|
||||||
|
execResult = string(jsonBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -582,11 +618,30 @@ func ExecuteTicket(c *gin.Context) {
|
|||||||
execTimeout := getSettingInt("executor_timeout", 300)
|
execTimeout := getSettingInt("executor_timeout", 300)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
execStart := time.Now()
|
||||||
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
|
result, ghErr := ghost.RunGhOst(ctx, *ghostTask, getGhostPath())
|
||||||
|
stmtMs := time.Since(execStart).Milliseconds()
|
||||||
|
ghRes := SqlExecResult{
|
||||||
|
Status: "success",
|
||||||
|
TotalMs: stmtMs,
|
||||||
|
StartTime: execStart.UTC().Format(time.RFC3339),
|
||||||
|
EndTime: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
if ghErr != nil {
|
if ghErr != nil {
|
||||||
execErr = fmt.Sprintf("gh-ost 执行失败: %s\n%s", result.Error, ghErr.Error())
|
ghRes.Status = "failed"
|
||||||
|
ghRes.Items = []SqlExecItem{{
|
||||||
|
SQL: ticket.SQLContent, Status: "fail", DurationMs: stmtMs,
|
||||||
|
Error: fmt.Sprintf("%s\n%s", result.Error, ghErr.Error()),
|
||||||
|
}}
|
||||||
|
execErr = ghErr.Error()
|
||||||
} else {
|
} else {
|
||||||
execResult = fmt.Sprintf("[gh-ost OK] %s\n%s", truncateStr(ticket.SQLContent, 80), result.Output)
|
ghRes.Items = []SqlExecItem{{
|
||||||
|
SQL: ticket.SQLContent, Status: "success", DurationMs: stmtMs,
|
||||||
|
Error: result.Output,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if jsonBytes, jsonErr := json.Marshal(ghRes); jsonErr == nil {
|
||||||
|
execResult = string(jsonBytes)
|
||||||
}
|
}
|
||||||
// Skip normal execution path
|
// Skip normal execution path
|
||||||
goto executionDone
|
goto executionDone
|
||||||
@@ -645,25 +700,34 @@ func ExecuteTicket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Split multi-statement by semicolons
|
// Split multi-statement by semicolons
|
||||||
|
execStart := time.Now()
|
||||||
statements := splitSQL(ticket.SQLContent)
|
statements := splitSQL(ticket.SQLContent)
|
||||||
var results []string
|
execRes := SqlExecResult{Status: "success", StartTime: execStart.UTC().Format(time.RFC3339)}
|
||||||
for _, stmt := range statements {
|
for _, stmt := range statements {
|
||||||
stmt = strings.TrimSpace(stmt)
|
stmt = strings.TrimSpace(stmt)
|
||||||
if stmt == "" {
|
if stmt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, err := db.ExecContext(ctx, stmt)
|
stmtStart := time.Now()
|
||||||
|
sqlResult, err := db.ExecContext(ctx, stmt)
|
||||||
|
stmtMs := time.Since(stmtStart).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err))
|
execRes.Items = append(execRes.Items, SqlExecItem{
|
||||||
// stop_on_error
|
SQL: stmt, Status: "fail", DurationMs: stmtMs, Error: err.Error(),
|
||||||
|
})
|
||||||
|
execRes.Status = "failed"
|
||||||
execErr = err.Error()
|
execErr = err.Error()
|
||||||
break
|
break
|
||||||
} else {
|
|
||||||
results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50)))
|
|
||||||
}
|
}
|
||||||
|
affected, _ := sqlResult.RowsAffected()
|
||||||
|
execRes.Items = append(execRes.Items, SqlExecItem{
|
||||||
|
SQL: stmt, Status: "success", AffectedRows: affected, DurationMs: stmtMs,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if len(results) > 0 {
|
execRes.TotalMs = time.Since(execStart).Milliseconds()
|
||||||
execResult = strings.Join(results, "\n")
|
execRes.EndTime = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if jsonBytes, err := json.Marshal(execRes); err == nil {
|
||||||
|
execResult = string(jsonBytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,16 +84,20 @@ interface TicketStatistics {
|
|||||||
failed: number;
|
failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecuteResult {
|
interface SqlExecItem {
|
||||||
status: string;
|
sql: string;
|
||||||
|
status: string; // success / fail
|
||||||
affected_rows: number;
|
affected_rows: number;
|
||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
validation_passed: boolean;
|
error: string;
|
||||||
warnings: string[];
|
}
|
||||||
errors: string[];
|
|
||||||
raw_output: string;
|
interface ExecuteResult {
|
||||||
before_snapshot?: string;
|
status: string; // success / failed
|
||||||
after_snapshot?: string;
|
total_ms: number;
|
||||||
|
items: SqlExecItem[];
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -725,8 +729,6 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
{ label: '目标库', value: ticket.target_database },
|
{ label: '目标库', value: ticket.target_database },
|
||||||
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
{ label: '数据库类型', value: ticket.db_type.toUpperCase() },
|
||||||
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
|
||||||
{ label: '变更原因', value: ticket.reason || '-' },
|
|
||||||
{ label: '优先级', value: ticket.priority === 'high' ? '高' : ticket.priority === 'low' ? '低' : '中' },
|
|
||||||
].map((item, idx) => (
|
].map((item, idx) => (
|
||||||
<div key={idx} className="flex justify-between py-1.5">
|
<div key={idx} className="flex justify-between py-1.5">
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
||||||
@@ -745,15 +747,15 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Execute Result — 结构化展示 */}
|
{/* Execute Result — 结构化表格展示 */}
|
||||||
{execResult && (
|
{execResult && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
<h3 className="text-sm font-medium mb-2 flex items-center gap-1.5">
|
||||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary row */}
|
||||||
<div className="grid grid-cols-4 gap-3 mb-3">
|
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">状态</div>
|
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">状态</div>
|
||||||
<div className={`text-sm font-medium ${execResult.status === 'success' ? 'text-green-400' : 'text-red-400'}`}>
|
<div className={`text-sm font-medium ${execResult.status === 'success' ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
@@ -761,53 +763,54 @@ function TicketDetailDialog({ ticket: initialTicket, onClose, canApprove, canExe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">影响行数</div>
|
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">总耗时</div>
|
||||||
<div className="text-sm font-medium">{execResult.affected_rows}</div>
|
<div className="text-sm font-medium">{execResult.total_ms}ms</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">执行耗时</div>
|
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">SQL 数量</div>
|
||||||
<div className="text-sm font-medium">{execResult.duration_ms}ms</div>
|
<div className="text-sm font-medium">{execResult.items?.length || 0}</div>
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-center">
|
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">验证</div>
|
|
||||||
<div className={`text-sm font-medium ${execResult.validation_passed ? 'text-green-400' : 'text-red-400'}`}>
|
|
||||||
{execResult.validation_passed ? '通过' : '未通过'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Raw Output */}
|
{/* Per-SQL result table */}
|
||||||
{execResult.raw_output && (
|
{execResult.items && execResult.items.length > 0 && (
|
||||||
<div className="mb-3">
|
<div className="overflow-x-auto">
|
||||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1">原始输出</div>
|
<table className="w-full text-xs">
|
||||||
<pre className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono overflow-x-auto whitespace-pre-wrap max-h-[120px] overflow-y-auto">
|
<thead>
|
||||||
{execResult.raw_output}
|
<tr className="text-[var(--dg-text-muted)] text-left">
|
||||||
</pre>
|
<th className="pb-2 pr-3 font-medium w-6">#</th>
|
||||||
</div>
|
<th className="pb-2 pr-3 font-medium">SQL 语句</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium w-16">执行结果</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium w-16">影响行数</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium w-20">执行时间</th>
|
||||||
|
<th className="pb-2 font-medium">错误信息</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{execResult.items.map((item, idx) => (
|
||||||
|
<tr key={idx} className="border-t border-[var(--dg-border)]">
|
||||||
|
<td className="py-2 pr-3 text-[var(--dg-text-muted)]">{idx + 1}</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<pre className="font-mono text-xs whitespace-pre-wrap max-h-[60px] overflow-y-auto">{item.sql}</pre>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<span className={item.status === 'success' ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{item.status === 'success' ? '成功' : '失败'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">{item.affected_rows}</td>
|
||||||
|
<td className="py-2 pr-3">{item.duration_ms}ms</td>
|
||||||
|
<td className="py-2">
|
||||||
|
{item.error && (
|
||||||
|
<span className={item.status === 'fail' ? 'text-red-400' : 'text-[var(--dg-text-muted)]'}>
|
||||||
|
{item.error.length > 80 ? item.error.substring(0, 80) + '...' : item.error}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</td>
|
||||||
{/* Errors */}
|
</tr>
|
||||||
{execResult.errors && execResult.errors.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] text-red-400 mb-1">错误信息</div>
|
|
||||||
{execResult.errors.map((err, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2 p-2 mb-1 bg-red-500/5 border border-red-500/20 rounded-md text-xs text-red-400">
|
|
||||||
<AlertTriangle className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
|
||||||
{err}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Warnings */}
|
|
||||||
{execResult.warnings && execResult.warnings.length > 0 && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<div className="text-[10px] text-amber-400 mb-1">警告信息</div>
|
|
||||||
{execResult.warnings.map((w, i) => (
|
|
||||||
<div key={i} className="p-2 mb-1 bg-amber-500/5 border border-amber-500/20 rounded-md text-xs text-amber-400">
|
|
||||||
{w}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user