Files
MoviePilot/app/domain/classification/validation.py
Aqr-K bcbf79f030 fix(media): preserve recognition and rule mutation identities (#7144)
* fix(media): preserve recognition and rule mutation identities

* test: cover typed classification facts with alias policies

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 07:33:27 +08:00

926 lines
34 KiB
Python

"""
分类策略和目录路径的纯校验,汇总可发布性错误与警告。
"""
import json
import re
import unicodedata
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Final, cast
from app.domain.classification.fields import (
ALL_MEDIA_TYPES,
VALUE_TYPE_OPERATORS,
standard_field_definitions,
)
from app.schemas.category import (
ClassificationCondition,
ClassificationConditionGroup,
ClassificationConditionNode,
ClassificationFactScalar,
ClassificationFieldDefinition,
ClassificationMediaType,
ClassificationPolicy,
ClassificationRule,
ClassificationValidationIssue,
ClassificationValidationResult,
)
MAX_CATEGORY_DEPTH: Final[int] = 4
MAX_CATEGORY_SEGMENT_LENGTH: Final[int] = 64
MAX_CATEGORY_PATH_LENGTH: Final[int] = 240
MAX_CONDITION_DEPTH: Final[int] = 3
MAX_CONDITIONS_PER_RULE: Final[int] = 30
MAX_RULES: Final[int] = 1000
MAX_TOTAL_CONDITIONS: Final[int] = MAX_RULES * MAX_CONDITIONS_PER_RULE
_STABLE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
_SOURCE_ID_PATTERN = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
_WINDOWS_RESERVED_NAMES = {
"CON",
"PRN",
"AUX",
"NUL",
*(f"COM{index}" for index in range(1, 10)),
*(f"LPT{index}" for index in range(1, 10)),
}
_ILLEGAL_PATH_CHARACTERS = frozenset('<>:"/\\|?*')
@dataclass(frozen=True, slots=True)
class _CategoryPathViolation:
"""
描述分类路径中的一个结构化违规位置和消息。
:param message: 可展示给用户的分类路径违规说明
:param segment_index: 违规路径段的零基序号;整体路径错误时为 None
"""
message: str
segment_index: int | None = None
def validate_classification_category_path(
segments: Sequence[str],
) -> tuple[str, ...]:
"""
校验并冻结相对于媒体类型根目录的分类路径段。
"""
violations = _category_path_violations(segments)
if violations:
raise ValueError(violations[0].message)
return tuple(segments)
@dataclass
class _ValidationCollector:
"""
按检查顺序收集分类策略的错误和警告。
:param issues: 按检查顺序累积的错误与警告,默认从空列表开始
"""
issues: list[ClassificationValidationIssue] = field(default_factory=list)
def error(self, code: str, message: str, path: list[str | int]) -> None:
"""
追加阻止策略发布的结构化错误。
"""
self.issues.append(
ClassificationValidationIssue(
severity="error",
code=code,
message=message,
path=path,
)
)
def warning(self, code: str, message: str, path: list[str | int]) -> None:
"""
追加允许策略发布但需要用户确认的结构化警告。
"""
self.issues.append(
ClassificationValidationIssue(
severity="warning",
code=code,
message=message,
path=path,
)
)
class ClassificationPolicyValidator:
"""
媒体分类策略纯校验器。
校验器只检查策略、标准字段目录和调用方提供的扩展字段,不访问配置、插件或持久化状态。
"""
@classmethod
def validate(
cls,
policy: ClassificationPolicy,
extra_fields: Iterable[ClassificationFieldDefinition] = (),
) -> ClassificationValidationResult:
"""
校验分类策略是否满足发布约束。
:param policy: 待发布的完整分类策略
:param extra_fields: 当前已注册来源声明的扩展字段
:return: 包含 error 和 warning 的结构化校验结果
"""
collector = _ValidationCollector()
field_definitions = (*standard_field_definitions(), *tuple(extra_fields))
cls._validate_fields(field_definitions, collector)
fields = {definition.id: definition for definition in field_definitions}
categories = cls._validate_categories(policy, collector)
cls._validate_rules(policy, categories, fields, collector)
cls._validate_fallbacks(policy, categories, collector)
cls._validate_aliases(policy, fields, collector)
cls._warn_overlapping_rules(policy, collector)
return ClassificationValidationResult(
valid=not any(issue.severity == "error" for issue in collector.issues),
issues=collector.issues,
)
@classmethod
def _validate_fields(
cls,
extra_fields: Sequence[ClassificationFieldDefinition],
collector: _ValidationCollector,
) -> None:
"""
校验扩展字段的 ID、类型、操作符和来源命名空间。
"""
standard_ids = {definition.id for definition in standard_field_definitions()}
seen_ids: set[str] = set()
for index, definition in enumerate(extra_fields):
path: list[str | int] = ["extra_fields", index]
if definition.id in seen_ids:
collector.error(
"duplicate_field_id",
f"字段 ID {definition.id} 重复或覆盖了标准字段",
[*path, "id"],
)
seen_ids.add(definition.id)
if definition.value_type not in VALUE_TYPE_OPERATORS:
collector.error(
"unsupported_value_type",
f"字段 {definition.id} 使用了不支持的值类型 {definition.value_type}",
[*path, "value_type"],
)
else:
allowed = set(VALUE_TYPE_OPERATORS[definition.value_type])
unsupported = [operator for operator in definition.operators if operator not in allowed]
if unsupported:
collector.error(
"unsupported_field_operator",
f"字段 {definition.id} 声明了不兼容操作符:{', '.join(unsupported)}",
[*path, "operators"],
)
cls._validate_media_types(
definition.media_types,
[*path, "media_types"],
collector,
)
if not definition.id.startswith("extensions."):
if definition.id not in standard_ids:
collector.error(
"invalid_extension_field_id",
f"非标准字段 {definition.id} 必须位于 extensions 命名空间",
[*path, "id"],
)
continue
extension_sources = [
source for source, support in definition.source_support.items() if support == "extension"
]
if len(extension_sources) != 1:
collector.error(
"invalid_extension_source",
f"扩展字段 {definition.id} 必须声明且只能声明一个 extension 来源",
[*path, "source_support"],
)
continue
source = extension_sources[0]
prefix = f"extensions.{source}."
if not _SOURCE_ID_PATTERN.fullmatch(source) or not definition.id.startswith(prefix):
collector.error(
"extension_namespace_mismatch",
f"扩展字段 {definition.id} 必须位于 {prefix} 命名空间",
[*path, "id"],
)
suffix = definition.id[len(prefix) :]
if not suffix or any(not _SOURCE_ID_PATTERN.fullmatch(segment) for segment in suffix.split(".")):
collector.error(
"invalid_extension_field_id",
f"扩展字段 {definition.id} 的来源内字段路径无效",
[*path, "id"],
)
@classmethod
def _validate_categories(
cls,
policy: ClassificationPolicy,
collector: _ValidationCollector,
) -> dict[str, Any]:
"""
校验分类 ID、名称、路径和同媒体类型路径唯一性。
"""
categories: dict[str, Any] = {}
paths: dict[tuple[str, tuple[str, ...]], str] = {}
for index, category in enumerate(policy.categories):
path: list[str | int] = ["categories", index]
if category.id in categories:
collector.error(
"duplicate_category_id",
f"分类 ID {category.id} 重复",
[*path, "id"],
)
else:
categories[category.id] = category
if not _STABLE_ID_PATTERN.fullmatch(category.id):
collector.error(
"invalid_category_id",
f"分类 ID {category.id} 只能包含小写字母、数字、点、下划线和连字符",
[*path, "id"],
)
if not category.name.strip():
collector.error(
"empty_category_name",
"分类名称不能为空",
[*path, "name"],
)
cls._validate_category_path(category.path, path, collector)
normalized_path = tuple(_normalize_path_segment(item) for item in category.path)
path_key = (category.media_type, normalized_path)
existing = paths.get(path_key)
if normalized_path and existing:
collector.error(
"duplicate_category_path",
f"分类 {category.id} 与 {existing} 在同一媒体类型下使用了相同路径",
[*path, "path"],
)
elif normalized_path:
paths[path_key] = category.id
return categories
@classmethod
def _validate_category_path(
cls,
segments: Sequence[str],
base_path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
校验分类相对路径的层级、段长度和跨平台文件名安全性。
"""
for violation in _category_path_violations(segments):
path = [*base_path, "path"]
if violation.segment_index is not None:
path.append(violation.segment_index)
collector.error(
"invalid_category_path",
violation.message,
path,
)
@classmethod
def _validate_rules(
cls,
policy: ClassificationPolicy,
categories: Mapping[str, Any],
fields: Mapping[str, ClassificationFieldDefinition],
collector: _ValidationCollector,
) -> None:
"""
校验规则身份、目标引用、条件复杂度和字段契约。
"""
if len(policy.rules) > MAX_RULES:
collector.error(
"max_rules_exceeded",
f"策略最多允许 {MAX_RULES} 条规则",
["rules"],
)
seen_ids: set[str] = set()
total_conditions = 0
for index, rule in enumerate(policy.rules):
path: list[str | int] = ["rules", index]
if rule.id in seen_ids:
collector.error(
"duplicate_rule_id",
f"规则 ID {rule.id} 重复",
[*path, "id"],
)
seen_ids.add(rule.id)
if not _STABLE_ID_PATTERN.fullmatch(rule.id):
collector.error(
"invalid_rule_id",
f"规则 ID {rule.id} 只能包含小写字母、数字、点、下划线和连字符",
[*path, "id"],
)
if not rule.name.strip():
collector.error("empty_rule_name", "规则名称不能为空", [*path, "name"])
cls._validate_media_types(rule.media_types, [*path, "media_types"], collector)
for source_index, source in enumerate(rule.sources):
if not _SOURCE_ID_PATTERN.fullmatch(source):
collector.error(
"invalid_rule_source",
f"规则来源 {source} 不是合法数据源标识",
[*path, "sources", source_index],
)
cls._validate_target(rule, categories, path, collector)
condition_count = cls._validate_condition_node(
rule.when,
rule,
fields,
[*path, "when"],
1,
collector,
)
total_conditions += condition_count
if condition_count == 0:
collector.error(
"empty_rule_condition",
"普通规则必须至少包含一个叶子条件;兜底分类请使用 fallbacks",
[*path, "when"],
)
if condition_count > MAX_CONDITIONS_PER_RULE:
collector.error(
"max_conditions_exceeded",
f"每条规则最多允许 {MAX_CONDITIONS_PER_RULE} 个叶子条件",
[*path, "when"],
)
if total_conditions > MAX_TOTAL_CONDITIONS:
collector.error(
"too_many_policy_conditions",
f"策略最多允许 {MAX_TOTAL_CONDITIONS} 个叶子条件",
["rules"],
)
@classmethod
def _validate_target(
cls,
rule: ClassificationRule,
categories: Mapping[str, Any],
base_path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
校验分类规则和标签规则各自允许的目标结构。
"""
category_id = rule.target.category_id
if rule.kind == "label":
if category_id:
collector.error(
"label_rule_category_target",
"标签规则不能指定目标分类",
[*base_path, "target", "category_id"],
)
if not rule.target.labels:
collector.error(
"empty_label_target",
"标签规则至少需要输出一个标签",
[*base_path, "target", "labels"],
)
return
if not category_id:
collector.error(
"missing_category_target",
"分类规则必须指定目标分类 ID",
[*base_path, "target", "category_id"],
)
return
category = categories.get(category_id)
if not category:
collector.error(
"target_category_not_found",
f"规则引用了不存在的分类 {category_id}",
[*base_path, "target", "category_id"],
)
return
if not category.enabled:
collector.error(
"disabled_category_target",
f"规则引用的分类 {category_id} 已禁用",
[*base_path, "target", "category_id"],
)
unsupported_types = [media_type for media_type in rule.media_types if media_type != category.media_type]
if unsupported_types:
collector.error(
"target_media_type_mismatch",
f"规则媒体类型与目标分类 {category_id} 的媒体类型不一致",
[*base_path, "target", "category_id"],
)
@classmethod
def _validate_condition_node(
cls,
node: ClassificationConditionNode,
rule: ClassificationRule,
fields: Mapping[str, ClassificationFieldDefinition],
path: list[str | int],
depth: int,
collector: _ValidationCollector,
) -> int:
"""
递归校验条件树并返回叶子条件数量。
"""
if isinstance(node, ClassificationCondition):
cls._validate_condition(node, rule, fields, path, collector)
return 1
if depth > MAX_CONDITION_DEPTH:
collector.error(
"max_depth_exceeded",
f"条件组最多允许 {MAX_CONDITION_DEPTH} 层",
path,
)
group_name, children = _group_children(node)
count = 0
for index, child in enumerate(children):
count += cls._validate_condition_node(
child,
rule,
fields,
[*path, group_name, index] if group_name != "not" else [*path, "not"],
depth + 1,
collector,
)
return count
@classmethod
def _validate_condition(
cls,
condition: ClassificationCondition,
rule: ClassificationRule,
fields: Mapping[str, ClassificationFieldDefinition],
path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
校验叶子条件的字段、操作符、媒体类型、来源范围和值结构。
"""
definition = fields.get(condition.field)
if not definition:
collector.error(
"unknown_field",
f"条件引用了未登记字段 {condition.field}",
[*path, "field"],
)
return
if condition.operator not in definition.operators:
collector.error(
"unsupported_operator",
f"字段 {condition.field} 不支持操作符 {condition.operator}",
[*path, "operator"],
)
unsupported_types = [media_type for media_type in rule.media_types if media_type not in definition.media_types]
if unsupported_types:
collector.error(
"field_media_type_mismatch",
f"字段 {condition.field} 不适用于媒体类型:{', '.join(unsupported_types)}",
[*path, "field"],
)
cls._validate_condition_value(condition, definition, path, collector)
cls._validate_extension_scope(condition.field, rule, definition, path, collector)
cls._warn_partial_support(condition.field, rule, definition, path, collector)
cls._validate_unavailable_support(condition.field, rule, definition, path, collector)
@classmethod
def _validate_condition_value(
cls,
condition: ClassificationCondition,
definition: ClassificationFieldDefinition,
path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
按字段值类型校验条件值和范围结构。
"""
operator = condition.operator
value = condition.value
if operator in {"exists", "not_exists", "is_true", "is_false"}:
if value is not None:
collector.error(
"unexpected_condition_value",
f"操作符 {operator} 不接受条件值",
[*path, "value"],
)
return
if operator in {"in", "not_in"}:
if not _is_non_empty_scalar_list(value):
collector.error(
"empty_membership_value",
f"操作符 {operator} 需要非空标量列表",
[*path, "value"],
)
else:
members = cast(list[ClassificationFactScalar], value)
if all(_valid_scalar_type(definition.value_type, item) for item in members):
return
collector.error(
"invalid_condition_value",
f"字段 {condition.field} 的成员值必须符合 {definition.value_type} 类型",
[*path, "value"],
)
return
if operator in {"contains_any", "contains_all", "contains_none"}:
if not _is_non_empty_scalar_list(value):
collector.error(
"invalid_condition_value",
f"操作符 {operator} 需要非空字符串列表",
[*path, "value"],
)
return
members = cast(list[ClassificationFactScalar], value)
if not all(isinstance(item, str) for item in members):
collector.error(
"invalid_condition_value",
f"操作符 {operator} 需要非空字符串列表",
[*path, "value"],
)
return
if operator == "between":
if not isinstance(value, list) or len(value) != 2 or not all(_is_number(item) for item in value):
collector.error(
"invalid_condition_value",
"between 需要包含两个数字的闭区间",
[*path, "value"],
)
return
bounds = cast(list[int | float], value)
if bounds[0] > bounds[1]:
collector.error(
"invalid_between_range",
"between 的范围起点不能大于终点",
[*path, "value"],
)
return
valid = _valid_scalar_type(definition.value_type, value)
if not valid:
collector.error(
"invalid_condition_value",
f"条件值与字段 {condition.field} 的类型 {definition.value_type} 不匹配",
[*path, "value"],
)
if definition.value_type == "enum" and valid:
options = {_option_value(item) for item in definition.options}
if options and value not in options:
collector.error(
"unknown_enum_value",
f"条件值 {value} 不在字段 {condition.field} 的可选值中",
[*path, "value"],
)
@classmethod
def _validate_extension_scope(
cls,
field_id: str,
rule: ClassificationRule,
definition: ClassificationFieldDefinition,
path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
确保来源扩展字段只被其声明来源范围内的规则引用。
"""
extension_sources = [source for source, support in definition.source_support.items() if support == "extension"]
if not field_id.startswith("extensions.") or not extension_sources:
return
source = extension_sources[0]
if rule.sources and source not in rule.sources:
collector.error(
"extension_namespace_mismatch",
f"字段 {field_id} 属于来源 {source},但规则未包含该来源",
[*path, "field"],
)
elif not rule.sources:
collector.warning(
"extension_field_all_sources",
f"字段 {field_id} 只由来源 {source} 提供,其它来源下条件不会命中",
[*path, "field"],
)
@classmethod
def _warn_partial_support(
cls,
field_id: str,
rule: ClassificationRule,
definition: ClassificationFieldDefinition,
path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
提示规则范围内只提供部分事实覆盖的数据源。
"""
scoped_sources = rule.sources or list(definition.source_support)
partial_sources = [source for source in scoped_sources if definition.source_support.get(source) == "partial"]
if partial_sources:
collector.warning(
"partial_field_support",
f"字段 {field_id} 在以下来源仅部分可用:{', '.join(partial_sources)}",
[*path, "field"],
)
@classmethod
def _validate_unavailable_support(
cls,
field_id: str,
rule: ClassificationRule,
definition: ClassificationFieldDefinition,
path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
阻止规则限定到全部无法提供该字段的数据源。
"""
scoped_sources = rule.sources or list(definition.source_support)
if not scoped_sources:
return
unavailable = [
source
for source in scoped_sources
if definition.source_support.get(source, "unavailable") == "unavailable"
]
if len(unavailable) == len(scoped_sources):
collector.error(
"unavailable_field_support",
f"字段 {field_id} 在所选来源中均不可用:{', '.join(unavailable)}",
[*path, "field"],
)
@classmethod
def _validate_fallbacks(
cls,
policy: ClassificationPolicy,
categories: Mapping[str, Any],
collector: _ValidationCollector,
) -> None:
"""
确保每种媒体类型的全局兜底引用同类型的可用分类。
"""
for media_type in ALL_MEDIA_TYPES:
category_id = policy.fallbacks.get(cast(ClassificationMediaType, media_type))
path: list[str | int] = ["fallbacks", media_type]
if not category_id:
collector.error(
"missing_fallback",
f"媒体类型 {media_type} 未配置兜底分类",
path,
)
continue
category = categories.get(category_id)
if not category:
collector.error(
"unknown_fallback_category",
f"兜底分类 {category_id} 不存在",
path,
)
elif category.media_type != media_type:
collector.error(
"fallback_media_type_mismatch",
f"兜底分类 {category_id} 不属于媒体类型 {media_type}",
path,
)
elif not category.enabled:
collector.error(
"disabled_fallback_category",
f"兜底分类 {category_id} 已禁用",
path,
)
@classmethod
def _validate_aliases(
cls,
policy: ClassificationPolicy,
fields: Mapping[str, ClassificationFieldDefinition],
collector: _ValidationCollector,
) -> None:
"""
校验字段别名只引用已登记字段且不形成直接或间接循环。
"""
for field_id, aliases in policy.field_aliases.items():
path: list[str | int] = ["field_aliases", field_id]
if field_id not in fields:
collector.error(
"unknown_alias_field",
f"字段别名引用了未登记字段 {field_id}",
path,
)
for alias, canonical in aliases.items():
if not alias or not canonical:
collector.error(
"empty_field_alias",
"字段别名和规范值均不能为空",
[*path, alias],
)
elif _alias_has_cycle(alias, aliases):
collector.error(
"cyclic_field_alias",
f"字段 {field_id} 的别名 {alias} 形成循环映射",
[*path, alias],
)
@classmethod
def _validate_media_types(
cls,
media_types: Sequence[str],
path: list[str | int],
collector: _ValidationCollector,
) -> None:
"""
校验媒体类型集合非空、不重复且属于分类体系支持范围。
"""
if not media_types:
collector.error("empty_media_types", "媒体类型不能为空", path)
return
if len(set(media_types)) != len(media_types):
collector.error("duplicate_media_types", "媒体类型不能重复", path)
unsupported = [item for item in media_types if item not in ALL_MEDIA_TYPES]
if unsupported:
collector.error(
"unsupported_media_type",
f"不支持的媒体类型:{', '.join(unsupported)}",
path,
)
@classmethod
def _warn_overlapping_rules(
cls,
policy: ClassificationPolicy,
collector: _ValidationCollector,
) -> None:
"""
对条件完全相同且适用范围重叠的主分类规则给出顺序提示。
"""
seen: dict[str, ClassificationRule] = {}
for index, rule in enumerate(policy.rules):
if not rule.enabled or rule.kind != "category":
continue
fingerprint = json.dumps(
rule.when.model_dump(mode="json", by_alias=True),
ensure_ascii=False,
sort_keys=True,
)
previous = seen.get(fingerprint)
if previous and _rules_overlap(previous, rule):
collector.warning(
"overlapping_category_rules",
f"规则 {rule.id} 与 {previous.id} 条件相同,将按列表顺序首条命中",
["rules", index, "when"],
)
else:
seen[fingerprint] = rule
def _group_children(
group: ClassificationConditionGroup,
) -> tuple[str, list[ClassificationConditionNode]]:
"""
读取严格条件组中的操作符和子节点。
"""
if group.all is not None:
return "all", group.all
if group.any is not None:
return "any", group.any
if group.not_ is None:
return "not", []
return "not", [group.not_]
def _path_segment_is_illegal(segment: str) -> bool:
"""
判断路径段是否触发目录穿越、控制字符或跨平台非法文件名约束。
"""
if segment in {".", ".."} or segment != segment.strip():
return True
if segment.endswith((".", " ")):
return True
if any(character in _ILLEGAL_PATH_CHARACTERS for character in segment):
return True
if any(ord(character) < 32 for character in segment):
return True
basename = segment.split(".", 1)[0].upper()
return basename in _WINDOWS_RESERVED_NAMES
def _category_path_violations(
segments: Sequence[str],
) -> tuple[_CategoryPathViolation, ...]:
"""
返回分类路径的全部违规,供发布校验和运行时防御复用。
"""
if isinstance(segments, (str, bytes)):
return (_CategoryPathViolation("分类目录路径必须使用路径段数组"),)
values = tuple(segments)
if not values:
return (_CategoryPathViolation("分类目录路径不能为空"),)
violations: list[_CategoryPathViolation] = []
if len(values) > MAX_CATEGORY_DEPTH:
violations.append(
_CategoryPathViolation(
f"分类目录最多允许 {MAX_CATEGORY_DEPTH} 级"
)
)
for index, segment in enumerate(values):
if not isinstance(segment, str) or not segment or not segment.strip():
violations.append(_CategoryPathViolation("目录路径段不能为空", index))
continue
if len(segment) > MAX_CATEGORY_SEGMENT_LENGTH:
violations.append(
_CategoryPathViolation(
f"目录路径段最多允许 {MAX_CATEGORY_SEGMENT_LENGTH} 个字符",
index,
)
)
if _path_segment_is_illegal(segment):
violations.append(
_CategoryPathViolation(
f"目录路径段 {segment} 包含目录穿越或平台非法文件名",
index,
)
)
if all(isinstance(segment, str) for segment in values) and len(
"/".join(values)
) > MAX_CATEGORY_PATH_LENGTH:
violations.append(
_CategoryPathViolation(
f"分类目录总长度最多允许 {MAX_CATEGORY_PATH_LENGTH} 个字符"
)
)
return tuple(violations)
def _normalize_path_segment(segment: str) -> str:
"""
按 Unicode NFC 和大小写折叠构造跨平台路径唯一键。
"""
return unicodedata.normalize("NFC", segment).casefold()
def _valid_scalar_type(value_type: str, value: Any) -> bool:
"""
为标量条件与成员列表复用严格类型规则,布尔值不能冒充数字。
:param value_type: 字段目录声明的值类型
:param value: 待校验的标量值
:return: 值符合字段类型时返回 True
"""
if value_type in {"integer", "year"}:
return isinstance(value, int) and not isinstance(value, bool)
if value_type == "number":
return _is_number(value)
if value_type in {"string", "enum"}:
return isinstance(value, str)
return value is not None
def _is_number(value: Any) -> bool:
"""
判断值是否为分类规则支持的整数或浮点数,布尔值不属于数字。
"""
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _is_non_empty_scalar_list(value: Any) -> bool:
"""
判断值是否为不含 null 或嵌套对象的非空标量列表。
"""
return isinstance(value, list) and bool(value) and all(isinstance(item, (str, int, float, bool)) for item in value)
def _option_value(option: Any) -> Any:
"""
兼容简单枚举值和包含 value 字段的前端选项目录。
"""
if isinstance(option, Mapping):
return option.get("value")
if hasattr(option, "value"):
return option.value
return option
def _alias_has_cycle(alias: str, aliases: Mapping[str, str]) -> bool:
"""
检测从一个别名出发是否会回到已经访问过的别名。
"""
visited: set[str] = set()
current = alias
while current in aliases:
if current in visited:
return True
visited.add(current)
current = aliases[current]
return False
def _rules_overlap(left: ClassificationRule, right: ClassificationRule) -> bool:
"""
判断两条规则的媒体类型和来源范围是否存在交集。
"""
if not set(left.media_types).intersection(right.media_types):
return False
if not left.sources or not right.sources:
return True
return bool(set(left.sources).intersection(right.sources))