# -*- coding: utf-8 -*- """路由键归一与候选键派生(KBFF-v11c,2026-10-12) 背景(隐患): 知识文章列表接口原用 ``KnowledgeArticle.related_page.contains(related_page)`` ⇒ SQL ``LIKE '%%'`` **子串包含**。prop 取短键/前缀即超集命中: prop=``/maps`` 会同时命中 ``/maps/canvas`` 与 ``/maps-review``;prop=``/kpi`` 命中 ``/kpis``。 口径(本模块 = related_page 匹配口径的**唯一实现处**): 匹配 = 「归一化候选键集合的**精确等值**」∪「模板子路由(``<键>/:<占位符>``)」。 调用方只传原始路由串,**不得**自行截断/拼接/大小写变换 —— 否则会出现第二套口径。 归一化规则(``normalize_route``): ① 去首尾空白;丢弃 ``?query`` 与 ``#fragment``; ② 反斜杠 → ``/``;压缩连续 ``/``;补前导 ``/``; ③ 去末尾 ``/``(根 ``/`` 保留); ④ 统一小写(CMA 前端路由均小写定义,容忍手工录入大小写差异)。 候选键(``candidate_route_keys``)—— 带参路由兼容: 入参可能是**具体实例路径**(如 ``/maps/canvas/42``),而表内存的是**页面身份** (``/maps/canvas``)或**模板形式**(``/maps/canvas/:id``,见模型注释)。 故候选键 = 归一化完整路径 ∪ 父路径(末段为变量时)∪ 各自模板形式, 匹配走 ``related_page IN (候选键)`` —— 全程等值,无任意子串包含。 变量段判据:纯数字 / 长十六进制(≥8) / UUID。 占位符命名不统一时(他方可能写 ``:articleId``),由调用方叠加 ``route_template_child_pattern(key)`` 给出的前缀模式兜住,见该函数。 """ from __future__ import annotations import re from typing import List, Optional, Tuple #: 变量段占位符(与 app/models/knowledge_article.py 注释 ``如 /maps/canvas/:id`` 同约定) PARAM_PLACEHOLDER = ":id" _UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" ) _HEX_RE = re.compile(r"^[0-9a-f]{8,}$") _NUM_RE = re.compile(r"^\d+$") _MULTI_SLASH_RE = re.compile(r"/{2,}") def normalize_route(path: Optional[str]) -> str: """把任意路由串归一为比较键。不可归一时返回空串(调用方须按“无命中”处理)。""" if path is None: return "" s = str(path).strip() if not s: return "" # 丢弃 query / fragment(保留原有 path 语义) s = s.split("?", 1)[0].split("#", 1)[0] s = s.replace("\\", "/").strip() if not s: return "" if not s.startswith("/"): s = "/" + s s = _MULTI_SLASH_RE.sub("/", s) s = s.lower() if len(s) > 1: s = s.rstrip("/") return s or "/" def is_variable_segment(seg: str) -> bool: """段是否形如变量实例(数字 / 长十六进制 / UUID)。""" if not seg: return False if _UUID_RE.match(seg): return True if _NUM_RE.match(seg): return True return bool(_HEX_RE.match(seg)) def is_dynamic_segment(seg: str) -> bool: """段是否「动态」:变量实例(``42``)或占位符本身(``:id`` / ``:articleId``)。 用于从动态末段回推页面身份(父路径),使「传具体实例」与「传模板串」两种调用姿势 都能命中表内的页面身份键。 """ return seg.startswith(":") or is_variable_segment(seg) def _split(path: str) -> List[str]: return [p for p in path.split("/") if p] def template_route(path: str) -> str: """把变量段替换为占位符,得到模板形式(``/maps/canvas/42`` → ``/maps/canvas/:id``)。""" segs = _split(path) if not segs: return path return "/" + "/".join(PARAM_PLACEHOLDER if is_variable_segment(s) else s for s in segs) def candidate_route_keys(path: Optional[str]) -> Tuple[str, ...]: """归一化候选键集合(已排序去重)。空元组 = 不可归一 ⇒ 无命中。""" key = normalize_route(path) if not key: return () keys = {key} segs = _split(key) if segs: keys.add(template_route(key)) # 末段是动态段(变量实例 / 占位符)⇒ 补父路径(及父路径的模板形式) if is_dynamic_segment(segs[-1]) and len(segs) > 1: parent = "/" + "/".join(segs[:-1]) keys.add(parent) keys.add(template_route(parent)) return tuple(sorted(keys)) def escape_like(value: str) -> str: """转义 LIKE 元字符(配合 ``escape='\\\\'`` 使用)。""" return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") def route_template_child_pattern(key: str) -> str: """模板子路由前缀模式:``<键>/:<任意占位符>``(含更深层级)。 ``/maps/canvas`` → ``/maps/canvas/:%`` —— 只命中“把该页声明为模板”的存储值 (``/maps/canvas/:id`` / ``/maps/canvas/:articleId``), 不会命中兄弟页(``/maps-review`` 之类),也不命中更深的静态页 (``/maps/canvas/detail`` 不含前导 ``:``)。 """ return escape_like(key) + "/:%"