@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.
@@ -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
+ }