@kne/fastify-app-manager 0.1.1 → 0.1.2

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 CHANGED
@@ -48,7 +48,7 @@ Client / Admin UI
48
48
  | Domain gateway | Host 精确匹配且状态为 `running` 时透明反代,不改写路径 |
49
49
  | Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
50
50
  | PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 落 `appsRoot/{name}/logs` |
51
- | Bootstrap | `onReady` 连接 PM2、挂 log bus、对 `running`/`deploying` 做 reconcile |
51
+ | Bootstrap | `onReady` 连接 PM2、挂 log bus;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
52
52
 
53
53
  ##### 部署时序
54
54
 
@@ -98,9 +98,14 @@ deploy(versionId)
98
98
  | 来源 | 优先级与规则 |
99
99
  |------|----------------|
100
100
  | 宿主 `passthroughEnvKeys` | 仅注入白名单键的当前值;API **不回传**宿主值,只暴露键名列表 |
101
- | 应用 `env` | 覆盖透传键;响应中匹配 `secretEnvKeyPattern` 的键显示为 `********` |
101
+ | 应用 `env` | 覆盖透传键;响应中匹配 `secretEnvKeyPattern` **或**显式 `secretEnvKeys` 的键显示为 `********` |
102
102
  | `PORT` | 始终强制为分配端口,不可被 app.env 覆盖 |
103
103
  | `save-env` patch | `null` 删除键;`********` 对 secret 键表示保持原值 |
104
+ | 显式密钥 | 存于 `options.secretEnvKeys`;`save-env` 可传 `secretEnvKeys` 将普通键标为密钥;响应顶层回传合并后的键名列表(不含明文) |
105
+ | 默认托管库 | `defaultAppDb`(默认 `appsRoot/_shared/apps-data.sqlite`),与宿主主库强制分离;应用未配 `DB_*` 时注入 |
106
+ | 应用自有库 | 应用 `env` 含完整 `DB_*` 时为独享库(`dbScope=dedicated`) |
107
+ | 共享库表前缀 | 启动时注入 `DB_TABLE_PREFIX=t_{appName}_`(应用可显式覆盖);子应用 sequelize 须读取该变量建表 |
108
+ | 表归属 | `options.ownedTables`;共享库启动就绪后按前缀自动认领,亦可 `sync-owned` 扫描;运维仅限归属表 |
104
109
 
105
110
  ##### SQL 迁移
106
111
 
@@ -111,11 +116,12 @@ deploy(versionId)
111
116
  | 特性 | 说明 |
112
117
  |------|------|
113
118
  | 版本上传 | Zip Slip 防护、条目数/体积上限、Fullstack 包格式校验、server 生产依赖安装 |
114
- | 短部署 | `deploy` 立即返回 `deploying`,后台健康检查切 `running`/`error`;可用 SSE 看日志 |
115
- | 生命周期 | `start` / `stop` / `restart` / `remove`;启动时 reconcile 丢失的 PM2 进程 |
119
+ | 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
120
+ | 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
116
121
  | 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
117
- | 日志 | 文件落盘 + 历史分页 + SSE(含回放行与心跳) |
122
+ | 日志 | 文件落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
118
123
  | 鉴权 | `createAuthenticate` 可注入;默认尝试 `fastify.account.authenticate.admin` |
124
+ | 数据运维 | 归属表浏览、行 CRUD、只读 query SQL、导出 zip、cleanup(无权限返回 SQL) |
119
125
 
120
126
  #### 使用方法
121
127
 
@@ -179,7 +185,9 @@ await fastify.listen({ port: 3000 });
179
185
  | `portMax` | number | 否 | `7999` | 端口池上限(含) |
180
186
  | `pathPrefix` | string | 否 | `'/app'` | 路径访问前缀(无尾斜杠亦可) |
181
187
  | `passthroughEnvKeys` | array | 否 | `[]` | 允许注入子进程的宿主 env 键名 |
182
- | `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 响应脱敏匹配 |
188
+ | `secretEnvKeyPattern` | RegExp | 否 | `/(SECRET\|PASSWORD\|TOKEN\|KEY\|PRIVATE)/i` | 按键名正则脱敏;与显式 `secretEnvKeys` 取并集 |
189
+ | `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
190
+ | `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
183
191
  | `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
