feat(config): 添加YAML配置文件的JSON Schema校验功能

- 实现了YAML配置文件与JSON Schema的运行时校验能力
- 支持嵌套对象、对象数组、标量数组的递归校验
- 提供跨表引用的约束检查与引用采集功能
- 支持enum枚举值与数值范围约束验证
- 实现详细的错误诊断信息与字段路径定位
- 包含完整的异常处理与错误报告机制
This commit is contained in:
GeWuYou 2026-04-03 17:08:24 +08:00
parent 0e538738df
commit b4e026a70d
3 changed files with 97 additions and 19 deletions

View File

@ -913,7 +913,21 @@ internal static class YamlConfigSchemaValidator
{ {
case YamlConfigSchemaPropertyType.Integer: case YamlConfigSchemaPropertyType.Integer:
case YamlConfigSchemaPropertyType.Number: case YamlConfigSchemaPropertyType.Number:
var numericValue = double.Parse(normalizedValue, CultureInfo.InvariantCulture); if (!double.TryParse(
normalizedValue,
NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture,
out var numericValue))
{
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' could not be normalized into a comparable numeric value.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: rawValue);
}
if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value) if (constraints.Minimum.HasValue && numericValue < constraints.Minimum.Value)
{ {
@ -975,6 +989,16 @@ internal static class YamlConfigSchemaValidator
} }
return; return;
default:
throw ConfigLoadExceptionFactory.Create(
ConfigLoadFailureKind.UnexpectedFailure,
tableName,
$"Property '{displayPath}' in config file '{yamlPath}' resolved unsupported constraint host type '{schemaNode.NodeType}'.",
yamlPath: yamlPath,
schemaPath: schemaNode.SchemaPathHint,
displayPath: GetDiagnosticPath(displayPath),
rawValue: schemaNode.NodeType.ToString());
} }
} }
@ -1153,16 +1177,29 @@ internal static class YamlConfigSchemaValidator
return expectedType switch return expectedType switch
{ {
YamlConfigSchemaPropertyType.String => value, YamlConfigSchemaPropertyType.String => value,
YamlConfigSchemaPropertyType.Integer => long.Parse( YamlConfigSchemaPropertyType.Integer when long.TryParse(
value, value,
NumberStyles.Integer, NumberStyles.Integer,
CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture,
YamlConfigSchemaPropertyType.Number => double.Parse( out var integerValue) =>
integerValue.ToString(CultureInfo.InvariantCulture),
YamlConfigSchemaPropertyType.Number when double.TryParse(
value, value,
NumberStyles.Float | NumberStyles.AllowThousands, NumberStyles.Float | NumberStyles.AllowThousands,
CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture,
YamlConfigSchemaPropertyType.Boolean => bool.Parse(value).ToString().ToLowerInvariant(), out var numberValue) =>
_ => value numberValue.ToString(CultureInfo.InvariantCulture),
YamlConfigSchemaPropertyType.Boolean when bool.TryParse(value, out var booleanValue) =>
booleanValue.ToString().ToLowerInvariant(),
YamlConfigSchemaPropertyType.Integer =>
throw new InvalidOperationException($"Value '{value}' cannot be normalized as integer."),
YamlConfigSchemaPropertyType.Number =>
throw new InvalidOperationException($"Value '{value}' cannot be normalized as number."),
YamlConfigSchemaPropertyType.Boolean =>
throw new InvalidOperationException($"Value '{value}' cannot be normalized as boolean."),
_ =>
throw new InvalidOperationException(
$"Schema node type '{expectedType}' cannot be normalized as a scalar value.")
}; };
} }

View File

@ -505,10 +505,18 @@ function parseSchemaNode(rawNode, displayPath) {
title: metadata.title, title: metadata.title,
description: metadata.description, description: metadata.description,
defaultValue: metadata.defaultValue, defaultValue: metadata.defaultValue,
minimum: metadata.minimum, minimum: type === "integer" || type === "number"
maximum: metadata.maximum, ? metadata.minimum
minLength: metadata.minLength, : undefined,
maxLength: metadata.maxLength, maximum: type === "integer" || type === "number"
? metadata.maximum
: undefined,
minLength: type === "string"
? metadata.minLength
: undefined,
maxLength: type === "string"
? metadata.maxLength
: undefined,
enumValues: normalizeSchemaEnumValues(value.enum), enumValues: normalizeSchemaEnumValues(value.enum),
refTable: metadata.refTable refTable: metadata.refTable
}; };
@ -587,7 +595,12 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
} }
const scalarValue = unquoteScalar(yamlNode.value); const scalarValue = unquoteScalar(yamlNode.value);
if (typeof schemaNode.minimum === "number" && Number(scalarValue) < schemaNode.minimum) { const supportsNumericConstraints = schemaNode.type === "integer" || schemaNode.type === "number";
const supportsLengthConstraints = schemaNode.type === "string";
if (supportsNumericConstraints &&
typeof schemaNode.minimum === "number" &&
Number(scalarValue) < schemaNode.minimum) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.minimumViolation, localizer, { message: localizeValidationMessage(ValidationMessageKeys.minimumViolation, localizer, {
@ -597,7 +610,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}); });
} }
if (typeof schemaNode.maximum === "number" && Number(scalarValue) > schemaNode.maximum) { if (supportsNumericConstraints &&
typeof schemaNode.maximum === "number" &&
Number(scalarValue) > schemaNode.maximum) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.maximumViolation, localizer, { message: localizeValidationMessage(ValidationMessageKeys.maximumViolation, localizer, {
@ -607,7 +622,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}); });
} }
if (typeof schemaNode.minLength === "number" && scalarValue.length < schemaNode.minLength) { if (supportsLengthConstraints &&
typeof schemaNode.minLength === "number" &&
scalarValue.length < schemaNode.minLength) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.minLengthViolation, localizer, { message: localizeValidationMessage(ValidationMessageKeys.minLengthViolation, localizer, {
@ -617,7 +634,9 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer)
}); });
} }
if (typeof schemaNode.maxLength === "number" && scalarValue.length > schemaNode.maxLength) { if (supportsLengthConstraints &&
typeof schemaNode.maxLength === "number" &&
scalarValue.length > schemaNode.maxLength) {
diagnostics.push({ diagnostics.push({
severity: "error", severity: "error",
message: localizeValidationMessage(ValidationMessageKeys.maxLengthViolation, localizer, { message: localizeValidationMessage(ValidationMessageKeys.maxLengthViolation, localizer, {

View File

@ -266,6 +266,28 @@ test("parseSchemaContent should capture scalar range and length metadata", () =>
assert.equal(schema.properties.tags.items.maxLength, 6); assert.equal(schema.properties.tags.items.maxLength, 6);
}); });
test("parseSchemaContent should ignore mismatched constraint metadata on unsupported scalar types", () => {
const schema = parseSchemaContent(`
{
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"minimum": 1,
"minLength": 3
}
}
}
`);
const yaml = parseTopLevelYaml(`
enabled: true
`);
assert.equal(schema.properties.enabled.minimum, undefined);
assert.equal(schema.properties.enabled.minLength, undefined);
assert.deepEqual(validateParsedConfig(schema, yaml), []);
});
test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => { test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => {
const schema = parseSchemaContent(` const schema = parseSchemaContent(`
{ {