@kne/fastify-app-manager 0.1.8 → 0.1.10

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.
@@ -3,10 +3,12 @@ const path = require('node:path');
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('fs-extra');
5
5
  const portPool = require('../utils/portPool');
6
- const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys } = require('../utils/env');
6
+ const { mergeEnv, maskEnvForResponse, applyEnvPatch, mergePm2Config, normalizeSecretKeys, collectSecretKeys, isSystemEnvKey, omitSystemEnv } = require('../utils/env');
7
7
  const { prepareVersionArtifact } = require('../utils/version');
8
8
  const { injectEntryHtml } = require('../utils/entryInject');
9
- const { ensureLogFiles, readLogTail, readLastLines, appendLog } = require('../utils/logFiles');
9
+ const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = require('../utils/logFiles');
10
+ const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
11
+ const { toIsoInTz, convertLogText } = require('../utils/logTime');
10
12
  const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
11
13
  const { listSqlFiles } = require('../utils/validatePackage');
12
14
  const pm2Util = require('../utils/pm2');
@@ -43,6 +45,27 @@ module.exports = fp(async (fastify, options) => {
43
45
  }
44
46
  return category.code ? { code: category.code, name: category.name || category.code } : null;
45
47
  };
48
+ // 应用入口:label + 相对应用根的 path;去空、补前导 /、按 path 去重
49
+ const normalizeEntries = entries => {
50
+ const seen = new Set();
51
+ return (Array.isArray(entries) ? entries : []).reduce((result, item) => {
52
+ const label = String(item?.label || '').trim();
53
+ const rawPath = String(item?.path || '').trim();
54
+ if (!label || !rawPath) {
55
+ return result;
56
+ }
57
+ const entryPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
58
+ if (!seen.has(entryPath)) {
59
+ seen.add(entryPath);
60
+ result.push({ label, path: entryPath });
61
+ }
62
+ return result;
63
+ }, []);
64
+ };
65
+ const resolveEntries = appLike => {
66
+ const base = mountPrefix(appLike.name);
67
+ return normalizeEntries(appLike.options?.entries).map(entry => Object.assign({}, entry, { url: `${base}${entry.path}` }));
68
+ };
46
69
 
