befly 3.15.1 → 3.15.3

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.
@@ -1,4 +1,3 @@
1
- const DB_DIALECT_SET = new Set(["mysql", "postgresql", "sqlite"]);
2
1
  function isNonEmptyString(value) {
3
2
  return typeof value === "string" && value.trim().length > 0;
4
3
  }
@@ -8,39 +7,11 @@ function isValidPort(value) {
8
7
  function isValidNonNegativeInt(value) {
9
8
  return typeof value === "number" && Number.isFinite(value) && value >= 0 && Math.floor(value) === value;
10
9
  }
11
- function normalizeDbDialect(dialect) {
12
- const d = String(dialect || "")
13
- .toLowerCase()
14
- .trim();
15
- if (d === "postgresql")
16
- return "postgresql";
17
- if (d === "sqlite")
18
- return "sqlite";
19
- if (d === "mysql")
20
- return "mysql";
21
- return d;
22
- }
23
10
  function validateDbConfig(db) {
24
11
  if (!db) {
25
12
  throw new Error("配置错误:缺少 db 配置(config.db)");
26
13
  }
27
- const dialect = normalizeDbDialect(db.dialect);
28
- if (!DB_DIALECT_SET.has(dialect)) {
29
- throw new Error(`配置错误:db.dialect 只允许 mysql/postgresql/sqlite,当前值=${String(db.dialect)}`);
30
- }
31
- // sqlite:database 表示文件路径或 ":memory:";这里要求非空。
32
- if (dialect === "sqlite") {
33
- if (!isNonEmptyString(db.database)) {
34
- throw new Error(`配置错误:db.dialect=sqlite 时必须设置 db.database(sqlite 文件路径或 :memory:),当前值=${String(db.database)}`);
35
- }
36
- if (db.poolMax !== undefined) {
37
- if (typeof db.poolMax !== "number" || !Number.isFinite(db.poolMax) || db.poolMax <= 0) {
38
- throw new Error(`配置错误:db.poolMax 必须为正数,当前值=${String(db.poolMax)}`);
39
- }
40
- }
41
- return;
42
- }
43
- // mysql/postgresql:必须提供 host/port/username/password/database
14
+ // MySQL:必须提供 host/port/username/password/database
44
15
  if (!isNonEmptyString(db.host)) {
45
16
  throw new Error(`配置错误:db.host 必须为非空字符串,当前值=${String(db.host)}`);
46
17
  }
@@ -88,10 +88,13 @@ const LOWER_CAMEL_CASE_REGEX = /^_?[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)*$/;
88
88
  * 必须为中文、数字、字母、下划线、短横线、空格
89
89
  */
90
90
  const FIELD_NAME_REGEX = /^[\u4e00-\u9fa5a-zA-Z0-9 _-]+$/;
91
- /**
92
- * VARCHAR 最大长度限制
93
- */
94
- const MAX_VARCHAR_LENGTH = 65535;
91
+ // MySQL VARCHAR 最大长度(utf8mb4 下受行大小限制;本项目采用保守上限,用于表定义校验)
92
+ const MAX_VARCHAR_LENGTH = 16383;
93
+ // 项目索引策略(更简单、更保守):
94
+ // - index=true: 只允许 max<=500
95
+ // - unique=true: 只允许 max<=180
96
+ const MAX_INDEX_STRING_LENGTH_FOR_INDEX = 500;
97
+ const MAX_INDEX_STRING_LENGTH_FOR_UNIQUE = 180;
95
98
  /**
96
99
  * 检查表定义文件
97
100
  * @throws 当检查失败时抛出异常
@@ -261,6 +264,16 @@ export async function checkTable(tables) {
261
264
  Logger.warn(`${tablePrefix}${fileName} 文件 ${colKey} 最大长度 ${fieldMax} 越界,` + `${fieldType} 类型长度必须在 1..${MAX_VARCHAR_LENGTH} 范围内`);
262
265
  hasError = true;
263
266
  }
267
+ else {
268
+ if (field.index === true && fieldMax > MAX_INDEX_STRING_LENGTH_FOR_INDEX) {
269
+ Logger.warn(`${tablePrefix}${fileName} 文件 ${colKey} 设置了 index=true,` + `但 max=${fieldMax} 超出允许范围(要求 <= ${MAX_INDEX_STRING_LENGTH_FOR_INDEX})`);
270
+ hasError = true;
271
+ }
272
+ if (field.unique === true && fieldMax > MAX_INDEX_STRING_LENGTH_FOR_UNIQUE) {
273
+ Logger.warn(`${tablePrefix}${fileName} 文件 ${colKey} 设置了 unique=true,` + `但 max=${fieldMax} 超出允许范围(要求 <= ${MAX_INDEX_STRING_LENGTH_FOR_UNIQUE})`);
274
+ hasError = true;
275
+ }
276
+ }
264
277
  // default 规则(table 定义专用):
265
278
  // - string:default 若存在且非 null,必须为 string
266
279
  // - array_*_string:default 若存在且非 null,必须为 string,且建议为 JSON 数组字符串(如 "[]")
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ import { syncApi } from "./sync/syncApi";
25
25
  import { syncCache } from "./sync/syncCache";
26
26
  import { syncDev } from "./sync/syncDev";
27
27
  import { syncMenu } from "./sync/syncMenu";
28
- import { syncTable } from "./sync/syncTable";
28
+ import { SyncTable } from "./sync/syncTable";
29
29
  // 工具
30
30
  import { calcPerfTime } from "./utils/calcPerfTime";
31
31
  import { getProcessRole } from "./utils/processInfo";
@@ -92,7 +92,7 @@ export class Befly {
92
92
  // 注意:这里不做兼容别名(例如 dbHelper=db),要求上下文必须注入标准字段。
93
93
  this.assertStartContextReady();
94
94
  // 5. 自动同步 (仅主进程执行,避免集群模式下重复执行)
95
- await syncTable(this.context, tables);
95
+ await new SyncTable(this.context).run(tables);
96
96
  await syncApi(this.context, apis);
97
97
  await syncMenu(this.context, checkedMenus);
98
98
  const devEmail = this.config.devEmail;
@@ -18,6 +18,4 @@ export declare class CacheKeys {
18
18
  * - member: url.pathname(例如 /api/user/login;与 method 无关)
19
19
  */
20
20
  static roleApis(roleCode: string): string;
21
- /** 表结构缓存 */
22
- static tableColumns(table: string): string;
23
21
  }
@@ -26,8 +26,4 @@ export class CacheKeys {
26
26
  static roleApis(roleCode) {
27
27
  return `role:apis:${roleCode}`;
28
28
  }
29
- /** 表结构缓存 */
30
- static tableColumns(table) {
31
- return `table:columns:${table}`;
32
- }
33
29
  }
@@ -14,6 +14,8 @@ export declare class Connect {
14
14
  private static sqlConnectedAt;
15
15
  private static redisConnectedAt;
16
16
  private static sqlPoolMax;
17
+ private static mysqlVersionText;
18
+ private static mysqlVersionMajor;
17
19
  /**
18
20
  * 连接 SQL 数据库
19
21
  * @returns SQL 客户端实例
@@ -69,6 +71,8 @@ export declare class Connect {
69
71
  connectedAt: number | null;
70
72
  uptime: number | null;
71
73
  poolMax: number;
74
+ mysqlVersionText: string | null;
75
+ mysqlVersionMajor: number | null;
72
76
  };
73
77
  redis: {
74
78
  connected: boolean;
@@ -15,6 +15,9 @@ export class Connect {
15
15
  static sqlConnectedAt = null;
16
16
  static redisConnectedAt = null;
17
17
  static sqlPoolMax = 1;
18
+ // MySQL 版本信息(用于启动期校验与监控)
19
+ static mysqlVersionText = null;
20
+ static mysqlVersionMajor = null;
18
21
  // ========================================
19
22
  // SQL 连接管理
20
23
  // ========================================
@@ -24,51 +27,50 @@ export class Connect {
24
27
  */
25
28
  static async connectSql(dbConfig) {
26
29
  const config = dbConfig || {};
27
- // 构建数据库连接字符串
28
- const dialect = config.dialect || "mysql";
29
- const host = config.host || "127.0.0.1";
30
- const port = config.port || 3306;
31
- const user = encodeURIComponent(config.username || "root");
32
- const password = encodeURIComponent(config.password || "root");
33
- const database = encodeURIComponent(config.database || "befly_demo");
34
- let finalUrl;
35
- if (dialect === "sqlite") {
36
- finalUrl = database;
30
+ // 构建 MySQL 连接字符串(不做隐式默认;缺失直接报错,避免连错库/错密码)
31
+ const host = typeof config.host === "string" ? config.host.trim() : "";
32
+ const port = typeof config.port === "number" ? config.port : NaN;
33
+ const username = typeof config.username === "string" ? config.username.trim() : "";
34
+ const password = config.password === undefined ? "" : typeof config.password === "string" ? config.password : "";
35
+ const database = typeof config.database === "string" ? config.database.trim() : "";
36
+ if (!host) {
37
+ throw new Error("数据库配置不完整:db.host 缺失");
37
38
  }
38
- else {
39
- if (!host || !database) {
40
- throw new Error("数据库配置不完整,请检查配置参数");
41
- }
42
- finalUrl = `${dialect}://${user}:${password}@${host}:${port}/${database}`;
39
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
40
+ throw new Error(`数据库配置不完整:db.port 非法(当前值:${String(config.port)})`);
43
41
  }
44
- let sql;
45
- if (dialect === "sqlite") {
46
- sql = new SQL(finalUrl);
42
+ if (!username) {
43
+ throw new Error("数据库配置不完整:db.username 缺失");
47
44
  }
48
- else {
49
- sql = new SQL({
50
- url: finalUrl,
51
- max: config.poolMax ?? 1,
52
- bigint: false
53
- });
45
+ if (!database) {
46
+ throw new Error("数据库配置不完整:db.database 缺失");
54
47
  }
48
+ const user = encodeURIComponent(username);
49
+ const pass = encodeURIComponent(password);
50
+ const db = encodeURIComponent(database);
51
+ const finalUrl = `mysql://${user}:${pass}@${host}:${port}/${db}`;
52
+ const sql = new SQL({
53
+ url: finalUrl,
54
+ max: config.poolMax ?? 1,
55
+ bigint: false
56
+ });
55
57
  try {
56
58
  const timeout = 30000;
57
59
  const healthCheckPromise = (async () => {
58
- let version = "";
59
- if (dialect === "sqlite") {
60
- const v = await sql `SELECT sqlite_version() AS version`;
61
- version = v?.[0]?.version;
62
- }
63
- else if (dialect === "postgresql") {
64
- const v = await sql `SELECT version() AS version`;
65
- version = v?.[0]?.version;
60
+ const v = await sql `SELECT VERSION() AS version`;
61
+ const versionText = typeof v?.[0]?.version === "string" ? v?.[0]?.version : String(v?.[0]?.version || "");
62
+ // 常见格式:8.0.36 / 8.0.36-xxx
63
+ const majorText = versionText.split(".")[0];
64
+ const major = Number(majorText);
65
+ if (!Number.isFinite(major)) {
66
+ throw new Error(`无法解析 MySQL 版本信息: ${versionText}`);
66
67
  }
67
- else {
68
- const v = await sql `SELECT VERSION() AS version`;
69
- version = v?.[0]?.version;
68
+ if (major < 8) {
69
+ throw new Error(`仅支持 MySQL 8.0+,当前版本:${versionText}`);
70
70
  }
71
- return version;
71
+ this.mysqlVersionText = versionText;
72
+ this.mysqlVersionMajor = major;
73
+ return versionText;
72
74
  })();
73
75
  const timeoutPromise = new Promise((_, reject) => {
74
76
  setTimeout(() => {
@@ -132,11 +134,13 @@ export class Connect {
132
134
  const password = config.password || "";
133
135
  const db = config.db || 0;
134
136
  let auth = "";
135
- if (username && password) {
136
- auth = `${username}:${password}@`;
137
+ const encodedUsername = username ? encodeURIComponent(username) : "";
138
+ const encodedPassword = password ? encodeURIComponent(password) : "";
139
+ if (encodedUsername && encodedPassword) {
140
+ auth = `${encodedUsername}:${encodedPassword}@`;
137
141
  }
138
- else if (password) {
139
- auth = `:${password}@`;
142
+ else if (encodedPassword) {
143
+ auth = `:${encodedPassword}@`;
140
144
  }
141
145
  const url = `redis://${auth}${host}:${port}/${db}`;
142
146
  const redis = new RedisClient(url, {
@@ -228,7 +232,9 @@ export class Connect {
228
232
  connected: this.sqlClient !== null,
229
233
  connectedAt: this.sqlConnectedAt,
230
234
  uptime: this.sqlConnectedAt ? now - this.sqlConnectedAt : null,
231
- poolMax: this.sqlPoolMax
235
+ poolMax: this.sqlPoolMax,
236
+ mysqlVersionText: this.mysqlVersionText,
237
+ mysqlVersionMajor: this.mysqlVersionMajor
232
238
  },
233
239
  redis: {
234
240
  connected: this.redisClient !== null,
@@ -261,5 +267,7 @@ export class Connect {
261
267
  this.sqlConnectedAt = null;
262
268
  this.redisConnectedAt = null;
263
269
  this.sqlPoolMax = 1;
270
+ this.mysqlVersionText = null;
271
+ this.mysqlVersionMajor = null;
264
272
  }
265
273
  }
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import type { WhereConditions, SqlValue } from "../types/common";
6
6
  import type { QueryOptions, InsertOptions, UpdateOptions, DeleteOptions, DbPageResult, DbListResult, TransactionCallback, DbResult, ListSql } from "../types/database";
7
- import type { DbDialect } from "./dbDialect";
8
7
  type RedisCacheLike = {
9
8
  getObject<T = unknown>(key: string): Promise<T | null>;
10
9
  setObject<T = unknown>(key: string, obj: T, ttl?: number | null): Promise<string | null>;
@@ -13,14 +12,16 @@ type RedisCacheLike = {
13
12
  type SqlClientLike = {
14
13
  unsafe<TResult = unknown>(sqlStr: string, params?: SqlValue[]): Promise<TResult>;
15
14
  };
15
+ type DbIdMode = "timeId" | "autoId";
16
16
  /**
17
17
  * 数据库助手类
18
18
  */
19
19
  export declare class DbHelper {
20
20
  private redis;
21
- private dialect;
21
+ private dbName;
22
22
  private sql;
23
23
  private isTransaction;
24
+ private idMode;
24
25
  /**
25
26
  * 构造函数
26
27
  * @param redis - Redis 实例
@@ -28,8 +29,9 @@ export declare class DbHelper {
28
29
  */
29
30
  constructor(options: {
30
31
  redis: RedisCacheLike;
32
+ dbName: string;
31
33
  sql?: SqlClientLike | null;
32
- dialect?: DbDialect;
34
+ idMode?: DbIdMode;
33
35
  });
34
36
  private createSqlBuilder;
35
37
  private getTableColumns;
@@ -149,7 +151,7 @@ export declare class DbHelper {
149
151
  * @param options.table - 表名(支持小驼峰或下划线格式,会自动转换)
150
152
  */
151
153
  insData<TInsert extends Record<string, SqlValue> = Record<string, SqlValue>>(options: Omit<InsertOptions, "data"> & {
152
- data: TInsert | TInsert[];
154
+ data: TInsert;
153
155
  }): Promise<DbResult<number>>;
154
156
  /**
155
157
  * 批量插入数据(真正的批量操作)
@@ -4,16 +4,22 @@
4
4
  */
5
5
  import { convertBigIntFields } from "../utils/convertBigIntFields";
6
6
  import { fieldClear } from "../utils/fieldClear";
7
- import { toSqlParams } from "../utils/sqlParams";
8
- import { toNumberFromSql } from "../utils/sqlResult";
7
+ import { toNumberFromSql, toSqlParams } from "../utils/sqlUtil";
9
8
  import { arrayKeysToCamel, isPlainObject, keysToCamel, snakeCase } from "../utils/util";
10
- import { CacheKeys } from "./cacheKeys";
11
- import { MySqlDialect } from "./dbDialect";
12
9
  import { DbUtils } from "./dbUtils";
13
10
  import { Logger } from "./logger";
14
11
  import { SqlBuilder } from "./sqlBuilder";
15
12
  import { SqlCheck } from "./sqlCheck";
16
- const TABLE_COLUMNS_CACHE_TTL_SECONDS = 3600;
13
+ function quoteIdentMySql(identifier) {
14
+ if (typeof identifier !== "string") {
15
+ throw new Error(`quoteIdentifier 需要字符串类型标识符 (identifier: ${String(identifier)})`);
16
+ }
17
+ const trimmed = identifier.trim();
18
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(trimmed)) {
19
+ throw new Error(`无效的 SQL 标识符: ${trimmed}`);
20
+ }
21
+ return `\`${trimmed}\``;
22
+ }
17
23
  function hasBegin(sql) {
18
24
  return typeof sql.begin === "function";
19
25
  }
@@ -35,9 +41,10 @@ class DbSqlError extends Error {
35
41
  */
36
42
  export class DbHelper {
37
43
  redis;
38
- dialect;
44
+ dbName;
39
45
  sql = null;
40
46
  isTransaction = false;
47
+ idMode;
41
48
  /**
42
49
  * 构造函数
43
50
  * @param redis - Redis 实例
@@ -45,38 +52,37 @@ export class DbHelper {
45
52
  */
46
53
  constructor(options) {
47
54
  this.redis = options.redis;
55
+ if (typeof options.dbName !== "string" || options.dbName.trim() === "") {
56
+ throw new Error("DbHelper 初始化失败:dbName 必须为非空字符串");
57
+ }
58
+ this.dbName = options.dbName;
48
59
  this.sql = options.sql || null;
49
60
  this.isTransaction = Boolean(options.sql);
50
- // 默认使用 MySQL 方言(当前 core 的表结构/语法也主要基于 MySQL)
51
- this.dialect = options.dialect ? options.dialect : new MySqlDialect();
61
+ // 默认保持历史行为:timeId
62
+ this.idMode = options.idMode === "autoId" ? "autoId" : "timeId";
52
63
  }
53
64
  createSqlBuilder() {
54
- return new SqlBuilder({ quoteIdent: this.dialect.quoteIdent.bind(this.dialect) });
65
+ return new SqlBuilder({ quoteIdent: quoteIdentMySql });
55
66
  }
56
67
  /**
57
- * 获取表的所有字段名(Redis 缓存)
68
+ * 获取表的所有字段名
58
69
  * @param table - 表名(下划线格式)
59
70
  * @returns 字段名数组(下划线格式)
60
71
  */
61
72
  async getTableColumns(table) {
62
- // 1. 先查 Redis 缓存
63
- const cacheKey = CacheKeys.tableColumns(table);
64
- const columns = await this.redis.getObject(cacheKey);
65
- if (columns && columns.length > 0) {
66
- return columns;
67
- }
68
- // 2. 缓存未命中,查询数据库
69
- const query = this.dialect.getTableColumnsQuery(table);
70
- const execRes = await this.executeSelect(query.sql, query.params);
73
+ // 查询数据库
74
+ const quotedTable = quoteIdentMySql(table);
75
+ const execRes = await this.executeSelect(`SHOW COLUMNS FROM ${quotedTable}`, []);
71
76
  const result = execRes.data;
72
77
  if (!result || result.length === 0) {
73
78
  throw new Error(`表 ${table} 不存在或没有字段`);
74
79
  }
75
- const columnNames = this.dialect.getTableColumnsFromResult(result);
76
- // 3. 写入 Redis 缓存
77
- const cacheRes = await this.redis.setObject(cacheKey, columnNames, TABLE_COLUMNS_CACHE_TTL_SECONDS);
78
- if (cacheRes === null) {
79
- Logger.warn({ table: table, cacheKey: cacheKey, msg: "表字段缓存写入 Redis 失败" });
80
+ const columnNames = [];
81
+ for (const row of result) {
82
+ const name = row["Field"];
83
+ if (typeof name === "string" && name.length > 0) {
84
+ columnNames.push(name);
85
+ }
80
86
  }
81
87
  return columnNames;
82
88
  }
@@ -215,8 +221,7 @@ export class DbHelper {
215
221
  async tableExists(tableName) {
216
222
  // 将表名转换为下划线格式
217
223
  const snakeTableName = snakeCase(tableName);
218
- const query = this.dialect.tableExistsQuery(snakeTableName);
219
- const execRes = await this.executeSelect(query.sql, query.params);
224
+ const execRes = await this.executeSelect("SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", [snakeTableName]);
220
225
  const exists = (execRes.data?.[0]?.count || 0) > 0;
221
226
  return {
222
227
  data: exists,
@@ -510,14 +515,20 @@ export class DbHelper {
510
515
  const { table, data } = options;
511
516
  const snakeTable = snakeCase(table);
512
517
  const now = Date.now();
513
- let id;
514
- try {
515
- id = await this.redis.genTimeID();
516
- }
517
- catch (error) {
518
- throw new Error(`生成 ID 失败,Redis 可能不可用 (table: ${table})`, { cause: error });
518
+ let processed;
519
+ if (this.idMode === "autoId") {
520
+ processed = DbUtils.buildInsertRow({ idMode: "autoId", data: data, now: now });
521
+ }
522
+ else {
523
+ let id;
524
+ try {
525
+ id = await this.redis.genTimeID();
526
+ }
527
+ catch (error) {
528
+ throw new Error(`生成 ID 失败,Redis 可能不可用 (table: ${table})`, { cause: error });
529
+ }
530
+ processed = DbUtils.buildInsertRow({ idMode: "timeId", data: data, id: id, now: now });
519
531
  }
520
- const processed = DbUtils.buildInsertRow({ data: data, id: id, now: now });
521
532
  // 入口校验:保证进入 SqlBuilder 的数据无 undefined
522
533
  SqlCheck.assertNoUndefinedInRecord(processed, `insData 插入数据 (table: ${snakeTable})`);
523
534
  // 构建 SQL
@@ -528,7 +539,11 @@ export class DbHelper {
528
539
  const processedId = processed["id"];
529
540
  const processedIdNum = typeof processedId === "number" ? processedId : 0;
530
541
  const lastInsertRowidNum = toNumberFromSql(execRes.data?.lastInsertRowid);
531
- const insertedId = processedIdNum || lastInsertRowidNum || 0;
542
+ // timeId:优先返回显式写入的 id;autoId:依赖 lastInsertRowid
543
+ const insertedId = this.idMode === "autoId" ? lastInsertRowidNum || 0 : processedIdNum || lastInsertRowidNum || 0;
544
+ if (this.idMode === "autoId" && insertedId <= 0) {
545
+ throw new Error(`插入失败:autoId 模式下无法获取 lastInsertRowid (table: ${table})`);
546
+ }
532
547
  return {
533
548
  data: insertedId,
534
549
  sql: execRes.sql
@@ -556,20 +571,30 @@ export class DbHelper {
556
571
  }
557
572
  // 转换表名:小驼峰 → 下划线
558
573
  const snakeTable = snakeCase(table);
559
- // 批量生成 ID(逐个获取)
560
- const ids = [];
561
- for (let i = 0; i < dataList.length; i++) {
562
- ids.push(await this.redis.genTimeID());
563
- }
564
574
  const now = Date.now();
565
575
  // 处理所有数据(自动添加系统字段)
566
- const processedList = dataList.map((data, index) => {
567
- const id = ids[index];
568
- if (typeof id !== "number") {
569
- throw new Error(`批量插入生成 ID 失败:ids[${index}] 不是 number (table: ${snakeTable})`);
576
+ let ids = [];
577
+ let processedList;
578
+ if (this.idMode === "autoId") {
579
+ processedList = dataList.map((data) => {
580
+ return DbUtils.buildInsertRow({ idMode: "autoId", data: data, now: now });
581
+ });
582
+ }
583
+ else {
584
+ // 批量生成 ID(逐个获取)
585
+ const nextIds = [];
586
+ for (let i = 0; i < dataList.length; i++) {
587
+ nextIds.push(await this.redis.genTimeID());
570
588
  }
571
- return DbUtils.buildInsertRow({ data: data, id: id, now: now });
572
- });
589
+ ids = nextIds;
590
+ processedList = dataList.map((data, index) => {
591
+ const id = nextIds[index];
592
+ if (typeof id !== "number") {
593
+ throw new Error(`批量插入生成 ID 失败:ids[${index}] 不是 number (table: ${snakeTable})`);
594
+ }
595
+ return DbUtils.buildInsertRow({ idMode: "timeId", data: data, id: id, now: now });
596
+ });
597
+ }
573
598
  // 入口校验:保证进入 SqlBuilder 的批量数据结构一致且无 undefined
574
599
  const insertFields = SqlCheck.assertBatchInsertRowsConsistent(processedList, { table: snakeTable });
575
600
  // 构建批量插入 SQL
@@ -578,6 +603,22 @@ export class DbHelper {
578
603
  // 在事务中执行批量插入
579
604
  try {
580
605
  const execRes = await this.executeRun(sql, params);
606
+ if (this.idMode === "autoId") {
607
+ const firstId = toNumberFromSql(execRes.data?.lastInsertRowid);
608
+ if (firstId <= 0) {
609
+ throw new Error(`批量插入失败:autoId 模式下无法获取 lastInsertRowid (table: ${table})`);
610
+ }
611
+ // 说明:这里假设 auto_increment_increment = 1(默认)。
612
+ // 如需支持非 1,请在此处增加查询 @@auto_increment_increment 并调整推导规则。
613
+ const outIds = [];
614
+ for (let i = 0; i < dataList.length; i++) {
615
+ outIds.push(firstId + i);
616
+ }
617
+ return {
618
+ data: outIds,
619
+ sql: execRes.sql
620
+ };
621
+ }
581
622
  return {
582
623
  data: ids,
583
624
  sql: execRes.sql
@@ -608,7 +649,7 @@ export class DbHelper {
608
649
  table: snakeTable,
609
650
  idField: "id",
610
651
  ids: ids,
611
- quoteIdent: this.dialect.quoteIdent.bind(this.dialect)
652
+ quoteIdent: quoteIdentMySql
612
653
  });
613
654
  const execRes = await this.executeRun(query.sql, query.params);
614
655
  const changes = toNumberFromSql(execRes.data?.changes);
@@ -649,7 +690,7 @@ export class DbHelper {
649
690
  idField: "id",
650
691
  rows: processedList,
651
692
  fields: fields,
652
- quoteIdent: this.dialect.quoteIdent.bind(this.dialect),
693
+ quoteIdent: quoteIdentMySql,
653
694
  updatedAtField: "updated_at",
654
695
  updatedAtValue: now,
655
696
  stateField: "state",
@@ -766,7 +807,7 @@ export class DbHelper {
766
807
  // 使用 Bun SQL 的 begin 方法开启事务
767
808
  // begin 方法会自动处理 commit/rollback
768
809
  return await sql.begin(async (tx) => {
769
- const trans = new DbHelper({ redis: this.redis, sql: tx, dialect: this.dialect });
810
+ const trans = new DbHelper({ redis: this.redis, dbName: this.dbName, sql: tx, idMode: this.idMode });
770
811
  return await callback(trans);
771
812
  });
772
813
  }
@@ -781,29 +822,29 @@ export class DbHelper {
781
822
  * @param options.table - 表名(支持小驼峰或下划线格式,会自动转换)
782
823
  */
783
824
  async exists(options) {
784
- const prepareOptions = {
785
- table: options.table,
786
- page: 1,
787
- limit: 1
788
- };
789
- if (options.where !== undefined)
790
- prepareOptions.where = options.where;
791
- if (options.joins !== undefined)
792
- prepareOptions.joins = options.joins;
793
- const { table, where, tableQualifier } = await this.prepareQueryOptions(prepareOptions);
794
- // 使用 COUNT(1) 性能更好
795
- const builder = this.createSqlBuilder()
796
- .selectRaw("COUNT(1) as cnt")
797
- .from(table)
798
- .where(DbUtils.addDefaultStateFilter(where, tableQualifier, false))
799
- .limit(1);
825
+ if (Array.isArray(options.joins) && options.joins.length > 0) {
826
+ throw new Error("exists 不支持 joins(请使用显式 query 或拆分查询)");
827
+ }
828
+ const rawTable = typeof options.table === "string" ? options.table.trim() : "";
829
+ if (!rawTable) {
830
+ throw new Error("exists.table 不能为空");
831
+ }
832
+ if (rawTable.includes(" ")) {
833
+ throw new Error(`exists 不支持别名表写法(table: ${rawTable})`);
834
+ }
835
+ if (rawTable.includes(".")) {
836
+ throw new Error(`exists 不支持 schema.table 写法(table: ${rawTable})`);
837
+ }
838
+ const snakeTable = snakeCase(rawTable);
839
+ const cleanWhere = fieldClear(options.where || {}, { excludeValues: [null, undefined] });
840
+ const snakeWhere = DbUtils.whereKeysToSnake(cleanWhere);
841
+ const whereFiltered = DbUtils.addDefaultStateFilter(snakeWhere, snakeTable, false);
842
+ // 使用 COUNT(1) 实现:语义清晰、适配现有返回结构
843
+ const builder = this.createSqlBuilder().selectRaw("COUNT(1) as cnt").from(snakeTable).where(whereFiltered).limit(1);
800
844
  const { sql, params } = builder.toSelectSql();
801
845
  const execRes = await this.executeSelect(sql, params);
802
846
  const exists = (execRes.data?.[0]?.cnt || 0) > 0;
803
- return {
804
- data: exists,
805
- sql: execRes.sql
806
- };
847
+ return { data: exists, sql: execRes.sql };
807
848
  }
808
849
  /**
809
850
  * 查询单个字段值(带字段名验证)
@@ -811,6 +852,20 @@ export class DbHelper {
811
852
  */
812
853
  async getFieldValue(options) {
813
854
  const field = options.field;
855
+ if (Array.isArray(options.joins) && options.joins.length > 0) {
856
+ throw new Error("getFieldValue 不支持 joins(请使用 getOne/getList 并自行取字段)");
857
+ }
858
+ const rawTable = typeof options.table === "string" ? options.table.trim() : "";
859
+ if (!rawTable) {
860
+ throw new Error("getFieldValue.table 不能为空");
861
+ }
862
+ if (rawTable.includes(" ")) {
863
+ throw new Error(`getFieldValue 不支持别名表写法(table: ${rawTable})`);
864
+ }
865
+ if (rawTable.includes(".")) {
866
+ throw new Error(`getFieldValue 不支持 schema.table 写法(table: ${rawTable})`);
867
+ }
868
+ // (其余逻辑保持不变)
814
869
  // 验证字段名格式(只允许字母、数字、下划线)
815
870
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) {
816
871
  throw new Error(`无效的字段名: ${field},只允许字母、数字和下划线`);
@@ -895,8 +950,8 @@ export class DbHelper {
895
950
  const builder = this.createSqlBuilder().where(whereFiltered);
896
951
  const { sql: whereClause, params: whereParams } = builder.getWhereConditions();
897
952
  // 构建安全的 UPDATE SQL(表名和字段名使用反引号转义,已经是下划线格式)
898
- const quotedTable = this.dialect.quoteIdent(snakeTable);
899
- const quotedField = this.dialect.quoteIdent(snakeField);
953
+ const quotedTable = quoteIdentMySql(snakeTable);
954
+ const quotedField = quoteIdentMySql(snakeField);
900
955
  const sql = whereClause ? `UPDATE ${quotedTable} SET ${quotedField} = ${quotedField} + ? WHERE ${whereClause}` : `UPDATE ${quotedTable} SET ${quotedField} = ${quotedField} + ?`;
901
956
  const execRes = await this.executeRun(sql, [value, ...whereParams]);
902
957
  const changes = toNumberFromSql(execRes.data?.changes);