+
+
+
+
+
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 088807e..b7b69bc 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -66,6 +66,20 @@ const MODE_NAMES = { plan: "规划", edit: "编辑", readonly: "只读" };
*/
const MAPPING_NAMES = { path: "路径", label: "标签" };
+/**
+ * string 类型值范围的常见格式正则预设(前端选择后填入值范围输入框,后端按 /正则/ 校验)
+ * regex 为去掉首尾斜杠与修饰符的正则体
+ */
+const RANGE_PRESETS = {
+ phone: { label: "手机号", regex: "^1[3-9]\\d{9}$" },
+ email: { label: "邮箱", regex: "^[\\w.%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$" },
+ idcard: { label: "身份证", regex: "^\\d{17}[\\dXx]$" },
+ ipv4: { label: "IPv4", regex: "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$" },
+ url: { label: "URL", regex: "^https?:\\/\\/[\\w.-]+(?:\\.[a-zA-Z]{2,})?(?:\\/[^\\s]*)?$" },
+ port: { label: "端口号", regex: "^([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$" },
+ alphanumeric: { label: "字母数字", regex: "^[A-Za-z0-9]+$" },
+};
+
/**
* 应用当前模式到界面(更新样式类、按钮状态、状态栏)
* @returns {void}
@@ -197,10 +211,16 @@ function renderDimensionBar() {
return;
}
box.style.display = "";
- // 填充筛选下拉
+ // 填充筛选下拉(用 DOM 构建避免属性上下文注入)
const sel = document.getElementById(it.sel);
const values = collectDimValues(it.field);
- sel.innerHTML = '' + values.map((v) => ``).join("");
+ sel.innerHTML = '';
+ values.forEach((v) => {
+ const opt = document.createElement("option");
+ opt.value = v;
+ opt.textContent = v;
+ sel.appendChild(opt);
+ });
sel.value = state.dimFilter[it.key] || "";
// 展示映射方式
document.getElementById(it.map).textContent = `[${MAPPING_NAMES[p[it.mapping]] || "路径"}]`;
@@ -231,16 +251,28 @@ function renderConfigTable(configs, path) {
tbody.innerHTML = "";
if (!configs || configs.length === 0) {
- tbody.innerHTML = '
| 当前节点暂无配置项 |
';
+ tbody.innerHTML = '| 当前节点暂无配置项 |
';
return;
}
configs.forEach((cfg) => {
+ const exists = cfg.exists !== false;
const tr = document.createElement("tr");
+ if (!exists) tr.classList.add("row-not-exists");
+
+ const statusHtml = [];
+ if (cfg.required === true) statusHtml.push('必填');
+ statusHtml.push(
+ exists
+ ? '存在'
+ : '不存在'
+ );
+
tr.innerHTML = `
${escapeHtml(cfg.key)} |
- ${escapeHtml(String(cfg.value))} |
+ ${buildValueCell(cfg, exists)} |
${escapeHtml(cfg.type)} |
+ ${statusHtml.join("")} |
${escapeHtml(cfg.description)} |
@@ -252,6 +284,24 @@ function renderConfigTable(configs, path) {
});
}
+/**
+ * 构建表格值列 HTML:存在且值非空显示实际值;值为空但配置了默认值时回退展示默认值;不存在显示 —
+ * @param {Object} cfg - 配置项
+ * @param {boolean} exists - 是否存在
+ * @returns {string} 值列 HTML
+ */
+function buildValueCell(cfg, exists) {
+ if (!exists) return '—';
+ const value = String(cfg.value);
+ if (value.trim() !== "") return escapeHtml(value);
+ const hasDefault =
+ cfg.defaultValue !== undefined && cfg.defaultValue !== null && String(cfg.defaultValue).trim() !== "";
+ if (hasDefault) {
+ return `默认 ${escapeHtml(String(cfg.defaultValue))}`;
+ }
+ return "";
+}
+
/**
* 根据当前项目维度开关,控制配置模态框中维度字段的显示
* @returns {void}
@@ -287,16 +337,36 @@ function openConfigModal(config, readonly) {
document.getElementById("config-environment").value = preEnv;
document.getElementById("config-group").value = preGroup;
document.getElementById("config-value").value = config ? config.value : "";
+ document.getElementById("config-default-value").value =
+ config && config.defaultValue !== undefined && config.defaultValue !== null ? config.defaultValue : "";
+ // 编辑模式下提示默认值(留空则回退展示默认值)
+ document.getElementById("config-value").placeholder =
+ config && config.defaultValue !== undefined && config.defaultValue !== null && String(config.defaultValue).trim() !== ""
+ ? `留空则使用默认值:${config.defaultValue}`
+ : "配置值";
document.getElementById("config-type").value = config ? config.type : "string";
document.getElementById("config-value-range").value = config ? config.valueRange || "" : "";
document.getElementById("config-description").value = config ? config.description : "";
document.getElementById("config-purpose").value = config ? config.purpose || "" : "";
+ document.getElementById("config-required").checked = config ? config.required === true : false;
+ document.getElementById("config-exists").checked = config ? config.exists !== false : true;
+ document.getElementById("config-range-preset").value = "";
updateDimensionFields();
+ updateRangePresetVisibility();
applyFieldPermissions(readonly);
openModal("modal-config");
}
+/**
+ * 根据配置类型显示/隐藏“常见格式”正则预设下拉(仅 string 类型提供)
+ * @returns {void}
+ */
+function updateRangePresetVisibility() {
+ const isString = document.getElementById("config-type").value === "string";
+ document.getElementById("config-range-preset-group").style.display = isString ? "" : "none";
+}
+
/**
* 根据模式和是否只读查看,设置配置模态框各字段的可编辑性
* 规划模式:结构字段(类型/说明/用途/范围/键名/维度)可改,值不可改
@@ -307,16 +377,21 @@ function openConfigModal(config, readonly) {
*/
function applyFieldPermissions(readonly) {
const structFields = [
- "config-key", "config-type", "config-value-range",
+ "config-key", "config-type", "config-value-range", "config-required",
+ "config-default-value",
"config-description", "config-purpose",
"config-idc", "config-environment", "config-group",
];
const valueField = "config-value";
+ const existsField = "config-exists";
+ const presetField = "config-range-preset";
const saveBtn = document.getElementById("btn-save-config");
const disableAll = (disabled) => {
structFields.forEach((id) => (document.getElementById(id).disabled = disabled));
document.getElementById(valueField).disabled = disabled;
+ document.getElementById(existsField).disabled = disabled;
+ document.getElementById(presetField).disabled = disabled;
};
if (readonly || state.mode === "readonly") {
@@ -328,14 +403,18 @@ function applyFieldPermissions(readonly) {
saveBtn.style.display = "";
if (state.mode === "plan") {
- // 规划模式:可改结构,值不可改
+ // 规划模式:可改结构(含是否必须),值/存在性不可改
structFields.forEach((id) => (document.getElementById(id).disabled = false));
document.getElementById(valueField).disabled = true;
+ document.getElementById(existsField).disabled = true;
+ document.getElementById(presetField).disabled = false;
} else if (state.mode === "edit") {
- // 编辑模式:新增时结构可填;编辑已有项时仅值可改(键名/维度也允许调整路径)
+ // 编辑模式:新增时结构可填;编辑已有项时仅值/存在性可改(键名/维度也允许调整路径)
const isNew = !state.editingConfigId;
document.getElementById("config-type").disabled = !isNew;
document.getElementById("config-value-range").disabled = !isNew;
+ document.getElementById("config-required").disabled = !isNew;
+ document.getElementById("config-default-value").disabled = !isNew;
document.getElementById("config-description").disabled = !isNew;
document.getElementById("config-purpose").disabled = !isNew;
document.getElementById("config-key").disabled = false;
@@ -343,6 +422,8 @@ function applyFieldPermissions(readonly) {
document.getElementById("config-environment").disabled = false;
document.getElementById("config-group").disabled = false;
document.getElementById(valueField).disabled = false;
+ document.getElementById(existsField).disabled = false;
+ document.getElementById(presetField).disabled = !isNew;
}
}
@@ -359,6 +440,9 @@ function collectConfigForm() {
value: document.getElementById("config-value").value,
type: document.getElementById("config-type").value,
valueRange: document.getElementById("config-value-range").value.trim(),
+ defaultValue: document.getElementById("config-default-value").value,
+ required: document.getElementById("config-required").checked,
+ exists: document.getElementById("config-exists").checked,
description: document.getElementById("config-description").value.trim(),
purpose: document.getElementById("config-purpose").value.trim(),
};
@@ -632,6 +716,19 @@ function bindEvents() {
});
document.getElementById("btn-save-config").addEventListener("click", saveConfig);
+ // 类型切换:仅 string 类型展示“常见格式”预设
+ document.getElementById("config-type").addEventListener("change", updateRangePresetVisibility);
+
+ // 常见格式预设:选择后自动填入值范围(/正则/ 格式)
+ document.getElementById("config-range-preset").addEventListener("change", (e) => {
+ const key = e.target.value;
+ if (!key) return;
+ const preset = RANGE_PRESETS[key];
+ if (!preset) return;
+ document.getElementById("config-value-range").value = `/${preset.regex}/`;
+ toast(`已填入${preset.label}格式正则,可继续编辑`, "info");
+ });
+
// 维度筛选下拉
document.getElementById("dim-idc-select").addEventListener("change", (e) => {
state.dimFilter.idc = e.target.value;
diff --git a/src/services/configService.js b/src/services/configService.js
index 70b59fb..4f66fa7 100644
--- a/src/services/configService.js
+++ b/src/services/configService.js
@@ -6,7 +6,7 @@
*/
const { loadConfigs, saveConfigs } = require("../persistence/jsonAdapter");
const { getProjectById } = require("./projectService");
-const { validateByType, validateRange } = require("../utils/validator");
+const { validateByType, validateRange, VALID_TYPES } = require("../utils/validator");
/**
* 生成唯一 ID
@@ -106,6 +106,9 @@ function getConfigById(projectId, configId) {
* @param {string} data.group - 分组(维度启用时必填)
* @param {string} data.purpose - 用途说明(规划模式)
* @param {string} data.valueRange - 值范围(规划模式)
+ * @param {boolean} data.required - 是否必须(规划模式,编辑模式下必填校验)
+ * @param {boolean} data.exists - 是否存在(编辑模式,false 视为该配置项当前不存在)
+ * @param {any} data.defaultValue - 默认值(规划模式,编辑模式下值为空时展示回退)
* @param {string} mode - 当前模式(plan/edit/readonly)
* @returns {Object} 创建的配置项
*/
@@ -120,29 +123,6 @@ function createConfig(projectId, data, mode) {
}
const key = normalizeKey(data.key);
- if (key === "") {
- throw new Error("配置键名不能为空");
- }
-
- if (!data.description || data.description.trim() === "") {
- throw new Error("配置说明不能为空");
- }
-
- // 校验维度必填
- if (project.enableIdc && (!data.idc || data.idc.trim() === "")) {
- throw new Error("项目启用了机房维度,机房不能为空");
- }
- if (project.enableEnvironment && (!data.environment || data.environment.trim() === "")) {
- throw new Error("项目启用了环境维度,环境不能为空");
- }
- if (project.enableGroup && (!data.group || data.group.trim() === "")) {
- throw new Error("项目启用了分组维度,分组不能为空");
- }
-
- // 校验类型
- if (data.type && !["string", "number", "boolean", "json"].includes(data.type)) {
- throw new Error(`不支持的类型: ${data.type}`);
- }
const now = new Date().toISOString();
const config = {
@@ -151,9 +131,12 @@ function createConfig(projectId, data, mode) {
key,
value: data.value !== undefined ? data.value : "",
type: data.type || "string",
- description: data.description.trim(),
+ description: String(data.description ?? "").trim(),
purpose: data.purpose || "",
valueRange: data.valueRange || "",
+ required: data.required === true,
+ exists: data.exists !== false,
+ defaultValue: data.defaultValue !== undefined ? data.defaultValue : "",
idc: data.idc || "",
environment: data.environment || "",
group: data.group || "",
@@ -161,16 +144,12 @@ function createConfig(projectId, data, mode) {
updatedAt: now,
};
- // 编辑模式下校验值和范围
+ // 结构校验(键名/类型/说明/维度必填)
+ validateStructure(config, project);
+
+ // 编辑模式下校验必填、类型和范围
if (mode === "edit") {
- const typeResult = validateByType(config.value, config.type);
- if (!typeResult.valid) {
- throw new Error(typeResult.message);
- }
- const rangeResult = validateRange(config.value, config.valueRange);
- if (!rangeResult.valid) {
- throw new Error(rangeResult.message);
- }
+ validateEditValue(config);
}
const configs = loadConfigs(projectId);
@@ -183,10 +162,84 @@ function createConfig(projectId, data, mode) {
};
}
+/**
+ * 编辑模式下的值校验:必填(required + exists)、类型、范围
+ * 规则:
+ * - exists 为 false 时该配置项视为当前不存在,跳过全部值校验(允许值为空)
+ * - required 为 true 且 exists 为 true 时,值不能为空
+ * - 其余情况按类型与范围约束校验
+ * @param {Object} config - 配置项(含 value/type/valueRange/required/exists)
+ * @returns {void} 校验不通过时抛错
+ */
+function validateEditValue(config) {
+ const exists = config.exists !== false;
+ if (!exists) {
+ return;
+ }
+
+ const value = config.value;
+ // 必填判断:字符串按去空白后是否为空;非字符串(json 数组/对象、number 0、boolean false)不算空
+ const isEmpty =
+ typeof value === "string" ? value.trim() === "" : value === undefined || value === null;
+ if (config.required === true && isEmpty) {
+ throw new Error(`配置 "${config.key}" 为必填项,值不能为空`);
+ }
+
+ const typeResult = validateByType(value, config.type);
+ if (!typeResult.valid) {
+ throw new Error(typeResult.message);
+ }
+ const rangeResult = validateRange(value, config.valueRange);
+ if (!rangeResult.valid) {
+ throw new Error(rangeResult.message);
+ }
+}
+
+/**
+ * 校验配置项结构定义(键名/类型/说明/维度必填),创建与更新共用
+ * @param {Object} config - 配置项对象
+ * @param {Object} project - 项目对象
+ * @returns {void} 校验不通过时抛错
+ */
+function validateStructure(config, project) {
+ if (!config.key || config.key.trim() === "") {
+ throw new Error("配置键名不能为空");
+ }
+ if (!VALID_TYPES.includes(config.type)) {
+ throw new Error(`不支持的类型: ${config.type}`);
+ }
+ if (!config.description || config.description.trim() === "") {
+ throw new Error("配置说明不能为空");
+ }
+ if (project.enableIdc && (!config.idc || config.idc.trim() === "")) {
+ throw new Error("项目启用了机房维度,机房不能为空");
+ }
+ if (project.enableEnvironment && (!config.environment || config.environment.trim() === "")) {
+ throw new Error("项目启用了环境维度,环境不能为空");
+ }
+ if (project.enableGroup && (!config.group || config.group.trim() === "")) {
+ throw new Error("项目启用了分组维度,分组不能为空");
+ }
+ // 默认值(若填写)需符合值类型与值范围,避免编辑模式下回退展示非法数据
+ const hasDefault =
+ config.defaultValue !== undefined &&
+ config.defaultValue !== null &&
+ String(config.defaultValue).trim() !== "";
+ if (hasDefault) {
+ if (!validateByType(config.defaultValue, config.type).valid) {
+ throw new Error(`默认值不符合类型 ${config.type} 的要求`);
+ }
+ const rangeResult = validateRange(config.defaultValue, config.valueRange);
+ if (!rangeResult.valid) {
+ throw new Error(`默认值校验失败:${rangeResult.message}`);
+ }
+ }
+}
+
/**
* 更新配置项
- * 规划模式:可修改 type、description、purpose、valueRange
- * 编辑模式:可修改 value
+ * 规划模式:可修改 type、description、purpose、valueRange、required
+ * 编辑模式:可修改 value、exists
* 只读模式:不可修改
* @param {string} projectId - 项目 ID
* @param {string} configId - 配置项 ID
@@ -215,31 +268,27 @@ function updateConfig(projectId, configId, data, mode) {
if (mode === "plan") {
// 规划模式:修改结构定义
if (data.type !== undefined) config.type = data.type;
- if (data.description !== undefined) config.description = data.description;
+ if (data.description !== undefined) config.description = String(data.description ?? "").trim();
if (data.purpose !== undefined) config.purpose = data.purpose;
if (data.valueRange !== undefined) config.valueRange = data.valueRange;
+ if (data.required !== undefined) config.required = data.required === true;
+ if (data.defaultValue !== undefined) config.defaultValue = data.defaultValue;
if (data.key !== undefined) config.key = normalizeKey(data.key);
if (data.idc !== undefined) config.idc = data.idc;
if (data.environment !== undefined) config.environment = data.environment;
if (data.group !== undefined) config.group = data.group;
+ validateStructure(config, project);
} else if (mode === "edit") {
- // 编辑模式:修改值
- if (data.value !== undefined) {
- const typeResult = validateByType(data.value, config.type);
- if (!typeResult.valid) {
- throw new Error(typeResult.message);
- }
- const rangeResult = validateRange(data.value, config.valueRange);
- if (!rangeResult.valid) {
- throw new Error(rangeResult.message);
- }
- config.value = data.value;
- }
+ // 编辑模式:修改值与存在性
+ if (data.value !== undefined) config.value = data.value;
+ if (data.exists !== undefined) config.exists = data.exists !== false;
+ validateEditValue(config);
// 编辑模式下也可以修改 key 和维度(路径调整)
if (data.key !== undefined) config.key = normalizeKey(data.key);
if (data.idc !== undefined) config.idc = data.idc;
if (data.environment !== undefined) config.environment = data.environment;
if (data.group !== undefined) config.group = data.group;
+ validateStructure(config, project);
}
config.updatedAt = new Date().toISOString();
@@ -282,4 +331,6 @@ module.exports = {
deleteConfig,
buildConfigPath,
normalizeKey,
+ validateEditValue,
+ validateStructure,
};
diff --git a/src/utils/validator.js b/src/utils/validator.js
index 4748a6d..1007ac4 100644
--- a/src/utils/validator.js
+++ b/src/utils/validator.js
@@ -26,6 +26,9 @@ function validateByType(value, type) {
}
break;
case "number": {
+ if (value === null || value === undefined || String(value).trim() === "") {
+ return { valid: false, message: "值必须是有效的数字" };
+ }
const num = Number(value);
if (isNaN(num)) {
return { valid: false, message: "值必须是有效的数字" };
@@ -50,7 +53,11 @@ function validateByType(value, type) {
/**
* 校验值范围约束(仅在编辑模式下生效)
- * 支持简单的范围描述,如 "合法 IPv4 地址"、"1-100"、"枚举: A,B,C"
+ * 支持:
+ * - 正则约束(string 类型常用):"/正则/flags",如 /^1[3-9]\d{9}$/(手机号)
+ * - 区间范围:"1-100" 或 "0.0-1.0"
+ * - 枚举范围:"枚举: A,B,C"
+ * - 描述性范围(如 "合法 IPv4 地址")仅作提示,不做强制校验
* @param {any} value - 配置值
* @param {string} valueRange - 值范围描述
* @returns {{ valid: boolean, message: string }} 校验结果
@@ -62,11 +69,37 @@ function validateRange(value, valueRange) {
const range = valueRange.trim();
+ // 正则约束: "/pattern/flags",如 /^1[3-9]\d{9}$/(手机号)
+ if (range.startsWith("/")) {
+ // pattern 中未转义的 / 不允许(与 JS 正则字面量一致);flags 仅允许字母
+ const regexMatch = range.match(/^\/((?:\\.|[^\\/])*)\/([a-z]*)$/);
+ if (!regexMatch) {
+ return { valid: false, message: `正则格式无效: ${range},正确格式为 /pattern/flags` };
+ }
+ const pattern = regexMatch[1];
+ const flags = regexMatch[2] || "";
+ let re;
+ try {
+ re = new RegExp(pattern, flags);
+ } catch (e) {
+ return { valid: false, message: `正则表达式无效: /${pattern}/` };
+ }
+ // 保留 g/y 标志语义:test 前重置 lastIndex,避免状态残留
+ re.lastIndex = 0;
+ if (!re.test(String(value))) {
+ return { valid: false, message: `值不符合格式要求: /${pattern}/` };
+ }
+ return { valid: true, message: "" };
+ }
+
// 区间范围: "1-100" 或 "0.0-1.0"
const rangeMatch = range.match(/^(-?\d+(\.\d+)?)\s*-\s*(-?\d+(\.\d+)?)$/);
if (rangeMatch) {
const min = Number(rangeMatch[1]);
const max = Number(rangeMatch[3]);
+ if (min > max) {
+ return { valid: false, message: `范围下限 ${min} 大于上限 ${max}` };
+ }
const num = Number(value);
if (isNaN(num) || num < min || num > max) {
return { valid: false, message: `值必须在 ${min} 到 ${max} 之间` };
diff --git a/test/configService.test.js b/test/configService.test.js
index 24dbc1b..b9430a5 100644
--- a/test/configService.test.js
+++ b/test/configService.test.js
@@ -5,7 +5,7 @@
*/
const test = require("node:test");
const assert = require("node:assert");
-const { buildConfigPath, normalizeKey } = require("../src/services/configService");
+const { buildConfigPath, normalizeKey, validateEditValue, validateStructure } = require("../src/services/configService");
// 构造测试用项目对象的辅助函数
function makeProject(overrides) {
@@ -55,3 +55,91 @@ test("2-2 规整键:压缩连续斜杠并去除空段", () => {
test("2-3 规整键:空输入返回空字符串", () => {
assert.strictEqual(normalizeKey(" "), "");
});
+
+test("3-1 必填且存在:空值校验失败", () => {
+ const cfg = { key: "a", value: " ", type: "string", valueRange: "", required: true, exists: true };
+ assert.throws(() => validateEditValue(cfg), /必填项/);
+});
+
+test("3-2 必填且存在:非空值通过", () => {
+ const cfg = { key: "a", value: "x", type: "string", valueRange: "", required: true, exists: true };
+ assert.doesNotThrow(() => validateEditValue(cfg));
+});
+
+test("3-3 必填但不存在:允许空值", () => {
+ const cfg = { key: "a", value: "", type: "string", valueRange: "", required: true, exists: false };
+ assert.doesNotThrow(() => validateEditValue(cfg));
+});
+
+test("3-4 不存在:跳过类型与范围校验", () => {
+ const cfg = { key: "a", value: "not-number", type: "number", valueRange: "", required: false, exists: false };
+ assert.doesNotThrow(() => validateEditValue(cfg));
+});
+
+test("3-5 存在且值类型错误:校验失败", () => {
+ const cfg = { key: "a", value: "abc", type: "number", valueRange: "", required: false, exists: true };
+ assert.throws(() => validateEditValue(cfg), /有效的数字/);
+});
+
+test("3-6 存在且不符合正则范围:校验失败", () => {
+ const cfg = { key: "phone", value: "123", type: "string", valueRange: "/^1[3-9]\\d{9}$/", required: false, exists: true };
+ assert.throws(() => validateEditValue(cfg), /格式要求/);
+});
+
+test("3-7 必填且 json 值为空数组:不算空值", () => {
+ const cfg = { key: "a", value: [], type: "json", valueRange: "", required: true, exists: true };
+ assert.doesNotThrow(() => validateEditValue(cfg));
+});
+
+test("3-8 必填且值为 0 / false:不算空值", () => {
+ assert.doesNotThrow(() => validateEditValue({ key: "a", value: 0, type: "number", valueRange: "", required: true, exists: true }));
+ assert.doesNotThrow(() => validateEditValue({ key: "a", value: false, type: "boolean", valueRange: "", required: true, exists: true }));
+});
+
+test("4-1 默认值类型校验:number 类型默认值必须为数字", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z" };
+ assert.throws(() => validateStructure({ ...base, defaultValue: "abc" }, project), /默认值不符合类型/);
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "42" }, project));
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: 0 }, project));
+});
+
+test("4-2 默认值类型校验:空默认值不做校验", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z" };
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "" }, project));
+ assert.doesNotThrow(() => validateStructure({ ...base }, project));
+});
+
+test("4-3 默认值类型校验:json 类型默认值需为合法 JSON", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "json", description: "d", idc: "x", environment: "y", group: "z" };
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "{\"a\":1}" }, project));
+ assert.throws(() => validateStructure({ ...base, defaultValue: "{bad json}" }, project), /默认值不符合类型/);
+});
+
+test("4-4 默认值范围校验:默认值需符合值范围约束", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "string", description: "d", idc: "x", environment: "y", group: "z", valueRange: "/^1[3-9]\\d{9}$/" };
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "13800138000" }, project));
+ assert.throws(() => validateStructure({ ...base, defaultValue: "12345" }, project), /默认值校验失败/);
+});
+
+test("4-5 默认值范围校验:number 区间默认值", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z", valueRange: "1-100" };
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: "50" }, project));
+ assert.throws(() => validateStructure({ ...base, defaultValue: "500" }, project), /默认值校验失败/);
+});
+
+test("4-6 默认值类型校验:boolean false 为合法默认值", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "boolean", description: "d", idc: "x", environment: "y", group: "z" };
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: false }, project));
+});
+
+test("4-7 默认值判断:纯空白默认值视为未填写", () => {
+ const project = makeProject({});
+ const base = { key: "a", type: "number", description: "d", idc: "x", environment: "y", group: "z" };
+ assert.doesNotThrow(() => validateStructure({ ...base, defaultValue: " " }, project));
+});
diff --git a/test/validator.test.js b/test/validator.test.js
new file mode 100644
index 0000000..98646c2
--- /dev/null
+++ b/test/validator.test.js
@@ -0,0 +1,110 @@
+/**
+ * 配置值校验器单元测试
+ * 覆盖正则范围约束(/正则/ 格式)、既有区间/枚举约束
+ * 运行:npm test
+ */
+const test = require("node:test");
+const assert = require("node:assert");
+const { validateByType, validateRange } = require("../src/utils/validator");
+
+test("1-1 正则范围:手机号格式通过", () => {
+ const range = "/^1[3-9]\\d{9}$/";
+ assert.strictEqual(validateRange("13800138000", range).valid, true);
+});
+
+test("1-2 正则范围:手机号格式不通过", () => {
+ const range = "/^1[3-9]\\d{9}$/";
+ assert.strictEqual(validateRange("2380013800", range).valid, false);
+});
+
+test("1-3 正则范围:邮箱格式", () => {
+ const range = "/^[\\w.%+-]+@[\\w.-]+\\.[A-Za-z]{2,}$/";
+ assert.strictEqual(validateRange("user@example.com", range).valid, true);
+ assert.strictEqual(validateRange("not-an-email", range).valid, false);
+});
+
+test("1-4 正则范围:身份证(18 位)", () => {
+ const range = "/^\\d{17}[\\dXx]$/";
+ assert.strictEqual(validateRange("110101199003071234", range).valid, true);
+ assert.strictEqual(validateRange("11010119900307", range).valid, false);
+});
+
+test("1-5 正则范围:支持修饰符(i 忽略大小写)", () => {
+ const range = "/^[A-Z]+$/i";
+ assert.strictEqual(validateRange("abc", range).valid, true);
+ assert.strictEqual(validateRange("abc1", range).valid, false);
+});
+
+test("1-6 正则范围:无效正则返回错误提示", () => {
+ const res = validateRange("abc", "/[/");
+ assert.strictEqual(res.valid, false);
+ assert.ok(res.message.includes("正则表达式无效"));
+});
+
+test("1-7 正则格式:缺少结尾斜杠明确报格式错误(不静默放行)", () => {
+ const res = validateRange("abc", "/abc");
+ assert.strictEqual(res.valid, false);
+ assert.ok(res.message.includes("正则格式无效"));
+});
+
+test("1-8 正则格式:pattern 中未转义的斜杠报格式错误", () => {
+ const res = validateRange("a/b", "/a/b/i");
+ assert.strictEqual(res.valid, false);
+ assert.ok(res.message.includes("正则格式无效"));
+});
+
+test("1-9 正则格式:转义斜杠可用", () => {
+ const res = validateRange("a/b", "/a\\/b/");
+ assert.strictEqual(res.valid, true);
+});
+
+test("1-10 正则范围:g 标志 test 多次结果稳定", () => {
+ const range = "/^abc$/g";
+ assert.strictEqual(validateRange("abc", range).valid, true);
+ assert.strictEqual(validateRange("abc", range).valid, true);
+});
+
+test("1-11 空值范围不做校验", () => {
+ assert.strictEqual(validateRange("anything", "").valid, true);
+ assert.strictEqual(validateRange("anything", " ").valid, true);
+});
+
+test("1-12 正则格式:URL 预设(含转义斜杠)可正常校验", () => {
+ const range = "/^https?:\\/\\/[\\w.-]+(?:\\.[a-zA-Z]{2,})?(?:\\/[^\\s]*)?$/";
+ assert.strictEqual(validateRange("https://example.com/path", range).valid, true);
+ assert.strictEqual(validateRange("not a url", range).valid, false);
+});
+
+test("1-13 正则格式:非法 flags 报错", () => {
+ const res = validateRange("abc", "/^abc$/q");
+ assert.strictEqual(res.valid, false);
+ assert.ok(res.message.includes("正则表达式无效") || res.message.includes("正则格式无效"));
+});
+
+test("3-3 类型校验:number 拒绝 null", () => {
+ assert.strictEqual(validateByType(null, "number").valid, false);
+});
+
+test("2-1 区间范围仍生效", () => {
+ assert.strictEqual(validateRange("50", "1-100").valid, true);
+ assert.strictEqual(validateRange("150", "1-100").valid, false);
+});
+
+test("2-2 区间下限大于上限时报错", () => {
+ const res = validateRange("5", "10-1");
+ assert.strictEqual(res.valid, false);
+ assert.ok(res.message.includes("大于上限"));
+});
+
+test("2-3 枚举范围仍生效", () => {
+ assert.strictEqual(validateRange("A", "枚举: A,B,C").valid, true);
+ assert.strictEqual(validateRange("D", "枚举: A,B,C").valid, false);
+});
+
+test("3-1 类型校验:number 拒绝非数字", () => {
+ assert.strictEqual(validateByType("abc", "number").valid, false);
+});
+
+test("3-2 类型校验:number 拒绝空串(不再当作 0 放行)", () => {
+ assert.strictEqual(validateByType("", "number").valid, false);
+});
|