47
70
  const toPublicApp = app => {
48
71
  if (!app) {
@@ -57,6 +80,7 @@ module.exports = fp(async (fastify, options) => {
57
80
  json.pathUrl = `${mountPrefix(json.name)}/`;
58
81
  json.category = json.options?.category || null;
59
82
  json.isPublic = isAppPublic(json);
83
+ json.entries = resolveEntries(json);
60
84
  return json;
61
85
  };
62
86
 
@@ -69,7 +93,7 @@ module.exports = fp(async (fastify, options) => {
69
93
  };
70
94
 
71
95
  const create = async data => {
72
- const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
96
+ const { name, label, domain, icon, description, category, isPublic, entries, env, pm2Config, options: appOptions } = data;
73
97
  if (!name || !NAME_RE.test(name)) {
74
98
  httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
75
99
  }
@@ -109,6 +133,9 @@ module.exports = fp(async (fastify, options) => {
109
133
  if (isPublic !== undefined) {
110
134
  normalizedOptions.isPublic = !!isPublic;
111
135
  }
136
+ if (entries !== undefined) {
137
+ normalizedOptions.entries = normalizeEntries(entries);
138
+ }
112
139
 
113
140
  const app = await models.app.create({
114
141
  name,
@@ -116,7 +143,7 @@ module.exports = fp(async (fastify, options) => {
116
143
  domain: domain || null,
117
144
  icon: icon || null,
118
145
  description: description || null,
119
- env: env || {},
146
+ env: omitSystemEnv(env, options.systemEnvKeys),
120
147
  pm2Config: pm2Config || {},
121
148
  options: normalizedOptions,
122
149
  port,
@@ -128,7 +155,7 @@ module.exports = fp(async (fastify, options) => {
128
155
  return toPublicApp(app);
129
156
  };
130
157
 
131
- const save = async ({ name, category, isPublic, ...data }) => {
158
+ const save = async ({ name, category, isPublic, entries, ...data }) => {
132
159
  const app = await getByName(name);
133
160
  const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
134
161
  const patch = {};
@@ -150,7 +177,7 @@ module.exports = fp(async (fastify, options) => {
150
177
  }
151
178
  if (patch.options && patch.options.secretEnvKeys !== undefined) {
152
179
  patch.options = Object.assign({}, patch.options, {
153
- secretEnvKeys: normalizeSecretKeys(patch.options.secretEnvKeys)
180
+ secretEnvKeys: normalizeSecretKeys([...resolveSecretEnvKeys(app), ...patch.options.secretEnvKeys])
154
181
  });
155
182
  }
156
183
  if (category !== undefined) {
@@ -159,16 +186,19 @@ module.exports = fp(async (fastify, options) => {
159
186
  if (isPublic !== undefined) {
160
187
  patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
161
188
  }
189
+ if (entries !== undefined) {
190
+ patch.options = Object.assign({}, patch.options || app.options || {}, { entries: normalizeEntries(entries) });
191
+ }
162
192
  await app.update(patch);
163
193
  return toPublicApp(app);
164
194
  };
165
195
 
166
196
  const saveEnv = async ({ name, env, secretEnvKeys: nextSecretKeys } = {}) => {
167
197
  const app = await getByName(name);
168
- const currentSecrets = resolveSecretEnvKeys(app);
169
- const secretEnvKeys = nextSecretKeys !== undefined ? normalizeSecretKeys(nextSecretKeys) : currentSecrets;
198
+ // 密钥类型不可撤销,只随变量删除而移除
199
+ const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
170
200
  const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
171
- const cleanedSecrets = secretEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(nextEnv, key));
201
+ const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
172
202
  await app.update({
173
203
  env: nextEnv,
174
204
  options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
@@ -209,7 +239,8 @@ module.exports = fp(async (fastify, options) => {
209
239
  description: app.description,
210
240
  category: app.options?.category || null,
211
241
  isPublic: isAppPublic(app),
212
- pathUrl: `${mountPrefix(app.name)}/`
242
+ pathUrl: `${mountPrefix(app.name)}/`,
243
+ entries: resolveEntries(app)
213
244
  }));
214
245
  return { pageData, totalCount: pageData.length };
215
246
  };
@@ -478,6 +509,26 @@ module.exports = fp(async (fastify, options) => {
478
509
  await app.reload();
479
510
  }
480
511
 
512
+ if (typeof options.resolveSystemEnv === 'function') {
513
+ const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
514
+ if (systemEnv && typeof systemEnv === 'object') {
515
+ const persisted = {};
516
+ for (const [key, value] of Object.entries(systemEnv)) {
517
+ if (value == null) {
518
+ continue;
519
+ }
520
+ env[key] = String(value);
521
+ if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
522
+ persisted[key] = env[key];
523
+ }
524
+ }
525
+ if (Object.keys(persisted).length) {
526
+ await app.update({ env: Object.assign({}, app.env || {}, persisted) });
527
+ await app.reload();
528
+ }
529
+ }
530
+ }
531
+
481
532
  const pathBase = mountPrefix(app.name);
482
533
  // Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
483
534
  // Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
@@ -661,9 +712,17 @@ module.exports = fp(async (fastify, options) => {
661
712
  }
662
713
  }
663
714
 
715
+ const snapshot = app.toJSON();
664
716
  await models.appVersion.destroy({ where: { appName: name } });
665
717
  await fs.remove(app.rootPath).catch(() => {});
666
718
  await app.destroy();
719
+ if (typeof options.onAppRemoved === 'function') {
720
+ try {
721
+ await options.onAppRemoved({ app: snapshot });
722
+ } catch (e) {
723
+ fastify.log.warn({ err: e, appName: name }, 'onAppRemoved hook failed');
724
+ }
725
+ }
667
726
  return {
668
727
  removed: true,
669
728
  export: exportResult,
@@ -671,10 +730,103 @@ module.exports = fp(async (fastify, options) => {
671
730
  };
672
731
  };
673
732
 
674
- const logs = async ({ name, stream = 'out', perPage = 100, currentPage = 1, beforeLine } = {}) => {
733
+ const logsDirOf = app => path.join(app.rootPath, 'logs');
734
+ const currentLogFile = (app, stream) => path.join(logsDirOf(app), currentFileName(stream));
735
+
736
+ const resolveExistingLogFile = async (app, fileName) => {
737
+ let filePath;
738
+ try {
739
+ filePath = resolveLogFile(logsDirOf(app), fileName);
740
+ } catch (e) {
741
+ httpError(400, e.message);
742
+ }
743
+ if (!(await fs.pathExists(filePath))) {
744
+ httpError(404, `log file ${fileName} not found`);
745
+ }
746
+ return filePath;
747
+ };
748
+
749
+ const logs = async ({ name, stream = 'out', file, perPage = 100, currentPage = 1, beforeLine } = {}) => {
750
+ const app = await getByName(name);
751
+ const filePath = file ? await resolveExistingLogFile(app, file) : currentLogFile(app, stream);
752
+ return readLogTail(filePath, { perPage, currentPage, beforeLine, timezone: options.logTimezone });
753
+ };
754
+
755
+ const logFiles = async ({ name }) => {
675
756
  const app = await getByName(name);
676
- const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
677
- return readLogTail(file, { perPage, currentPage, beforeLine });
757
+ const pageData = (await listLogFiles(logsDirOf(app))).map(item => Object.assign({}, item, { mtime: toIsoInTz(item.mtime, options.logTimezone) }));
758
+ return { pageData, totalCount: pageData.length };
759
+ };
760
+
761
+ const resolveAppLogFile = async ({ name, file }) => {
762
+ const app = await getByName(name);
763
+ const filePath = await resolveExistingLogFile(app, file);
764
+ const { size } = await fs.stat(filePath);
765
+ return { path: filePath, fileName: file, size, compressed: file.endsWith('.gz') };
766
+ };
767
+
768
+ const resolveAppLogFiles = async ({ name, files = [] }) => {
769
+ const app = await getByName(name);
770
+ const items = [];
771
+ for (const fileName of Array.from(new Set(files))) {
772
+ const filePath = await resolveExistingLogFile(app, fileName);
773
+ const { size, mtime } = await fs.stat(filePath);
774
+ items.push({ path: filePath, fileName, size, mtime, compressed: fileName.endsWith('.gz') });
775
+ }
776
+ return { name: app.name, files: items };
777
+ };
778
+
779
+ const removeLogFiles = async ({ name, files = [] }) => {
780
+ const app = await getByName(name);
781
+ const targets = [];
782
+ for (const fileName of files) {
783
+ if (isCurrentFileName(fileName)) {
784
+ httpError(400, `cannot remove current log file ${fileName}`);
785
+ }
786
+ try {
787
+ targets.push({ fileName, filePath: resolveLogFile(logsDirOf(app), fileName) });
788
+ } catch (e) {
789
+ httpError(400, e.message);
790
+ }
791
+ }
792
+ const removed = [];
793
+ for (const { fileName, filePath } of targets) {
794
+ if (await fs.pathExists(filePath)) {
795
+ await fs.remove(filePath);
796
+ removed.push(fileName);
797
+ }
798
+ }
799
+ return { removed };
800
+ };
801
+
802
+ const getLoadStore = () => fastify[options.name].loadStore;
803
+
804
+ const load = async ({ name }) => {
805
+ const app = await getByName(name);
806
+ const store = getLoadStore();
807
+ return {
808
+ intervalMs: options.loadSampleIntervalMs > 0 ? options.loadSampleIntervalMs : 0,
809
+ current: store ? store.latest(app.name) : null,
810
+ pageData: store ? store.history(app.name) : []
811
+ };
812
+ };
813
+
814
+ const emitAppLog = async (appName, stream, content) => {
815
+ const hub = getLogHub();
816
+ if (!hub) {
817
+ return;
818
+ }
819
+ const app = await models.app.findOne({ where: { name: appName } });
820
+ if (!app) {
821
+ return;
822
+ }
823
+ hub.emit(`log:${appName}`, {
824
+ appName,
825
+ stream,
826
+ content: convertLogText(content, options.logTimezone),
827
+ line: await countLines(currentLogFile(app, stream)),
828
+ loggedAt: toIsoInTz(new Date(), options.logTimezone)
829
+ });
678
830
  };
679
831
 
680
832
  const findByHost = async (host, { running = true } = {}) => {
@@ -707,34 +859,29 @@ module.exports = fp(async (fastify, options) => {
707
859
  syncAllStatuses,
708
860
  remove,
709
861
  logs,
862
+ logFiles,
863
+ resolveAppLogFile,
864
+ resolveAppLogFiles,
865
+ removeLogFiles,
866
+ load,
867
+ getLoadStore,
710
868
  toPublicApp,
711
869
  mountPrefix,
712
870
  findByHost,
713
871
  findByPathName,
714
872
  readLastLines: async (name, stream, n) => {
715
873
  const app = await getByName(name);
716
- const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
717
- return readLastLines(file, n);
874
+ return readLastLines(currentLogFile(app, stream), n, { timezone: options.logTimezone });
718
875
  },
719
876
  getLogHub,
877
+ emitAppLog,
720
878
  appendAppLog: async (appName, stream, content) => {
721
879
  const app = await models.app.findOne({ where: { name: appName } });
722
880
  if (!app) {
723
881
  return;
724
882
  }
725
- const file = path.join(app.rootPath, 'logs', stream === 'err' ? 'err.log' : 'out.log');
726
- await appendLog(file, content);
727
- const hub = getLogHub();
728
- if (hub) {
729
- const { totalCount } = await readLogTail(file, { perPage: 1, currentPage: 1 });
730
- hub.emit(`log:${appName}`, {
731
- appName,
732
- stream,
733
- content,
734
- line: totalCount,
735
- loggedAt: new Date().toISOString()
736
- });
737
- }
883
+ await appendLog(currentLogFile(app, stream), content);
884
+ await emitAppLog(appName, stream, content);
738
885
  }
739
886
  }
740
887
  });
@@ -1,14 +1,34 @@
1
1
  const fp = require('fastify-plugin');
2
+ const path = require('node:path');
3
+ const fs = require('fs-extra');
2
4
  const { EventEmitter } = require('node:events');
3
5
  const pm2Util = require('../utils/pm2');
6
+ const { LOG_STREAMS, rotateIfNeeded, compressArchive, applyRetention } = require('../utils/logRotate');
7
+ const { summarizeProcesses, createLoadStore } = require('../utils/loadMonitor');
8
+ const { createRequestMetrics } = require('../utils/requestMetrics');
9
+ const { toIsoInTz } = require('../utils/logTime');
10
+
11
+ const OFFLINE_LOAD = { status: 'offline', cpu: 0, memory: 0, instances: 0, pids: [], uptime: 0, restarts: 0 };
4
12
 
5
13
  module.exports = fp(async (fastify, options) => {
6
14
  const logHub = new EventEmitter();
7
15
  logHub.setMaxListeners(0);
8
16
  fastify[options.name].logHub = logHub;
9
17
 
18
+ const loadStore = createLoadStore({
19
+ size: options.loadSampleIntervalMs > 0 ? Math.ceil((options.loadHistoryMinutes * 60 * 1000) / options.loadSampleIntervalMs) : 1
20
+ });
21
+ fastify[options.name].loadStore = loadStore;
22
+
23
+ const requestMetrics = createRequestMetrics({ intervalMs: options.loadSampleIntervalMs });
24
+ fastify[options.name].requestMetrics = requestMetrics;
25
+
10
26
  let bus = null;
11
27
  let connected = false;
28
+ let rotateTimer = null;
29
+ let rotating = null;
30
+ let loadTimer = null;
31
+ let sampling = null;
12
32
 
13
33
  const mapPm2NameToApp = async pm2Name => {
14
34
  const { models } = fastify[options.name];
@@ -41,7 +61,8 @@ module.exports = fp(async (fastify, options) => {
41
61
  if (!content) {
42
62
  return;
43
63
  }
44
- await fastify[options.name].services.app.appendAppLog(app.name, stream, content);
64
+ // PM2 already writes out_file/error_file; the bus only feeds SSE subscribers.
65
+ await fastify[options.name].services.app.emitAppLog(app.name, stream, content);
45
66
  } catch (e) {
46
67
  fastify.log.debug({ err: e }, 'log bus handler error');
47
68
  }
@@ -52,16 +73,33 @@ module.exports = fp(async (fastify, options) => {
52
73
  fastify.log.warn({ err: e }, 'pm2 launchBus failed');
53
74
  }
54
75
 
76
+ if (options.logRotateIntervalMs > 0) {
77
+ rotateTimer = setInterval(() => {
78
+ rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
79
+ }, options.logRotateIntervalMs);
80
+ rotateTimer.unref?.();
81
+ }
82
+
83
+ if (options.loadSampleIntervalMs > 0) {
84
+ loadTimer = setInterval(() => {
85
+ sampleLoad().catch(err => fastify.log.debug({ err }, 'load sampling failed'));
86
+ }, options.loadSampleIntervalMs);
87
+ loadTimer.unref?.();
88
+ }
89
+
55
90
  // 父应用重启后不阻塞 ready:后台按 PM2 真实状态异步对齐 DB
56
91
  setImmediate(() => {
57
92
  reconcile().catch(err => {
58
93
  fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
59
94
  });
95
+ rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
96
+ if (options.loadSampleIntervalMs > 0) {
97
+ sampleLoad().catch(err => fastify.log.debug({ err }, 'load sampling failed'));
98
+ }
60
99
  });
61
100
  };
62
101
 
63
102
  const fsEnsureAppsRoot = async () => {
64
- const fs = require('fs-extra');
65
103
  await fs.ensureDir(options.appsRoot);
66
104
  };
67
105
 
@@ -73,7 +111,126 @@ module.exports = fp(async (fastify, options) => {
73
111
  return [];
74
112
  };
75
113
 
114
+ const runRotation = async ({ now = new Date() } = {}) => {
115
+ const { models } = fastify[options.name];
116
+ const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
117
+ const targets = [];
118
+ for (const app of apps) {
119
+ const logsDir = path.join(app.rootPath, 'logs');
120
+ if (await fs.pathExists(logsDir)) {
121
+ targets.push({ name: app.name, logsDir });
122
+ }
123
+ }
124
+
125
+ const rotated = [];
126
+ for (const { name, logsDir } of targets) {
127
+ for (const stream of LOG_STREAMS) {
128
+ try {
129
+ const result = await rotateIfNeeded({
130
+ logsDir,
131
+ stream,
132
+ maxSize: options.logMaxSize,
133
+ daily: options.logRotateDaily,
134
+ timezone: options.logTimezone,
135
+ now
136
+ });
137
+ if (result) {
138
+ rotated.push(Object.assign({ name, stream }, result));
139
+ }
140
+ } catch (e) {
141
+ fastify.log.warn({ err: e, app: name, stream }, 'rotate log file failed');
142
+ }
143
+ }
144
+ }
145
+
146
+ // Until PM2 reopens its handles it keeps writing into the renamed file, so compress only afterwards.
147
+ if (rotated.length && connected) {
148
+ try {
149
+ await pm2Util.reloadLogs();
150
+ } catch (e) {
151
+ fastify.log.warn({ err: e }, 'pm2 reloadLogs failed');
152
+ }
153
+ }
154
+
155
+ if (options.logCompress) {
156
+ for (const item of rotated) {
157
+ try {
158
+ item.archive = await compressArchive(item.archive);
159
+ } catch (e) {
160
+ fastify.log.warn({ err: e, app: item.name, archive: item.archive }, 'compress log archive failed');
161
+ }
162
+ }
163
+ }
164
+
165
+ for (const { name, logsDir } of targets) {
166
+ for (const stream of LOG_STREAMS) {
167
+ try {
168
+ await applyRetention({ logsDir, stream, maxFiles: options.logRetentionMaxFiles, maxDays: options.logRetentionDays, now });
169
+ } catch (e) {
170
+ fastify.log.warn({ err: e, app: name, stream }, 'log retention failed');
171
+ }
172
+ }
173
+ }
174
+
175
+ return rotated.map(({ name, stream, archive, reason }) => ({ name, stream, fileName: path.basename(archive), reason }));
176
+ };
177
+
178
+ const rotateLogs = async opts => {
179
+ if (rotating) {
180
+ return rotating;
181
+ }
182
+ rotating = runRotation(opts).finally(() => {
183
+ rotating = null;
184
+ });
185
+ return rotating;
186
+ };
187
+
188
+ const runSampling = async ({ now = new Date() } = {}) => {
189
+ if (!connected) {
190
+ return [];
191
+ }
192
+ const { models } = fastify[options.name];
193
+ const summary = summarizeProcesses(await pm2Util.list(), { now: now.getTime() });
194
+ const apps = await models.app.findAll({ attributes: ['name', 'pm2Name'] });
195
+ const base = { ts: now.getTime(), sampledAt: toIsoInTz(now, options.logTimezone) };
196
+ const samples = apps.map(app => {
197
+ const sample = Object.assign({ name: app.name }, base, summary.get(app.pm2Name) || OFFLINE_LOAD, {
198
+ requests: requestMetrics.snapshot(app.name, { now: base.ts })
199
+ });
200
+ loadStore.push(app.name, sample);
201
+ return sample;
202
+ });
203
+ const names = apps.map(app => app.name);
204
+ loadStore.retain(names);
205
+ requestMetrics.retain(names);
206
+ return samples;
207
+ };
208
+
209
+ const sampleLoad = async opts => {
210
+ if (sampling) {
211
+ return sampling;
212
+ }
213
+ sampling = runSampling(opts).finally(() => {
214
+ sampling = null;
215
+ });
216
+ return sampling;
217
+ };
218
+
76
219
  const onClose = async () => {
220
+ if (rotateTimer) {
221
+ clearInterval(rotateTimer);
222
+ rotateTimer = null;
223
+ }
224
+ if (loadTimer) {
225
+ clearInterval(loadTimer);
226
+ loadTimer = null;
227
+ }
228
+ if (rotating) {
229
+ await rotating.catch(() => {});
230
+ }
231
+ if (sampling) {
232
+ await sampling.catch(() => {});
233
+ }
77
234
  if (bus) {
78
235
  try {
79
236
  bus.close?.();
@@ -87,6 +244,6 @@ module.exports = fp(async (fastify, options) => {
87
244
  };
88
245
 
89
246
  Object.assign(fastify[options.name].services, {
90
- bootstrap: { onReady, onClose, reconcile }
247
+ bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
91
248
  });
92
249
  });
@@ -4,7 +4,7 @@ const fs = require('fs-extra');
4
4
  const AdmZip = require('adm-zip');
5
5
  const { createSequelizeFromEnv, SQL_MIGRATIONS_TABLE } = require('../utils/migrate');
6
6
  const { resolveAppDbEnv, hasAppDbConfig } = require('../utils/env');
7
- const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix } = require('../utils/dbIdentity');
7
+ const { resolveDbScope, normalizeOwnedTables, isSystemTable, resolveTablePrefix, filterOwnedTables } = require('../utils/dbIdentity');
8
8
  const { assertReadOnlyQuerySql } = require('../utils/sqlQueryGuard');
9
9
 
10
10
  module.exports = fp(async (fastify, options) => {
@@ -83,15 +83,7 @@ module.exports = fp(async (fastify, options) => {
83
83
  return { table, columns, primaryKey };
84
84
  };
85
85
 
86
- const getOwnedTables = (app, allTables) => {
87
- const scope = resolveDbScope(app.env || {}, app.options || {});
88
- const existing = new Set(allTables);
89
- if (scope === 'dedicated') {
90
- return allTables.filter(t => !isSystemTable(t));
91
- }
92
- const owned = normalizeOwnedTables(app.options?.ownedTables);
93
- return owned.filter(t => existing.has(t));
94
- };
86
+ const getOwnedTables = (app, allTables) => filterOwnedTables({ appEnv: app.env || {}, ownedTables: app.options?.ownedTables, allTables });
95
87
 
96
88
  const assertTableOwned = (app, allTables, table) => {
97
89
  const owned = getOwnedTables(app, allTables);
@@ -1,5 +1,5 @@
1
1
  const path = require('node:path');
2
- const { hasAppDbConfig } = require('./env');
2
+ const { isAppDbEnabled } = require('./env');
3
3
 
4
4
  const SYSTEM_TABLES = new Set(['_fs_sql_migrations']);
5
5
 
@@ -69,12 +69,8 @@ const assertDefaultAppDbSeparated = (defaultAppDb, hostSequelize) => {
69
69
  }
70
70
  };
71
71
 
72
- const resolveDbScope = (appEnv = {}, options = {}) => {
73
- if (options.dbScope === 'dedicated' || options.dbScope === 'shared') {
74
- return options.dbScope;
75
- }
76
- return hasAppDbConfig(appEnv) ? 'dedicated' : 'shared';
77
- };
72
+ // 与 startProcess 注入的连接一致:只看 env(独立库配置 + DB_DEDICATED 开关)。options.dbScope 只是认领表时写入的缓存,env 变化后会过期
73
+ const resolveDbScope = (appEnv = {}) => (isAppDbEnabled(appEnv) ? 'dedicated' : 'shared');
78
74
 
79
75
  const normalizeOwnedTables = tables => [...new Set((Array.isArray(tables) ? tables : []).map(t => String(t || '').trim()).filter(Boolean))];
80
76
 
@@ -104,8 +100,21 @@ const resolveTablePrefix = (appName, appEnv = {}) => {
104
100
  return buildTablePrefix(appName);
105
101
  };
106
102
 
103
+ /**
104
+ * 共享库:只认登记过的表;独立库:库内业务表全归本应用,但显式配了 DB_TABLE_PREFIX 说明该库与其它系统共用,只认本前缀的表
105
+ */
106
+ const filterOwnedTables = ({ appEnv = {}, ownedTables, allTables = [] }) => {
107
+ if (resolveDbScope(appEnv) === 'dedicated') {
108
+ const prefix = String(appEnv.DB_TABLE_PREFIX || '').trim();
109
+ return allTables.filter(t => !isSystemTable(t) && (!prefix || String(t).startsWith(prefix)));
110
+ }
111
+ const existing = new Set(allTables);
112
+ return normalizeOwnedTables(ownedTables).filter(t => existing.has(t));
113
+ };
114
+
107
115
  module.exports = {
108
116
  SYSTEM_TABLES,
117
+ filterOwnedTables,
109
118
  fingerprintDbConfig,
110
119
  fingerprintFromEnv,
111
120
  assertDefaultAppDbSeparated,