184
192
  | `createAuthenticate` | function | 否 | 见说明 | 返回 `onRequest` 钩子数组;默认尝试 admin |
185
193
  | `healthCheckPath` | string | 否 | `'/'` | 部署后就绪探测路径 |
@@ -231,9 +239,9 @@ await fastify.listen({ port: 3000 });
231
239
  | description | string | 否 | - | 描述 |
232
240
  | env | object | 否 | `{}` | 应用自有环境变量 |
233
241
  | pm2Config | object | 否 | `{}` | PM2 覆盖项 |
234
- | options | object | 否 | `{}` | 扩展字段 |
242
+ | options | object | 否 | `{}` | 扩展字段;可含 `secretEnvKeys: string[]` 显式密钥键名 |
235
243
 
236
- 返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`status: 'idle'` 等)。
244
+ 返回脱敏后的应用对象(含 `port`、`pathUrl`、`passthroughEnvKeys`、`secretEnvKeys`、`status: 'idle'` 等)。
237
245
 
238
246
  ##### POST `{prefix}/app/save`
239
247
 
@@ -246,12 +254,49 @@ await fastify.listen({ port: 3000 });
246
254
 
247
255
  ##### POST `{prefix}/app/save-env`
248
256
 
249
- 仅合并环境变量。
257
+ 仅合并环境变量;可选更新显式密钥键列表。
250
258
 
251
259
  | 参数名 | 类型 | 必填 | 说明 |
252
260
  |--------|------|------|------|
253
261
  | name | string | 是 | 应用 slug |
254
262
  | env | object | 是 | patch:`null` 删键;secret 键值为 `********` 时保持原值 |
