224 lines
11 KiB
JavaScript
224 lines
11 KiB
JavaScript
import { readFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { isIP } from 'node:net';
|
|
import { parseAllDocuments } from 'yaml';
|
|
|
|
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
const canonical = key => key.replace(/[-_]/g, '').toLowerCase();
|
|
const environmentName = path => path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[.-]/g, '_').toUpperCase();
|
|
|
|
function merge(target, source) {
|
|
const result = Object.assign(Object.create(null), target);
|
|
for (const [key, value] of Object.entries(source)) {
|
|
result[key] = record(value) && record(result[key]) ? merge(result[key], value) : value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function property(root, path) {
|
|
const parts = path.split('.');
|
|
let current = root;
|
|
for (let index = 0; index < parts.length; index++) {
|
|
if (!record(current)) return undefined;
|
|
const remainder = parts.slice(index).join('.');
|
|
if (own(current, remainder)) return current[remainder];
|
|
const part = parts[index];
|
|
let key = part;
|
|
if (!own(current, key)) {
|
|
const matches = Object.keys(current).filter(candidate => canonical(candidate) === canonical(part));
|
|
if (matches.length > 1) throw new Error('配置存在多个等价属性名,无法自动选择;请使用明确的迁移配置');
|
|
if (!matches.length) return undefined;
|
|
key = matches[0];
|
|
}
|
|
current = current[key];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function resolver(configuration, env) {
|
|
const raw = name => {
|
|
if (own(env, name) && env[name] !== undefined) return env[name];
|
|
const key = environmentName(name);
|
|
if (own(env, key) && env[key] !== undefined) return env[key];
|
|
return property(configuration, name);
|
|
};
|
|
const scalar = value => {
|
|
if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
|
|
throw new Error('自动配置所需字段必须为单个值,请检查环境配置');
|
|
}
|
|
return String(value);
|
|
};
|
|
function expand(value, trail = []) {
|
|
const text = scalar(value);
|
|
let output = '', cursor = 0;
|
|
while (cursor < text.length) {
|
|
const start = text.indexOf('${', cursor);
|
|
if (start < 0) return output + text.slice(cursor);
|
|
output += text.slice(cursor, start);
|
|
let depth = 1, end = start + 2;
|
|
for (; end < text.length && depth; end++) {
|
|
if (text.startsWith('${', end)) { depth++; end++; }
|
|
else if (text[end] === '}') depth--;
|
|
}
|
|
if (depth) throw new Error('自动配置所需字段包含未闭合的占位符,请检查环境配置');
|
|
const expression = text.slice(start + 2, end - 1);
|
|
let separator = -1, nested = 0;
|
|
for (let index = 0; index < expression.length; index++) {
|
|
if (expression.startsWith('${', index)) { nested++; index++; }
|
|
else if (expression[index] === '}') nested--;
|
|
else if (expression[index] === ':' && nested === 0) { separator = index; break; }
|
|
}
|
|
const name = (separator < 0 ? expression : expression.slice(0, separator)).trim();
|
|
if (!name || name.includes('${')) throw new Error('自动配置不支持动态占位符名称,请使用明确的迁移配置');
|
|
if (trail.includes(name) || trail.length >= 32) throw new Error('自动配置所需字段存在循环占位符引用,请检查环境配置');
|
|
let replacement = raw(name);
|
|
if (replacement === undefined || replacement === null) {
|
|
if (separator < 0) throw new Error('自动配置所需占位符未提供,请设置对应环境变量或使用明确的迁移配置');
|
|
replacement = expression.slice(separator + 1);
|
|
}
|
|
output += expand(replacement, [...trail, name]);
|
|
cursor = end;
|
|
}
|
|
return output;
|
|
}
|
|
return {
|
|
raw,
|
|
field(name, fallback) {
|
|
const value = raw(name);
|
|
if (value === undefined || value === null) {
|
|
if (fallback !== undefined) return expand(fallback);
|
|
throw new Error('项目配置缺少迁移所需字段,请检查主数据源配置或使用明确的迁移配置');
|
|
}
|
|
return expand(value, [name]);
|
|
},
|
|
expand,
|
|
};
|
|
}
|
|
|
|
async function yamlFile(path) {
|
|
let text;
|
|
try { text = await readFile(path, 'utf8'); }
|
|
catch { throw new Error('无法读取基础或所选环境 YAML,请检查项目路径和 --profile'); }
|
|
try {
|
|
const documents = parseAllDocuments(text, { uniqueKeys: true, intAsBigInt: true });
|
|
let result = Object.create(null);
|
|
for (const document of documents) {
|
|
if (document.errors.length || document.warnings.length) throw new Error();
|
|
const value = document.toJS({ maxAliasCount: 100 });
|
|
if (value === null) continue;
|
|
if (!record(value)) throw new Error();
|
|
if (property(value, 'spring.config.activate.on-profile') !== undefined
|
|
|| typeof property(value, 'spring.profiles') === 'string'
|
|
|| Array.isArray(property(value, 'spring.profiles'))) {
|
|
throw Object.assign(new Error('自动配置不支持带 profile 条件的 YAML 文档,请使用明确的迁移配置'), { unsupported: true });
|
|
}
|
|
result = merge(result, value);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (error.unsupported) throw error;
|
|
throw new Error('YAML 无法安全解析,请检查缩进、重复键和文档结构');
|
|
}
|
|
}
|
|
|
|
function singleProfile(value) {
|
|
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value.trim())) {
|
|
throw new Error('自动配置仅支持单个 profile,请使用 --profile dev、local 或 prod 明确选择');
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function assertSelectionSupported(configuration, profile, env) {
|
|
const resolved = resolver(configuration, env);
|
|
const imports = resolved.raw('spring.config.import');
|
|
const includes = resolved.raw('spring.profiles.include');
|
|
if (imports !== undefined && imports !== null && imports !== '' && (!Array.isArray(imports) || imports.length)) {
|
|
throw new Error('自动配置不支持 spring.config.import,请使用明确的迁移配置');
|
|
}
|
|
if (includes !== undefined && includes !== null && includes !== '' && (!Array.isArray(includes) || includes.length)) {
|
|
throw new Error('自动配置不支持附加 profiles.include,请使用明确的迁移配置');
|
|
}
|
|
const groups = property(configuration, 'spring.profiles.group');
|
|
if ((record(groups) && own(groups, profile))
|
|
|| resolved.raw('spring.profiles.group.' + profile) !== undefined) {
|
|
throw new Error('所选 profile 是配置组,无法唯一确定数据源;请使用具体环境或明确的迁移配置');
|
|
}
|
|
}
|
|
|
|
function port(value, fallbackMessage) {
|
|
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new Error(fallbackMessage);
|
|
return Number(value);
|
|
}
|
|
|
|
function mysqlAddress(value) {
|
|
if (!value.startsWith('jdbc:mysql://')) throw new Error('自动配置仅支持单主机 jdbc:mysql:// URL,请使用明确的迁移配置');
|
|
try {
|
|
const url = new URL(value.slice(5));
|
|
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
if (!host || (!isIP(host) && !/^[a-zA-Z0-9.-]+$/.test(host)) || url.username || url.password || url.hash) throw new Error();
|
|
const parts = url.pathname.split('/');
|
|
if (parts.length !== 2 || !parts[1]) throw new Error();
|
|
const database = decodeURIComponent(parts[1]);
|
|
if (!database || /[/\\\u0000]/.test(database)) throw new Error();
|
|
return { host, port: url.port ? port(url.port, '') : 3306, database };
|
|
} catch {
|
|
throw new Error('MySQL URL 必须指定单个主机、有效端口和数据库,不能含账号或多主机地址');
|
|
}
|
|
}
|
|
|
|
function route(value, allowEmpty) {
|
|
if (value === '' && allowEmpty) return '';
|
|
if (!value.startsWith('/') || /[?#\\]/.test(value)
|
|
|| value.split('/').some(part => part === '.' || part === '..')) {
|
|
throw new Error('HTTP context-path 或 API 前缀无效,请使用明确的迁移下载地址');
|
|
}
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
/**
|
|
* Read only the selected project's YAML. This deliberately supports a narrow
|
|
* Spring configuration subset and never logs credentials or parser source text.
|
|
*/
|
|
export async function loadProjectConfig({ projectRoot, profile, env = process.env }) {
|
|
if (!projectRoot || typeof projectRoot !== 'string') throw new Error('请指定后端项目根目录');
|
|
const resourceRoot = resolve(projectRoot, 'yudao-server/src/main/resources');
|
|
const baseFile = resolve(resourceRoot, 'application.yaml');
|
|
const base = await yamlFile(baseFile);
|
|
const baseResolver = resolver(base, env);
|
|
let selected;
|
|
if (profile !== undefined) selected = profile;
|
|
else if (own(env, 'SPRING_PROFILES_ACTIVE') && env.SPRING_PROFILES_ACTIVE !== undefined) selected = env.SPRING_PROFILES_ACTIVE;
|
|
else selected = baseResolver.raw('spring.profiles.active');
|
|
if (typeof selected === 'string') selected = baseResolver.expand(selected);
|
|
const selectedProfile = singleProfile(selected);
|
|
assertSelectionSupported(base, selectedProfile, env);
|
|
const selectedFile = resolve(resourceRoot, 'application-' + selectedProfile + '.yaml');
|
|
const selectedConfiguration = await yamlFile(selectedFile);
|
|
if (property(selectedConfiguration, 'spring.profiles.active') !== undefined) {
|
|
throw new Error('所选环境文件再次声明 active profile,自动选择存在歧义;请使用明确的迁移配置');
|
|
}
|
|
const configuration = merge(base, selectedConfiguration);
|
|
assertSelectionSupported(configuration, selectedProfile, env);
|
|
const selectedResolver = resolver(configuration, env);
|
|
const primary = selectedResolver.field('spring.datasource.dynamic.primary', 'master');
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(primary)) throw new Error('主数据源名称无效,请检查 dynamic.primary');
|
|
const datasource = 'spring.datasource.dynamic.datasource.' + primary + '.';
|
|
const address = mysqlAddress(selectedResolver.field(datasource + 'url'));
|
|
const user = selectedResolver.field(datasource + 'username');
|
|
const password = selectedResolver.field(datasource + 'password');
|
|
if (!user.trim() || !password.trim()) throw new Error('主数据库用户名或密码缺失,请配置凭据后重试');
|
|
if (/^\s*ENC\s*\(/i.test(password)) throw new Error('数据库密码仍为 ENC 加密内容,请通过环境变量提供已解密密码');
|
|
const serverPort = port(selectedResolver.field('server.port', '8080'), 'HTTP 端口无效,无法推导下载地址');
|
|
const context = route(selectedResolver.field('server.servlet.context-path', ''), true);
|
|
const prefix = route(selectedResolver.field('yudao.web.admin-api.prefix', '/admin-api'), false);
|
|
const ssl = selectedResolver.field('server.ssl.enabled', 'false');
|
|
if (!['true', 'false'].includes(ssl.toLowerCase())) throw new Error('HTTP SSL 配置无效,无法推导下载地址');
|
|
return {
|
|
profile: selectedProfile,
|
|
mysql: { ...address, user, password },
|
|
downloadBaseUrl: (ssl.toLowerCase() === 'true' ? 'https' : 'http') + '://127.0.0.1:' + serverPort + context + prefix + '/infra/file',
|
|
sourceFiles: [baseFile, selectedFile],
|
|
};
|
|
}
|