Files
dzc f55561c98d feat:上传到阿里云oss
feat:把以前的图片迁移到阿里云oss
2026-11-05 15:22:55 +08:00

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