KBFF-v11c(派单 KBFF-v11c-relatedpage-exact-match-filter · A 侧 wecom-yanxue · 执行人 wecom-project) 本提交=代码面切片:未 push、未部署;生产部署另走发布单 + approval-gate(回滚 = git revert <本 sha>)。 - backend/app/api/knowledge_articles.py:related_page 过滤由 LIKE '%<v>%'(子串包含,短键/前缀超集命中) 收敛为「归一候选键 IN ∪ 模板子路由 <键>/:<占位符>」;不可归一入参 -> 空集(不退化为全量) - backend/app/utils/route_key.py(新增):匹配口径唯一实现处(归一化 / 候选键派生 / 模板模式) - backend/tests/test_kbff_v11c_related_page_exact_match_20261012.py(新增):42 用例 —— 生产 seed 7 值逐值回归(各中 1 行)、短键 /maps->0 行 /kpi->0 行、带参路由与尾斜杠/query/大小写兼容、 改造前 contains 语义负对照、账套分区与 category 组合语义保持 (cherry picked from commit 2a5cb182c237ae724d4b506b68eb48810ba27b16)
129 lines
5.1 KiB
Python
129 lines
5.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""路由键归一与候选键派生(KBFF-v11c,2026-10-12)
|
||
|
||
背景(隐患):
|
||
知识文章列表接口原用 ``KnowledgeArticle.related_page.contains(related_page)``
|
||
⇒ SQL ``LIKE '%<prop>%'`` **子串包含**。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) + "/:%"
|