@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/analysis.js +4 -1
- package/bin/nl2sql-mcp.mjs +103 -0
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/filters.js +87 -33
- package/index.js +21 -3
- package/lib/client.js +2122 -153
- package/package.json +6 -1
- package/service/alerts.js +60 -26
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +39 -10
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature "proactive": data finds people.
|
|
3
|
+
* - Daily digest (state.digests): yesterday vs 7-day mean and vs same weekday last week per
|
|
4
|
+
* metric (or dashboard time-series widget), attribution of the top movers, Markdown summary +
|
|
5
|
+
* up to 3 charts pushed to a channel at HH:MM. Record kind 'digestRun'.
|
|
6
|
+
* - Alert auto-attribution: beforeAlertPush(alert, result) → "主要来自:…" line (≤ 20 s).
|
|
7
|
+
* - Goals (state.goals): actual-to-date, run-rate projection (weekday-aware), status; optional
|
|
8
|
+
* weekly push (state.goalPush).
|
|
9
|
+
* - Forecast: forecast({ resultId, horizon }) for single-measure time series; agent tool nl2sql_forecast.
|
|
10
|
+
* Every query goes through queryMetric / runWidget / explainChange (→ runQuery).
|
|
11
|
+
*/
|
|
12
|
+
import { isAdditive } from '../analysis.js'
|
|
13
|
+
import { forecastPoints, dailyStats, isNotable, periodBounds, projectRunRate, goalStatus, parseTimeLabel } from './forecast.js'
|
|
14
|
+
import { UserError, clampInt, fmtNum, lastDailySlot, newId, oneOf, requireText, upsert } from './shared.js'
|
|
15
|
+
|
|
16
|
+
const DAY = 86400000
|
|
17
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
18
|
+
/** Local calendar date 'YYYY-MM-DD'. */
|
|
19
|
+
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
20
|
+
const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
|
|
21
|
+
const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
22
|
+
/** A time label (any driver format) → local 'YYYY-MM-DD', or null. */
|
|
23
|
+
function dayKey(v) {
|
|
24
|
+
const s = String(v ?? '')
|
|
25
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
|
|
26
|
+
if (!m) return null
|
|
27
|
+
// Midnight UTC ISO from a DATE column is the date itself; other ISO timestamps are converted to local.
|
|
28
|
+
if (/T/.test(s) && !/T00:00:00(\.0+)?Z?$/.test(s)) { const d = new Date(s); return Number.isNaN(d.getTime()) ? null : ymd(d) }
|
|
29
|
+
return `${m[1]}-${m[2]}-${m[3]}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function timeout(ms, label) {
|
|
33
|
+
return new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`${label} 超时`)), ms); t.unref?.() })
|
|
34
|
+
}
|
|
35
|
+
const withTimeout = (p, ms, label = '') => Promise.race([p, timeout(ms, label)])
|
|
36
|
+
|
|
37
|
+
function numText(v, lang) {
|
|
38
|
+
if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
|
|
39
|
+
if (lang === 'zh') return fmtNum(v)
|
|
40
|
+
const n = Number(v)
|
|
41
|
+
const a = Math.abs(n)
|
|
42
|
+
if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`
|
|
43
|
+
if (a >= 1e6) return `${(n / 1e6).toFixed(2)}M`
|
|
44
|
+
if (a >= 1e4) return `${(n / 1e3).toFixed(1)}K`
|
|
45
|
+
return Number.isInteger(n) ? n.toLocaleString('en-US') : n.toFixed(2)
|
|
46
|
+
}
|
|
47
|
+
const pctText = (p) => (p === null || p === undefined ? '—' : `${p >= 0 ? '+' : ''}${(p * 100).toFixed(0)}%`)
|
|
48
|
+
|
|
49
|
+
/** Time series view of a result: { xi, yi, points:[{x,y}] } or null (needs a time x and one measure). */
|
|
50
|
+
function seriesOf(result) {
|
|
51
|
+
if (!result?.columns?.length || !Array.isArray(result.rows)) return null
|
|
52
|
+
const cols = result.columns
|
|
53
|
+
if (result.chart?.series) return null
|
|
54
|
+
let xi = cols.findIndex(c => c.name === result.chart?.x)
|
|
55
|
+
if (xi < 0 || cols[xi].kind !== 'time') xi = cols.findIndex(c => c.kind === 'time')
|
|
56
|
+
if (xi < 0) return null
|
|
57
|
+
const ys = (result.chart?.y?.length ? result.chart.y : cols.filter(c => c.kind === 'number').map(c => c.name)).map(n => cols.findIndex(c => c.name === n)).filter(i => i >= 0 && i !== xi && cols[i].kind === 'number')
|
|
58
|
+
if (ys.length !== 1) return null
|
|
59
|
+
// Several rows per x (another dimension column) cannot be read as one series.
|
|
60
|
+
const xsSeen = new Set(result.rows.map(r => String(r[xi])))
|
|
61
|
+
if (xsSeen.size !== result.rows.length) return null
|
|
62
|
+
const yi = ys[0]
|
|
63
|
+
return { xi, yi, x: cols[xi].name, measure: cols[yi].name, points: result.rows.map(r => ({ x: r[xi], y: r[yi] === null ? null : Number(r[yi]) })) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const T = {
|
|
67
|
+
zh: {
|
|
68
|
+
title: (name) => `📊 ${name}`,
|
|
69
|
+
line: (m, v, avg, wk) => `**${m}** 昨天 ${v},比 7 日均值${avg}${wk ? `,比上周同期${wk}` : ''}`,
|
|
70
|
+
hi: (_p, a) => `高 ${(a * 100).toFixed(0)}%`, lo: (_p, a) => `低 ${(a * 100).toFixed(0)}%`, flat: '持平',
|
|
71
|
+
from: (s) => `(主要来自 ${s})`,
|
|
72
|
+
notable: '值得关注', normal: '其他指标', none: '昨天各指标均在正常波动范围内。',
|
|
73
|
+
err: (m, e) => `- ${m}:取数失败(${e})`,
|
|
74
|
+
main: '主要来自:',
|
|
75
|
+
goal: '目标进度',
|
|
76
|
+
},
|
|
77
|
+
en: {
|
|
78
|
+
title: (name) => `📊 ${name}`,
|
|
79
|
+
line: (m, v, avg, wk) => `**${m}** yesterday ${v}, ${avg} vs 7-day avg${wk ? `, ${wk} vs same day last week` : ''}`,
|
|
80
|
+
hi: (p) => `${p}`, lo: (p) => `${p}`, flat: 'flat',
|
|
81
|
+
from: (s) => ` (mainly ${s})`,
|
|
82
|
+
notable: 'Notable', normal: 'Other metrics', none: 'All metrics were within their normal range yesterday.',
|
|
83
|
+
err: (m, e) => `- ${m}: query failed (${e})`,
|
|
84
|
+
main: 'Mainly from: ',
|
|
85
|
+
goal: 'Goal progress',
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cmpText(p, L) {
|
|
90
|
+
if (p === null || p === undefined) return '—'
|
|
91
|
+
if (Math.abs(p) < 0.005) return L.flat
|
|
92
|
+
return p > 0 ? L.hi(pctText(p), Math.abs(p)) : L.lo(pctText(p), Math.abs(p))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** "dim=value +Δ" list for the top contributors of an explainChange result. */
|
|
96
|
+
function contributors(why, n, lang) {
|
|
97
|
+
const top = why?.dimensions?.find(d => d.attribution)
|
|
98
|
+
if (!top) return null
|
|
99
|
+
const items = top.attribution.items.filter(i => i.value !== '其他' && Math.sign(i.delta) === Math.sign(top.attribution.total.delta || i.delta) && i.delta !== 0).slice(0, n)
|
|
100
|
+
if (!items.length) return null
|
|
101
|
+
return { dimension: top.dimension, text: items.map(i => `${i.value ?? 'NULL'} ${i.delta >= 0 ? '+' : ''}${numText(i.delta, lang)}`).join(lang === 'zh' ? '、' : ', ') }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const methods = {
|
|
105
|
+
// ------------------------------------------------------------ digests
|
|
106
|
+
|
|
107
|
+
listDigests() {
|
|
108
|
+
return (this.state.digests ?? []).map(d => ({ ...d, datasourceName: this.findDatasource(d.datasourceId)?.name ?? d.datasourceId, channelName: this.state.channels.find(c => c.id === d.channelId)?.name ?? null }))
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async saveDigest(input = {}) {
|
|
112
|
+
const existing = input.id ? (this.state.digests ?? []).find(d => d.id === input.id) : undefined
|
|
113
|
+
if (input.id && !existing) throw new UserError('nl2sql/invalid', '日报不存在')
|
|
114
|
+
const d = existing ? { ...existing } : { id: newId('dg'), createdAt: Date.now() }
|
|
115
|
+
d.name = requireText(input.name ?? d.name, 'name', 60)
|
|
116
|
+
const ds = this.requireDatasource(input.datasourceId ?? d.datasourceId)
|
|
117
|
+
d.datasourceId = ds.id
|
|
118
|
+
const dashboardId = input.dashboardId !== undefined ? input.dashboardId : d.dashboardId
|
|
119
|
+
if (dashboardId) {
|
|
120
|
+
this.requireDashboard(dashboardId)
|
|
121
|
+
d.dashboardId = dashboardId
|
|
122
|
+
d.metrics = []
|
|
123
|
+
} else {
|
|
124
|
+
const metrics = Array.isArray(input.metrics) ? input.metrics : (d.metrics ?? [])
|
|
125
|
+
if (!metrics.length) throw new UserError('nl2sql/invalid', '请至少选择一个指标或一个看板', 'metrics')
|
|
126
|
+
d.metrics = metrics.slice(0, 20).map(ref => {
|
|
127
|
+
const m = this.findMetric(ds, ref)
|
|
128
|
+
if (!m) throw new UserError('nl2sql/unknown-metric', `指标库中没有「${ref}」`)
|
|
129
|
+
return m.name
|
|
130
|
+
})
|
|
131
|
+
delete d.dashboardId
|
|
132
|
+
}
|
|
133
|
+
d.channelId = input.channelId !== undefined ? (input.channelId && this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null) : (d.channelId ?? null)
|
|
134
|
+
d.time = /^\d{2}:\d{2}$/.test(String(input.time ?? d.time ?? '')) ? (input.time ?? d.time) : '09:00'
|
|
135
|
+
d.enabled = input.enabled !== undefined ? Boolean(input.enabled) : (d.enabled ?? true)
|
|
136
|
+
d.language = oneOf(input.language ?? d.language ?? 'zh', ['zh', 'en'], 'language')
|
|
137
|
+
d.threshold = Number.isFinite(Number(input.threshold ?? d.threshold)) ? Math.max(1, Math.min(500, Number(input.threshold ?? d.threshold ?? 30))) : 30
|
|
138
|
+
d.updatedAt = Date.now()
|
|
139
|
+
// Runtime fields from the stored object (a run may have finished meanwhile).
|
|
140
|
+
const cur = (this.state.digests ?? []).find(x => x.id === d.id)
|
|
141
|
+
if (cur) for (const k of ['lastRunAt', 'lastStatus', 'lastError']) d[k] = cur[k]
|
|
142
|
+
this.state.digests = upsert(this.state.digests ?? [], d)
|
|
143
|
+
await this.save()
|
|
144
|
+
return d
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async deleteDigest(id) {
|
|
148
|
+
this.state.digests = (this.state.digests ?? []).filter(d => d.id !== id)
|
|
149
|
+
await this.save()
|
|
150
|
+
return { deleted: id }
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
listDigestRuns(digestId) {
|
|
154
|
+
return this.records.list('digestRun', { ref: digestId, limit: 50 })
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** Run a digest: preview (default) or send (send: true). */
|
|
158
|
+
async runDigest({ id, send = false, now = new Date() } = {}) {
|
|
159
|
+
const d = (this.state.digests ?? []).find(x => x.id === id)
|
|
160
|
+
if (!d) throw new UserError('nl2sql/invalid', '日报不存在')
|
|
161
|
+
const run = { id: newId('dr'), digestId: d.id, name: d.name, at: Date.now(), preview: !send }
|
|
162
|
+
try {
|
|
163
|
+
const out = await this.computeDigest(d, now)
|
|
164
|
+
Object.assign(run, { status: 'ok', title: out.title, text: out.text, items: out.items.map(({ result: _r, ...x }) => x), resultIds: out.resultIds })
|
|
165
|
+
if (send) {
|
|
166
|
+
if (!d.channelId) throw new UserError('nl2sql/invalid', '日报未设置推送渠道')
|
|
167
|
+
await this.sendToChannel({ channelId: d.channelId, title: out.title, text: out.text, resultIds: out.resultIds, source: 'digest' })
|
|
168
|
+
run.pushed = true
|
|
169
|
+
}
|
|
170
|
+
return { ...run, results: out.resultIds.map(r => this.lookupResult(r)).filter(Boolean) }
|
|
171
|
+
} catch (error) {
|
|
172
|
+
run.status = 'error'
|
|
173
|
+
run.error = error.message
|
|
174
|
+
throw error
|
|
175
|
+
} finally {
|
|
176
|
+
this.records.put('digestRun', run)
|
|
177
|
+
if (send) {
|
|
178
|
+
const cur = (this.state.digests ?? []).find(x => x.id === d.id)
|
|
179
|
+
if (cur) { cur.lastRunAt = Date.now(); cur.lastStatus = run.status; cur.lastError = run.error }
|
|
180
|
+
await this.save()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/** Series per metric / widget for the digest → statistics → summary. */
|
|
186
|
+
async computeDigest(d, now = new Date()) {
|
|
187
|
+
const lang = d.language === 'en' ? 'en' : 'zh'
|
|
188
|
+
const L = T[lang]
|
|
189
|
+
const today = midnight(now)
|
|
190
|
+
const yesterday = ymd(addDays(today, -1))
|
|
191
|
+
const days = Array.from({ length: 15 }, (_, i) => ymd(addDays(today, i - 15))) // 15 days ending yesterday
|
|
192
|
+
const sources = []
|
|
193
|
+
if (d.dashboardId) {
|
|
194
|
+
const board = this.requireDashboard(d.dashboardId)
|
|
195
|
+
for (const w of board.widgets.slice(0, 20)) sources.push({ name: w.title || w.id, load: () => this.runWidget(w.id, undefined, { useCache: false }) })
|
|
196
|
+
} else {
|
|
197
|
+
for (const m of d.metrics ?? []) {
|
|
198
|
+
sources.push({ name: m, load: () => this.queryMetric({ datasourceId: d.datasourceId, metric: m, grain: 'day', from: days[0], to: ymd(today), title: `${m} · ${lang === 'zh' ? '近 15 天' : 'last 15 days'}`, source: 'digest', chart: { type: 'line', x: '时间', y: [m] } }) })
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const items = []
|
|
202
|
+
for (const s of sources) {
|
|
203
|
+
try {
|
|
204
|
+
const result = await withTimeout(s.load(), 60000, s.name)
|
|
205
|
+
const series = seriesOf(result)
|
|
206
|
+
if (!series) { if (!d.dashboardId) items.push({ name: s.name, error: 'not a daily series' }); continue }
|
|
207
|
+
const byDay = new Map()
|
|
208
|
+
let rawLabel = {}
|
|
209
|
+
for (const p of series.points) { const k = dayKey(p.x); if (!k) continue; byDay.set(k, (byDay.get(k) ?? 0) + (p.y ?? 0)); rawLabel[k] = p.x }
|
|
210
|
+
if (!byDay.size) { if (!d.dashboardId) items.push({ name: s.name, error: 'no daily data' }); continue }
|
|
211
|
+
// Dashboard widgets may cover other ranges: only use them when they reach yesterday's window.
|
|
212
|
+
if (d.dashboardId && ![...byDay.keys()].some(k => k >= days[7])) continue
|
|
213
|
+
const values = days.map(k => byDay.get(k) ?? 0)
|
|
214
|
+
const st = dailyStats(values)
|
|
215
|
+
items.push({ name: d.dashboardId ? s.name : s.name, measure: series.measure, stats: st, notable: isNotable(st, { z: 2, pct: (d.threshold ?? 30) / 100 }), result, labels: { current: rawLabel[yesterday], baseline: rawLabel[days[7]] ?? rawLabel[days[13]] } })
|
|
216
|
+
} catch (error) {
|
|
217
|
+
items.push({ name: s.name, error: error.message })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Attribution for the top 1–2 notable movers.
|
|
221
|
+
const movers = items.filter(i => i.notable).sort((a, b) => Math.abs(b.stats.z ?? b.stats.vsAvg ?? 0) - Math.abs(a.stats.z ?? a.stats.vsAvg ?? 0))
|
|
222
|
+
for (const it of movers.slice(0, 2)) {
|
|
223
|
+
if (it.labels.current === undefined || it.labels.baseline === undefined) continue
|
|
224
|
+
try {
|
|
225
|
+
const why = await withTimeout(this.explainChange({ resultId: it.result.resultId, current: it.labels.current, baseline: it.labels.baseline }), 30000, 'attribution')
|
|
226
|
+
const c = contributors(why, 2, lang)
|
|
227
|
+
if (c) it.mainly = c.text
|
|
228
|
+
} catch {}
|
|
229
|
+
}
|
|
230
|
+
const line = (it) => `- ${L.line(it.name, numText(it.stats.value, lang), cmpText(it.stats.vsAvg, L), it.stats.vsLastWeek === null ? null : cmpText(it.stats.vsLastWeek, L))}${it.mainly ? L.from(it.mainly) : ''}`
|
|
231
|
+
const ok = items.filter(i => !i.error)
|
|
232
|
+
const lines = []
|
|
233
|
+
if (movers.length) lines.push(`**${L.notable}**`, ...movers.map(line))
|
|
234
|
+
else lines.push(L.none)
|
|
235
|
+
const rest = ok.filter(i => !i.notable)
|
|
236
|
+
if (rest.length) lines.push('', `<font color='grey'>${L.normal}</font>`, ...rest.map(line))
|
|
237
|
+
for (const i of items.filter(x => x.error)) lines.push(L.err(i.name, i.error))
|
|
238
|
+
const charts = [...movers, ...rest].slice(0, 3).map(i => i.result.resultId)
|
|
239
|
+
return {
|
|
240
|
+
title: `${L.title(d.name)} · ${yesterday}`,
|
|
241
|
+
text: lines.join('\n'),
|
|
242
|
+
items: items.map(i => ({ name: i.name, error: i.error, notable: i.notable, mainly: i.mainly, ...(i.stats ? { stats: i.stats } : {}), resultId: i.result?.resultId })),
|
|
243
|
+
resultIds: charts,
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
// ------------------------------------------------------------ alert attribution
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Called by checkAlertInner before pushing a fired alert: for a time series with ≥ 2 points,
|
|
251
|
+
* attribute the change of the last point vs the previous one. Returns a Markdown line or null.
|
|
252
|
+
*/
|
|
253
|
+
async beforeAlertPush(alert, result) {
|
|
254
|
+
const s = seriesOf(result)
|
|
255
|
+
if (!s || s.points.length < 2) return null
|
|
256
|
+
// Chat-created alerts run as their creator: attribute under the same row rules.
|
|
257
|
+
const actor = alert?.chatTarget && this.chatAlertActor ? await this.chatAlertActor(alert).catch(() => null) : undefined
|
|
258
|
+
if (actor === null) return null
|
|
259
|
+
const why = await withTimeout(this.explainChange({ resultId: result.resultId, actor }), this.config?.alertAttributionMs ?? 20000, 'attribution').catch(() => null)
|
|
260
|
+
const c = contributors(why, 3, 'zh')
|
|
261
|
+
return c ? `主要来自:${c.dimension} — ${c.text}` : null
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
// ------------------------------------------------------------ goals
|
|
265
|
+
|
|
266
|
+
listGoals() {
|
|
267
|
+
return (this.state.goals ?? []).map(g => ({ ...g, datasourceName: this.findDatasource(g.datasourceId)?.name ?? g.datasourceId }))
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async saveGoal(input = {}) {
|
|
271
|
+
const existing = input.id ? (this.state.goals ?? []).find(g => g.id === input.id) : undefined
|
|
272
|
+
if (input.id && !existing) throw new UserError('nl2sql/invalid', '目标不存在')
|
|
273
|
+
const g = existing ? { ...existing } : { id: newId('gl'), createdAt: Date.now() }
|
|
274
|
+
g.name = requireText(input.name ?? g.name, 'name', 60)
|
|
275
|
+
const ds = this.requireDatasource(input.datasourceId ?? g.datasourceId)
|
|
276
|
+
g.datasourceId = ds.id
|
|
277
|
+
const m = this.findMetric(ds, input.metric ?? g.metric)
|
|
278
|
+
if (!m) throw new UserError('nl2sql/unknown-metric', '指标库中没有这个指标')
|
|
279
|
+
if (!m.timeColumn) throw new UserError('nl2sql/invalid', `指标「${m.name}」未配置时间列,无法按周期统计`)
|
|
280
|
+
g.metric = m.name
|
|
281
|
+
g.period = oneOf(input.period ?? g.period ?? 'month', ['month', 'quarter', 'year'], 'period')
|
|
282
|
+
const target = Number(input.target ?? g.target)
|
|
283
|
+
if (!Number.isFinite(target)) throw new UserError('nl2sql/invalid', '目标值必须是数字', 'target')
|
|
284
|
+
g.target = target
|
|
285
|
+
g.direction = oneOf(input.direction ?? g.direction ?? 'up', ['up', 'down'], 'direction')
|
|
286
|
+
const dash = input.dashboardId !== undefined ? input.dashboardId : g.dashboardId
|
|
287
|
+
if (dash) { this.requireDashboard(dash); g.dashboardId = dash } else delete g.dashboardId
|
|
288
|
+
g.updatedAt = Date.now()
|
|
289
|
+
this.state.goals = upsert(this.state.goals ?? [], g)
|
|
290
|
+
await this.save()
|
|
291
|
+
return g
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
async deleteGoal(id) {
|
|
295
|
+
this.state.goals = (this.state.goals ?? []).filter(g => g.id !== id)
|
|
296
|
+
await this.save()
|
|
297
|
+
return { deleted: id }
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/** Goals relevant to a dashboard (tied to it, or untied goals on its data sources); all when no dashboard. */
|
|
301
|
+
goalsFor(dashboardId) {
|
|
302
|
+
const goals = this.state.goals ?? []
|
|
303
|
+
if (!dashboardId) return goals
|
|
304
|
+
const board = this.state.dashboards.find(d => d.id === dashboardId)
|
|
305
|
+
const dsIds = new Set((board?.widgets ?? []).map(w => w.datasourceId))
|
|
306
|
+
return goals.filter(g => g.dashboardId === dashboardId || (!g.dashboardId && dsIds.has(g.datasourceId)))
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
/** Progress of one goal at `now`. */
|
|
310
|
+
async goalProgressOne(g, now = new Date()) {
|
|
311
|
+
const { start, end } = periodBounds(g.period, now)
|
|
312
|
+
const today = midnight(now)
|
|
313
|
+
const tomorrow = addDays(today, 1)
|
|
314
|
+
const ds = this.requireDatasource(g.datasourceId)
|
|
315
|
+
const metric = this.findMetric(ds, g.metric)
|
|
316
|
+
if (!metric) throw new UserError('nl2sql/unknown-metric', `指标库中没有「${g.metric}」`)
|
|
317
|
+
const base = { datasourceId: ds.id, metric: g.metric, source: 'goal' }
|
|
318
|
+
const totalRes = await this.queryMetric({ ...base, from: ymd(start), to: ymd(tomorrow), title: `${g.name} · 累计` })
|
|
319
|
+
const actual = Number(totalRes.rows[0]?.[totalRes.rows[0].length - 1] ?? 0) || 0
|
|
320
|
+
let projected = actual
|
|
321
|
+
let weekdayAware = false
|
|
322
|
+
const additive = isAdditive(metric.expression)
|
|
323
|
+
if (additive) {
|
|
324
|
+
// Run rate from the trailing 28 complete days (independent of the period start).
|
|
325
|
+
const from = addDays(today, -28)
|
|
326
|
+
const daily = await this.queryMetric({ ...base, grain: 'day', from: ymd(from), to: ymd(tomorrow), title: `${g.name} · 日` })
|
|
327
|
+
const s = seriesOf(daily)
|
|
328
|
+
const byDay = new Map()
|
|
329
|
+
for (const p of s?.points ?? []) { const k = dayKey(p.x); if (k) byDay.set(k, (byDay.get(k) ?? 0) + (p.y ?? 0)) }
|
|
330
|
+
const todayValue = byDay.get(ymd(today)) ?? 0
|
|
331
|
+
const hist = []
|
|
332
|
+
for (let d = from; d < today; d = addDays(d, 1)) hist.push({ date: d, value: byDay.get(ymd(d)) ?? 0 })
|
|
333
|
+
const p = projectRunRate({ daily: hist, actualComplete: actual - todayValue, today, end })
|
|
334
|
+
projected = p.projected ?? actual
|
|
335
|
+
weekdayAware = p.weekdayAware
|
|
336
|
+
}
|
|
337
|
+
const ended = now >= end
|
|
338
|
+
const status = goalStatus({ direction: g.direction, target: g.target, actual, projected, ended })
|
|
339
|
+
const ratio = (v) => (g.target ? v / g.target : null)
|
|
340
|
+
const elapsed = (Math.min(now, end) - start) / (end - start)
|
|
341
|
+
return { ...g, actual, projected, pct: ratio(actual), projectedPct: ratio(projected), elapsedPct: elapsed, status, additive, weekdayAware, periodStart: ymd(start), periodEnd: ymd(addDays(end, -1)) }
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
async goalProgress({ dashboardId, now } = {}) {
|
|
345
|
+
const at = now ? new Date(now) : new Date()
|
|
346
|
+
const out = []
|
|
347
|
+
for (const g of this.goalsFor(dashboardId)) {
|
|
348
|
+
try { out.push(await this.goalProgressOne(g, at)) } catch (error) { out.push({ ...g, status: 'error', error: error.message }) }
|
|
349
|
+
}
|
|
350
|
+
return out
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
getGoalPush() {
|
|
354
|
+
return { enabled: false, channelId: null, weekday: 1, time: '09:00', ...(this.state.goalPush ?? {}) }
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async saveGoalPush(input = {}) {
|
|
358
|
+
const cur = this.getGoalPush()
|
|
359
|
+
const next = {
|
|
360
|
+
...cur,
|
|
361
|
+
enabled: input.enabled !== undefined ? Boolean(input.enabled) : cur.enabled,
|
|
362
|
+
channelId: input.channelId !== undefined ? (input.channelId && this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null) : cur.channelId,
|
|
363
|
+
weekday: input.weekday !== undefined ? clampInt(input.weekday, 0, 6, 'weekday') : cur.weekday,
|
|
364
|
+
time: /^\d{2}:\d{2}$/.test(String(input.time ?? '')) ? input.time : cur.time,
|
|
365
|
+
}
|
|
366
|
+
if (!cur.updatedAt || input.enabled) next.since = next.since ?? Date.now()
|
|
367
|
+
next.updatedAt = Date.now()
|
|
368
|
+
this.state.goalPush = next
|
|
369
|
+
await this.save()
|
|
370
|
+
return next
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
/** Markdown goal status report (also used by the weekly push). */
|
|
374
|
+
async goalReport({ send = false, now } = {}) {
|
|
375
|
+
const list = await this.goalProgress({ now })
|
|
376
|
+
const icon = { 'on-track': '🟢', 'at-risk': '🟠', achieved: '✅', missed: '🔴', error: '⚠️' }
|
|
377
|
+
const label = { 'on-track': '进度正常', 'at-risk': '有风险', achieved: '已达成', missed: '未达成', error: '出错' }
|
|
378
|
+
const text = list.length
|
|
379
|
+
? list.map(g => g.error ? `- ${icon.error} **${g.name}**:${g.error}` : `- ${icon[g.status]} **${g.name}**(${g.metric}):${fmtNum(g.actual)} / ${fmtNum(g.target)}(${(g.pct * 100).toFixed(0)}%),预计 ${fmtNum(g.projected)}(${(g.projectedPct * 100).toFixed(0)}%)· ${label[g.status]}`).join('\n')
|
|
380
|
+
: '尚未设置目标。'
|
|
381
|
+
if (send) {
|
|
382
|
+
const cfg = this.getGoalPush()
|
|
383
|
+
if (!cfg.channelId) throw new UserError('nl2sql/invalid', '目标周报未设置推送渠道')
|
|
384
|
+
await this.sendToChannel({ channelId: cfg.channelId, title: '🎯 目标进度', text, source: 'goal' })
|
|
385
|
+
}
|
|
386
|
+
return { text, goals: list }
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
/** Scheduler jobs: due digests and the weekly goal push. */
|
|
390
|
+
jobs_proactive(now) {
|
|
391
|
+
const jobs = []
|
|
392
|
+
for (const d of this.state.digests ?? []) {
|
|
393
|
+
if (!d.enabled || !d.channelId) continue
|
|
394
|
+
jobs.push({
|
|
395
|
+
id: `digest:${d.id}`,
|
|
396
|
+
due: (at) => (d.lastRunAt ?? d.createdAt ?? 0) < lastDailySlot(d.time, at),
|
|
397
|
+
run: () => this.runDigest({ id: d.id, send: true, now }),
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
const gp = this.state.goalPush
|
|
401
|
+
if (gp?.enabled && gp.channelId && (this.state.goals ?? []).length) {
|
|
402
|
+
jobs.push({
|
|
403
|
+
id: 'goal-push',
|
|
404
|
+
due: (at) => {
|
|
405
|
+
const slot = new Date(lastDailySlot(gp.time, at))
|
|
406
|
+
while (slot.getDay() !== gp.weekday) slot.setDate(slot.getDate() - 1)
|
|
407
|
+
return (gp.lastRunAt ?? gp.since ?? gp.updatedAt ?? 0) < slot.getTime()
|
|
408
|
+
},
|
|
409
|
+
run: async () => {
|
|
410
|
+
try { await this.goalReport({ send: true, now }) } finally { if (this.state.goalPush) this.state.goalPush.lastRunAt = Date.now(); await this.save() }
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
return jobs
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
// ------------------------------------------------------------ forecast
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Forecast a single-measure time-series result. Returns the history, the forecast with a
|
|
421
|
+
* 95% band, and `resultId` of a derived result (history + forecast columns) that can be sent or charted.
|
|
422
|
+
*/
|
|
423
|
+
async forecast({ resultId, horizon = 7, sessionId, actor } = {}) {
|
|
424
|
+
const r = this.lookupResult(resultId)
|
|
425
|
+
if (!r) throw new UserError('nl2sql/result-expired', '结果已过期,请重新查询')
|
|
426
|
+
this.assertResultAccess(r, actor ?? this.actorFor(sessionId))
|
|
427
|
+
const s = seriesOf(r)
|
|
428
|
+
if (!s) throw new UserError('nl2sql/invalid', '只能预测「时间 + 单个数值指标」的结果(不含分组系列)')
|
|
429
|
+
const H = clampInt(horizon ?? 7, 1, 90, 'horizon')
|
|
430
|
+
const points = s.points.filter(p => p.y !== null && Number.isFinite(p.y))
|
|
431
|
+
if (points.length < 3) throw new UserError('nl2sql/invalid', '至少需要 3 个时间点才能预测')
|
|
432
|
+
let fc
|
|
433
|
+
try { fc = forecastPoints(points, { horizon: H }) } catch (error) { throw new UserError('nl2sql/invalid', error.message) }
|
|
434
|
+
const round = (v) => (Number.isFinite(v) ? Math.round(v * 100) / 100 : null)
|
|
435
|
+
const cols = [{ name: s.x, kind: fc.kind === 'index' ? 'text' : 'time' }, { name: s.measure, kind: 'number' }, { name: '预测', kind: 'number' }, { name: '下限', kind: 'number' }, { name: '上限', kind: 'number' }]
|
|
436
|
+
const lastHist = fc.history.at(-1)
|
|
437
|
+
const rows = [
|
|
438
|
+
...fc.history.map(p => [p.x, p.y, null, null, null]),
|
|
439
|
+
...fc.forecast.map(f => [f.x, null, round(f.value), round(f.lower), round(f.upper)]),
|
|
440
|
+
]
|
|
441
|
+
// Join the forecast line to the last observation.
|
|
442
|
+
if (lastHist) rows[fc.history.length - 1][2] = lastHist.y
|
|
443
|
+
const derived = {
|
|
444
|
+
...r,
|
|
445
|
+
resultId: newId('r'),
|
|
446
|
+
title: `${r.title || s.measure} · 预测 ${H}${fc.kind === 'day' ? ' 天' : ' 期'}`,
|
|
447
|
+
columns: cols,
|
|
448
|
+
rows,
|
|
449
|
+
rowCount: rows.length,
|
|
450
|
+
truncated: false,
|
|
451
|
+
chart: { type: 'line', x: s.x, y: [s.measure, '预测'] },
|
|
452
|
+
suggestedChart: undefined,
|
|
453
|
+
forecastOf: r.resultId,
|
|
454
|
+
createdAt: Date.now(),
|
|
455
|
+
}
|
|
456
|
+
this.remember(derived)
|
|
457
|
+
const total = fc.forecast.reduce((a, f) => a + f.value, 0)
|
|
458
|
+
const methodName = fc.method === 'holt-winters' ? 'Holt-Winters(周季节性)' : 'Holt 线性趋势'
|
|
459
|
+
const summary = `基于 ${fc.history.length} 个历史点(${methodName}),未来 ${H} 期预测:${fc.forecast.slice(0, 3).map(f => `${f.x} ≈ ${fmtNum(f.value)}`).join(',')}${H > 3 ? ' …' : ''};${H} 期合计约 ${fmtNum(total)},95% 区间宽度 ±${fmtNum(1.96 * fc.sigma)}(第 1 期)。${fc.filled ? `缺失的 ${fc.filled} 天按 0 补齐。` : ''}`
|
|
460
|
+
return {
|
|
461
|
+
resultId: r.resultId,
|
|
462
|
+
forecastResultId: derived.resultId,
|
|
463
|
+
datasourceId: r.datasourceId,
|
|
464
|
+
title: r.title,
|
|
465
|
+
x: s.x,
|
|
466
|
+
measure: s.measure,
|
|
467
|
+
horizon: H,
|
|
468
|
+
method: fc.method,
|
|
469
|
+
params: fc.params,
|
|
470
|
+
sigma: fc.sigma,
|
|
471
|
+
kind: fc.kind,
|
|
472
|
+
filled: fc.filled,
|
|
473
|
+
history: fc.history,
|
|
474
|
+
forecast: fc.forecast.map(f => ({ x: f.x, h: f.h, value: round(f.value), lower: round(f.lower), upper: round(f.upper) })),
|
|
475
|
+
summary,
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export const FEATURE = {
|
|
481
|
+
name: 'proactive',
|
|
482
|
+
methods,
|
|
483
|
+
rpc: (service) => ({
|
|
484
|
+
listDigests: () => service.listDigests(),
|
|
485
|
+
saveDigest: (p) => service.saveDigest(p ?? {}),
|
|
486
|
+
deleteDigest: (p) => service.deleteDigest(p?.id),
|
|
487
|
+
runDigest: (p) => service.runDigest({ id: p?.id, send: Boolean(p?.send) }),
|
|
488
|
+
listDigestRuns: (p) => service.listDigestRuns(p?.digestId),
|
|
489
|
+
listGoals: () => service.listGoals(),
|
|
490
|
+
saveGoal: (p) => service.saveGoal(p ?? {}),
|
|
491
|
+
deleteGoal: (p) => service.deleteGoal(p?.id),
|
|
492
|
+
goalProgress: (p) => service.goalProgress({ dashboardId: p?.dashboardId }),
|
|
493
|
+
getGoalPush: () => service.getGoalPush(),
|
|
494
|
+
saveGoalPush: (p) => service.saveGoalPush(p ?? {}),
|
|
495
|
+
sendGoalReport: () => service.goalReport({ send: true }),
|
|
496
|
+
forecast: (p) => service.forecast({ resultId: p?.resultId, horizon: p?.horizon ?? 7 }),
|
|
497
|
+
}),
|
|
498
|
+
tools(ctx, service, { textBlock }) {
|
|
499
|
+
ctx.tools.register({
|
|
500
|
+
name: 'nl2sql_forecast',
|
|
501
|
+
description: 'Forecast the next N periods of an existing time-series result (resultId from nl2sql_query / nl2sql_metric with a time column and ONE numeric measure, no series split). Uses Holt-Winters with weekly seasonality for daily data with ≥ 21 points, else Holt linear trend; returns point forecasts with a 95% interval and shows a chart (history + dashed forecast + band) to the user. Use for "预测/forecast/下周会是多少" questions; state that it is a statistical extrapolation.',
|
|
502
|
+
parameters: {
|
|
503
|
+
type: 'object',
|
|
504
|
+
properties: {
|
|
505
|
+
resultId: { type: 'string', description: 'resultId of a time-series result.' },
|
|
506
|
+
horizon: { type: 'integer', minimum: 1, maximum: 90, description: 'Periods to forecast (default 7).' },
|
|
507
|
+
},
|
|
508
|
+
required: ['resultId'],
|
|
509
|
+
additionalProperties: false,
|
|
510
|
+
},
|
|
511
|
+
output: {
|
|
512
|
+
schema: { type: 'object' },
|
|
513
|
+
render: (_args, value) => textBlock(JSON.stringify(value.model, null, 1)),
|
|
514
|
+
presentationMeta: (_args, value) => value.display,
|
|
515
|
+
},
|
|
516
|
+
isConcurrencySafe: () => true,
|
|
517
|
+
presentCall: (args) => ({ card: 'generic', title: `预测未来 ${args?.horizon ?? 7} 期`, kind: 'read' }),
|
|
518
|
+
execute: async (args, exec) => {
|
|
519
|
+
try {
|
|
520
|
+
const f = await service.forecast({ resultId: args.resultId, horizon: args.horizon ?? 7, sessionId: exec.agent?.id })
|
|
521
|
+
return {
|
|
522
|
+
model: { summary: f.summary, method: f.method, forecastResultId: f.forecastResultId, measure: f.measure, forecast: f.forecast },
|
|
523
|
+
display: JSON.parse(JSON.stringify({ forecast: { ...f, history: f.history.slice(-120) } }, (_k, v) => (typeof v === 'number' && !Number.isFinite(v) ? null : v))),
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
throw error instanceof UserError ? new Error(error.message) : error
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
})
|
|
530
|
+
},
|
|
531
|
+
}
|
package/service/query.js
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*/
|
|
5
5
|
import { aliasExpr, buildAttributionSql, buildBreakdownSql, buildDetailSql, buildMetricSql, computeAttribution, isAdditive, parseSelect, quoteName, rankDimensions } from '../analysis.js'
|
|
6
|
-
import { applyFilters, normalizeFilters } from '../filters.js'
|
|
6
|
+
import { FilterInjectionError, applyFilters, normalizeFilters } from '../filters.js'
|
|
7
7
|
import { checkAccess, filterSchema, policyActive, referencedTables, resultColumnPolicy } from '../policy.js'
|
|
8
8
|
import { admitSql, prepareSql, stripSql } from '../sql-guard.js'
|
|
9
9
|
import { cacheKey } from '../store.js'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
10
11
|
import { writeXlsx } from '../xlsx.js'
|
|
11
12
|
import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, UserError, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
|
|
12
13
|
|
|
13
14
|
export const methods = {
|
|
14
15
|
// ---------------------------------------------------------------- query
|
|
15
16
|
|
|
16
|
-
async runQuery({ sql, datasourceId, sessionId, title, chart, source, confirmScan, filters, useCache, actor, governed }) {
|
|
17
|
+
async runQuery({ sql, datasourceId, sessionId, title, chart, source, confirmScan, filters, useCache, actor, governed, maxRows }) {
|
|
17
18
|
actor ??= this.actorFor(sessionId)
|
|
18
19
|
const ds = this.datasourceForSession(sessionId, datasourceId)
|
|
19
20
|
this.assertAccess(actor, ds)
|
|
@@ -23,7 +24,8 @@ export const methods = {
|
|
|
23
24
|
this.state.sessions[sessionId] = ds.id
|
|
24
25
|
await this.save()
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
// Optional per-call row cap (reconciliation loads up to 200k rows per side); invalid values are ignored.
|
|
28
|
+
const settings = { ...this.settingsFor(ds), ...(Number.isInteger(maxRows) && maxRows > 0 ? { maxRows: Math.min(maxRows, 200000) } : {}) }
|
|
27
29
|
const dialect = ds.type ?? 'mysql'
|
|
28
30
|
let admitted = admitSql(sql, { dialect })
|
|
29
31
|
const policy = this.policyFor(ds)
|
|
@@ -36,12 +38,19 @@ export const methods = {
|
|
|
36
38
|
}
|
|
37
39
|
let appliedFilters = []
|
|
38
40
|
if (filters?.length) {
|
|
39
|
-
|
|
41
|
+
let f
|
|
42
|
+
try { f = applyFilters(admitted.sql, normalizeFilters(filters), { dialect, schema: filterSchema(fullSchema, policy) }) } catch (error) {
|
|
43
|
+
if (!(error instanceof FilterInjectionError)) throw error
|
|
44
|
+
this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'blocked', error: 'filter injection refused', actor })
|
|
45
|
+
await this.save()
|
|
46
|
+
throw new UserError('nl2sql/invalid', '无法安全地为这条查询加上筛选条件,已拒绝。请改写查询(检查括号与注释)。')
|
|
47
|
+
}
|
|
40
48
|
appliedFilters = f.applied
|
|
41
49
|
if (f.applied.length) admitted = admitSql(f.sql, { dialect })
|
|
42
50
|
}
|
|
43
51
|
// Row-level security: every rule must be injected into the outer query; otherwise refuse.
|
|
44
52
|
const rowRules = this.rowRulesFor(actor, ds)
|
|
53
|
+
const rowRulesSig = rowRules.length ? rowRulesSignature(rowRules) : undefined
|
|
45
54
|
if (rowRules.length) {
|
|
46
55
|
const schemaForRules = fullSchema ?? await this.analyzeSchema(ds.id)
|
|
47
56
|
const refs = referencedTables(admitted.sql, schemaForRules.tables, { dialect })
|
|
@@ -55,7 +64,13 @@ export const methods = {
|
|
|
55
64
|
await this.save()
|
|
56
65
|
throw new UserError('nl2sql/row-policy', `当前成员受行级权限限制(${needed.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}),查询不能包含子查询、CTE 或 UNION,请改写为单层查询。`)
|
|
57
66
|
}
|
|
58
|
-
|
|
67
|
+
let f
|
|
68
|
+
try { f = applyFilters(admitted.sql, needed.map(r => ({ kind: 'in', column: r.column, values: r.values })), { dialect, schema: schemaForRules }) } catch (error) {
|
|
69
|
+
if (!(error instanceof FilterInjectionError)) throw error
|
|
70
|
+
this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'blocked', error: 'row-level policy: injection refused', actor })
|
|
71
|
+
await this.save()
|
|
72
|
+
throw new UserError('nl2sql/row-policy', `当前成员受行级权限限制(${needed.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}),这条查询无法安全地加上该过滤,已拒绝。`)
|
|
73
|
+
}
|
|
59
74
|
const missing = needed.filter(r => !f.applied.some(a => a.column.toLowerCase() === r.column.toLowerCase()))
|
|
60
75
|
// Every referenced table that has the column must be covered.
|
|
61
76
|
const tablesWithCol = (col) => refs.filter(t => schemaForRules.tables.find(x => x.name === t)?.columns.some(c => c.name.toLowerCase() === col.toLowerCase()))
|
|
@@ -71,10 +86,10 @@ export const methods = {
|
|
|
71
86
|
}
|
|
72
87
|
// Query cache: dashboards, reruns and reports reuse a recent identical result.
|
|
73
88
|
const ttlMs = (settings.cacheTtlSec ?? 0) * 1000
|
|
74
|
-
const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules]) : null
|
|
89
|
+
const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules, rowRulesSig ?? null]) : null
|
|
75
90
|
if (key) {
|
|
76
91
|
const hit = this.store.cached(key, ttlMs)
|
|
77
|
-
if (hit) {
|
|
92
|
+
if (hit && (hit.rowRulesSig ?? undefined) === rowRulesSig) {
|
|
78
93
|
const result = { ...hit, resultId: newId('r'), title: title ? String(title).slice(0, 120) : hit.title, chart: normalizeChart(chart, hit.columns) ?? hit.chart, cached: true, cachedAt: hit.createdAt, createdAt: Date.now(), ...(appliedFilters.length ? { filters: appliedFilters, baseSql: sql } : {}) }
|
|
79
94
|
this.remember(result)
|
|
80
95
|
this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'ok', rows: result.rowCount, elapsedMs: 0, resultId: result.resultId, cached: true, actor })
|
|
@@ -134,6 +149,7 @@ export const methods = {
|
|
|
134
149
|
executedSql: prepared,
|
|
135
150
|
...(appliedFilters.length ? { filters: appliedFilters, baseSql: sql } : {}),
|
|
136
151
|
...(colPolicy.drop.size ? { hiddenColumns: colPolicy.drop.size } : {}),
|
|
152
|
+
...(rowRulesSig ? { rowRulesSig } : {}),
|
|
137
153
|
title: title ? String(title).slice(0, 120) : '',
|
|
138
154
|
columns,
|
|
139
155
|
rows: cells,
|
|
@@ -211,11 +227,17 @@ export const methods = {
|
|
|
211
227
|
return this.lookupResult(resultId) ?? null
|
|
212
228
|
},
|
|
213
229
|
|
|
214
|
-
/**
|
|
230
|
+
/**
|
|
231
|
+
* A scoped actor may only use results of data sources in scope, and — when it
|
|
232
|
+
* has row rules — only results filtered by exactly its own rules (signature
|
|
233
|
+
* match; results without a signature, e.g. from older versions, are refused).
|
|
234
|
+
*/
|
|
215
235
|
assertResultAccess(r, actor) {
|
|
216
236
|
if (!actor || actor.kind === 'owner') return
|
|
217
|
-
|
|
218
|
-
|
|
237
|
+
const ds = this.requireDatasource(r.datasourceId)
|
|
238
|
+
this.assertAccess(actor, ds)
|
|
239
|
+
const rules = this.rowRulesFor(actor, ds)
|
|
240
|
+
if (rules.length && (!r.filters?.some(f => f.rowPolicy) || r.rowRulesSig !== rowRulesSignature(rules))) throw new UserError('nl2sql/row-policy', '该结果未按你的行级权限过滤,不能使用,请重新查询')
|
|
219
241
|
},
|
|
220
242
|
|
|
221
243
|
cacheStats() {
|
|
@@ -409,3 +431,10 @@ export const methods = {
|
|
|
409
431
|
return { fileName: file, mediaType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base64: buf.toString('base64'), bytes: buf.length, sheets: sheets.length }
|
|
410
432
|
},
|
|
411
433
|
}
|
|
434
|
+
|
|
435
|
+
/** Stable signature of a set of row rules (order of rules and of values does not matter). */
|
|
436
|
+
export function rowRulesSignature(rules) {
|
|
437
|
+
const norm = rules.map(r => ({ column: String(r.column).toLowerCase(), values: [...new Set((r.values ?? []).map(String))].sort() }))
|
|
438
|
+
.sort((a, b) => (a.column + '\0' + a.values.join('\0')).localeCompare(b.column + '\0' + b.values.join('\0')))
|
|
439
|
+
return createHash('sha256').update(JSON.stringify(norm)).digest('hex').slice(0, 32)
|
|
440
|
+
}
|