Skip to content

Commit 665bbb8

Browse files
committed
fix(ai): 修复对比查询可视化缺失并放宽只读SQL校验
1 parent e25aa44 commit 665bbb8

4 files changed

Lines changed: 283 additions & 57 deletions

File tree

packages/mobile/src/lib/detectVisualization.ts

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,120 @@ import {
1818
export { fieldToLabel, fmtNum, fmtQtyWithUnit, fmtFieldValue, fmtNumRaw }
1919

2020
const CHART_REQUEST_RE = /||||线||线||/
21-
22-
function pickPreferredNumericField(cols: string[]): string | undefined {
23-
const preferred = [
21+
const COMPARISON_REQUEST_RE = /|||.+||||||||||||/
22+
const QUANTITY_REQUEST_RE = /||||||||||||/
23+
const ORDER_COUNT_REQUEST_RE = /|||/
24+
const AMOUNT_REQUEST_RE = /|||||||gmv/i
25+
const DERIVED_CATEGORY_FIELD = '__viz_category__'
26+
27+
function pickPreferredNumericField(cols: string[], question = ''): string | undefined {
28+
const amountPreferred = [
29+
'net_sales', 'netSales', 'sales', 'revenue', 'amount', 'total_amount', 'totalAmount',
30+
'net_profit', 'netProfit', 'gross_profit', 'grossProfit', 'profit_rate', 'profitRate',
31+
'total_qty', 'totalQty', 'quantity', 'available_qty', 'availableQty', 'stock_qty', 'stockQty',
32+
'order_count', 'orderCount', 'orders', 'count', 'cnt',
33+
]
34+
const quantityPreferred = [
35+
'total_qty', 'totalQty', 'quantity', 'available_qty', 'availableQty', 'stock_qty', 'stockQty',
36+
'order_count', 'orderCount', 'orders', 'count', 'cnt',
37+
'net_sales', 'netSales', 'sales', 'revenue', 'amount', 'total_amount', 'totalAmount',
38+
'net_profit', 'netProfit', 'gross_profit', 'grossProfit', 'profit_rate', 'profitRate',
39+
]
40+
const orderPreferred = [
41+
'order_count', 'orderCount', 'orders', 'count', 'cnt',
42+
'total_qty', 'totalQty', 'quantity',
43+
'net_sales', 'netSales', 'sales', 'revenue', 'amount', 'total_amount', 'totalAmount',
44+
'net_profit', 'netProfit', 'gross_profit', 'grossProfit', 'profit_rate', 'profitRate',
45+
]
46+
const fallbackPreferred = [
2447
'net_sales', 'netSales', 'sales', 'revenue', 'amount', 'total_amount', 'totalAmount',
2548
'net_profit', 'netProfit', 'gross_profit', 'grossProfit', 'profit_rate', 'profitRate',
2649
'exchange_amount', 'exchangeAmount', 'return_amount', 'returnAmount', 'refund_amount', 'refundAmount',
2750
'quantity', 'total_qty', 'totalQty', 'available_qty', 'availableQty', 'stock_qty', 'stockQty',
2851
'order_count', 'orderCount', 'orders', 'count', 'cnt',
2952
]
3053
const filtered = cols.filter((field) => !NON_METRIC_NUMERIC_FIELDS.has(field))
54+
if (filtered.length === 0) return undefined
55+
56+
const normalizedQuestion = question.trim()
57+
const preferred = ORDER_COUNT_REQUEST_RE.test(normalizedQuestion)
58+
? orderPreferred
59+
: QUANTITY_REQUEST_RE.test(normalizedQuestion) && !AMOUNT_REQUEST_RE.test(normalizedQuestion)
60+
? quantityPreferred
61+
: AMOUNT_REQUEST_RE.test(normalizedQuestion)
62+
? amountPreferred
63+
: fallbackPreferred
64+
3165
return preferred.find((field) => filtered.includes(field)) ?? filtered[0]
3266
}
3367

68+
function getTextValue(row: Record<string, unknown>, fields: string[]) {
69+
for (const field of fields) {
70+
const value = row[field]
71+
if (typeof value === 'string' && value.trim()) return value.trim()
72+
}
73+
74+
return ''
75+
}
76+
77+
function getDuplicateProductNames(rows: Record<string, unknown>[]) {
78+
const countMap = new Map<string, number>()
79+
80+
rows.forEach((row) => {
81+
const productName = getTextValue(row, ['product_name', 'productName', 'name'])
82+
if (!productName) return
83+
countMap.set(productName, (countMap.get(productName) ?? 0) + 1)
84+
})
85+
86+
return new Set(
87+
[...countMap.entries()]
88+
.filter(([, count]) => count > 1)
89+
.map(([name]) => name),
90+
)
91+
}
92+
93+
function resolveCategoryField(rows: Record<string, unknown>[], stringCols: string[]) {
94+
const preferredField = stringCols[0]
95+
if (!preferredField) return undefined
96+
97+
if (['product_name', 'productName', 'name'].includes(preferredField) && getDuplicateProductNames(rows).size > 0) {
98+
return DERIVED_CATEGORY_FIELD
99+
}
100+
101+
return preferredField
102+
}
103+
104+
function buildDerivedCategoryLabel(row: Record<string, unknown>, duplicateProductNames: Set<string>) {
105+
const productName = getTextValue(row, ['product_name', 'productName', 'name'])
106+
if (!productName) return '-'
107+
if (!duplicateProductNames.has(productName)) return productName
108+
109+
const spec = getTextValue(row, ['spec'])
110+
const year = row.year
111+
const yearText = typeof year === 'number' && Number.isFinite(year)
112+
? `${year}年`
113+
: (typeof year === 'string' && year.trim() ? `${year.trim()}年` : '')
114+
const extraParts = [spec, yearText].filter(Boolean)
115+
116+
return extraParts.length > 0 ? `${productName}${extraParts.join('·')})` : productName
117+
}
118+
119+
export function prepareVisualizationRows(
120+
rows: Record<string, unknown>[],
121+
xField: string,
122+
yField?: string,
123+
) {
124+
const duplicateProductNames = xField === DERIVED_CATEGORY_FIELD ? getDuplicateProductNames(rows) : new Set<string>()
125+
126+
return rows.map((row) => ({
127+
...row,
128+
[xField]: xField === DERIVED_CATEGORY_FIELD
129+
? buildDerivedCategoryLabel(row, duplicateProductNames)
130+
: String(row[xField] ?? ''),
131+
...(yField ? { [yField]: Number(row[yField]) || 0 } : {}),
132+
}))
133+
}
134+
34135
export function detectVisualization(
35136
rows: Record<string, unknown>[],
36137
question: string,
@@ -60,47 +161,56 @@ export function detectVisualization(
60161

61162
const q = question
62163
const isChartRequest = CHART_REQUEST_RE.test(q)
164+
const isComparison = COMPARISON_REQUEST_RE.test(q) && rows.length > 1
63165
const isProportion = /||||/.test(q) && rows.length <= 10
64166
const isTrend = /||||||||/.test(q) &&
65167
(dateCols.length > 0 || stringCols.length > 0)
66168
const hasDetailIdentityField = DETAIL_RECORD_FIELDS.some((field) => cols.includes(field))
67-
const hasPreferredMetric = Boolean(pickPreferredNumericField(numericCols))
169+
const preferredMetric = pickPreferredNumericField(numericCols, q)
170+
const hasPreferredMetric = Boolean(preferredMetric)
171+
172+
if (isComparison && preferredMetric) {
173+
const xField = resolveCategoryField(rows, stringCols)
174+
if (xField) {
175+
return { type: 'bar', xField, yField: preferredMetric }
176+
}
177+
}
68178

69179
if (hasDetailIdentityField && !isChartRequest) {
70180
return { type: 'table' }
71181
}
72182

73183
if (isChartRequest && hasPreferredMetric) {
74-
const preferredYField = pickPreferredNumericField(numericCols)
184+
const preferredYField = preferredMetric
75185

76186
if (isProportion && stringCols.length >= 1 && preferredYField) {
77-
return { type: 'pie', nameField: stringCols[0], valueField: preferredYField }
187+
return { type: 'pie', nameField: resolveCategoryField(rows, stringCols) ?? stringCols[0], valueField: preferredYField }
78188
}
79189

80-
const xField = dateCols[0] || stringCols[0]
190+
const xField = dateCols[0] || resolveCategoryField(rows, stringCols)
81191
if (xField && preferredYField) {
82192
return { type: dateCols.length > 0 ? 'line' : 'bar', xField, yField: preferredYField }
83193
}
84194
}
85195

86196
if (isProportion && hasPreferredMetric && stringCols.length >= 1) {
87-
const valueField = pickPreferredNumericField(numericCols)
197+
const valueField = preferredMetric
88198
if (valueField) return { type: 'pie', nameField: stringCols[0], valueField }
89199
}
90200

91201
if (isTrend && hasPreferredMetric) {
92-
const xField = dateCols[0] || stringCols[0]
93-
const yField = pickPreferredNumericField(numericCols)
202+
const xField = dateCols[0] || resolveCategoryField(rows, stringCols)
203+
const yField = preferredMetric
94204
if (xField && yField) return { type: 'line', xField, yField }
95205
}
96206

97207
if (stringCols.length >= 1 && hasPreferredMetric && rows.length <= 20) {
98-
const yField = pickPreferredNumericField(numericCols)
99-
if (yField) return { type: 'bar', xField: stringCols[0], yField }
208+
const yField = preferredMetric
209+
if (yField) return { type: 'bar', xField: resolveCategoryField(rows, stringCols) ?? stringCols[0], yField }
100210
}
101211

102212
if (dateCols.length >= 1 && hasPreferredMetric) {
103-
const yField = pickPreferredNumericField(numericCols)
213+
const yField = preferredMetric
104214
if (yField) return { type: 'line', xField: dateCols[0], yField }
105215
}
106216

packages/mobile/src/pages/ai/components/AiChart.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
PieChart, Pie, Cell, Legend,
99
} from 'recharts'
1010
import type { AiVisualizationSpec } from '@/types'
11-
import { fieldToLabel, fmtNum, fmtQtyWithUnit, fmtFieldValue, fmtNumRaw } from '@/lib/detectVisualization'
11+
import { fieldToLabel, fmtNum, fmtQtyWithUnit, fmtFieldValue, fmtNumRaw, prepareVisualizationRows } from '@/lib/detectVisualization'
1212
import { getDisplayColumns, isSummarizableMetricField } from '@shared/constants/ai-field-label'
1313

1414
const COLORS = ['#D4A853', '#52b788', '#74c69d', '#e76f51', '#457b9d', '#e9c46a', '#a8dadc', '#f4a261']
@@ -28,34 +28,37 @@ function formatCellValue(field: string, value: unknown): string {
2828

2929
// ─── 数据摘要 ─────────────────────────────────────────────────────────────────
3030
function Summary({ type, rows, spec }: { type: string; rows: Record<string, unknown>[]; spec: AiVisualizationSpec }) {
31+
const chartRows = spec.type === 'bar' || spec.type === 'line' || spec.type === 'pie'
32+
? prepareVisualizationRows(rows, spec.type === 'pie' ? spec.nameField ?? '' : spec.xField ?? '', spec.type === 'pie' ? undefined : spec.yField)
33+
: rows
3134
let text = ''
3235

3336
if (type === 'bar' && spec.xField && spec.yField) {
3437
const yLabel = fieldToLabel(spec.yField)
35-
const vals = rows.map((r) => Number(r[spec.yField!]) || 0)
38+
const vals = chartRows.map((r) => Number(r[spec.yField!]) || 0)
3639
const total = vals.reduce((a, b) => a + b, 0)
3740
const maxVal = Math.max(...vals)
3841
const minVal = Math.min(...vals)
39-
const maxName = String(rows[vals.indexOf(maxVal)]?.[spec.xField] ?? '')
40-
const minName = String(rows[vals.indexOf(minVal)]?.[spec.xField] ?? '')
41-
const maxRow = rows[vals.indexOf(maxVal)] ?? {}
42-
const minRow = rows[vals.indexOf(minVal)] ?? {}
43-
text = `共 ${rows.length} 项,${yLabel}合计 ${fmtQtyWithUnit(total, spec.yField!, rows[0] ?? {}, rows)}。最高「${maxName}${fmtQtyWithUnit(maxVal, spec.yField!, maxRow, rows)},最低「${minName}${fmtQtyWithUnit(minVal, spec.yField!, minRow, rows)}。`
42+
const maxIdx = vals.indexOf(maxVal)
43+
const minIdx = vals.indexOf(minVal)
44+
const maxName = String(chartRows[maxIdx]?.[spec.xField] ?? '')
45+
const minName = String(chartRows[minIdx]?.[spec.xField] ?? '')
46+
text = `共 ${rows.length} 项,${yLabel}合计 ${fmtQtyWithUnit(total, spec.yField!, rows[0] ?? {}, rows)}。最高「${maxName}${fmtQtyWithUnit(maxVal, spec.yField!, rows[maxIdx] ?? {}, rows)},最低「${minName}${fmtQtyWithUnit(minVal, spec.yField!, rows[minIdx] ?? {}, rows)}。`
4447
}
4548

4649
if (type === 'line' && spec.xField && spec.yField) {
4750
const yLabel = fieldToLabel(spec.yField)
48-
const vals = rows.map((r) => Number(r[spec.yField!]) || 0)
51+
const vals = chartRows.map((r) => Number(r[spec.yField!]) || 0)
4952
const total = vals.reduce((a, b) => a + b, 0)
5053
const avg = total / vals.length
51-
const firstX = String(rows[0]?.[spec.xField] ?? '')
52-
const lastX = String(rows[rows.length - 1]?.[spec.xField] ?? '')
54+
const firstX = String(chartRows[0]?.[spec.xField] ?? '')
55+
const lastX = String(chartRows[chartRows.length - 1]?.[spec.xField] ?? '')
5356
text = `${firstX}${lastX}${rows.length} 个周期,${yLabel}均值 ${fmtQtyWithUnit(avg, spec.yField!, rows[0] ?? {}, rows)},合计 ${fmtQtyWithUnit(total, spec.yField!, rows[0] ?? {}, rows)}。`
5457
}
5558

5659
if (type === 'pie' && spec.nameField && spec.valueField) {
5760
const yLabel = fieldToLabel(spec.valueField)
58-
const data = rows
61+
const data = chartRows
5962
.map((r) => ({ name: String(r[spec.nameField!] ?? ''), value: Number(r[spec.valueField!]) || 0 }))
6063
.filter((d) => d.value > 0)
6164
.sort((a, b) => b.value - a.value)
@@ -121,7 +124,7 @@ function DataTable({ rows }: { rows: Record<string, unknown>[] }) {
121124

122125
// ─── 柱状图 ───────────────────────────────────────────────────────────────────
123126
function BarViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xField: string; yField: string }) {
124-
const data = rows.map((r) => ({ ...r, [yField]: Number(r[yField]) || 0 }))
127+
const data = prepareVisualizationRows(rows, xField, yField)
125128
return (
126129
<ResponsiveContainer width="100%" height={200}>
127130
<BarChart data={data} margin={{ top: 6, right: 8, left: -16, bottom: 36 }}>
@@ -137,7 +140,7 @@ function BarViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xFi
137140

138141
// ─── 折线图 ───────────────────────────────────────────────────────────────────
139142
function LineViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xField: string; yField: string }) {
140-
const data = rows.map((r) => ({ ...r, [yField]: Number(r[yField]) || 0 }))
143+
const data = prepareVisualizationRows(rows, xField, yField)
141144
return (
142145
<ResponsiveContainer width="100%" height={200}>
143146
<LineChart data={data} margin={{ top: 6, right: 8, left: -16, bottom: 36 }}>
@@ -161,7 +164,7 @@ function LineViz({ rows, xField, yField }: { rows: Record<string, unknown>[]; xF
161164

162165
// ─── 饼图 ─────────────────────────────────────────────────────────────────────
163166
function PieViz({ rows, nameField, valueField }: { rows: Record<string, unknown>[]; nameField: string; valueField: string }) {
164-
const data = rows
167+
const data = prepareVisualizationRows(rows, nameField)
165168
.map((r) => ({ name: String(r[nameField] ?? ''), value: Number(r[valueField]) || 0 }))
166169
.filter((d) => d.value > 0)
167170
const total = data.reduce((s, d) => s + d.value, 0)

packages/server/src/modules/ai/sql-guard.util.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@
44
* 防止 AI 生成危险的 SQL 语句破坏数据库
55
*/
66

7-
/** 匹配 SELECT 语句的正则 */
8-
const SELECT_SQL_PATTERN = /^\s*select\b/i;
7+
/** 匹配只读查询语句:允许 SELECT 或 WITH ... SELECT(CTE) */
8+
const READONLY_SQL_PATTERN = /^\s*(select|with)\b/i;
99
/** 禁止出现的危险关键字:包含所有写操作及敏感命令 */
1010
const FORBIDDEN_SQL_PATTERN = /\b(insert|update|delete|drop|alter|truncate|create|replace|attach|detach|pragma)\b/i;
1111

1212
/** 检查 SQL 是否为安全的 SELECT 语句 */
1313
export function isSafeSelectSql(sql: string) {
1414
const normalizedSql = sql.trim();
1515

16-
// 必须为 SELECT 开头
17-
if (!SELECT_SQL_PATTERN.test(normalizedSql)) {
16+
// 必须为只读查询开头
17+
if (!READONLY_SQL_PATTERN.test(normalizedSql)) {
1818
return false;
1919
}
2020

@@ -24,8 +24,8 @@ export function isSafeSelectSql(sql: string) {
2424

2525
/** 构建不安全 SQL 的错误提示原因 */
2626
export function buildSqlGuardReason(sql: string) {
27-
if (!SELECT_SQL_PATTERN.test(sql.trim())) {
28-
return 'AI 生成的 SQL 不是 SELECT 语句';
27+
if (!READONLY_SQL_PATTERN.test(sql.trim())) {
28+
return 'AI 生成的 SQL 不是只读查询语句';
2929
}
3030

3131
if (FORBIDDEN_SQL_PATTERN.test(sql)) {

0 commit comments

Comments
 (0)