263
+ | secretEnvKeys | array | 否 | 显式密钥键名;传入则整体替换并写入 `options.secretEnvKeys`(仅保留仍存在于 env 的键);省略则沿用原列表 |
264
+
265
+ > **关键设计**:密钥判定 = 键名匹配 `secretEnvKeyPattern` **或** 出现在 `options.secretEnvKeys`。响应顶层 `secretEnvKeys` 为二者并集,便于管理端把普通变量标成密钥而不改键名。
266
+
267
+ ##### 数据运维与表生命周期
268
+
269
+ 解析后的应用库:应用 `env` 的 `DB_*` 优先,否则注入 `defaultAppDb`。共享库(未自配 `DB_*`)仅能操作 `options.ownedTables`。
270
+
271
+ > **关键设计**:共享库启动时注入 `DB_TABLE_PREFIX=t_{appName}_`(可用应用 env 覆盖)。子应用若使用 `@kne/fastify-sequelize@>=4.0.4`,存在该环境变量时默认 `forcePrefix`,连接上所有模型(含 account/message/tenant)表名必须以该前缀开头,无法被 `addModels({ prefix })` 覆盖。就绪后按该前缀自动认领表。
272
+
273
+ | 方法 | 路径 | 说明 |
274
+ |------|------|------|
275
+ | GET | `{prefix}/app/db/tables` | 归属表 + 列/主键摘要;`scope=all` 仅独享库 |
276
+ | POST | `{prefix}/app/db/tables/register` | 登记 `ownedTables`(`mode`: `union`/`replace`) |
277
+ | POST | `{prefix}/app/db/tables/sync-owned` | 扫描库表并登记归属(共享库优先领取本应用前缀表;无前缀命中时再领取未被其它应用占用的表) |
278
+ | POST | `{prefix}/app/db/tables/unregister` | 取消登记 |
279
+ | GET | `{prefix}/app/db/rows` | 分页行;`filter` 为 JSON(等值或 `{ $gte,$lte,$gt,$lt }` 范围);`keyword` 在字符串列上 OR LIKE;`sort` 为 JSON `[{ name, sort: ASC\|DESC }]` |
280
+ | GET | `{prefix}/app/db/row` | 按 `id` 或 `pk` JSON 查一行 |
281
+ | POST | `{prefix}/app/db/row/save` | 按主键 upsert;可选 `autoGenerate`(主键字段 → boolean)空值时用宿主 `sequelize.generateId` 雪花填充 |
282
+ | POST | `{prefix}/app/db/row/remove` | 按主键删除 |
283
+ | POST | `{prefix}/app/db/row/restore` | 按主键恢复软删除(清空 deleted_at) |
284
+ | POST | `{prefix}/app/db/query` | **只读** SQL(`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`DESCRIBE`/`PRAGMA`);共享库引用表须归属 |
285
+ | POST | `{prefix}/app/db/export` | 导出归属表为 zip(`mode=file` 仅独享 sqlite) |
286
+ | POST | `{prefix}/app/db/cleanup` | `DROP` 归属表;失败项进入 `sql[]`,`manualRequired` |
287
+
288
+ ##### POST `{prefix}/app/remove`
289
+
290
+ 删除应用。默认先导出再清理所属表;清理不完整则默认**不删**元数据。
291
+
292
+ | 参数名 | 类型 | 必填 | 默认值 | 说明 |
293
+ |--------|------|------|--------|------|
294
+ | name | string | 是 | - | 应用 slug |
295
+ | exportBeforeRemove | boolean | 否 | `true` | 删除前导出 |
296
+ | cleanupData | boolean | 否 | `true` | 删除前 cleanup |
297
+ | allowRemoveIfCleanupIncomplete | boolean | 否 | `false` | 允许在 `manualRequired` 时仍删管控侧 |
298
+ | force | boolean | 否 | `false` | 跳过导出 |
299
+ | removeSqliteFile | boolean | 否 | `false` | 独享 sqlite 清理成功后删除文件 |
255
300
 
256
301
  ##### POST `{prefix}/app/version/upload`
257
302
 
@@ -349,21 +394,23 @@ await fastify.listen({ port: 3000 });
349
394
 
350
395
  ##### GET `{prefix}/app/logs`
351
396
 
352
- 读取日志文件尾部分页(较新页在前)。
397
+ 读取日志文件尾部分页(较新页在前)。`perPage` 上限 100。停止实时后前端可用 `beforeLine` 向上滚动加载更早日志。
353
398
 
354
399
  | 参数名 | 类型 | 必填 | 默认值 | 说明 |
355
400
  |--------|------|------|--------|------|
356
401
  | name | string | 是 | - | 应用 slug |
357
402
  | stream | string | 否 | `'out'` | `out` / `err` |
358
- | perPage | number | 否 | `100` | 每页行数 |
359
- | currentPage | number | 否 | `1` | 页码(1=最新一页) |
403
+ | perPage | number | 否 | `100` | 每页行数(最大 100) |
404
+ | currentPage | number | 否 | `1` | 页码(1=最新一页;与 `beforeLine` 二选一) |
405
+ | beforeLine | number | 否 | - | 取行号 `< beforeLine` 的尾部至多 `perPage` 行(上滚历史) |
360
406
 
361
407
  返回:
362
408
 
363
409
  ```json
364
410
  {
365
411
  "pageData": [{ "line": 12, "content": "..." }],
366
- "totalCount": 12
412
+ "totalCount": 12,
413
+ "hasMore": true
367
414
  }
368
415
  ```
369
416
 
@@ -395,13 +442,16 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
395
442
  |----------|------|
396
443
  | `create(data)` | 同 create 接口 |
397
444
  | `save({ name, ... })` | 同 save |
398
- | `saveEnv({ name, env })` | 同 save-env |
445
+ | `saveEnv({ name, env, secretEnvKeys? })` | 同 save-env |
399
446
  | `list({ filter, perPage, currentPage })` | 列表 |
400
447
  | `detail({ name })` | 详情(脱敏) |
401
448
  | `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
402
449
  | `listVersions({ name, perPage, currentPage })` | 版本列表 |
403
- | `deploy({ name, versionId, version, runMigration })` | 部署 |
404
- | `start` / `stop` / `restart` / `remove` (`{ name }`) | 生命周期 |
450
+ | `deploy({ name, versionId, version, runMigration })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
451
+ | `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
452
+ | `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
453
+ | `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
454
+ | `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
405
455
  | `logs({ name, stream, perPage, currentPage })` | 读日志文件 |
406
456
  | `findByHost(host)` | 网关:按 domain + `running` 查找 |
407
457
  | `findByPathName(name)` | 网关:按 name + `running` 查找 |
@@ -410,13 +460,22 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
410
460
  | `getLogHub()` | 获取 EventEmitter |
411
461
  | `toPublicApp(app)` / `mountPrefix(name)` | 序列化辅助 |
412
462
 
463
+ ##### services.dbops
464
+
465
+ | 方法签名 | 说明 |
466
+ |----------|------|
467
+ | `listTables` / `registerTables` / `unregisterTables` | 表归属 |
468
+ | `listRows` / `getRow` / `saveRow` / `removeRow` | 行级运维 |
469
+ | `runQuery({ name, sql, replacements? })` | 只读 SQL |
470
+ | `exportData` / `cleanup` | 导出与清理 |
471
+
413
472
  ##### services.bootstrap
414
473
 
415
474
  | 方法签名 | 说明 |
416
475
  |----------|------|
417
- | `onReady()` | 连接 PM2、挂 log bus、reconcile(由插件 `onReady` 调用) |
476
+ | `onReady()` | 连接 PM2、挂 log bus,并 **setImmediate** 异步 `reconcile`(不阻塞父应用 ready) |
418
477
  | `onClose()` | 断开 bus / PM2(由插件 `onClose` 调用) |
419
- | `reconcile()` | 对 DB 中 `running`/`deploying` 但 PM2 未 online 的应用尝试 start |
478
+ | `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
420
479
 
421
480
  #### 数据模型
422
481
 
@@ -434,7 +493,7 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
434
493
  | description | string / null | 描述 |
435
494
  | env | object | 自有环境变量 |
436
495
  | pm2Config | object | PM2 覆盖 |
437
- | options | object | 扩展字段 |
496
+ | options | object | 扩展字段;`secretEnvKeys` 为显式密钥键名列表 |
438
497
  | port | number | 分配端口 |
439
498
  | status | string | `idle` / `deploying` / `running` / `stopped` / `error` |
440
499
  | currentVersionId | string / null | 当前部署版本 id |
@@ -484,3 +543,5 @@ data: {"appName":"demo","stream":"out","content":"...","line":1}
484
543
  ```
485
544
  pickHostEnv(passthroughEnvKeys) → app.env → PORT=分配端口(强制)
486
545
  ```
546
+
547
+ API 响应脱敏:`secretEnvKeyPattern` ∪ `options.secretEnvKeys`;明文密钥不回传,顶层 `secretEnvKeys` 只暴露键名。
package/index.js CHANGED
@@ -1,15 +1,18 @@
1
1
  const fp = require('fastify-plugin');
2
2
  const path = require('node:path');
3
+ const fs = require('fs-extra');
4
+ const { assertDefaultAppDbSeparated } = require('./libs/utils/dbIdentity');
3
5
 
4
6
  module.exports = fp(
5
7
  async (fastify, options) => {
8
+ const appsRoot = options.appsRoot || path.join(process.cwd(), 'managed-apps');
6
9
  options = Object.assign(
7
10
  {},
8
11
  {
9
12
  dbTableNamePrefix: 't_app_manager_',
10
13
  name: 'appManager',
11
14
  prefix: '/api/v1/app-manager',
12
- appsRoot: path.join(process.cwd(), 'managed-apps'),
15
+ appsRoot,
13
16
  portMin: 4000,
14
17
  portMax: 7999,
15
18
  pathPrefix: '/app',
@@ -27,6 +30,15 @@ module.exports = fp(
27
30
  secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
28
31
  sqlPath: 'sql',
29
32
  migrateBeforeStart: false,
33
+ defaultAppDb: {
34
+ dialect: 'sqlite',
35
+ storage: path.join(appsRoot, '_shared', 'apps-data.sqlite'),
36
+ logging: false
37
+ },
38
+ dbQueryMaxRows: 500,
39
+ dbQueryTimeoutMs: 15000,
40
+ /** 宿主 fastify-sequelize 命名连接,用于默认托管库(推荐,复用宿主 pg 等驱动) */
41
+ defaultAppDbConnection: null,
30
42
  pm2Defaults: {
31
43
  exec_mode: 'fork',
32
44
  instances: 1,
@@ -47,6 +59,22 @@ module.exports = fp(
47
59
  options
48
60
  );
49
61
 
62
+ if (!options.defaultAppDb) {
63
+ options.defaultAppDb = {
64
+ dialect: 'sqlite',
65
+ storage: path.join(options.appsRoot, '_shared', 'apps-data.sqlite'),
66
+ logging: false
67
+ };
68
+ }
69
+
70
+ if (fastify.sequelize?.instance) {
71
+ assertDefaultAppDbSeparated(options.defaultAppDb, fastify.sequelize.instance);
72
+ }
73
+
74
+ if (options.defaultAppDb?.dialect === 'sqlite' && options.defaultAppDb.storage) {
75
+ await fs.ensureDir(path.dirname(options.defaultAppDb.storage));
76
+ }
77
+
50
78
  await fastify.register(require('@fastify/multipart'), {
51
79
  limits: {
52
80
  fileSize: options.maxZipSize
@@ -66,7 +66,8 @@ module.exports = fp(async (fastify, options) => {
66
66
  required: ['name', 'env'],
67
67
  properties: {
68
68
  name: { type: 'string' },
69
- env: { type: 'object' }
69
+ env: { type: 'object' },
70
+ secretEnvKeys: { type: 'array', items: { type: 'string' } }
70
71
  }
71
72
  }
72
73
  }
@@ -183,7 +184,7 @@ module.exports = fp(async (fastify, options) => {
183
184
  async request => services.app.detail(request.query)
184
185
  );
185
186
 
186
- for (const action of ['start', 'stop', 'restart', 'remove']) {
187
+ for (const action of ['start', 'stop', 'restart']) {
187
188
  fastify.post(
188
189
  `${options.prefix}/app/${action}`,
189
190
  {
@@ -201,6 +202,29 @@ module.exports = fp(async (fastify, options) => {
201
202
  );
202
203
  }
203
204
 
205
+ fastify.post(
206
+ `${options.prefix}/app/remove`,
207
+ {
208
+ onRequest: auth(),
209
+ schema: {
210
+ summary: '删除应用(默认可先导出并清理所属表)',
211
+ body: {
212
+ type: 'object',
213
+ required: ['name'],
214
+ properties: {
215
+ name: { type: 'string' },
216
+ exportBeforeRemove: { type: 'boolean', default: true },
217
+ cleanupData: { type: 'boolean', default: true },
218
+ allowRemoveIfCleanupIncomplete: { type: 'boolean', default: false },
219
+ force: { type: 'boolean', default: false },
220
+ removeSqliteFile: { type: 'boolean', default: false }
221
+ }
222
+ }
223
+ }
224
+ },
225
+ async request => services.app.remove(request.body)
226
+ );
227
+
204
228
  fastify.get(
205
229
  `${options.prefix}/app/logs`,
206
230
  {
@@ -213,8 +237,12 @@ module.exports = fp(async (fastify, options) => {
213
237
  properties: {
214
238
  name: { type: 'string' },
215
239
  stream: { type: 'string', enum: ['out', 'err'], default: 'out' },
216
- perPage: { type: 'number', default: 100 },
217
- currentPage: { type: 'number', default: 1 }
240
+ perPage: { type: 'number', default: 100, maximum: 100 },
241
+ currentPage: { type: 'number', default: 1 },
242
+ beforeLine: {
243
+ type: 'number',
244
+ description: 'Load up to perPage lines with line number < beforeLine (for scroll-up history)'
245
+ }
218
246
  }
219
247
  }
220
248
  }
@@ -0,0 +1,268 @@
1
+ const fp = require('fastify-plugin');
2
+
3
+ module.exports = fp(async (fastify, options) => {
4
+ const { services } = fastify[options.name];
5
+ const auth = () => options.createAuthenticate();
6
+
7
+ fastify.get(
8
+ `${options.prefix}/app/db/tables`,
9
+ {
10
+ onRequest: auth(),
11
+ schema: {
12
+ summary: '列出应用所属数据表',
13
+ query: {
14
+ type: 'object',
15
+ required: ['name'],
16
+ properties: {
17
+ name: { type: 'string' },
18
+ scope: { type: 'string', enum: ['owned', 'all'] }
19
+ }
20
+ }
21
+ }
22
+ },
23
+ async request => services.dbops.listTables(request.query)
24
+ );
25
+
26
+ fastify.post(
27
+ `${options.prefix}/app/db/tables/register`,
28
+ {
29
+ onRequest: auth(),
30
+ schema: {
31
+ summary: '登记应用所属表',
32
+ body: {
33
+ type: 'object',
34
+ required: ['name', 'tables'],
35
+ properties: {
36
+ name: { type: 'string' },
37
+ tables: { type: 'array', items: { type: 'string' } },
38
+ mode: { type: 'string', enum: ['union', 'replace'] }
39
+ }
40
+ }
41
+ }
42
+ },
43
+ async request => services.dbops.registerTables(request.body)
44
+ );
45
+
46
+ fastify.post(
47
+ `${options.prefix}/app/db/tables/sync-owned`,
48
+ {
49
+ onRequest: auth(),
50
+ schema: {
51
+ summary: '扫描数据库并登记本应用可管理表(共享库仅领取未被其它应用登记的表)',
52
+ body: {
53
+ type: 'object',
54
+ required: ['name'],
55
+ properties: {
56
+ name: { type: 'string' }
57
+ }
58
+ }
59
+ }
60
+ },
61
+ async request => services.dbops.syncOwnedFromDatabase(request.body)
62
+ );
63
+
64
+ fastify.get(
65
+ `${options.prefix}/app/db/rows`,
66
+ {
67
+ onRequest: auth(),
68
+ schema: {
69
+ summary: '分页查询表数据',
70
+ query: {
71
+ type: 'object',
72
+ required: ['name', 'table'],
73
+ properties: {
74
+ name: { type: 'string' },
75
+ table: { type: 'string' },
76
+ currentPage: { type: 'number', default: 1 },
77
+ perPage: { type: 'number', default: 20 },
78
+ filter: { type: 'string', description: 'JSON object: equality or range { $gte,$lte,$gt,$lt }' },
79
+ includeDeleted: { type: 'boolean', default: false },
80
+ keyword: { type: 'string', description: 'Fuzzy search across string columns (OR LIKE)' },
81
+ sort: {
82
+ type: 'string',
83
+ description: 'JSON array of { name, sort: ASC|DESC } for ORDER BY'
84
+ }
85
+ }
86
+ }
87
+ }
88
+ },
89
+ async request => {
90
+ const query = { ...request.query };
91
+ if (typeof query.filter === 'string' && query.filter) {
92
+ try {
93
+ query.filter = JSON.parse(query.filter);
94
+ } catch (e) {
95
+ const err = new Error('filter must be valid JSON');
96
+ err.statusCode = 400;
97
+ throw err;
98
+ }
99
+ }
100
+ if (typeof query.sort === 'string' && query.sort) {
101
+ try {
102
+ query.sort = JSON.parse(query.sort);
103
+ } catch (e) {
104
+ const err = new Error('sort must be valid JSON array');
105
+ err.statusCode = 400;
106
+ throw err;
107
+ }
108
+ }
109
+ return services.dbops.listRows(query);
110
+ }
111
+ );
112
+
113
+ fastify.get(
114
+ `${options.prefix}/app/db/row`,
115
+ {
116
+ onRequest: auth(),
117
+ schema: {
118
+ summary: '按主键查询一行',
119
+ query: {
120
+ type: 'object',
121
+ required: ['name', 'table'],
122
+ properties: {
123
+ name: { type: 'string' },
124
+ table: { type: 'string' },
125
+ id: {},
126
+ pk: { type: 'string', description: 'JSON primary key object' }
127
+ }
128
+ }
129
+ }
130
+ },
131
+ async request => {
132
+ const query = { ...request.query };
133
+ if (typeof query.pk === 'string' && query.pk) {
134
+ query.pk = JSON.parse(query.pk);
135
+ }
136
+ return services.dbops.getRow(query);
137
+ }
138
+ );
139
+
140
+ fastify.post(
141
+ `${options.prefix}/app/db/row/save`,
142
+ {
143
+ onRequest: auth(),
144
+ schema: {
145
+ summary: '插入或更新一行(autoGenerate 可对主键走雪花自动生成)',
146
+ body: {
147
+ type: 'object',
148
+ required: ['name', 'table', 'data'],
149
+ properties: {
150
+ name: { type: 'string' },
151
+ table: { type: 'string' },
152
+ data: { type: 'object' },
153
+ autoGenerate: {
154
+ type: 'object',
155
+ additionalProperties: { type: 'boolean' },
156
+ description: '主键字段名 → 是否用 snowflake generateId 自动填充(仅空值时)'
157
+ }
158
+ }
159
+ }
160
+ }
161
+ },
162
+ async request => services.dbops.saveRow(request.body)
163
+ );
164
+
165
+ fastify.post(
166
+ `${options.prefix}/app/db/row/remove`,
167
+ {
168
+ onRequest: auth(),
169
+ schema: {
170
+ summary: '按主键软删除一行(默认写 deleted_at;hard=true 才物理删除)',
171
+ body: {
172
+ type: 'object',
173
+ required: ['name', 'table'],
174
+ properties: {
175
+ name: { type: 'string' },
176
+ table: { type: 'string' },
177
+ id: {},
178
+ pk: { type: 'object' },
179
+ hard: { type: 'boolean', default: false }
180
+ }
181
+ }
182
+ }
183
+ },
184
+ async request => services.dbops.removeRow(request.body)
185
+ );
186
+
187
+ fastify.post(
188
+ `${options.prefix}/app/db/row/restore`,
189
+ {
190
+ onRequest: auth(),
191
+ schema: {
192
+ summary: '按主键恢复软删除行(清空 deleted_at)',
193
+ body: {
194
+ type: 'object',
195
+ required: ['name', 'table'],
196
+ properties: {
197
+ name: { type: 'string' },
198
+ table: { type: 'string' },
199
+ id: {},
200
+ pk: { type: 'object' }
201
+ }
202
+ }
203
+ }
204
+ },
205
+ async request => services.dbops.restoreRow(request.body)
206
+ );
207
+
208
+ fastify.post(
209
+ `${options.prefix}/app/db/query`,
210
+ {
211
+ onRequest: auth(),
212
+ schema: {
213
+ summary: '执行只读查询 SQL',
214
+ body: {
215
+ type: 'object',
216
+ required: ['name', 'sql'],
217
+ properties: {
218
+ name: { type: 'string' },
219
+ sql: { type: 'string' },
220
+ replacements: { type: 'object' },
221
+ maxRows: { type: 'number' }
222
+ }
223
+ }
224
+ }
225
+ },
226
+ async request => services.dbops.runQuery(request.body)
227
+ );
228
+
229
+ fastify.post(
230
+ `${options.prefix}/app/db/export`,
231
+ {
232
+ onRequest: auth(),
233
+ schema: {
234
+ summary: '导出应用所属表数据',
235
+ body: {
236
+ type: 'object',
237
+ required: ['name'],
238
+ properties: {
239
+ name: { type: 'string' },
240
+ tables: { type: 'array', items: { type: 'string' } },
241
+ mode: { type: 'string', enum: ['json', 'file'] }
242
+ }
243
+ }
244
+ }
245
+ },
246
+ async request => services.dbops.exportData(request.body)
247
+ );
248
+
249
+ fastify.post(
250
+ `${options.prefix}/app/db/cleanup`,
251
+ {
252
+ onRequest: auth(),
253
+ schema: {
254
+ summary: '清理应用所属表(失败时返回手工 SQL)',
255
+ body: {
256
+ type: 'object',
257
+ required: ['name'],
258
+ properties: {
259
+ name: { type: 'string' },
260
+ dryRun: { type: 'boolean' },
261
+ removeSqliteFile: { type: 'boolean' }
262
+ }
263
+ }
264
+ }
265
+ },
266
+ async request => services.dbops.cleanup(request.body)
267
+ );
268
+ });