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], }; }