@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1
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 +52 -0
- package/LICENSE +21 -0
- package/README.en.md +63 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +68 -0
- package/docs/CHANNELS.md +184 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +8 -0
- package/index.js +658 -0
- package/lark-bot.js +9 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +5757 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +95 -4
- package/policy.js +362 -0
- package/service/access.js +239 -0
- package/service/admin.js +555 -0
- package/service/alerts.js +233 -0
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +415 -0
- package/service/evaluation.js +121 -0
- package/service/push.js +163 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nl2SqlService methods: access.
|
|
3
|
+
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
|
+
*/
|
|
5
|
+
import { ROLE_RANK, UserError, newId, oneOf, requireText, upsert } from './shared.js'
|
|
6
|
+
|
|
7
|
+
export const PLATFORM_LABEL = { lark: '飞书', telegram: 'Telegram', wecom: '企业微信', dingtalk: '钉钉' }
|
|
8
|
+
const PLATFORM_RE = /^[a-z][\w-]{0,31}$/
|
|
9
|
+
|
|
10
|
+
/** Validate and dedupe an identities array: [{ platform, id, email? }]. */
|
|
11
|
+
export function normalizeIdentities(list) {
|
|
12
|
+
if (!Array.isArray(list)) throw new UserError('nl2sql/invalid', 'identities 必须是数组')
|
|
13
|
+
const out = []
|
|
14
|
+
for (const raw of list.slice(0, 20)) {
|
|
15
|
+
if (!raw || typeof raw !== 'object') continue
|
|
16
|
+
const platform = String(raw.platform ?? '').trim().toLowerCase()
|
|
17
|
+
const id = String(raw.id ?? '').trim().slice(0, 200)
|
|
18
|
+
const email = String(raw.email ?? '').trim().slice(0, 200)
|
|
19
|
+
if (!id && !email) continue
|
|
20
|
+
if (!PLATFORM_RE.test(platform)) throw new UserError('nl2sql/invalid', `无效的平台:${raw.platform ?? ''}`)
|
|
21
|
+
if (!id) throw new UserError('nl2sql/invalid', `请填写 ${PLATFORM_LABEL[platform] ?? platform} 账号 ID`)
|
|
22
|
+
if (out.some(i => i.platform === platform && i.id === id)) continue
|
|
23
|
+
out.push({ platform, id, ...(email ? { email } : {}) })
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Keep the legacy larkOpenId / larkChatId fields mirroring the first Lark identity. */
|
|
29
|
+
function mirrorLegacyIds(u) {
|
|
30
|
+
const lark = u.identities.find(i => i.platform === 'lark')?.id
|
|
31
|
+
delete u.larkOpenId
|
|
32
|
+
delete u.larkChatId
|
|
33
|
+
if (lark) { if (u.kind === 'chat') u.larkChatId = lark; else u.larkOpenId = lark }
|
|
34
|
+
return u
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Legacy member fields → identities, in place (idempotent). */
|
|
38
|
+
export function normalizeUserRecord(u) {
|
|
39
|
+
if (!u || typeof u !== 'object') return u
|
|
40
|
+
if (!Array.isArray(u.identities)) {
|
|
41
|
+
u.identities = []
|
|
42
|
+
const legacy = u.kind === 'chat' ? (u.larkChatId ?? u.larkOpenId) : (u.larkOpenId ?? u.larkChatId)
|
|
43
|
+
if (legacy) u.identities.push({ platform: 'lark', id: String(legacy) })
|
|
44
|
+
}
|
|
45
|
+
return mirrorLegacyIds(u)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Another member already owning one of u's identities (same platform + id) or its email. */
|
|
49
|
+
export function findIdentityClash(users, u) {
|
|
50
|
+
const mail = u.email?.toLowerCase()
|
|
51
|
+
return users.find(x => {
|
|
52
|
+
if (x.id === u.id) return false
|
|
53
|
+
const ids = normalizeUserRecord({ ...x, identities: x.identities ? [...x.identities] : undefined }).identities
|
|
54
|
+
if (u.identities.some(a => ids.some(b => a.platform === b.platform && a.id === b.id))) return true
|
|
55
|
+
return Boolean(mail && x.email && x.email.toLowerCase() === mail)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const methods = {
|
|
60
|
+
// ------------------------------------------------------------ access
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Actors (multi-user): the local page/agent owner has full access; Lark
|
|
64
|
+
* users/chats are mapped to users with datasource scopes.
|
|
65
|
+
*/
|
|
66
|
+
assertAccess(actor, ds) {
|
|
67
|
+
if (!actor || actor.kind === 'owner') return
|
|
68
|
+
const allowed = actor.datasourceIds
|
|
69
|
+
if (Array.isArray(allowed) && !allowed.includes(ds.id)) throw new UserError('nl2sql/access-denied', `${actor.name ?? '当前用户'} 无权访问数据源「${ds.name}」`)
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
/** The actor a session runs as (Lark user, evaluation), or undefined for the local owner. */
|
|
73
|
+
actorFor(sessionId) {
|
|
74
|
+
return sessionId ? this.state.sessionActors?.[sessionId] : undefined
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/** Viewer: only governed answers (metric library, verified examples, dashboards) — no free SQL. */
|
|
78
|
+
assertCanWriteSql(actor, what = '自由编写 SQL') {
|
|
79
|
+
if (actor?.role === 'viewer') throw new UserError('nl2sql/viewer', `当前成员是「只读」角色,不能${what}。只读成员可以:查询指标库中的指标(nl2sql_metric)、复用已验证的样例问答(nl2sql_knowledge search 后用样例 SQL 原样查询)、查看看板。请改用这些方式回答,或请管理员调整角色。`)
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** Whether a statement is (whitespace-insensitively) one of the verified example SQLs of the data source. */
|
|
83
|
+
isVerifiedExample(ds, sql) {
|
|
84
|
+
const norm = (q) => String(q ?? '').replace(/;\s*$/, '').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
85
|
+
const target = norm(sql)
|
|
86
|
+
return this.knowledgeFor(ds.id).examples.some(e => norm(e.sql) === target) || this.state.dashboards.some(d => d.widgets.some(w => w.datasourceId === ds.id && norm(w.sql) === target))
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Row-level rules for an actor on a data source: [{ column, values }] combined
|
|
91
|
+
* from the member and the group. Every query must be rewritable to include them.
|
|
92
|
+
*/
|
|
93
|
+
rowRulesFor(actor, ds) {
|
|
94
|
+
const rules = []
|
|
95
|
+
for (const r of actor?.rowRules ?? []) if (!r.datasourceId || r.datasourceId === ds.id) rules.push({ column: r.column, values: r.values })
|
|
96
|
+
return rules
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Members: people and group chats on IM platforms, each with a role
|
|
101
|
+
* (admin | analyst | viewer), a data source scope and platform-scoped
|
|
102
|
+
* identities [{ platform, id, email? }] (see docs/CHANNELS.md). The local
|
|
103
|
+
* owner is implicit. Bot messages from unknown people fall back to
|
|
104
|
+
* settings.botDefaultRole (none = refuse).
|
|
105
|
+
*/
|
|
106
|
+
listUsers() {
|
|
107
|
+
return (this.state.users ?? []).map(u => ({ ...normalizeUserRecord(u), datasourceNames: (u.datasourceIds ?? []).map(id => this.findDatasource(id)?.name ?? id) }))
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async saveUser(input) {
|
|
111
|
+
const existing = input.id ? (this.state.users ?? []).find(u => u.id === input.id) : undefined
|
|
112
|
+
const u = existing ? normalizeUserRecord({ ...existing, identities: structuredClone(existing.identities ?? []) }) : { id: newId('u'), createdAt: Date.now(), identities: [] }
|
|
113
|
+
u.kind = oneOf(input.kind ?? u.kind ?? 'person', ['person', 'chat'], 'kind')
|
|
114
|
+
u.name = requireText(input.name ?? u.name, 'name', 60)
|
|
115
|
+
u.role = oneOf(input.role ?? u.role ?? 'analyst', ['admin', 'analyst', 'viewer'], 'role')
|
|
116
|
+
u.email = String(input.email ?? u.email ?? '').trim().slice(0, 200) || undefined
|
|
117
|
+
if (input.identities !== undefined) {
|
|
118
|
+
u.identities = normalizeIdentities(input.identities)
|
|
119
|
+
} else {
|
|
120
|
+
// Legacy single Lark ids: replace the Lark identity (an empty value removes it).
|
|
121
|
+
const legacy = u.kind === 'chat' ? input.larkChatId : input.larkOpenId
|
|
122
|
+
const other = u.kind === 'chat' ? input.larkOpenId : input.larkChatId
|
|
123
|
+
if (legacy !== undefined || other !== undefined) {
|
|
124
|
+
const value = String(legacy ?? '').trim().slice(0, 100)
|
|
125
|
+
u.identities = u.identities.filter(i => i.platform !== 'lark')
|
|
126
|
+
if (value) u.identities.unshift({ platform: 'lark', id: value })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
mirrorLegacyIds(u)
|
|
130
|
+
if (u.kind === 'person' && !u.identities.length && !u.email) throw new UserError('nl2sql/invalid', '成员需要填写至少一个 IM 账号(如飞书 open_id、Telegram 用户 ID)或邮箱,用于识别身份')
|
|
131
|
+
if (u.kind === 'chat' && !u.identities.length) throw new UserError('nl2sql/invalid', '群成员需要填写群 ID(如飞书 chat_id、Telegram chat id)')
|
|
132
|
+
const allDs = input.allDatasources ?? (input.datasourceIds === undefined ? u.datasourceIds === null : false)
|
|
133
|
+
u.datasourceIds = allDs ? null : (input.datasourceIds ?? u.datasourceIds ?? []).filter(id => this.findDatasource(id))
|
|
134
|
+
u.defaultDatasourceId = input.defaultDatasourceId && this.findDatasource(input.defaultDatasourceId) ? input.defaultDatasourceId : (u.datasourceIds?.[0] ?? null)
|
|
135
|
+
u.disabled = Boolean(input.disabled ?? u.disabled ?? false)
|
|
136
|
+
u.note = String(input.note ?? u.note ?? '').slice(0, 300)
|
|
137
|
+
if (input.rowRules !== undefined) {
|
|
138
|
+
u.rowRules = (Array.isArray(input.rowRules) ? input.rowRules : []).map(r => ({
|
|
139
|
+
datasourceId: r.datasourceId && this.findDatasource(r.datasourceId) ? r.datasourceId : null,
|
|
140
|
+
column: requireText(r.column, 'rowRules.column', 64),
|
|
141
|
+
values: (Array.isArray(r.values) ? r.values : String(r.values ?? '').split(/[,,]/)).map(v => String(v).trim()).filter(Boolean).slice(0, 200),
|
|
142
|
+
})).filter(r => r.values.length).slice(0, 20)
|
|
143
|
+
if (u.rowRules.some(r => !/^[A-Za-z_\u4e00-\u9fa5][\w$\u4e00-\u9fa5]*$/.test(r.column))) throw new UserError('nl2sql/invalid', '行级规则的字段名只能包含字母、数字、下划线或中文')
|
|
144
|
+
}
|
|
145
|
+
const dup = findIdentityClash(this.state.users ?? [], u)
|
|
146
|
+
if (dup) throw new UserError('nl2sql/duplicate', `该身份已属于成员「${dup.name}」`)
|
|
147
|
+
u.updatedAt = Date.now()
|
|
148
|
+
this.state.users = upsert(this.state.users ?? [], u)
|
|
149
|
+
this.auditAdmin('user.save', `${u.name}(${u.role})`)
|
|
150
|
+
await this.save()
|
|
151
|
+
return u
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async deleteUser(id) {
|
|
155
|
+
const u = (this.state.users ?? []).find(x => x.id === id)
|
|
156
|
+
this.state.users = (this.state.users ?? []).filter(x => x.id !== id)
|
|
157
|
+
if (u) this.auditAdmin('user.delete', u.name)
|
|
158
|
+
await this.save()
|
|
159
|
+
return { deleted: id }
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
/** Migrate legacy member fields to identities (idempotent). Returns true when something changed. */
|
|
163
|
+
migrateUsers() {
|
|
164
|
+
let changed = false
|
|
165
|
+
for (const u of this.state.users ?? []) {
|
|
166
|
+
const before = JSON.stringify(u)
|
|
167
|
+
normalizeUserRecord(u)
|
|
168
|
+
if (JSON.stringify(u) !== before) changed = true
|
|
169
|
+
}
|
|
170
|
+
return changed
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
/** Default role / data sources for unknown bot users (new keys, falling back to the legacy lark* keys). */
|
|
174
|
+
botDefaults() {
|
|
175
|
+
const s = this.state.settings
|
|
176
|
+
return {
|
|
177
|
+
role: s.botDefaultRole ?? s.larkDefaultRole ?? 'none',
|
|
178
|
+
datasourceIds: s.botDefaultDatasourceIds !== undefined ? s.botDefaultDatasourceIds : s.larkDefaultDatasourceIds,
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the effective actor for a bot message or action: the person's own
|
|
184
|
+
* scope, narrowed by the chat's scope in group chats. People match by
|
|
185
|
+
* (platform, userId) or email; chats by (platform, chatId).
|
|
186
|
+
* Returns { actor } or { refused }.
|
|
187
|
+
*/
|
|
188
|
+
resolveActor({ platform = 'lark', userId, email, name, chatId, chatType }) {
|
|
189
|
+
const users = (this.state.users ?? []).filter(u => !u.disabled).map(u => normalizeUserRecord(u))
|
|
190
|
+
const mail = email ? String(email).toLowerCase() : ''
|
|
191
|
+
const person = users.find(u => u.kind === 'person' && userId && u.identities.some(i => i.platform === platform && i.id === String(userId)))
|
|
192
|
+
?? (mail ? users.find(u => u.kind === 'person' && ((u.email && u.email.toLowerCase() === mail) || u.identities.some(i => i.email && i.email.toLowerCase() === mail))) : undefined)
|
|
193
|
+
const chat = chatType === 'group' && chatId ? users.find(u => u.kind === 'chat' && u.identities.some(i => i.platform === platform && i.id === String(chatId))) : undefined
|
|
194
|
+
const defaults = this.botDefaults()
|
|
195
|
+
const fallbackRole = defaults.role
|
|
196
|
+
if (!person && !chat && fallbackRole === 'none') return { refused: `你还没有开通数据问答权限,请联系管理员在「数据问答 → 成员」中添加你的${PLATFORM_LABEL[platform] ?? platform}账号。` }
|
|
197
|
+
const scopes = [person?.datasourceIds, chat?.datasourceIds].filter(x => x !== undefined)
|
|
198
|
+
let datasourceIds = null
|
|
199
|
+
for (const sc of scopes) if (Array.isArray(sc)) datasourceIds = datasourceIds === null ? [...sc] : datasourceIds.filter(id => sc.includes(id))
|
|
200
|
+
if (!person && !chat) datasourceIds = defaults.datasourceIds ?? []
|
|
201
|
+
const role = person?.role ?? chat?.role ?? fallbackRole
|
|
202
|
+
if (Array.isArray(datasourceIds) && datasourceIds.length === 0) return { refused: '你在当前群里没有可访问的数据源。' }
|
|
203
|
+
return {
|
|
204
|
+
actor: {
|
|
205
|
+
kind: platform, platform, id: person?.id ?? `${platform}:${userId}`, name: person?.name ?? name ?? userId,
|
|
206
|
+
// Group chats: the narrower of the person's and the group's role.
|
|
207
|
+
role: [person?.role, chat?.role, (!person && !chat) ? fallbackRole : undefined].filter(Boolean).sort((a, b) => ROLE_RANK[a] - ROLE_RANK[b])[0] ?? role,
|
|
208
|
+
userId, ...(platform === 'lark' ? { openId: userId } : {}), chatId, chatName: chat?.name, datasourceIds,
|
|
209
|
+
rowRules: [...(person?.rowRules ?? []), ...(chat?.rowRules ?? [])],
|
|
210
|
+
defaultDatasourceId: chat?.defaultDatasourceId ?? person?.defaultDatasourceId ?? (datasourceIds?.[0] ?? null),
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/** Back-compat alias of resolveActor for Lark. */
|
|
216
|
+
resolveLarkActor({ openId, email, name, chatId, chatType }) {
|
|
217
|
+
return this.resolveActor({ platform: 'lark', userId: openId, email, name, chatId, chatType })
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
auditAdmin(action, detail, actor) {
|
|
221
|
+
this.records.put('admin', { id: newId('ad'), at: Date.now(), action, detail: String(detail).slice(0, 300), ...(actor ? { actor: { id: actor.id, name: actor.name, kind: actor.kind } } : {}) })
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/** Per-actor usage for the members page. */
|
|
225
|
+
usageByActor(days = 30) {
|
|
226
|
+
const since = Date.now() - days * 86400000
|
|
227
|
+
const by = new Map()
|
|
228
|
+
for (const h of this.records.list('history', { since, limit: 100000 })) {
|
|
229
|
+
const key = h.actor?.id ?? 'owner'
|
|
230
|
+
const e = by.get(key) ?? { actorId: key, name: h.actor?.name ?? '本机', kind: h.actor?.kind ?? 'owner', queries: 0, errors: 0, blocked: 0, lastAt: 0 }
|
|
231
|
+
e.queries++
|
|
232
|
+
if (h.status === 'error') e.errors++
|
|
233
|
+
if (h.status === 'blocked') e.blocked++
|
|
234
|
+
e.lastAt = Math.max(e.lastAt, h.at)
|
|
235
|
+
by.set(key, e)
|
|
236
|
+
}
|
|
237
|
+
return [...by.values()].sort((a, b) => b.queries - a.queries)
|
|
238
|
+
},
|
|
239
|
+
}
|