dsh-api-runner 0.2.1 → 0.3.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/README.md +115 -2
- package/client/client.js +1052 -232
- package/package.json +7 -5
- package/scripts/check-leak.mjs +150 -0
- package/src/db.js +1464 -0
- package/src/main.js +205 -5
- package/src/ops.js +97 -0
- package/test/client.mjs +141 -1
- package/test/db.mjs +512 -0
- package/test/plugin.mjs +150 -1
- package/test/security.mjs +200 -0
package/src/db.js
ADDED
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据库连接管理与执行层。
|
|
3
|
+
*
|
|
4
|
+
* 设计取舍(与 dsh-db-tool 的关键差别):
|
|
5
|
+
* - **不依赖任何 DSH 服务**(不 inject betterSidebar 之类)。原 dsh-db-tool 把
|
|
6
|
+
* betterSidebar 当硬依赖,服务没就绪就一直 pending,拖垮整个 web 启动
|
|
7
|
+
* (`web boot: 1 entry did not activate`)。这里只依赖 Node 内置能力。
|
|
8
|
+
* - **SQLite 用 Node 内置的 node:sqlite**(Node >= 22.5 自带),不装
|
|
9
|
+
* better-sqlite3 这类原生模块,避免 NODE_MODULE_VERSION 不匹配的坑。
|
|
10
|
+
* - 其它驱动(mysql2 / pg / oracledb)**按需动态 import**:没装对应包时
|
|
11
|
+
* 只影响那一种类型的连接,不会让整个插件加载失败。
|
|
12
|
+
*
|
|
13
|
+
* 安全约定:
|
|
14
|
+
* - 密码落盘前加密,任何对外返回都不含明文(maskSecret 同款思路)。
|
|
15
|
+
* - 只允许单条语句,防多语句注入。
|
|
16
|
+
* - 只读模式默认开启,拒绝写操作。
|
|
17
|
+
* - 结果集行数上限,防撑爆内存。
|
|
18
|
+
*/
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { isPlainObject, maskSecret, readStateNamespace, writeStateNamespace } from './util.js';
|
|
24
|
+
|
|
25
|
+
/** 支持的数据库类型 */
|
|
26
|
+
export const DB_TYPES = ['sqlite', 'mysql', 'postgres', 'oracle', 'dm'];
|
|
27
|
+
|
|
28
|
+
/** 各类型的默认端口 */
|
|
29
|
+
const DEFAULT_PORTS = {
|
|
30
|
+
mysql: 3306,
|
|
31
|
+
postgres: 5432,
|
|
32
|
+
oracle: 1521,
|
|
33
|
+
dm: 5236,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 是否毁灭性操作:即使有读写权限也要额外确认。
|
|
38
|
+
*
|
|
39
|
+
* 判定标准偏保守——宁可误拒,不可误放:
|
|
40
|
+
* - DROP / TRUNCATE:直接删结构或清空表
|
|
41
|
+
* - DELETE / UPDATE 没有 WHERE:影响整表
|
|
42
|
+
* - 任何 GRANT / REVOKE:改权限
|
|
43
|
+
*/
|
|
44
|
+
export function isDestructiveSql(sql) {
|
|
45
|
+
const stripped = stripStringsAndComments(String(sql || '')).trim();
|
|
46
|
+
if (/^\s*(drop|truncate|grant|revoke)\b/i.test(stripped)) return true;
|
|
47
|
+
// DELETE / UPDATE 缺少 WHERE 子句 = 全表操作
|
|
48
|
+
if (/^\s*(delete|update)\b/i.test(stripped) && !/\bwhere\b/i.test(stripped)) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 授权等级:none < readonly < readwrite */
|
|
53
|
+
export const ACCESS_LEVELS = ['none', 'readonly', 'readwrite'];
|
|
54
|
+
|
|
55
|
+
/** 等级比较:是否满足所需等级 */
|
|
56
|
+
function levelAtLeast(actual, required) {
|
|
57
|
+
const order = { none: 0, readonly: 1, readwrite: 2 };
|
|
58
|
+
return (order[actual] ?? 0) >= (order[required] ?? 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 结果集默认上限 */
|
|
62
|
+
const DEFAULT_MAX_ROWS = 1000;
|
|
63
|
+
/** 查询默认超时 */
|
|
64
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
65
|
+
|
|
66
|
+
/** 只读模式允许的语句前缀(忽略大小写与前置空白/注释) */
|
|
67
|
+
const READONLY_PREFIX = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*(select|with|show|desc|describe|explain|pragma|table)\b/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 去掉 SQL 末尾的分号,并判断是否只包含一条语句。
|
|
71
|
+
*
|
|
72
|
+
* 为什么不用简单的 `/;/` 判断:字符串字面量里合法地会有分号
|
|
73
|
+
* (如 `WHERE note = 'a;b'`),一律拒绝会误伤正常查询。
|
|
74
|
+
* 这里用「跳过字符串/注释」的扫描,只在语句层面找分隔符。
|
|
75
|
+
*/
|
|
76
|
+
export function splitStatements(sql) {
|
|
77
|
+
const text = String(sql || '');
|
|
78
|
+
const out = [];
|
|
79
|
+
let current = '';
|
|
80
|
+
let i = 0;
|
|
81
|
+
let quote = '';
|
|
82
|
+
while (i < text.length) {
|
|
83
|
+
const ch = text[i];
|
|
84
|
+
const next = text[i + 1];
|
|
85
|
+
if (quote) {
|
|
86
|
+
current += ch;
|
|
87
|
+
if (ch === '\\' && (quote === "'" || quote === '"')) {
|
|
88
|
+
current += next ?? '';
|
|
89
|
+
i += 2;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (ch === quote) {
|
|
93
|
+
// SQL 里的 '' 转义:连续两个引号不结束字符串
|
|
94
|
+
if (next === quote) {
|
|
95
|
+
current += next;
|
|
96
|
+
i += 2;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
quote = '';
|
|
100
|
+
}
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
105
|
+
quote = ch;
|
|
106
|
+
current += ch;
|
|
107
|
+
i += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '-' && next === '-') {
|
|
111
|
+
// 行注释:跳到行尾
|
|
112
|
+
const nl = text.indexOf('\n', i);
|
|
113
|
+
const end = nl === -1 ? text.length : nl + 1;
|
|
114
|
+
current += text.slice(i, end);
|
|
115
|
+
i = end;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === '/' && next === '*') {
|
|
119
|
+
const close = text.indexOf('*/', i + 2);
|
|
120
|
+
const end = close === -1 ? text.length : close + 2;
|
|
121
|
+
current += text.slice(i, end);
|
|
122
|
+
i = end;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === ';') {
|
|
126
|
+
out.push(current);
|
|
127
|
+
current = '';
|
|
128
|
+
i += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
current += ch;
|
|
132
|
+
i += 1;
|
|
133
|
+
}
|
|
134
|
+
out.push(current);
|
|
135
|
+
return out.map((item) => item.trim()).filter((item) => item !== '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 是否只读语句(仅看开头,作为第一道粗筛;真正的判定见 classifySql) */
|
|
139
|
+
export function isReadOnlySql(sql) {
|
|
140
|
+
return READONLY_PREFIX.test(String(sql || ''));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 危险关键字:出现在**任何位置**都视为写操作,即使语句以 SELECT 开头。
|
|
145
|
+
*
|
|
146
|
+
* 为什么要全串扫描而不是只看前缀:
|
|
147
|
+
* - `WITH x AS (SELECT 1) DELETE FROM t` 以 WITH 开头,但会删数据
|
|
148
|
+
* - `SELECT * FROM t FOR UPDATE` 会加锁,阻塞别人写入
|
|
149
|
+
* - `SELECT ... INTO OUTFILE` / `INTO DUMPFILE` 能往磁盘写文件
|
|
150
|
+
* - `SELECT load_file('/etc/passwd')` 能读任意文件
|
|
151
|
+
* - `EXPLAIN ANALYZE DELETE ...` 会真的执行
|
|
152
|
+
* 这些用「开头是不是 SELECT」都拦不住。
|
|
153
|
+
*/
|
|
154
|
+
const DANGEROUS_KEYWORDS = [
|
|
155
|
+
'insert', 'update', 'delete', 'merge', 'upsert',
|
|
156
|
+
'create', 'alter', 'drop', 'truncate', 'rename',
|
|
157
|
+
'grant', 'revoke', 'comment',
|
|
158
|
+
'exec', 'execute', 'call', 'perform',
|
|
159
|
+
'lock', 'unlock',
|
|
160
|
+
'copy', 'load', 'outfile', 'dumpfile',
|
|
161
|
+
'attach', 'detach', 'vacuum', 'reindex',
|
|
162
|
+
'set', 'commit', 'rollback', 'savepoint', 'begin',
|
|
163
|
+
'declare', 'prepare', 'deallocate',
|
|
164
|
+
'analyze', 'optimize', 'repair', 'shutdown', 'kill',
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
/** 需要额外警惕的「以 SELECT 开头但会写」的组合 */
|
|
168
|
+
const SELECT_WRITE_PATTERNS = [
|
|
169
|
+
/\bfor\s+(update|share|no\s+key\s+update)\b/i, // 加锁
|
|
170
|
+
/\binto\s+(outfile|dumpfile)\b/i, // 写文件
|
|
171
|
+
/\binto\s+@?[\w.]+\s+from\b/i, // SELECT INTO 建表
|
|
172
|
+
/\bload_file\s*\(/i, // 读服务器文件
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* PRAGMA 的两种形态:
|
|
177
|
+
* - 查询式:`PRAGMA table_info('t')`、`PRAGMA index_list(t)` —— 只读,放行
|
|
178
|
+
* - 赋值式:`PRAGMA journal_mode = WAL`、`PRAGMA user_version = 5` —— 改配置,拦
|
|
179
|
+
* 白名单只放行已知的只读 pragma,其余一律按写处理。
|
|
180
|
+
*/
|
|
181
|
+
const READONLY_PRAGMA = /^\s*pragma\s+(table_info|table_xinfo|index_info|index_list|index_xinfo|foreign_key_list|foreign_key_check|database_list|collation_list|function_list|module_list|pragma_list|compile_options|integrity_check|quick_check|encoding|page_count|page_size|freelist_count|schema_version|user_version|journal_mode|wal_checkpoint)\b/i;
|
|
182
|
+
/** 允许「读」但不允许「写」的 pragma:带 `=` 时是写 */
|
|
183
|
+
const MUTABLE_PRAGMA = /^\s*pragma\s+(journal_mode|user_version|synchronous|foreign_keys|cache_size|busy_timeout|locking_mode|page_size|auto_vacuum|wal_autocheckpoint|mmap_size|temp_store|encoding)\b/i;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 去掉 SQL 里的字符串字面量和注释,只留「结构性内容」。
|
|
187
|
+
* 关键字扫描必须基于这个结果,否则 `WHERE note = 'delete'` 会被误判成写操作。
|
|
188
|
+
*/
|
|
189
|
+
export function stripStringsAndComments(sql) {
|
|
190
|
+
const text = String(sql || '');
|
|
191
|
+
let out = '';
|
|
192
|
+
let i = 0;
|
|
193
|
+
let quote = '';
|
|
194
|
+
while (i < text.length) {
|
|
195
|
+
const ch = text[i];
|
|
196
|
+
const next = text[i + 1];
|
|
197
|
+
if (quote) {
|
|
198
|
+
if (ch === '\\' && (quote === "'" || quote === '"')) { i += 2; continue; }
|
|
199
|
+
if (ch === quote) {
|
|
200
|
+
if (next === quote) { i += 2; continue; }
|
|
201
|
+
quote = '';
|
|
202
|
+
}
|
|
203
|
+
i += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (ch === "'" || ch === '"' || ch === '`') { quote = ch; i += 1; continue; }
|
|
207
|
+
if (ch === '-' && next === '-') {
|
|
208
|
+
const nl = text.indexOf('\n', i);
|
|
209
|
+
i = nl === -1 ? text.length : nl + 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (ch === '/' && next === '*') {
|
|
213
|
+
const close = text.indexOf('*/', i + 2);
|
|
214
|
+
i = close === -1 ? text.length : close + 2;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
out += ch;
|
|
218
|
+
i += 1;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 判定一条 SQL 的性质。
|
|
225
|
+
*
|
|
226
|
+
* @returns {{ kind: 'read'|'write'|'unknown', reason?: string, keyword?: string }}
|
|
227
|
+
* - read:确定是只读查询,可以在只读模式下执行
|
|
228
|
+
* - write:包含写/危险操作,只读模式下必须拒绝
|
|
229
|
+
* - unknown:认不出来,按最严格的处理(只读模式下拒绝)
|
|
230
|
+
*/
|
|
231
|
+
export function classifySql(sql) {
|
|
232
|
+
const text = String(sql || '').trim();
|
|
233
|
+
if (text === '') return { kind: 'unknown', reason: 'SQL 为空' };
|
|
234
|
+
|
|
235
|
+
const stripped = stripStringsAndComments(text);
|
|
236
|
+
|
|
237
|
+
// 多语句:哪怕每一条都只读,也不允许(防止用分号绕过)
|
|
238
|
+
if (splitStatements(text).length > 1) {
|
|
239
|
+
return { kind: 'write', reason: '包含多条语句' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 危险关键字:全串扫描(已剔除字符串和注释)
|
|
243
|
+
for (const kw of DANGEROUS_KEYWORDS) {
|
|
244
|
+
const re = new RegExp(`\\b${kw}\\b`, 'i');
|
|
245
|
+
if (re.test(stripped)) {
|
|
246
|
+
return { kind: 'write', reason: `包含写操作关键字 ${kw.toUpperCase()}`, keyword: kw };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// SELECT 开头但带锁/写文件等
|
|
251
|
+
for (const pattern of SELECT_WRITE_PATTERNS) {
|
|
252
|
+
if (pattern.test(stripped)) {
|
|
253
|
+
return { kind: 'write', reason: '查询语句包含加锁或写文件动作' };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// PRAGMA 单独判定:赋值式会改库配置,必须拦
|
|
258
|
+
if (/^\s*pragma\b/i.test(stripped)) {
|
|
259
|
+
// 可变的 pragma 带 `=` 或带参数但不是查询式 → 写
|
|
260
|
+
if (MUTABLE_PRAGMA.test(stripped) && /\s*=\s*/.test(stripped)) {
|
|
261
|
+
return { kind: 'write', reason: 'PRAGMA 赋值会修改数据库配置' };
|
|
262
|
+
}
|
|
263
|
+
// 只读白名单
|
|
264
|
+
if (READONLY_PRAGMA.test(stripped)) {
|
|
265
|
+
return { kind: 'read' };
|
|
266
|
+
}
|
|
267
|
+
// 认不出来的 pragma:按最严格处理
|
|
268
|
+
return { kind: 'unknown', reason: '无法确认这条 PRAGMA 是只读的' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 结构上必须以只读关键字开头
|
|
272
|
+
if (!READONLY_PREFIX.test(text)) {
|
|
273
|
+
return { kind: 'unknown', reason: '无法识别为只读查询(只允许 SELECT / SHOW / DESCRIBE / EXPLAIN / PRAGMA)' };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { kind: 'read' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 机器特征派生密钥。
|
|
281
|
+
*
|
|
282
|
+
* 目的不是抗住有备而来的攻击者(本地文件能读就能解),而是
|
|
283
|
+
* **避免密码以明文出现在备份、同步盘、误提交里**。够用即可。
|
|
284
|
+
*/
|
|
285
|
+
function machineKey() {
|
|
286
|
+
const parts = [
|
|
287
|
+
os.hostname(),
|
|
288
|
+
os.userInfo().username,
|
|
289
|
+
process.platform,
|
|
290
|
+
process.env.USERPROFILE || process.env.HOME || '',
|
|
291
|
+
];
|
|
292
|
+
return crypto.createHash('sha256').update(parts.join('|')).digest();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 加密密码;空密码原样返回空串 */
|
|
296
|
+
export function encryptSecret(plain) {
|
|
297
|
+
const text = String(plain ?? '');
|
|
298
|
+
if (text === '') return '';
|
|
299
|
+
const iv = crypto.randomBytes(12);
|
|
300
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', machineKey(), iv);
|
|
301
|
+
const enc = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
|
302
|
+
const tag = cipher.getAuthTag();
|
|
303
|
+
return `v1:${Buffer.concat([iv, tag, enc]).toString('base64')}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 解密密码;解不开返回空串(换机器/文件损坏时按空密码处理,不让整个插件崩) */
|
|
307
|
+
export function decryptSecret(stored) {
|
|
308
|
+
const text = String(stored ?? '');
|
|
309
|
+
if (text === '') return '';
|
|
310
|
+
if (!text.startsWith('v1:')) return text; // 兼容早期明文
|
|
311
|
+
try {
|
|
312
|
+
const raw = Buffer.from(text.slice(3), 'base64');
|
|
313
|
+
const iv = raw.subarray(0, 12);
|
|
314
|
+
const tag = raw.subarray(12, 28);
|
|
315
|
+
const data = raw.subarray(28);
|
|
316
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', machineKey(), iv);
|
|
317
|
+
decipher.setAuthTag(tag);
|
|
318
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
|
319
|
+
} catch {
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 把连接串里的密码打码,用于界面回显与日志。
|
|
326
|
+
* 形如 `dm://FUND:***@192.168.0.144:8185?...` —— 保留用户名和其余部分,
|
|
327
|
+
* 用户能确认「填的是哪个连接」,但看不到密码。
|
|
328
|
+
*/
|
|
329
|
+
export function maskUrlPassword(connectString) {
|
|
330
|
+
const text = String(connectString || '');
|
|
331
|
+
// scheme://user:pass@rest
|
|
332
|
+
return text.replace(/^([a-z][a-z0-9+.-]*:\/\/)([^:/@]*):([^@]*)@/i, (_all, scheme, user, pass) => {
|
|
333
|
+
return `${scheme}${user}:${pass ? '***' : ''}@`;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 生成连接 id */
|
|
338
|
+
function makeId(name) {
|
|
339
|
+
const base = String(name || 'db')
|
|
340
|
+
.toLowerCase()
|
|
341
|
+
.replace(/[^\w-]+/g, '-')
|
|
342
|
+
.replace(/^-+|-+$/g, '')
|
|
343
|
+
.slice(0, 40) || 'db';
|
|
344
|
+
return `${base}-${crypto.randomBytes(3).toString('hex')}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export class DbManager {
|
|
348
|
+
/**
|
|
349
|
+
* @param {{homeDir?:string, settingsStore?:object, dataDir?:string, maxRows?:number, timeoutMs?:number}} options
|
|
350
|
+
*/
|
|
351
|
+
constructor(options = {}) {
|
|
352
|
+
this.homeDir = options.homeDir || process.env.DSH_HOME || path.join(process.cwd(), '.dsh');
|
|
353
|
+
this.settingsStore = options.settingsStore || null;
|
|
354
|
+
this.maxRows = Number(options.maxRows) || DEFAULT_MAX_ROWS;
|
|
355
|
+
this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
356
|
+
/**
|
|
357
|
+
* 试连的独立超时(默认 15s)。
|
|
358
|
+
* 比查询超时短:试连是交互动作,用户盯着等,不能让驱动自己卡 60 秒。
|
|
359
|
+
*/
|
|
360
|
+
this.probeTimeoutMs = Number(options.probeTimeoutMs) || 15000;
|
|
361
|
+
/** 连接池缓存:connectionId -> driver handle */
|
|
362
|
+
this.pools = new Map();
|
|
363
|
+
this._setDir(options.dataDir || this._preferredDataDir());
|
|
364
|
+
this._installDmGuard();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 拦掉 dmdb 已知的未捕获异步异常。
|
|
369
|
+
*
|
|
370
|
+
* dmdb 在连接失败后的池清理里会抛
|
|
371
|
+
* `TypeError: Cannot read properties of undefined (reading 'log')`
|
|
372
|
+
* (pool.js:263),且是未捕获的 Promise rejection —— 不拦就会把 DSH 主进程带崩。
|
|
373
|
+
*
|
|
374
|
+
* 只放行**这个精确签名**(message + 栈里含 dmdb/pool.js),
|
|
375
|
+
* 其它未捕获异常照常抛给宿主,避免把真正的问题藏起来。
|
|
376
|
+
*/
|
|
377
|
+
_installDmGuard() {
|
|
378
|
+
if (DbManager._dmGuardInstalled) return;
|
|
379
|
+
DbManager._dmGuardInstalled = true;
|
|
380
|
+
const onUnhandled = (reason) => {
|
|
381
|
+
const message = String(reason?.message || reason || '');
|
|
382
|
+
const stack = String(reason?.stack || '');
|
|
383
|
+
const isDmPoolBug = message.includes("reading 'log'") && stack.includes('dmdb');
|
|
384
|
+
if (isDmPoolBug) {
|
|
385
|
+
// 已知问题:连接失败后的清理异常,吞掉即可(连接失败本身已经通过返回值报告)
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// 其它未捕获异常不归这里管,交回默认行为
|
|
389
|
+
process.emitWarning(`[api-runner] unhandled rejection: ${message}`);
|
|
390
|
+
};
|
|
391
|
+
process.on('unhandledRejection', onUnhandled);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_preferredDataDir() {
|
|
395
|
+
try {
|
|
396
|
+
return this.settingsStore ? this.settingsStore.dataDir() : this.homeDir;
|
|
397
|
+
} catch {
|
|
398
|
+
return this.homeDir;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 数据目录变了之后重新指向(老数据不迁移,只换读写位置) */
|
|
403
|
+
_setDir(dir) {
|
|
404
|
+
this.dataDir = dir || this.homeDir;
|
|
405
|
+
this.file = path.join(this.dataDir, 'db-connections.json');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** 设置里改了数据目录后调用 */
|
|
409
|
+
refreshDataDir() {
|
|
410
|
+
const next = this._preferredDataDir();
|
|
411
|
+
if (next !== this.dataDir) {
|
|
412
|
+
this._setDir(next);
|
|
413
|
+
// 换了目录,旧池子的连接配置来源变了,直接清掉
|
|
414
|
+
this.dispose();
|
|
415
|
+
}
|
|
416
|
+
return this.dataDir;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------- 连接存储
|
|
420
|
+
/** 读原始记录(含加密后的密码),内部用 */
|
|
421
|
+
_loadRaw() {
|
|
422
|
+
const ns = readStateNamespace(this.file, 'connections');
|
|
423
|
+
return Array.isArray(ns.items) ? ns.items.filter(isPlainObject) : [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_saveRaw(items) {
|
|
427
|
+
writeStateNamespace(this.file, 'connections', { items });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------- 项目授权
|
|
431
|
+
/**
|
|
432
|
+
* 授权模型:**按项目(工作空间)授权,默认拒绝**。
|
|
433
|
+
*
|
|
434
|
+
* 为什么默认拒绝而不是默认允许:
|
|
435
|
+
* 一个连到生产库的连接,如果默认对所有项目开放,那么任何一个会话
|
|
436
|
+
* 都能读它——这不可接受。所以新连接创建后是「未授权」状态,
|
|
437
|
+
* 必须由用户在面板里显式授权给某个项目。
|
|
438
|
+
*
|
|
439
|
+
* 存储:`<数据目录>/db-connections.json` 的 `access` 命名空间,
|
|
440
|
+
* 形如 { "<projectKey>": { "<connectionId>": "readonly" } }。
|
|
441
|
+
*/
|
|
442
|
+
_loadAccess() {
|
|
443
|
+
const ns = readStateNamespace(this.file, 'access');
|
|
444
|
+
return isPlainObject(ns.projects) ? ns.projects : {};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
_saveAccess(projects) {
|
|
448
|
+
writeStateNamespace(this.file, 'access', { projects });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 某个项目对所有连接的授权表:{ connectionId: level } */
|
|
452
|
+
accessOf(projectKey) {
|
|
453
|
+
const key = String(projectKey || 'default');
|
|
454
|
+
const all = this._loadAccess();
|
|
455
|
+
const row = all[key];
|
|
456
|
+
return isPlainObject(row) ? row : {};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** 设置某项目对某连接的授权等级 */
|
|
460
|
+
setAccess(projectKey, connectionId, level) {
|
|
461
|
+
const key = String(projectKey || 'default');
|
|
462
|
+
const cid = String(connectionId || '');
|
|
463
|
+
if (cid === '') return { ok: false, code: 'MISSING_CONNECTION', message: '需要 connection_id' };
|
|
464
|
+
const lv = String(level || 'none');
|
|
465
|
+
if (!ACCESS_LEVELS.includes(lv)) {
|
|
466
|
+
return { ok: false, code: 'BAD_LEVEL', message: `授权等级只能是 ${ACCESS_LEVELS.join(' / ')}` };
|
|
467
|
+
}
|
|
468
|
+
const all = this._loadAccess();
|
|
469
|
+
const row = isPlainObject(all[key]) ? { ...all[key] } : {};
|
|
470
|
+
if (lv === 'none') delete row[cid];
|
|
471
|
+
else row[cid] = lv;
|
|
472
|
+
all[key] = row;
|
|
473
|
+
this._saveAccess(all);
|
|
474
|
+
return { ok: true, projectKey: key, connectionId: cid, level: lv, access: row };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 某项目的完整授权视图(给面板用):连接 + 当前等级 */
|
|
478
|
+
accessView(projectKey) {
|
|
479
|
+
const key = String(projectKey || 'default');
|
|
480
|
+
const row = this.accessOf(key);
|
|
481
|
+
const connections = this.listConnections().map((item) => ({
|
|
482
|
+
id: item.id,
|
|
483
|
+
name: item.name,
|
|
484
|
+
type: item.type,
|
|
485
|
+
target: item.urlMode ? item.connectString : (item.type === 'sqlite' ? item.file : `${item.host}:${item.port}/${item.database}`),
|
|
486
|
+
level: row[item.id] || 'none',
|
|
487
|
+
}));
|
|
488
|
+
const authorized = connections.filter((item) => item.level !== 'none').length;
|
|
489
|
+
return { ok: true, projectKey: key, connections, authorized, total: connections.length };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 解析某个项目对某连接的访问权。
|
|
494
|
+
*
|
|
495
|
+
* options.projectKey 缺省时用 'default'(没有会话上下文的情况),
|
|
496
|
+
* 这样工具调用和面板请求都能落到同一个可预期的档位。
|
|
497
|
+
*
|
|
498
|
+
* connectionOverride:面板试连时用草稿配置,此时没有连接 id,
|
|
499
|
+
* 允许跳过授权检查(试连只做最轻量的 SELECT 1,不读业务数据)。
|
|
500
|
+
*/
|
|
501
|
+
resolveAccess(connectionId, projectKey, connectionOverride) {
|
|
502
|
+
if (connectionOverride) return { ok: true, level: 'readonly', projectKey: String(projectKey || 'default') };
|
|
503
|
+
const key = String(projectKey || 'default');
|
|
504
|
+
const cid = String(connectionId || '');
|
|
505
|
+
if (cid === '') return { ok: false, code: 'MISSING_CONNECTION', message: '需要 connection_id' };
|
|
506
|
+
const level = this.accessOf(key)[cid] || 'none';
|
|
507
|
+
if (level === 'none') {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
code: 'NOT_AUTHORIZED',
|
|
511
|
+
message: `项目「${key}」没有获得这条连接的授权。请到面板「项目授权」里授权(只读 / 读写)后再试。`,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return { ok: true, level, projectKey: key };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 脱敏后的连接视图:对外一律走这个 */
|
|
518
|
+
_view(item) {
|
|
519
|
+
return {
|
|
520
|
+
id: item.id,
|
|
521
|
+
name: item.name,
|
|
522
|
+
type: item.type,
|
|
523
|
+
host: item.host || '',
|
|
524
|
+
port: Number(item.port) || DEFAULT_PORTS[item.type] || 0,
|
|
525
|
+
database: item.database || '',
|
|
526
|
+
username: item.username || '',
|
|
527
|
+
/** 只给掩码,绝不回传明文 */
|
|
528
|
+
hasPassword: Boolean(item.password),
|
|
529
|
+
passwordMask: item.password ? maskSecret('********') : '',
|
|
530
|
+
/** URL 模式:回显时把密码打码,避免明文出现在界面/日志里 */
|
|
531
|
+
connectString: item.connectString ? maskUrlPassword(item.connectString) : '',
|
|
532
|
+
urlMode: Boolean(item.connectString),
|
|
533
|
+
extra: isPlainObject(item.extra) ? item.extra : {},
|
|
534
|
+
createdAt: item.createdAt || '',
|
|
535
|
+
updatedAt: item.updatedAt || '',
|
|
536
|
+
/** sqlite 特有:文件路径 */
|
|
537
|
+
file: item.type === 'sqlite' ? String(item.database || '') : undefined,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
listConnections() {
|
|
542
|
+
return this._loadRaw().map((item) => this._view(item));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
getConnection(id) {
|
|
546
|
+
const raw = this._loadRaw().find((item) => String(item.id) === String(id));
|
|
547
|
+
return raw ? this._view(raw) : undefined;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** 内部取完整配置(含明文密码),只在执行层用,不对外 */
|
|
551
|
+
_fullConfig(id) {
|
|
552
|
+
const raw = this._loadRaw().find((item) => String(item.id) === String(id));
|
|
553
|
+
if (!raw) return undefined;
|
|
554
|
+
return { ...raw, password: decryptSecret(raw.password) };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 保存连接(新建或更新)。
|
|
559
|
+
* 更新时若 password 传空/未传,保留原密码(避免"编辑时看不到密码→保存后被清空")。
|
|
560
|
+
*/
|
|
561
|
+
saveConnection(patch = {}) {
|
|
562
|
+
const input = isPlainObject(patch) ? patch : {};
|
|
563
|
+
const items = this._loadRaw();
|
|
564
|
+
const id = String(input.id || '').trim();
|
|
565
|
+
const existingIndex = id ? items.findIndex((item) => String(item.id) === id) : -1;
|
|
566
|
+
const existing = existingIndex >= 0 ? items[existingIndex] : null;
|
|
567
|
+
|
|
568
|
+
const type = String(input.type || existing?.type || 'sqlite').toLowerCase();
|
|
569
|
+
if (!DB_TYPES.includes(type)) {
|
|
570
|
+
return { ok: false, code: 'BAD_TYPE', message: `不支持的数据库类型:${type}(支持 ${DB_TYPES.join('/')})` };
|
|
571
|
+
}
|
|
572
|
+
const name = String(input.name || existing?.name || '').trim();
|
|
573
|
+
if (name === '') return { ok: false, code: 'MISSING_NAME', message: '连接名称不能为空' };
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* 两种录入方式:
|
|
577
|
+
* - 分字段:host / port / database / username / password
|
|
578
|
+
* - URL:connectString 原样使用(dm://...、mysql://... 等)
|
|
579
|
+
* URL 模式下不再强制 host,字段校验要按模式分支,否则「填了 URL 却报缺 host」。
|
|
580
|
+
*/
|
|
581
|
+
const connectString = String(input.connectString ?? existing?.connectString ?? '').trim();
|
|
582
|
+
const urlMode = connectString !== '';
|
|
583
|
+
|
|
584
|
+
// sqlite 用 database 字段存文件路径;其它类型(非 URL 模式)需要 host
|
|
585
|
+
const database = String(input.database ?? existing?.database ?? '').trim();
|
|
586
|
+
if (type === 'sqlite' && database === '') {
|
|
587
|
+
return { ok: false, code: 'MISSING_DATABASE', message: 'SQLite 连接需要填写数据库文件路径(:memory: 表示内存库)' };
|
|
588
|
+
}
|
|
589
|
+
if (type !== 'sqlite' && !urlMode && String(input.host ?? existing?.host ?? '').trim() === '') {
|
|
590
|
+
return { ok: false, code: 'MISSING_HOST', message: `${type} 连接需要填写 host(或改用 URL 模式)` };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 密码:显式传了新值就加密存;传空或未传则沿用旧值
|
|
594
|
+
let password = existing?.password || '';
|
|
595
|
+
if (input.password !== undefined && input.password !== null && String(input.password) !== '') {
|
|
596
|
+
password = encryptSecret(String(input.password));
|
|
597
|
+
} else if (input.clearPassword === true) {
|
|
598
|
+
password = '';
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const record = {
|
|
602
|
+
id: existing?.id || id || makeId(name),
|
|
603
|
+
name,
|
|
604
|
+
type,
|
|
605
|
+
host: String(input.host ?? existing?.host ?? '').trim(),
|
|
606
|
+
port: Number(input.port ?? existing?.port) || DEFAULT_PORTS[type] || 0,
|
|
607
|
+
database,
|
|
608
|
+
username: String(input.username ?? existing?.username ?? '').trim(),
|
|
609
|
+
password,
|
|
610
|
+
/** URL 模式下的完整连接串(空表示分字段模式) */
|
|
611
|
+
connectString,
|
|
612
|
+
extra: isPlainObject(input.extra) ? input.extra : (existing?.extra || {}),
|
|
613
|
+
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
614
|
+
updatedAt: new Date().toISOString(),
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
if (existingIndex >= 0) items[existingIndex] = record;
|
|
618
|
+
else items.push(record);
|
|
619
|
+
this._saveRaw(items);
|
|
620
|
+
// 配置变了,旧池子必须销毁,否则还在用老连接串
|
|
621
|
+
this._closePool(record.id);
|
|
622
|
+
return { ok: true, connection: this._view(record), message: `已保存连接 ${record.name}` };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
deleteConnection(id) {
|
|
626
|
+
const key = String(id || '');
|
|
627
|
+
const items = this._loadRaw();
|
|
628
|
+
const next = items.filter((item) => String(item.id) !== key);
|
|
629
|
+
if (next.length === items.length) {
|
|
630
|
+
return { ok: false, code: 'NOT_FOUND', message: `没有连接 ${key}` };
|
|
631
|
+
}
|
|
632
|
+
this._saveRaw(next);
|
|
633
|
+
this._closePool(key);
|
|
634
|
+
return { ok: true, message: `已删除连接 ${key}` };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------- 驱动
|
|
638
|
+
/**
|
|
639
|
+
* 取得(或建立)某个连接的驱动句柄。
|
|
640
|
+
* 失败返回 { ok:false, code, message },不抛异常。
|
|
641
|
+
*/
|
|
642
|
+
async _handle(id) {
|
|
643
|
+
const key = String(id || '');
|
|
644
|
+
const cached = this.pools.get(key);
|
|
645
|
+
if (cached) return { ok: true, handle: cached };
|
|
646
|
+
|
|
647
|
+
const config = this._fullConfig(key);
|
|
648
|
+
if (!config) return { ok: false, code: 'NOT_FOUND', message: `没有连接 ${key}` };
|
|
649
|
+
|
|
650
|
+
try {
|
|
651
|
+
let handle;
|
|
652
|
+
if (config.type === 'sqlite') handle = await this._openSqlite(config);
|
|
653
|
+
else if (config.type === 'mysql') handle = await this._openMysql(config);
|
|
654
|
+
else if (config.type === 'postgres') handle = await this._openPostgres(config);
|
|
655
|
+
else if (config.type === 'oracle') handle = await this._openOracle(config);
|
|
656
|
+
else if (config.type === 'dm') handle = await this._openDm(config);
|
|
657
|
+
else return { ok: false, code: 'BAD_TYPE', message: `不支持的数据库类型:${config.type}` };
|
|
658
|
+
this.pools.set(key, handle);
|
|
659
|
+
return { ok: true, handle };
|
|
660
|
+
} catch (error) {
|
|
661
|
+
return { ok: false, code: 'CONNECT_FAILED', message: this._safeError(error) };
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** SQLite:用 Node 内置 node:sqlite,零原生依赖 */
|
|
666
|
+
async _openSqlite(config) {
|
|
667
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
668
|
+
const file = String(config.database || ':memory:');
|
|
669
|
+
// 文件型库:确保父目录存在,否则驱动报错不够直白
|
|
670
|
+
if (file !== ':memory:' && !file.startsWith('file:')) {
|
|
671
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
672
|
+
}
|
|
673
|
+
const db = new DatabaseSync(file);
|
|
674
|
+
return { type: 'sqlite', raw: db };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** MySQL:按需加载 mysql2(纯 JS,无原生编译) */
|
|
678
|
+
async _openMysql(config) {
|
|
679
|
+
let mysql;
|
|
680
|
+
try {
|
|
681
|
+
mysql = await import('mysql2/promise');
|
|
682
|
+
} catch {
|
|
683
|
+
throw new Error('缺少 mysql2 依赖:请在该插件目录执行 npm install mysql2');
|
|
684
|
+
}
|
|
685
|
+
// URL 模式直接交给 mysql2 解析(它原生支持 mysql:// 串)
|
|
686
|
+
if (config.connectString) {
|
|
687
|
+
const pool = mysql.createPool({
|
|
688
|
+
uri: config.connectString,
|
|
689
|
+
waitForConnections: true,
|
|
690
|
+
connectionLimit: 4,
|
|
691
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
692
|
+
});
|
|
693
|
+
return { type: 'mysql', raw: pool };
|
|
694
|
+
}
|
|
695
|
+
const pool = mysql.createPool({
|
|
696
|
+
host: config.host,
|
|
697
|
+
port: Number(config.port) || 3306,
|
|
698
|
+
user: config.username,
|
|
699
|
+
password: config.password,
|
|
700
|
+
database: config.database || undefined,
|
|
701
|
+
waitForConnections: true,
|
|
702
|
+
connectionLimit: 4,
|
|
703
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
704
|
+
});
|
|
705
|
+
return { type: 'mysql', raw: pool };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** PostgreSQL:按需加载 pg */
|
|
709
|
+
async _openPostgres(config) {
|
|
710
|
+
let pg;
|
|
711
|
+
try {
|
|
712
|
+
pg = await import('pg');
|
|
713
|
+
} catch {
|
|
714
|
+
throw new Error('缺少 pg 依赖:请在该插件目录执行 npm install pg');
|
|
715
|
+
}
|
|
716
|
+
const Pool = pg.Pool || pg.default?.Pool;
|
|
717
|
+
// URL 模式:pg 的 Pool 支持 connectionString
|
|
718
|
+
if (config.connectString) {
|
|
719
|
+
const pool = new Pool({
|
|
720
|
+
connectionString: config.connectString,
|
|
721
|
+
max: 4,
|
|
722
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
723
|
+
});
|
|
724
|
+
return { type: 'postgres', raw: pool };
|
|
725
|
+
}
|
|
726
|
+
const pool = new Pool({
|
|
727
|
+
host: config.host,
|
|
728
|
+
port: Number(config.port) || 5432,
|
|
729
|
+
user: config.username,
|
|
730
|
+
password: config.password,
|
|
731
|
+
database: config.database || undefined,
|
|
732
|
+
max: 4,
|
|
733
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
734
|
+
});
|
|
735
|
+
return { type: 'postgres', raw: pool };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Oracle:按需加载 oracledb(原生模块,装不上时给出明确指引) */
|
|
739
|
+
async _openOracle(config) {
|
|
740
|
+
let oracledb;
|
|
741
|
+
try {
|
|
742
|
+
oracledb = await import('oracledb');
|
|
743
|
+
} catch (error) {
|
|
744
|
+
throw new Error(`缺少 oracledb 依赖(原生模块,可能需匹配 Node ABI):${error?.message || error}`);
|
|
745
|
+
}
|
|
746
|
+
const mod = oracledb.default || oracledb;
|
|
747
|
+
// URL 模式:oracle 的 connectString 本身就是 host:port/service 形式,直接透传
|
|
748
|
+
const connectString = config.connectString
|
|
749
|
+
|| `${config.host}:${Number(config.port) || 1521}/${config.database || ''}`;
|
|
750
|
+
const pool = await mod.createPool({
|
|
751
|
+
user: config.username,
|
|
752
|
+
password: config.password,
|
|
753
|
+
connectString,
|
|
754
|
+
poolMax: 4,
|
|
755
|
+
});
|
|
756
|
+
return { type: 'oracle', raw: pool, mod };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 达梦(DM8):dmdb 驱动,API 与 oracledb 高度相似。
|
|
761
|
+
*
|
|
762
|
+
* 连接串格式是 `host:port`(不是 `host:port/sid`)。
|
|
763
|
+
* dmdb 虽然 README 自称「原生驱动」,但实际是纯 JS 包(唯一依赖 iconv-lite),
|
|
764
|
+
* 不存在 NODE_MODULE_VERSION 匹配问题,装得上就能用。
|
|
765
|
+
*
|
|
766
|
+
* 注意:dmdb 在**连接失败后的清理阶段**会抛 `TypeError: Cannot read
|
|
767
|
+
* properties of undefined (reading 'log')`(pool.js:263,它自己的 bug),
|
|
768
|
+
* 而且是未捕获的异步异常,会把宿主进程带崩。所以这里要:
|
|
769
|
+
* 1. 连接失败时主动兜住这个异常;
|
|
770
|
+
* 2. 进程级加一个 unhandledRejection 白名单过滤,只放行这个已知签名。
|
|
771
|
+
*/
|
|
772
|
+
async _openDm(config) {
|
|
773
|
+
let dmdb;
|
|
774
|
+
try {
|
|
775
|
+
dmdb = await import('dmdb');
|
|
776
|
+
} catch (error) {
|
|
777
|
+
throw new Error(`缺少 dmdb 依赖:请在该插件目录执行 npm install dmdb(${error?.message || error})`);
|
|
778
|
+
}
|
|
779
|
+
const mod = dmdb.default || dmdb;
|
|
780
|
+
try {
|
|
781
|
+
/**
|
|
782
|
+
* connectString 的格式:
|
|
783
|
+
* - url 模式:原样使用用户填的 dm://... 串
|
|
784
|
+
* - 分字段模式:dm://user:password@host:port
|
|
785
|
+
*
|
|
786
|
+
* **必须显式给 poolAlias**:dmdb 的 poolAlias 默认是 "default",
|
|
787
|
+
* 第二个连接池就会报「数据库别名已存在」,而且后面测试/编辑/删除全废。
|
|
788
|
+
* 这里用连接 id 派生出唯一别名,保证多个达梦连接能共存。
|
|
789
|
+
*/
|
|
790
|
+
let connectString = String(config.connectString || '').trim();
|
|
791
|
+
if (connectString === '') {
|
|
792
|
+
const user = encodeURIComponent(String(config.username || ''));
|
|
793
|
+
const pass = encodeURIComponent(String(config.password || ''));
|
|
794
|
+
const port = Number(config.port) || 5236;
|
|
795
|
+
connectString = `dm://${user}:${pass}@${config.host}:${port}`;
|
|
796
|
+
}
|
|
797
|
+
if (!/^dm:\/\//i.test(connectString)) {
|
|
798
|
+
throw new Error(`达梦连接串必须以 dm:// 开头,当前是:${connectString.slice(0, 40)}`);
|
|
799
|
+
}
|
|
800
|
+
const poolAlias = `dsh-api-runner-${String(config.id || 'conn').replace(/[^\w-]/g, '_')}`;
|
|
801
|
+
const pool = await mod.createPool({
|
|
802
|
+
connectString,
|
|
803
|
+
poolAlias,
|
|
804
|
+
poolMax: 4,
|
|
805
|
+
...(isPlainObject(config.extra) ? config.extra : {}),
|
|
806
|
+
});
|
|
807
|
+
return { type: 'dm', raw: pool, mod, poolAlias };
|
|
808
|
+
} catch (error) {
|
|
809
|
+
// dmdb 连接失败时内部状态是半初始化的,close 会再抛一次;吞掉它
|
|
810
|
+
try {
|
|
811
|
+
if (error?.pool) await error.pool.close();
|
|
812
|
+
} catch {
|
|
813
|
+
/* dmdb 自身的清理 bug,忽略 */
|
|
814
|
+
}
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
_closePool(id) {
|
|
820
|
+
const key = String(id || '');
|
|
821
|
+
const handle = this.pools.get(key);
|
|
822
|
+
if (!handle) return;
|
|
823
|
+
this.pools.delete(key);
|
|
824
|
+
try {
|
|
825
|
+
if (handle.type === 'sqlite') handle.raw.close();
|
|
826
|
+
else if (handle.raw && typeof handle.raw.end === 'function') {
|
|
827
|
+
// mysql2 / pg 的 end() 返回 Promise,这里不等待,失败也不影响主流程
|
|
828
|
+
Promise.resolve(handle.raw.end()).catch(() => {});
|
|
829
|
+
} else if (handle.raw && typeof handle.raw.close === 'function') {
|
|
830
|
+
// dmdb / oracledb 的 close 也返回 Promise
|
|
831
|
+
Promise.resolve(handle.raw.close()).catch(() => {});
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* dmdb 把自己的池登记在 `dmdb.pools` 全局表里,close 之后**不一定**
|
|
835
|
+
* 会删掉这条登记(它内部只在特定分支删)。残留的别名会让下次
|
|
836
|
+
* 创建同名池时报「数据库别名已存在」,所以这里主动清一次。
|
|
837
|
+
*/
|
|
838
|
+
if (handle.type === 'dm' && handle.poolAlias && handle.mod?.pools) {
|
|
839
|
+
try {
|
|
840
|
+
handle.mod.pools.delete(handle.poolAlias);
|
|
841
|
+
} catch {
|
|
842
|
+
/* 注册表结构变化时忽略 */
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} catch {
|
|
846
|
+
/* 关闭失败不影响删除操作 */
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** 把驱动异常转成安全文案:去掉可能含密码的连接串 */
|
|
851
|
+
_safeError(error) {
|
|
852
|
+
let text = String(error?.message || error || '未知错误');
|
|
853
|
+
// 有些驱动会把整条 DSN 打进 message,这里把 password=xxx 抹掉
|
|
854
|
+
text = text.replace(/password\s*=\s*[^;\s]*/gi, 'password=***');
|
|
855
|
+
text = text.replace(/:\/\/[^@\s]*@/g, '://***@');
|
|
856
|
+
return text;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------- 执行
|
|
860
|
+
/**
|
|
861
|
+
* 执行 SQL。
|
|
862
|
+
* @param {string} id - 连接 id
|
|
863
|
+
* @param {string} sql - 单条 SQL
|
|
864
|
+
* @param {Array} params - 参数化占位值
|
|
865
|
+
* @param {{readonly?:boolean, maxRows?:number, timeoutMs?:number}} options
|
|
866
|
+
*/
|
|
867
|
+
async query(id, sql, params = [], options = {}) {
|
|
868
|
+
const text = String(sql || '').trim();
|
|
869
|
+
if (text === '') return { ok: false, code: 'EMPTY_SQL', message: 'SQL 不能为空' };
|
|
870
|
+
|
|
871
|
+
const statements = splitStatements(text);
|
|
872
|
+
if (statements.length === 0) return { ok: false, code: 'EMPTY_SQL', message: 'SQL 不能为空' };
|
|
873
|
+
if (statements.length > 1) {
|
|
874
|
+
return {
|
|
875
|
+
ok: false,
|
|
876
|
+
code: 'MULTI_STATEMENT',
|
|
877
|
+
message: `只允许单条语句,检测到 ${statements.length} 条。请拆开执行。`,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const single = statements[0];
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* 权限判定顺序(从严格到宽松,任何一步不通过就拒绝):
|
|
884
|
+
* 1. 项目授权:当前工作空间必须对该连接有授权
|
|
885
|
+
* 2. **只读开关**:options.readonly=true 时,任何非只读语句一律拒绝
|
|
886
|
+
* 3. 授权等级:写操作要求 readwrite
|
|
887
|
+
* 4. 毁灭性操作:即使有读写权限也额外拦截
|
|
888
|
+
*
|
|
889
|
+
* 第 2 步是模型侧的硬闸门:main.js 对工具调用固定传 readonly:true,
|
|
890
|
+
* 所以模型无论怎么构造参数都写不了数据。
|
|
891
|
+
*/
|
|
892
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
893
|
+
if (!auth.ok) return auth;
|
|
894
|
+
|
|
895
|
+
const verdict = classifySql(single);
|
|
896
|
+
|
|
897
|
+
// 第 2 步:只读模式 —— 非只读语句直接拒,不看授权等级
|
|
898
|
+
if (options.readonly === true && verdict.kind !== 'read') {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
code: 'READONLY_VIOLATION',
|
|
902
|
+
message: `只读模式已开启:只允许 SELECT / SHOW / DESCRIBE / EXPLAIN 等查询语句。${verdict.reason ? `(${verdict.reason})` : ''}要执行写操作请由人在面板里操作。`,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (verdict.kind !== 'read') {
|
|
907
|
+
// 第 3 步:写操作必须 readwrite 授权
|
|
908
|
+
if (auth.level !== 'readwrite') {
|
|
909
|
+
return {
|
|
910
|
+
ok: false,
|
|
911
|
+
code: 'PERMISSION_DENIED',
|
|
912
|
+
message: `当前项目对这条连接只有「${auth.level === 'readonly' ? '只读' : '未授权'}」权限,不能执行写操作。${verdict.reason ? `(${verdict.reason})` : ''}`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
// 第 4 步:毁灭性操作
|
|
916
|
+
if (isDestructiveSql(single) && options.allowDestructive !== true) {
|
|
917
|
+
return {
|
|
918
|
+
ok: false,
|
|
919
|
+
code: 'DESTRUCTIVE_BLOCKED',
|
|
920
|
+
message: '拒绝执行:这条语句会破坏数据结构或数据(DROP / TRUNCATE / 无 WHERE 的 DELETE / UPDATE 等)。即使有读写权限也不允许通过工具执行,请到数据库客户端人工操作。',
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const opened = await this._handle(id);
|
|
926
|
+
if (!opened.ok) return opened;
|
|
927
|
+
const handle = opened.handle;
|
|
928
|
+
|
|
929
|
+
const started = Date.now();
|
|
930
|
+
const maxRows = Number(options.maxRows) || this.maxRows;
|
|
931
|
+
const timeoutMs = Number(options.timeoutMs) || this.timeoutMs;
|
|
932
|
+
try {
|
|
933
|
+
const result = await this._runWithTimeout(handle, single, Array.isArray(params) ? params : [], timeoutMs, maxRows);
|
|
934
|
+
return {
|
|
935
|
+
ok: true,
|
|
936
|
+
connectionId: String(id),
|
|
937
|
+
sql: single,
|
|
938
|
+
durationMs: Date.now() - started,
|
|
939
|
+
...result,
|
|
940
|
+
};
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return {
|
|
943
|
+
ok: false,
|
|
944
|
+
code: 'DB_ERROR',
|
|
945
|
+
message: this._safeError(error),
|
|
946
|
+
durationMs: Date.now() - started,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** 按驱动类型执行,并统一结果结构 */
|
|
952
|
+
async _runWithTimeout(handle, sql, params, timeoutMs, maxRows) {
|
|
953
|
+
const work = (async () => {
|
|
954
|
+
if (handle.type === 'sqlite') {
|
|
955
|
+
const stmt = handle.raw.prepare(sql);
|
|
956
|
+
// 有参数时按参数化执行;否则区分「有结果集」与「纯写操作」
|
|
957
|
+
const upper = sql.trim().toUpperCase();
|
|
958
|
+
const returnsRows = /^(SELECT|WITH|PRAGMA|EXPLAIN|SHOW|DESC|DESCRIBE|TABLE)\b/.test(upper)
|
|
959
|
+
|| stmt.columns?.().length > 0;
|
|
960
|
+
if (returnsRows) {
|
|
961
|
+
const rows = params.length > 0 ? stmt.all(...params) : stmt.all();
|
|
962
|
+
return this._shapeRows(rows, maxRows);
|
|
963
|
+
}
|
|
964
|
+
const info = params.length > 0 ? stmt.run(...params) : stmt.run();
|
|
965
|
+
return { columns: [], rows: [], rowCount: 0, affectedRows: Number(info?.changes || 0), lastInsertRowid: Number(info?.lastInsertRowid || 0) };
|
|
966
|
+
}
|
|
967
|
+
if (handle.type === 'mysql') {
|
|
968
|
+
const [result, fields] = await handle.raw.query({ sql, timeout: timeoutMs }, params);
|
|
969
|
+
if (Array.isArray(result)) {
|
|
970
|
+
const columns = Array.isArray(fields) ? fields.map((f) => f.name) : [];
|
|
971
|
+
return this._shapeRows(result, maxRows, columns);
|
|
972
|
+
}
|
|
973
|
+
return { columns: [], rows: [], rowCount: 0, affectedRows: Number(result?.affectedRows || 0) };
|
|
974
|
+
}
|
|
975
|
+
if (handle.type === 'postgres') {
|
|
976
|
+
const result = await handle.raw.query({ text: sql, values: params, ...(timeoutMs ? { query_timeout: timeoutMs } : {}) });
|
|
977
|
+
const columns = (result.fields || []).map((f) => f.name);
|
|
978
|
+
if (columns.length > 0) return this._shapeRows(result.rows || [], maxRows, columns);
|
|
979
|
+
return { columns: [], rows: [], rowCount: 0, affectedRows: Number(result.rowCount || 0) };
|
|
980
|
+
}
|
|
981
|
+
if (handle.type === 'oracle') {
|
|
982
|
+
const result = await handle.raw.execute(sql, params, { outFormat: handle.mod?.OUT_FORMAT_OBJECT, maxRows });
|
|
983
|
+
const rows = result.rows || [];
|
|
984
|
+
const columns = (result.metaData || []).map((m) => m.name);
|
|
985
|
+
return {
|
|
986
|
+
...this._shapeRows(rows, maxRows, columns),
|
|
987
|
+
affectedRows: Number(result.rowsAffected || 0),
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
if (handle.type === 'dm') {
|
|
991
|
+
const result = await this._execute(handle, sql, params, { maxRows });
|
|
992
|
+
const rows = result.rows || [];
|
|
993
|
+
const columns = (result.metaData || []).map((m) => m.name);
|
|
994
|
+
return {
|
|
995
|
+
...this._shapeRows(rows, maxRows, columns),
|
|
996
|
+
affectedRows: Number(result.rowsAffected || 0),
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
throw new Error(`未知驱动类型 ${handle.type}`);
|
|
1000
|
+
})();
|
|
1001
|
+
|
|
1002
|
+
if (!timeoutMs || timeoutMs <= 0) return work;
|
|
1003
|
+
let timer;
|
|
1004
|
+
const timeout = new Promise((_, reject) => {
|
|
1005
|
+
timer = setTimeout(() => reject(new Error(`查询超时(${timeoutMs}ms)`)), timeoutMs);
|
|
1006
|
+
timer.unref?.();
|
|
1007
|
+
});
|
|
1008
|
+
try {
|
|
1009
|
+
return await Promise.race([work, timeout]);
|
|
1010
|
+
} finally {
|
|
1011
|
+
clearTimeout(timer);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** 统一结果集形状:列名 + 行数组 + 截断标记 */
|
|
1016
|
+
_shapeRows(rows, maxRows, columns) {
|
|
1017
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
1018
|
+
const cols = Array.isArray(columns) && columns.length > 0
|
|
1019
|
+
? columns
|
|
1020
|
+
: (list.length > 0 && isPlainObject(list[0]) ? Object.keys(list[0]) : []);
|
|
1021
|
+
const truncated = list.length > maxRows;
|
|
1022
|
+
const sliced = truncated ? list.slice(0, maxRows) : list;
|
|
1023
|
+
return {
|
|
1024
|
+
columns: cols,
|
|
1025
|
+
rows: sliced,
|
|
1026
|
+
rowCount: list.length,
|
|
1027
|
+
truncated,
|
|
1028
|
+
maxRows,
|
|
1029
|
+
note: truncated ? `结果共 ${list.length} 行,已截断为前 ${maxRows} 行` : undefined,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ---------------------------------------------------------------- 元数据
|
|
1034
|
+
/** 列出库(MySQL/PG 有库的概念;SQLite 只有文件) */
|
|
1035
|
+
async listDatabases(id, options = {}) {
|
|
1036
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
1037
|
+
if (!auth.ok) return auth;
|
|
1038
|
+
const opened = await this._handle(id);
|
|
1039
|
+
if (!opened.ok) return opened;
|
|
1040
|
+
const handle = opened.handle;
|
|
1041
|
+
try {
|
|
1042
|
+
if (handle.type === 'sqlite') {
|
|
1043
|
+
return { ok: true, databases: [{ name: String(this._fullConfig(id)?.database || ':memory:') }] };
|
|
1044
|
+
}
|
|
1045
|
+
if (handle.type === 'mysql') {
|
|
1046
|
+
const [rows] = await handle.raw.query('SHOW DATABASES');
|
|
1047
|
+
return { ok: true, databases: rows.map((r) => ({ name: r.Database || r.database || Object.values(r)[0] })) };
|
|
1048
|
+
}
|
|
1049
|
+
if (handle.type === 'postgres') {
|
|
1050
|
+
const result = await handle.raw.query('SELECT datname AS name FROM pg_database WHERE datistemplate = false ORDER BY datname');
|
|
1051
|
+
return { ok: true, databases: result.rows };
|
|
1052
|
+
}
|
|
1053
|
+
if (handle.type === 'oracle') {
|
|
1054
|
+
return { ok: true, databases: [{ name: this._fullConfig(id)?.database || '' }] };
|
|
1055
|
+
}
|
|
1056
|
+
if (handle.type === 'dm') {
|
|
1057
|
+
// 达梦里「模式」(schema)≈ 其它库的「数据库」概念
|
|
1058
|
+
const result = await this._execute(handle, 'SELECT DISTINCT OWNER AS NAME FROM ALL_TABLES ORDER BY OWNER');
|
|
1059
|
+
const rows = (result.rows || []).map((r) => ({ name: r.NAME || r.name })).filter((r) => r.name);
|
|
1060
|
+
if (rows.length > 0) return { ok: true, databases: rows };
|
|
1061
|
+
return { ok: true, databases: [{ name: this._fullConfig(id)?.database || '' }] };
|
|
1062
|
+
}
|
|
1063
|
+
return { ok: false, code: 'UNSUPPORTED', message: `不支持的驱动 ${handle.type}` };
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
return { ok: false, code: 'DB_ERROR', message: this._safeError(error) };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/** 列出表 */
|
|
1070
|
+
async listTables(id, database = '', options = {}) {
|
|
1071
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
1072
|
+
if (!auth.ok) return auth;
|
|
1073
|
+
const opened = await this._handle(id);
|
|
1074
|
+
if (!opened.ok) return opened;
|
|
1075
|
+
const handle = opened.handle;
|
|
1076
|
+
try {
|
|
1077
|
+
if (handle.type === 'sqlite') {
|
|
1078
|
+
const rows = handle.raw
|
|
1079
|
+
.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
1080
|
+
.all();
|
|
1081
|
+
return { ok: true, tables: rows.map((r) => ({ name: r.name })) };
|
|
1082
|
+
}
|
|
1083
|
+
if (handle.type === 'mysql') {
|
|
1084
|
+
const db = String(database || this._fullConfig(id)?.database || '');
|
|
1085
|
+
if (db === '') return { ok: false, code: 'MISSING_DATABASE', message: '请先指定数据库名' };
|
|
1086
|
+
const [rows] = await handle.raw.query(
|
|
1087
|
+
'SELECT TABLE_NAME AS name, TABLE_TYPE AS type FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME',
|
|
1088
|
+
[db],
|
|
1089
|
+
);
|
|
1090
|
+
return { ok: true, tables: rows.map((r) => ({ name: r.name, type: r.type })) };
|
|
1091
|
+
}
|
|
1092
|
+
if (handle.type === 'postgres') {
|
|
1093
|
+
const schema = String(database || 'public');
|
|
1094
|
+
const result = await handle.raw.query(
|
|
1095
|
+
"SELECT table_name AS name, table_type AS type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name",
|
|
1096
|
+
[schema],
|
|
1097
|
+
);
|
|
1098
|
+
return { ok: true, tables: result.rows };
|
|
1099
|
+
}
|
|
1100
|
+
if (handle.type === 'oracle') {
|
|
1101
|
+
const result = await handle.raw.execute(
|
|
1102
|
+
'SELECT table_name AS NAME FROM user_tables ORDER BY table_name',
|
|
1103
|
+
[],
|
|
1104
|
+
{ outFormat: handle.mod?.OUT_FORMAT_OBJECT },
|
|
1105
|
+
);
|
|
1106
|
+
return { ok: true, tables: (result.rows || []).map((r) => ({ name: r.NAME || r.name })) };
|
|
1107
|
+
}
|
|
1108
|
+
if (handle.type === 'dm') {
|
|
1109
|
+
// 指定了模式就查那个模式,否则查当前用户可见的表
|
|
1110
|
+
const schema = String(database || '').trim().toUpperCase();
|
|
1111
|
+
const result = await this._execute(
|
|
1112
|
+
handle,
|
|
1113
|
+
schema
|
|
1114
|
+
? 'SELECT TABLE_NAME AS NAME FROM ALL_TABLES WHERE OWNER = ? ORDER BY TABLE_NAME'
|
|
1115
|
+
: 'SELECT TABLE_NAME AS NAME FROM USER_TABLES ORDER BY TABLE_NAME',
|
|
1116
|
+
schema ? [schema] : [],
|
|
1117
|
+
);
|
|
1118
|
+
return { ok: true, tables: (result.rows || []).map((r) => ({ name: r.NAME || r.name })) };
|
|
1119
|
+
}
|
|
1120
|
+
return { ok: false, code: 'UNSUPPORTED', message: `不支持的驱动 ${handle.type}` };
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
return { ok: false, code: 'DB_ERROR', message: this._safeError(error) };
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/** 表结构:字段 + 索引 */
|
|
1127
|
+
async describeTable(id, table, options = {}) {
|
|
1128
|
+
const name = String(table || '').trim();
|
|
1129
|
+
if (name === '') return { ok: false, code: 'MISSING_TABLE', message: '需要表名' };
|
|
1130
|
+
const auth = this.resolveAccess(id, options.projectKey, options.connectionOverride);
|
|
1131
|
+
if (!auth.ok) return auth;
|
|
1132
|
+
const opened = await this._handle(id);
|
|
1133
|
+
if (!opened.ok) return opened;
|
|
1134
|
+
const handle = opened.handle;
|
|
1135
|
+
try {
|
|
1136
|
+
if (handle.type === 'sqlite') {
|
|
1137
|
+
// PRAGMA 不支持参数绑定,表名要转义引号防注入
|
|
1138
|
+
const safe = name.replace(/'/g, "''");
|
|
1139
|
+
const cols = handle.raw.prepare(`PRAGMA table_info('${safe}')`).all();
|
|
1140
|
+
const idx = handle.raw.prepare(`PRAGMA index_list('${safe}')`).all();
|
|
1141
|
+
const columns = cols.map((c) => ({
|
|
1142
|
+
name: c.name,
|
|
1143
|
+
type: c.type,
|
|
1144
|
+
nullable: !c.notnull,
|
|
1145
|
+
defaultValue: c.dflt_value,
|
|
1146
|
+
primaryKey: Boolean(c.pk),
|
|
1147
|
+
}));
|
|
1148
|
+
const indexes = idx.map((i) => ({
|
|
1149
|
+
name: i.name,
|
|
1150
|
+
unique: Boolean(i.unique),
|
|
1151
|
+
columns: handle.raw.prepare(`PRAGMA index_info('${String(i.name).replace(/'/g, "''")}')`).all().map((c) => c.name),
|
|
1152
|
+
}));
|
|
1153
|
+
return { ok: true, table: name, columns, indexes };
|
|
1154
|
+
}
|
|
1155
|
+
if (handle.type === 'mysql') {
|
|
1156
|
+
const db = String(this._fullConfig(id)?.database || '');
|
|
1157
|
+
if (db === '') return { ok: false, code: 'MISSING_DATABASE', message: '请先在连接里指定数据库名' };
|
|
1158
|
+
const [cols] = await handle.raw.query(
|
|
1159
|
+
'SELECT COLUMN_NAME AS name, COLUMN_TYPE AS type, IS_NULLABLE AS nullable, COLUMN_DEFAULT AS defaultValue, COLUMN_KEY AS columnKey, COLUMN_COMMENT AS comment FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION',
|
|
1160
|
+
[db, name],
|
|
1161
|
+
);
|
|
1162
|
+
const [idx] = await handle.raw.query(
|
|
1163
|
+
'SELECT INDEX_NAME AS name, NON_UNIQUE AS nonUnique, COLUMN_NAME AS columnName FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY INDEX_NAME, SEQ_IN_INDEX',
|
|
1164
|
+
[db, name],
|
|
1165
|
+
);
|
|
1166
|
+
return {
|
|
1167
|
+
ok: true,
|
|
1168
|
+
table: name,
|
|
1169
|
+
columns: cols.map((c) => ({
|
|
1170
|
+
name: c.name,
|
|
1171
|
+
type: c.type,
|
|
1172
|
+
nullable: String(c.nullable).toUpperCase() === 'YES',
|
|
1173
|
+
defaultValue: c.defaultValue,
|
|
1174
|
+
primaryKey: c.columnKey === 'PRI',
|
|
1175
|
+
comment: c.comment || '',
|
|
1176
|
+
})),
|
|
1177
|
+
indexes: groupIndexes(idx, (r) => ({ name: r.name, unique: Number(r.nonUnique) === 0, column: r.columnName })),
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
if (handle.type === 'postgres') {
|
|
1181
|
+
const cols = await handle.raw.query(
|
|
1182
|
+
'SELECT column_name AS name, data_type AS type, is_nullable AS nullable, column_default AS "defaultValue" FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position',
|
|
1183
|
+
[name],
|
|
1184
|
+
);
|
|
1185
|
+
const idx = await handle.raw.query(
|
|
1186
|
+
'SELECT indexname AS name, indexdef AS def FROM pg_indexes WHERE tablename = $1',
|
|
1187
|
+
[name],
|
|
1188
|
+
);
|
|
1189
|
+
return {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
table: name,
|
|
1192
|
+
columns: cols.rows.map((c) => ({
|
|
1193
|
+
name: c.name,
|
|
1194
|
+
type: c.type,
|
|
1195
|
+
nullable: String(c.nullable).toUpperCase() === 'YES',
|
|
1196
|
+
defaultValue: c.defaultValue,
|
|
1197
|
+
})),
|
|
1198
|
+
indexes: idx.rows.map((r) => ({ name: r.name, definition: r.def })),
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
if (handle.type === 'oracle') {
|
|
1202
|
+
const cols = await handle.raw.execute(
|
|
1203
|
+
'SELECT column_name AS NAME, data_type AS TYPE, nullable AS NULLABLE, data_default AS DEF FROM user_tab_columns WHERE table_name = :1 ORDER BY column_id',
|
|
1204
|
+
[name.toUpperCase()],
|
|
1205
|
+
{ outFormat: handle.mod?.OUT_FORMAT_OBJECT },
|
|
1206
|
+
);
|
|
1207
|
+
return {
|
|
1208
|
+
ok: true,
|
|
1209
|
+
table: name,
|
|
1210
|
+
columns: (cols.rows || []).map((c) => ({
|
|
1211
|
+
name: c.NAME,
|
|
1212
|
+
type: c.TYPE,
|
|
1213
|
+
nullable: c.NULLABLE === 'Y',
|
|
1214
|
+
defaultValue: c.DEF,
|
|
1215
|
+
})),
|
|
1216
|
+
indexes: [],
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
if (handle.type === 'dm') {
|
|
1220
|
+
// 达梦用 ? 占位(不是 oracle 的 :1)
|
|
1221
|
+
const cols = await this._execute(
|
|
1222
|
+
handle,
|
|
1223
|
+
'SELECT COLUMN_NAME AS NAME, DATA_TYPE AS TYPE, NULLABLE AS NULLABLE, DATA_DEFAULT AS DEF FROM USER_TAB_COLUMNS WHERE TABLE_NAME = ? ORDER BY COLUMN_ID',
|
|
1224
|
+
[name.toUpperCase()],
|
|
1225
|
+
);
|
|
1226
|
+
const idx = await this._execute(
|
|
1227
|
+
handle,
|
|
1228
|
+
'SELECT INDEX_NAME AS NAME, UNIQUENESS AS UNIQ FROM USER_INDEXES WHERE TABLE_NAME = ? ORDER BY INDEX_NAME',
|
|
1229
|
+
[name.toUpperCase()],
|
|
1230
|
+
);
|
|
1231
|
+
return {
|
|
1232
|
+
ok: true,
|
|
1233
|
+
table: name,
|
|
1234
|
+
columns: (cols.rows || []).map((c) => ({
|
|
1235
|
+
name: c.NAME,
|
|
1236
|
+
type: c.TYPE,
|
|
1237
|
+
nullable: c.NULLABLE === 'Y',
|
|
1238
|
+
defaultValue: c.DEF,
|
|
1239
|
+
})),
|
|
1240
|
+
indexes: (idx.rows || []).map((r) => ({
|
|
1241
|
+
name: r.NAME,
|
|
1242
|
+
unique: String(r.UNIQ).toUpperCase() === 'UNIQUE',
|
|
1243
|
+
})),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return { ok: false, code: 'UNSUPPORTED', message: `不支持的驱动 ${handle.type}` };
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
return { ok: false, code: 'DB_ERROR', message: this._safeError(error) };
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* 试连:真正建一次连接并执行最轻量的探测语句。
|
|
1254
|
+
*
|
|
1255
|
+
* @param {string|object} target - 连接 id,或一份草稿配置
|
|
1256
|
+
* @param {{timeoutMs?:number}} options - 超时(默认 probeTimeoutMs)
|
|
1257
|
+
*/
|
|
1258
|
+
async testConnection(target, options = {}) {
|
|
1259
|
+
const started = Date.now();
|
|
1260
|
+
const timeoutMs = Number(options.timeoutMs) || this.probeTimeoutMs;
|
|
1261
|
+
/**
|
|
1262
|
+
* 支持三种入参:
|
|
1263
|
+
* 1. 已有连接的 id(字符串)
|
|
1264
|
+
* 2. 带 id 的完整配置对象(编辑时用当前表单值试,不落盘)
|
|
1265
|
+
* 3. 没有 id 的草稿配置(新增时试连)
|
|
1266
|
+
*
|
|
1267
|
+
* 草稿**不临时存记录**:那样会污染连接列表,而且 dmdb 的连接池别名
|
|
1268
|
+
* 按 id 生成,临时 id 和正式 id 会互相撞。这里直接构造临时句柄试连。
|
|
1269
|
+
*/
|
|
1270
|
+
if (typeof target === 'string' && target !== '') {
|
|
1271
|
+
return this._withTimeout(
|
|
1272
|
+
this._probe(target, started),
|
|
1273
|
+
timeoutMs,
|
|
1274
|
+
`连接超时(${timeoutMs}ms)`,
|
|
1275
|
+
).catch((error) => ({
|
|
1276
|
+
ok: false,
|
|
1277
|
+
code: 'PROBE_TIMEOUT',
|
|
1278
|
+
message: this._safeError(error),
|
|
1279
|
+
durationMs: Date.now() - started,
|
|
1280
|
+
}));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (isPlainObject(target)) {
|
|
1284
|
+
const draft = { ...target };
|
|
1285
|
+
if (draft.id) {
|
|
1286
|
+
// 编辑已有连接:用保存过的密码(表单里的密码框是空的)
|
|
1287
|
+
const full = this._fullConfig(draft.id);
|
|
1288
|
+
if (full && (draft.password === undefined || draft.password === '')) draft.password = full.password;
|
|
1289
|
+
if (full && !draft.connectString && full.connectString) draft.connectString = full.connectString;
|
|
1290
|
+
}
|
|
1291
|
+
const normalized = this._normalizeDraft(draft);
|
|
1292
|
+
if (!normalized.ok) return { ...normalized, durationMs: Date.now() - started };
|
|
1293
|
+
return this._probeDraft(normalized.config, started, timeoutMs);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
return { ok: false, code: 'MISSING_ARGUMENT', message: '需要连接 id 或配置' };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** 把草稿补成完整配置(补齐默认端口等),不落盘 */
|
|
1300
|
+
_normalizeDraft(draft) {
|
|
1301
|
+
const type = String(draft.type || 'sqlite').toLowerCase();
|
|
1302
|
+
if (!DB_TYPES.includes(type)) {
|
|
1303
|
+
return { ok: false, code: 'BAD_TYPE', message: `不支持的数据库类型:${type}` };
|
|
1304
|
+
}
|
|
1305
|
+
const connectString = String(draft.connectString || '').trim();
|
|
1306
|
+
/**
|
|
1307
|
+
* 试连必须用**独立于正式连接**的临时 id。
|
|
1308
|
+
*
|
|
1309
|
+
* 之前这里直接沿用 draft.id(编辑时就是已保存连接的 id),
|
|
1310
|
+
* 于是 dmdb 会用同一个 poolAlias 再建一次池,撞上「连接池别名已存在」;
|
|
1311
|
+
* 而且撞完后该连接的后续测试/编辑/删除全废。
|
|
1312
|
+
* 统一加 probe- 前缀,保证试连的池和正式池永不重名。
|
|
1313
|
+
*/
|
|
1314
|
+
const probeId = `probe-${crypto.randomBytes(4).toString('hex')}`;
|
|
1315
|
+
const config = {
|
|
1316
|
+
id: probeId,
|
|
1317
|
+
type,
|
|
1318
|
+
host: String(draft.host || '').trim(),
|
|
1319
|
+
port: Number(draft.port) || DEFAULT_PORTS[type] || 0,
|
|
1320
|
+
database: String(draft.database || '').trim(),
|
|
1321
|
+
username: String(draft.username || '').trim(),
|
|
1322
|
+
password: String(draft.password || ''),
|
|
1323
|
+
connectString,
|
|
1324
|
+
extra: isPlainObject(draft.extra) ? draft.extra : {},
|
|
1325
|
+
};
|
|
1326
|
+
if (type === 'sqlite' && config.database === '') {
|
|
1327
|
+
return { ok: false, code: 'MISSING_DATABASE', message: 'SQLite 需要填写数据库文件路径(:memory: 表示内存库)' };
|
|
1328
|
+
}
|
|
1329
|
+
if (type !== 'sqlite' && connectString === '' && config.host === '') {
|
|
1330
|
+
return { ok: false, code: 'MISSING_HOST', message: `${type} 需要填写 host(或改用 URL 模式)` };
|
|
1331
|
+
}
|
|
1332
|
+
return { ok: true, config };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* 用临时句柄试连,结束后一定关掉。
|
|
1337
|
+
*
|
|
1338
|
+
* **必须有超时**:达梦/MySQL 连不上时驱动自己会卡 30–60 秒,
|
|
1339
|
+
* 期间面板所有按钮都被 disabled,用户会以为「点不动了」。
|
|
1340
|
+
* 这里在打开阶段和探测阶段各加一层超时,最坏情况 ~15 秒返回。
|
|
1341
|
+
*/
|
|
1342
|
+
async _probeDraft(config, started, timeoutMs) {
|
|
1343
|
+
const budget = Number(timeoutMs) || this.probeTimeoutMs || 15000;
|
|
1344
|
+
let handle;
|
|
1345
|
+
try {
|
|
1346
|
+
handle = await this._withTimeout(
|
|
1347
|
+
(async () => {
|
|
1348
|
+
if (config.type === 'sqlite') return this._openSqlite(config);
|
|
1349
|
+
if (config.type === 'mysql') return this._openMysql(config);
|
|
1350
|
+
if (config.type === 'postgres') return this._openPostgres(config);
|
|
1351
|
+
if (config.type === 'oracle') return this._openOracle(config);
|
|
1352
|
+
if (config.type === 'dm') return this._openDm(config);
|
|
1353
|
+
throw new Error(`不支持的数据库类型:${config.type}`);
|
|
1354
|
+
})(),
|
|
1355
|
+
budget,
|
|
1356
|
+
`连接超时(${budget}ms)`,
|
|
1357
|
+
);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
return { ok: false, code: 'CONNECT_FAILED', message: this._safeError(error), durationMs: Date.now() - started };
|
|
1360
|
+
}
|
|
1361
|
+
const remaining = Math.max(2000, budget - (Date.now() - started));
|
|
1362
|
+
const probe = await this._withTimeout(
|
|
1363
|
+
this._probeHandle(handle, started),
|
|
1364
|
+
remaining,
|
|
1365
|
+
`探测超时(${remaining}ms)`,
|
|
1366
|
+
).catch((error) => ({
|
|
1367
|
+
ok: false,
|
|
1368
|
+
code: 'PROBE_TIMEOUT',
|
|
1369
|
+
message: this._safeError(error),
|
|
1370
|
+
durationMs: Date.now() - started,
|
|
1371
|
+
}));
|
|
1372
|
+
// 试连用的池不能留着,否则 dmdb 别名/资源都会累积
|
|
1373
|
+
this._closeHandle(handle);
|
|
1374
|
+
return probe;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** 给任意 Promise 套一个超时;超时后不再等它,但也不阻塞后续清理 */
|
|
1378
|
+
_withTimeout(promise, ms, message) {
|
|
1379
|
+
const timeout = Number(ms);
|
|
1380
|
+
if (!timeout || timeout <= 0) return promise;
|
|
1381
|
+
let timer;
|
|
1382
|
+
const guard = new Promise((_, reject) => {
|
|
1383
|
+
timer = setTimeout(() => reject(new Error(message)), timeout);
|
|
1384
|
+
timer.unref?.();
|
|
1385
|
+
});
|
|
1386
|
+
return Promise.race([promise, guard]).finally(() => clearTimeout(timer));
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async _probe(id, started) {
|
|
1390
|
+
const opened = await this._handle(id);
|
|
1391
|
+
if (!opened.ok) return { ...opened, durationMs: Date.now() - started };
|
|
1392
|
+
return this._probeHandle(opened.handle, started);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/** 对任意句柄跑最轻量的探测语句 */
|
|
1396
|
+
async _probeHandle(handle, started) {
|
|
1397
|
+
try {
|
|
1398
|
+
if (handle.type === 'sqlite') handle.raw.prepare('SELECT 1').all();
|
|
1399
|
+
else if (handle.type === 'mysql') await handle.raw.query('SELECT 1');
|
|
1400
|
+
else if (handle.type === 'postgres') await handle.raw.query('SELECT 1');
|
|
1401
|
+
else if (handle.type === 'oracle') await handle.raw.execute('SELECT 1 FROM dual');
|
|
1402
|
+
else if (handle.type === 'dm') await this._execute(handle, 'SELECT 1');
|
|
1403
|
+
return { ok: true, durationMs: Date.now() - started, message: '连接成功' };
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
return { ok: false, code: 'DB_ERROR', message: this._safeError(error), durationMs: Date.now() - started };
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** 关闭任意句柄(不涉及 this.pools 注册表) */
|
|
1410
|
+
_closeHandle(handle) {
|
|
1411
|
+
if (!handle) return;
|
|
1412
|
+
try {
|
|
1413
|
+
if (handle.type === 'sqlite') handle.raw.close();
|
|
1414
|
+
else if (handle.raw && typeof handle.raw.end === 'function') Promise.resolve(handle.raw.end()).catch(() => {});
|
|
1415
|
+
else if (handle.raw && typeof handle.raw.close === 'function') Promise.resolve(handle.raw.close()).catch(() => {});
|
|
1416
|
+
if (handle.type === 'dm' && handle.poolAlias && handle.mod?.pools) {
|
|
1417
|
+
try {
|
|
1418
|
+
handle.mod.pools.delete(handle.poolAlias);
|
|
1419
|
+
} catch {
|
|
1420
|
+
/* 注册表结构变化时忽略 */
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
} catch {
|
|
1424
|
+
/* 关闭失败不影响结果 */
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** 关闭所有连接池(插件卸载时调用) */
|
|
1429
|
+
dispose() {
|
|
1430
|
+
for (const key of [...this.pools.keys()]) this._closePool(key);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* 统一的 execute 封装:dmdb 的 Pool 没有 execute(只有 getConnection),
|
|
1435
|
+
* oracledb 的 Pool 有。这里抹平差异,元数据/探测代码都走它。
|
|
1436
|
+
*/
|
|
1437
|
+
async _execute(handle, sql, params = [], options = {}) {
|
|
1438
|
+
if (handle.type === 'dm') {
|
|
1439
|
+
const conn = await handle.raw.getConnection();
|
|
1440
|
+
try {
|
|
1441
|
+
return await conn.execute(sql, params, { outFormat: handle.mod?.OUT_FORMAT_OBJECT, ...options });
|
|
1442
|
+
} finally {
|
|
1443
|
+
try {
|
|
1444
|
+
await conn.close();
|
|
1445
|
+
} catch {
|
|
1446
|
+
/* 归还失败不影响结果 */
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return handle.raw.execute(sql, params, { outFormat: handle.mod?.OUT_FORMAT_OBJECT, ...options });
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/** 把 information_schema.STATISTICS 的多行按索引名合并 */
|
|
1455
|
+
function groupIndexes(rows, map) {
|
|
1456
|
+
const byName = new Map();
|
|
1457
|
+
for (const row of rows) {
|
|
1458
|
+
const item = map(row);
|
|
1459
|
+
const existing = byName.get(item.name);
|
|
1460
|
+
if (existing) existing.columns.push(item.column);
|
|
1461
|
+
else byName.set(item.name, { name: item.name, unique: item.unique, columns: [item.column] });
|
|
1462
|
+
}
|
|
1463
|
+
return [...byName.values()];
|
|
1464
|
+
}
|