Files
cma-management/backend/app/utils/route_key.py
T
wecom-project 5fa83e2532 fix(knowledge): related_page 过滤精确化(子串包含 -> 归一候选键等值)
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)
2026-10-18 16:31:52 +08:00

129 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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) + "/:%"