@nsyan/db 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -2,20 +2,22 @@ import { randomUUID } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { registry } from "./src/dialects/index.js";
5
- import { decide } from "./src/core/policy.js";
5
+ import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
6
+ import { appendAuditLog } from "./src/core/audit.js";
6
7
  import { scanProject } from "./src/core/scan/candidates.js";
7
8
  import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
8
9
  import {
9
10
  loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
10
11
  findConfig, toRuntimeConfig, shortTypeLabel, fullTypeLabel,
11
12
  parseConnectionString, getDefaultConfig, setDefaultConfig, writeQueryExport,
13
+ recordLastUsed, recordTestResult, connSummaryLine, envTagLabel, formatRelativeTime,
12
14
  } from "./src/config.js";
13
15
  import type { PluginConfig } from "./src/config.js";
14
16
 
15
17
  // ── URL 解析:遍历 registry 各方言 parseUrl,首个非 null 胜出 ────
16
18
  // 关系型 JDBC + 原生 URI 双形态由各方言 parseUrl 兼收(Spec §7)
17
19
 
18
- function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number } | null {
20
+ function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number; options?: Record<string, string> } | null {
19
21
  for (const d of registry.values()) {
20
22
  const p = d.parseUrl(url);
21
23
  if (p) return { dialectId: d.id, ...p };
@@ -37,9 +39,7 @@ function buildDisplayList(configs: ConnConfig[]): { list: string[]; map: Map<str
37
39
  const map = new Map<string, ConnConfig>();
38
40
  const list: string[] = [];
39
41
  for (const c of configs) {
40
- const typeLabel = shortTypeLabel(c.type);
41
- const desc = c.description ? ` - ${c.description}` : "";
42
- const display = `${c.name} [${typeLabel}]${desc}`;
42
+ const display = connSummaryLine(c);
43
43
  list.push(display);
44
44
  map.set(display, c);
45
45
  }
@@ -56,6 +56,10 @@ function familyHint(c: ConnConfig): string {
56
56
  case "mysql":
57
57
  case "oracle":
58
58
  return "关系型,sql 参数填 SQL";
59
+ case "mongodb":
60
+ return "MongoDB 文档库,sql 参数填 JSON 命令信封(如 {\"find\":\"users\",\"filter\":{}};读命令 find/count/distinct/aggregate)";
61
+ case "neo4j":
62
+ return "Neo4j 图数据库,sql 参数填 Cypher(如 MATCH (n:Person) RETURN n LIMIT 10;list_tables 列出 label 与关系类型,describe_table 目标填 label 名或 rel:类型)";
59
63
  default:
60
64
  return `${fullTypeLabel(c.type)},sql 参数填查询或命令`;
61
65
  }
@@ -93,13 +97,15 @@ function buildDbListHint(configs: ConnConfig[], cfg: PluginConfig): string {
93
97
  }
94
98
  const lines = configs.map((c) => {
95
99
  const desc = c.description ? ` - ${c.description}` : "";
96
- return `- ${c.name}[${shortTypeLabel(c.type)}] - ${fullTypeLabel(c.type)},${familyHint(c)}${desc}`;
100
+ const tag = envTagLabel(c);
101
+ return `- ${c.name}[${shortTypeLabel(c.type)}]${tag ? " " + tag : ""} - ${fullTypeLabel(c.type)},${familyHint(c)}${desc}`;
97
102
  });
98
103
  return [
99
104
  "[数据库工具执行策略]",
100
105
  policy,
101
106
  confirmation,
102
107
  safety,
108
+ "标注 [prod·强制只读] 的连接无论全局只读设置如何均只允许查询。",
103
109
  "[可用数据库]",
104
110
  ...lines,
105
111
  "query_database / list_tables / describe_table 的 database 参数必须使用上述名称(不含中括号内容,名称区分大小写)。",
@@ -209,6 +215,7 @@ export default function (pi: ExtensionAPI) {
209
215
  const idx = all.findIndex((x) => x.name === name);
210
216
  if (idx >= 0) all[idx] = conn; else all.push(conn);
211
217
  saveConfigs(all);
218
+ recordTestResult(name, result);
212
219
  ctx.ui.notify(`配置已保存: ${name}`, "success");
213
220
  }
214
221
  };
@@ -218,32 +225,60 @@ export default function (pi: ExtensionAPI) {
218
225
  const name = (await ctx.ui.input("连接名称", ""))?.trim();
219
226
  if (!name) { ctx.ui.notify("连接名称不能为空", "error"); return; }
220
227
 
221
- // 首问:一键连接串 / 逐步填写
228
+ // 首问:一键连接串 / 逐步填写 / 从现有复制(v1.1 UX 共识 Q2)
222
229
  const mode = await ctx.ui.select("添加方式", [
223
230
  "⚡ 粘贴连接串(一键)",
224
231
  "📝 逐步填写",
232
+ "📋 从现有复制",
225
233
  ]);
226
234
  if (!mode) return;
227
235
 
236
+ if (mode === "📋 从现有复制") {
237
+ const source = await selectDbConfig(ctx, loadConfigs(), "选择要复制的连接");
238
+ if (!source) return;
239
+ let newName = (await ctx.ui.input("新连接名称", `${source.name}-copy`))?.trim();
240
+ while (newName && loadConfigs().some((c) => c.name === newName)) {
241
+ newName = (await ctx.ui.input(`名称已存在: ${newName},请换一个`, `${newName}-2`))?.trim();
242
+ }
243
+ if (!newName) { ctx.ui.notify("已取消复制", "info"); return; }
244
+ const copy: ConnConfig = {
245
+ ...source,
246
+ id: randomUUID(),
247
+ name: newName,
248
+ isDefault: false,
249
+ lastUsedAt: undefined,
250
+ lastTest: undefined,
251
+ createdAt: new Date().toISOString(),
252
+ };
253
+ const all = loadConfigs();
254
+ all.push(copy);
255
+ saveConfigs(all);
256
+ ctx.ui.notify(`已复制为 ${newName}(类型/账号/环境标签等设置一并带上)`, "success");
257
+ if (await ctx.ui.confirm("从现有复制", "立即编辑副本(主机/端口/库/标签等)?")) {
258
+ await editDbConfig(ctx, copy);
259
+ }
260
+ return;
261
+ }
262
+
228
263
  let parsed: ReturnType<typeof parseDbUrl>;
229
264
  let username = "";
230
265
  let password = "";
231
266
  let dbIndex: number | undefined;
232
267
 
233
268
  if (mode === "⚡ 粘贴连接串(一键)") {
234
- const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0"))?.trim();
269
+ const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0 或 mongodb://user:pass@host:27017/db 或 neo4j://user:pass@host:7687"))?.trim();
235
270
  if (!url) { ctx.ui.notify("连接串不能为空", "error"); return; }
236
271
  const pcs = parseConnectionString(url);
237
272
  if (!pcs) {
238
- ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、http(s)://host:9200", "error");
273
+ ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、mongodb(srv)://、neo4j/bolt(s)://、http(s)://host:9200", "error");
239
274
  return;
240
275
  }
241
- parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
276
+ parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database, options: pcs.options };
242
277
  username = pcs.username ?? "";
243
278
  password = pcs.password ?? "";
244
279
  dbIndex = pcs.dbIndex;
245
280
  } else {
246
- const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database"))?.trim();
281
+ const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database 或 mongodb://host:27017/db"))?.trim();
247
282
  if (!url) { ctx.ui.notify("连接 URL 不能为空", "error"); return; }
248
283
  const pcs = parseConnectionString(url);
249
284
  if (!pcs) {
@@ -251,17 +286,27 @@ export default function (pi: ExtensionAPI) {
251
286
  " PostgreSQL/MySQL/DM/Hive: jdbc:<dialect>://host:port/db\n" +
252
287
  " Oracle: jdbc:oracle:thin:@//host:port/service 或 @host:port:SID\n" +
253
288
  " Redis: redis://[:password@]host:port[/db]\n" +
289
+ " MongoDB: mongodb://user:pass@host:27017/db 或 mongodb+srv://...\n" +
290
+ " Neo4j: neo4j://user:pass@host:7687/db 或 bolt://host:7687(路径段为图数据库名)\n" +
254
291
  " ES: http://host:9200", "error");
255
292
  return;
256
293
  }
257
- parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
294
+ parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database, options: pcs.options };
258
295
  dbIndex = pcs.dbIndex;
259
296
 
260
- // 家族分支字段:Redis 无账号要求、需库号;ES 账号/密码;其余 URL+账号+密码
297
+ // 家族分支字段:Redis 无账号要求、需库号;MongoDB 账号可空;ES/其余 账号+密码
261
298
  if (parsed.dialectId === "redis") {
262
299
  password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
263
300
  const idxInput = (await ctx.ui.input("库号 dbIndex(0-15,缺省 0)", String(dbIndex ?? 0)))?.trim();
264
301
  dbIndex = idxInput !== undefined && idxInput !== "" ? parseInt(idxInput, 10) : dbIndex;
302
+ } else if (parsed.dialectId === "mongodb") {
303
+ username = (await ctx.ui.input("账号(可空,本地无认证留空)", ""))?.trim() ?? "";
304
+ password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
305
+ } else if (parsed.dialectId === "neo4j") {
306
+ username = (await ctx.ui.input("账号", "neo4j"))?.trim() || "neo4j";
307
+ password = (await ctx.ui.input("密码", ""))?.trim() ?? "";
308
+ const dbInput = (await ctx.ui.input("图数据库名(缺省 neo4j)", parsed.database ?? "neo4j"))?.trim();
309
+ parsed.database = dbInput || parsed.database || "neo4j";
265
310
  } else {
266
311
  username = (await ctx.ui.input("账号", "root"))?.trim() || "root";
267
312
  password = (await ctx.ui.input("密码", ""))?.trim() ?? "";
@@ -286,6 +331,20 @@ export default function (pi: ExtensionAPI) {
286
331
  }
287
332
  ctx.ui.notify(`连接成功 (${result.version}, ${result.latency})`, "success");
288
333
 
334
+ // 环境标签(v1.1 UX 共识 Q3):测试通过后再问,失败不浪费输入;prod 主动建议强制只读
335
+ let envTag: ConnConfig["envTag"];
336
+ let forceReadonly: boolean | undefined;
337
+ const envChoice = await ctx.ui.select("环境标签(可跳过)", ["跳过", "dev", "test", "prod"]);
338
+ if (envChoice && envChoice !== "跳过") {
339
+ envTag = envChoice as ConnConfig["envTag"];
340
+ if (envTag === "prod") {
341
+ forceReadonly = await ctx.ui.confirm(
342
+ "生产库安全建议",
343
+ "将此连接设为强制只读?\n开启后该连接无视全局只读开关,永远只接受查询(生产库建议开启)。",
344
+ );
345
+ }
346
+ }
347
+
289
348
  const configs = loadConfigs();
290
349
  if (configs.some((c) => c.name === name)) {
291
350
  ctx.ui.notify(`已存在同名配置: ${name}`, "error");
@@ -303,13 +362,17 @@ export default function (pi: ExtensionAPI) {
303
362
  password,
304
363
  database: parsed.database,
305
364
  dbIndex,
365
+ options: parsed.options,
366
+ envTag,
367
+ forceReadonly,
306
368
  isDefault: configs.length === 0, // 首个连接自动设为默认(与提示文案一致)
307
369
  createdAt: new Date().toISOString(),
308
370
  };
309
371
 
310
372
  configs.push(config);
311
373
  saveConfigs(configs);
312
- ctx.ui.notify(`配置已保存: ${name}${configs.length === 1 ? "(首个连接已设为默认)" : ""}`, "success");
374
+ recordTestResult(name, result);
375
+ ctx.ui.notify(`配置已保存: ${name}${envTag ? ` [${envTag}${forceReadonly ? "·强制只读" : ""}]` : ""}${configs.length === 1 ? "(首个连接已设为默认)" : ""}`, "success");
313
376
  };
314
377
 
315
378
  // ── 编辑数据库连接 ──────────────────────────────
@@ -320,6 +383,8 @@ export default function (pi: ExtensionAPI) {
320
383
  `URL: ${currentUrl}`,
321
384
  `账号: ${original.username}`,
322
385
  `密码: ${original.password || ""}`,
386
+ `环境标签: ${original.envTag ?? ""}`,
387
+ `强制只读: ${original.forceReadonly ? "是" : "否"}`,
323
388
  `说明: ${original.description || ""}`,
324
389
  ].join("\n");
325
390
 
@@ -353,6 +418,14 @@ export default function (pi: ExtensionAPI) {
353
418
  const descRaw = getValue("说明");
354
419
  const description = descRaw === "" ? undefined : (descRaw || original.description);
355
420
 
421
+ // 环境标签/强制只读(行缺省=保持原值;标签仅接受 dev/test/prod,其余/清空=去除标签)
422
+ const envRaw = getValue("环境标签")?.trim().toLowerCase();
423
+ const envTag = envRaw === undefined || envRaw === ""
424
+ ? (envRaw === "" ? undefined : original.envTag)
425
+ : (["dev", "test", "prod"].includes(envRaw) ? envRaw as ConnConfig["envTag"] : original.envTag);
426
+ const roRaw = getValue("强制只读")?.trim();
427
+ const forceReadonly = roRaw === undefined ? original.forceReadonly : roRaw === "是";
428
+
356
429
  const updated: ConnConfig = {
357
430
  ...original,
358
431
  name,
@@ -362,6 +435,9 @@ export default function (pi: ExtensionAPI) {
362
435
  username,
363
436
  password,
364
437
  database: parsed.database,
438
+ options: parsed.options ?? original.options, // displayUrl 不带查询参数,保留原 options(authSource 等)
439
+ envTag,
440
+ forceReadonly,
365
441
  description,
366
442
  };
367
443
 
@@ -379,6 +455,18 @@ export default function (pi: ExtensionAPI) {
379
455
  all[idx] = updated;
380
456
  saveConfigs(all);
381
457
  ctx.ui.notify(`已更新: ${updated.name}`, "success");
458
+
459
+ // 编辑后自动回测(v1.1 UX 共识 Q1):结果回写 lastTest,不阻塞保存
460
+ const testDialect = registry.get(updated.type)!;
461
+ ctx.ui.notify(`正在测试 ${testDialect.label} 连接...`, "info");
462
+ const test = await testDialect.testConnection(toRuntimeConfig(updated, testDialect.defaultPort));
463
+ recordTestResult(updated.name, test);
464
+ ctx.ui.notify(
465
+ test.success
466
+ ? `✓ 连接正常 (${test.version ?? "?"}, ${test.latency ?? "?"})`
467
+ : `⚠ 已保存,但连接测试失败: ${test.error}`,
468
+ test.success ? "success" : "error",
469
+ );
382
470
  };
383
471
 
384
472
  // ── 选择数据库公共操作 ────────────────────────────
@@ -398,10 +486,7 @@ export default function (pi: ExtensionAPI) {
398
486
  ctx.ui.notify("尚无数据库连接", "info");
399
487
  return;
400
488
  }
401
- const lines = configs.map((c) => {
402
- const desc = c.description ? ` - ${c.description}` : "";
403
- return ` ${c.name} [${shortTypeLabel(c.type)}]${desc}`;
404
- });
489
+ const lines = configs.map((c) => ` ${connSummaryLine(c)}`);
405
490
  ctx.ui.notify(`数据库连接 (${configs.length}):\n${lines.join("\n")}`, "info");
406
491
  };
407
492
 
@@ -423,6 +508,7 @@ export default function (pi: ExtensionAPI) {
423
508
  "📝 执行查询",
424
509
  "📋 列出表",
425
510
  "🔍 查看详情",
511
+ "🧪 测试连接",
426
512
  config.isDefault ? "⭐ 取消默认" : "⭐ 设为默认",
427
513
  "✏️ 编辑",
428
514
  "🗑️ 删除",
@@ -441,8 +527,9 @@ export default function (pi: ExtensionAPI) {
441
527
  if (!sql) return;
442
528
  const cfg = loadPluginConfig();
443
529
  const dialect = registry.get(config.type)!;
444
- const verdict = dialect.isAllowed(sql, cfg.ai_readonly);
445
- const action = decide(verdict, cfg.ai_readonly, cfg.confirm_before_exec);
530
+ const effRo = effectiveReadonly(cfg.ai_readonly, config); // 连接级强制只读生效(v1.2 Q3)
531
+ const verdict = dialect.isAllowed(sql, effRo);
532
+ const action = decide(verdict, effRo, cfg.confirm_before_exec);
446
533
  if (action === "deny") {
447
534
  ctx.ui.notify(`不允许执行: ${verdict.reason}`, "error");
448
535
  continue;
@@ -456,9 +543,10 @@ export default function (pi: ExtensionAPI) {
456
543
  }
457
544
  ctx.ui.notify("正在执行查询...", "info");
458
545
  const result = await dialect.executeOn(toRuntimeConfig(config, dialect.defaultPort), sql, {
459
- readonly: cfg.ai_readonly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout,
546
+ readonly: effRo, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout,
460
547
  });
461
548
  if (result.success) {
549
+ recordLastUsed(config.name);
462
550
  const lines = [`查询完成 (${result.duration})`, `返回 ${result.rowCount} 行`];
463
551
  if (result.columns && result.columns.length > 0) {
464
552
  lines.push("列: " + result.columns.join(", "));
@@ -483,6 +571,18 @@ export default function (pi: ExtensionAPI) {
483
571
  } else {
484
572
  ctx.ui.notify(`查询失败: ${result.error}`, "error");
485
573
  }
574
+ } else if (choice === "🧪 测试连接") {
575
+ const dialect = registry.get(config.type)!;
576
+ ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
577
+ const result = await dialect.testConnection(toRuntimeConfig(config, dialect.defaultPort));
578
+ recordTestResult(config.name, result);
579
+ ctx.ui.notify(
580
+ result.success
581
+ ? `✓ 连接正常 (${result.version ?? "?"}, ${result.latency ?? "?"})`
582
+ : `✗ 连接失败: ${result.error}`,
583
+ result.success ? "success" : "error",
584
+ );
585
+ continue;
486
586
  } else if (choice === "📋 列出表") {
487
587
  const dialect = registry.get(config.type)!;
488
588
  const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort));
@@ -555,6 +655,12 @@ export default function (pi: ExtensionAPI) {
555
655
  label: "查询超时(s)",
556
656
  current: String(cfg.query_timeout),
557
657
  },
658
+ {
659
+ key: "audit_enabled" as const,
660
+ label: "审计日志",
661
+ current: cfg.audit_enabled ? "是" : "否",
662
+ options: ["是", "否"],
663
+ },
558
664
  ];
559
665
 
560
666
  // 选择要修改的字段
@@ -596,7 +702,7 @@ export default function (pi: ExtensionAPI) {
596
702
  const updatedFields = fields.map((f) => {
597
703
  const val = (newCfg as any)[f.key];
598
704
  const display =
599
- f.key === "ai_readonly" ? (val ? "是" : "否") :
705
+ f.key === "ai_readonly" || f.key === "audit_enabled" ? (val ? "是" : "否") :
600
706
  f.key === "confirm_before_exec" ?
601
707
  (val === "never" ? "不确认" : val === "write" ? "写操作确认" : "每次都确认") :
602
708
  String(val);
@@ -644,13 +750,14 @@ export default function (pi: ExtensionAPI) {
644
750
  pi.registerTool({
645
751
  name: "query_database",
646
752
  label: "数据库查询",
647
- description: "执行 SQL 语句,支持关系型(PostgreSQL/MySQL/Oracle/达梦)/ Redis / Elasticsearch / Hive / Spark 八种数据库,返回执行结果。支持读和写,写操作受确认策略约束;是否允许写以及是否需确认,以系统提示中的当前数据库工具执行策略为准。DROP TABLE 始终禁止。",
648
- promptSnippet: "执行 SQL 语句。先根据系统提示中的当前数据库工具执行策略判断是否允许写操作;database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。使用 list_tables 查看表结构后再编写 SQL。",
753
+ description: "执行语句,支持关系型(PostgreSQL/MySQL/Oracle/达梦)/ Redis / Elasticsearch / MongoDB / Neo4j / Hive / Spark 十种数据库,返回执行结果。支持读和写,写操作受确认策略约束且必须附 reason 执行理由(动机+影响范围),用户确认框将展示该理由;是否允许写以及是否需确认,以系统提示中的当前数据库工具执行策略为准。DROP TABLE 始终禁止。MongoDB 的 sql 参数填 JSON 命令信封(db.runCommand 形态,如 {\"find\":\"users\",\"filter\":{}});Neo4j 填 Cypher(如 MATCH (n:Person) RETURN n LIMIT 10)。",
754
+ promptSnippet: "执行 SQL 语句。先根据系统提示中的当前数据库工具执行策略判断是否允许写操作;写操作必须在 reason 参数说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),否则会被拒绝。database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。使用 list_tables 查看表结构后再编写 SQL。",
649
755
  parameters: Type.Object({
650
756
  database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
651
- sql: Type.String({ description: "SQL 语句" }),
757
+ sql: Type.String({ description: "语句;关系型填 SQL,MongoDB 填 JSON 命令信封,Redis 填命令,ES 填 DSL,Neo4j 填 Cypher" }),,
758
+ reason: Type.Optional(Type.String({ description: "执行理由,写操作必填:动机+影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\")。读操作无需填写" })),
652
759
  }),
653
- async execute(_toolCallId: string, params: { database?: string; sql: string }, _signal: any, _onUpdate?: any, ctx?: any) {
760
+ async execute(_toolCallId: string, params: { database?: string; sql: string; reason?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
654
761
  const cfg = loadPluginConfig();
655
762
 
656
763
  const target = resolveTargetDb(params.database);
@@ -661,6 +768,9 @@ export default function (pi: ExtensionAPI) {
661
768
  }
662
769
  const config = target.config;
663
770
 
771
+ // 生效只读 = 全局只读 ∨ 连接级强制只读(v1.1 UX 共识 Q3)
772
+ const effectiveReadOnly = effectiveReadonly(cfg.ai_readonly, config);
773
+
664
774
  // verdict 流程(策略层统一裁决,含 DROP 硬限制与只读检查):
665
775
  // verdict = dialect.isAllowed(sql, readonly) → decide → deny/confirm/run
666
776
  const dialect = registry.get(config.type);
@@ -669,11 +779,20 @@ export default function (pi: ExtensionAPI) {
669
779
  content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
670
780
  };
671
781
  }
672
- const verdict = dialect.isAllowed(params.sql, cfg.ai_readonly);
673
- const action = decide(verdict, cfg.ai_readonly, cfg.confirm_before_exec);
782
+ const verdict = dialect.isAllowed(params.sql, effectiveReadOnly);
783
+ const action = decide(verdict, effectiveReadOnly, cfg.confirm_before_exec);
674
784
  if (action === "deny") {
785
+ const note = !cfg.ai_readonly && config.forceReadonly === true
786
+ ? `(连接 ${config.name} 已设置强制只读)`
787
+ : "";
788
+ return {
789
+ content: [{ type: "text" as const, text: (verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。") + note }],
790
+ };
791
+ }
792
+ // 写操作强制附执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦;缺 reason 拒绝并引导 AI 补充)
793
+ if (writeRequiresReason(verdict, params.reason)) {
675
794
  return {
676
- content: [{ type: "text" as const, text: verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。" }],
795
+ content: [{ type: "text" as const, text: "写操作必须附执行理由:请在 reason 参数中说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),补充后重试。" }],
677
796
  };
678
797
  }
679
798
  if (action === "confirm") {
@@ -684,7 +803,7 @@ export default function (pi: ExtensionAPI) {
684
803
  }
685
804
  const ok = await ctx.ui.confirm(
686
805
  "SQL 执行确认",
687
- `${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
806
+ `理由: ${params.reason}\n\n${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
688
807
  );
689
808
  if (!ok) {
690
809
  return {
@@ -697,7 +816,7 @@ export default function (pi: ExtensionAPI) {
697
816
  const result = await dialect.executeOn(
698
817
  toRuntimeConfig(config, dialect.defaultPort),
699
818
  params.sql,
700
- { readonly: cfg.ai_readonly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout },
819
+ { readonly: effectiveReadOnly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout },
701
820
  );
702
821
 
703
822
  if (!result.success) {
@@ -706,6 +825,20 @@ export default function (pi: ExtensionAPI) {
706
825
  };
707
826
  }
708
827
 
828
+ recordLastUsed(config.name);
829
+ // 写操作审计(v1.1 UX 共识 Q4 + 可配置修订:默认关闭,cfg.audit_enabled 开启才落盘;失败不阻断主流程)
830
+ if (verdict.isWrite && cfg.audit_enabled) {
831
+ appendAuditLog({
832
+ time: new Date().toISOString(),
833
+ project: process.cwd(),
834
+ connection: config.name,
835
+ type: config.type,
836
+ summary: verdict.summary ?? "",
837
+ reason: (params.reason ?? "").trim(),
838
+ sql: params.sql,
839
+ readonly: effectiveReadOnly,
840
+ });
841
+ }
709
842
  let text = `查询完成 (${result.duration}),返回 ${result.rowCount} 行\n`;
710
843
  if (result.columns && result.columns.length > 0) {
711
844
  text += `列: ${result.columns.join(", ")}\n\n`;
@@ -730,6 +863,10 @@ export default function (pi: ExtensionAPI) {
730
863
  } else {
731
864
  text += "无数据返回。";
732
865
  }
866
+ // 写操作理由随结果回显(v1.1 UX 共识 Q2:说了什么→做了什么闭环)
867
+ if (verdict.isWrite) {
868
+ text += `\n执行理由: ${params.reason}`;
869
+ }
733
870
 
734
871
  return { content: [{ type: "text" as const, text }] };
735
872
  },
@@ -767,6 +904,7 @@ export default function (pi: ExtensionAPI) {
767
904
  content: [{ type: "text" as const, text: `获取表列表失败: ${result.error}` }],
768
905
  };
769
906
  }
907
+ recordLastUsed(config.name);
770
908
 
771
909
  const lines = result.tables.map((t) => {
772
910
  const schema = t.schema ? `${t.schema}.` : "";
@@ -812,6 +950,7 @@ export default function (pi: ExtensionAPI) {
812
950
  content: [{ type: "text" as const, text: `获取表结构失败: ${result.error}` }],
813
951
  };
814
952
  }
953
+ recordLastUsed(config.name);
815
954
 
816
955
  const lines = [`表: ${params.table}`, `共 ${result.count} 列\n`];
817
956
  // 表头
@@ -861,6 +1000,35 @@ export default function (pi: ExtensionAPI) {
861
1000
  },
862
1001
  });
863
1002
 
1003
+ // 工具 5: db_connections(v1.1 UX 共识 Q5:AI 自查连接清单与环境标签,识别生产库)
1004
+ pi.registerTool({
1005
+ name: "db_connections",
1006
+ label: "列出数据库连接",
1007
+ description: "列出所有已配置的数据库连接(名称/类型/环境标签/默认标记/最近测试结果/连接地址,不含密码)。用于确认可用连接、识别生产库。增删改连接需用户在终端执行 /db。",
1008
+ promptSnippet: "当用户提到某个环境(如“生产库”)或不确定该用哪个连接时,先调用本工具确认连接清单再查询;database 参数应使用返回列表中的名称。",
1009
+ parameters: Type.Object({}),
1010
+ async execute() {
1011
+ const configs = loadConfigs();
1012
+ if (configs.length === 0) {
1013
+ return {
1014
+ content: [{ type: "text" as const, text: "尚无数据库连接。请引导用户在终端执行 /db add 或 /db scan 建连。" }],
1015
+ };
1016
+ }
1017
+ const lines = configs.map((c) => {
1018
+ const marks = [c.isDefault ? "⭐默认" : "", envTagLabel(c)].filter(Boolean).join(" ");
1019
+ const test = c.lastTest
1020
+ ? `测试 ${formatRelativeTime(c.lastTest.at)} ${c.lastTest.ok ? "✓" : "✗"}${c.lastTest.latency ? " " + c.lastTest.latency : ""}${c.lastTest.version ? " · " + c.lastTest.version : ""}`
1021
+ : "未测试";
1022
+ const used = c.lastUsedAt ? `上次使用 ${formatRelativeTime(c.lastUsedAt)}` : "";
1023
+ const url = registry.get(c.type)?.displayUrl(c) ?? "";
1024
+ return `- ${c.name} [${shortTypeLabel(c.type)}]${marks ? " " + marks : ""} - ${fullTypeLabel(c.type)}${c.database ? " · 库: " + c.database : ""} · ${url} · ${test}${used ? " · " + used : ""}${c.description ? " · " + c.description : ""}`;
1025
+ });
1026
+ return {
1027
+ content: [{ type: "text" as const, text: `共 ${configs.length} 个连接:\n${lines.join("\n")}\n\n标注 [prod·强制只读] 的连接永远只读(即使全局允许写操作)。` }],
1028
+ };
1029
+ },
1030
+ });
1031
+
864
1032
  // ── 注册 /db 命令(给用户管理连接) ──────────────
865
1033
  pi.registerCommand("db", {
866
1034
  description: "AI 接入数据库",
@@ -872,6 +1040,7 @@ export default function (pi: ExtensionAPI) {
872
1040
  // 导航菜单:查看 / 编辑 / 新增 / 删除 / 设置
873
1041
  const navActions = [
874
1042
  "📋 打开连接",
1043
+ "⚡ 切换默认",
875
1044
  "✏️ 编辑连接",
876
1045
  "➕ 新增连接",
877
1046
  "🔎 扫描建连",
@@ -886,6 +1055,22 @@ export default function (pi: ExtensionAPI) {
886
1055
  } else if (navChoice === "📋 打开连接") {
887
1056
  const config = await selectDbConfig(ctx, configs, "选择连接");
888
1057
  if (config) await showDbActions(ctx, config);
1058
+ } else if (navChoice === "⚡ 切换默认") {
1059
+ // v1.1 UX 共识 Q1:两层直达,替代“打开→动作→设为默认”三层路径
1060
+ const configsNow = loadConfigs();
1061
+ if (configsNow.length === 0) {
1062
+ ctx.ui.notify("尚无数据库连接", "info");
1063
+ } else {
1064
+ const target = await selectDbConfig(ctx, configsNow, "设为默认连接(⭐ 为当前默认)");
1065
+ if (target) {
1066
+ if (target.isDefault) {
1067
+ ctx.ui.notify(`已是默认连接: ${target.name}`, "info");
1068
+ } else {
1069
+ saveConfigs(setDefaultConfig(configsNow, target.id));
1070
+ ctx.ui.notify(`默认连接已切换: ${target.name}`, "success");
1071
+ }
1072
+ }
1073
+ }
889
1074
  } else if (navChoice === "✏️ 编辑连接") {
890
1075
  const config = await selectDbConfig(ctx, configs, "选择要编辑的连接");
891
1076
  if (config) await editDbConfig(ctx, config);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nsyan/db",
3
- "version": "1.0.0",
4
- "description": "AI 接入数据库扩展 —— 四大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/Hive/Spark,提供查询/表结构/扫描建连工具给 LLM",
3
+ "version": "1.2.0",
4
+ "description": "AI 接入数据库扩展 —— 六大家族方言架构,支持 PostgreSQL/MySQL/Oracle/达梦/Redis/Elasticsearch/MongoDB/Neo4j/Hive/Spark 十种数据库,提供查询/表结构/扫描建连/连接清单工具给 LLM",
5
5
  "keywords": [
6
6
  "pi-extension",
7
7
  "pi-package",
@@ -13,6 +13,11 @@
13
13
  "dm",
14
14
  "redis",
15
15
  "elasticsearch",
16
+ "mongodb",
17
+ "mongo",
18
+ "neo4j",
19
+ "graph",
20
+ "cypher",
16
21
  "hive",
17
22
  "spark"
18
23
  ],
@@ -30,6 +35,7 @@
30
35
  "files": [
31
36
  "index.ts",
32
37
  "src/",
38
+ "docs/",
33
39
  "README.md"
34
40
  ],
35
41
  "exports": {
@@ -43,8 +49,11 @@
43
49
  "es7": "npm:@elastic/elasticsearch@7",
44
50
  "hive-driver": "^1.0.1",
45
51
  "ioredis": "^6.0.0",
52
+ "mongodb": "^6.21.0",
46
53
  "mysql2": "^3.23.1",
54
+ "neo4j-driver": "^5.28.3",
47
55
  "oracledb": "^7.0.1",
56
+ "neo4j-driver": "^5.28.3",
48
57
  "pg": "^8.22.0"
49
58
  },
50
59
  "devDependencies": {