Files
group_fqcd_jr/tools/check_portal_modules.py
T
lzf_0626 ae7a89f33d 投顾工作台:修推荐结果渲染(幂等响应形状归一)+ 对齐门户版式规范
投顾页「生成推荐方案」拿不到产品的根因:该端点标了 idempotent,浏览器必带
Idempotency-Key,而后端此时返回的是 {data:{content_id, status, plan:{...}}, meta}
—— 真正的文档嵌在 data.plan 里。此前前端只处理了不带键时的裸文档形状。

- api-client:ADVISOR_RECOMMEND 撤掉 raw(带幂等键时确为信封,需正常解包);
  ADVISOR_ALLOCATION / ADVISOR_ANALYSIS 保留 raw(不标幂等,返回裸文档)
- actions-module:新增 normalizeRecommend(),把「信封 + plan 嵌套」与「裸文档」
  两种响应归一成一种形状;结果卡片显示方案编号与待审核状态
- 投顾页:hero 大图 + 4 张概览指标卡 + 左栏/主区两栏构图
- 投顾页:页头常显「退出登录」按钮;新增风评预警数、快捷问句按钮
- 投顾页:动作名统一为「生成推荐方案」(对齐软件需求文档 5.4 ⑦)
- 管理台「待审投顾内容」空态文案同步改名
- 前端模块相对 import 加 ?v= 版本号:改文件内容而 URL 不变会被浏览器缓存挡住
2026-09-14 22:17:39 +08:00

252 lines
10 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.
"""验证投顾工作台的前端模块:语法正确、import 能解析、用到的符号有来源。
## 为什么要这个检查
2026-09-13 合并组员提交后发现:`dashboard.js` 被拆成三个模块后,
**`CONTENT_TYPE_LABELS` 的定义搬走了、import 却忘了加** —— 文件里还在用这个常量。
这类问题单元测试抓不到(前端 js 不过 Python 的导入检查),
只会在浏览器里以 `ReferenceError: CONTENT_TYPE_LABELS is not defined` 的形式爆出来,
表现为「投顾工作台打开是白板」,而且**测试全绿**。
所以这里做四件事:
1. 每个文件交给 `node --check` 按 ES module 解析(语法错当场暴露);
2. 每个相对 import 的目标文件必须存在;
3. 每个 import 进来的名字,在目标文件里必须真的有 `export`;
4. 文件里用到的全大写常量(`FOO_BAR` 形态、且不是 `x.FOO_BAR` 属性访问),
必须在「import 进来的 + 本文件声明的 + 已知全局」里 —— 第 4 条正是抓上面那个 bug 的。
## 第 4 条的取词范围(`code_only()`)
只保留**真代码**:注释、`'…'` / `"…"` 字符串、以及模板字符串里的**文字部分**都被清空,
唯独 `${…}` 里的表达式保留下来(那里是真代码,也正是原 bug 容易藏身的地方)。
早期版本只剔除引号字符串,结果注释里的 `HHI`、文案里的 `AUM`、模板串里的 `QDII`
全被当成"未声明的变量" —— 误报比真问题还多,检查会被当成摆设跳过。
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
PORTAL = PROJECT_ROOT / "app" / "static" / "portal"
DASHBOARD_DIR = PORTAL / "employee-advisor" / "dashboard"
MODULES = [
"dashboard.js",
"actions-module.js",
"published-module.js",
"assistant-module.js",
"customer-module.js",
"advisor-engine.js",
"advisor-config.js",
]
#: 浏览器与运行时天然存在的名字,不算"没来源"。
KNOWN_GLOBALS = {
"JSON", "Math", "Number", "String", "Boolean", "Object", "Array", "Promise",
"Date", "Error", "Map", "Set", "RegExp", "Symbol", "URL", "URLSearchParams",
"NaN", "Infinity", "undefined", "null", "true", "false", "console", "window",
"document", "localStorage", "sessionStorage", "fetch", "setTimeout",
"clearTimeout", "setInterval", "clearInterval", "requestAnimationFrame",
"HTMLElement", "Node", "Event", "CustomEvent", "AbortController",
}
IMPORT_RE = re.compile(
r"import\s+(?:(?P<names>\{[^}]*\})|(?P<default>[\w$]+))\s+from\s+['\"](?P<src>[^'\"]+)['\"]"
)
EXPORT_RE = re.compile(
r"export\s+(?:const|let|var|function|class)\s+(?P<name>[\w$]+)"
r"|export\s*\{(?P<list>[^}]*)\}"
)
#: 全大写常量:至少两个字符、全大写/数字/下划线,且不是"前面带点"的属性访问。
CONST_USE_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\b")
#: 对象字面量里作为 key 的大写标识符(`{ FOO: 1 }`)不算使用。
OBJECT_KEY_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\s*:")
def code_only(source: str) -> str:
"""只保留真代码:清空注释、引号字符串与模板字符串的文字部分(`${…}` 里的表达式保留)。
逐字符扫描而不是一把正则:模板字符串里可以嵌套 `${…}`(甚至再嵌模板),
正则匹配不了嵌套,只能老老实实走一遍。
"""
out: list[str] = []
index = 0
length = len(source)
while index < length:
char = source[index]
following = source[index + 1] if index + 1 < length else ""
if char == "/" and following == "*":
end = source.find("*/", index + 2)
index = length if end == -1 else end + 2
out.append(" ")
continue
if char == "/" and following == "/":
end = source.find("\n", index)
index = length if end == -1 else end
out.append(" ")
continue
if char in "'\"":
cursor = index + 1
while cursor < length:
if source[cursor] == "\\":
cursor += 2
continue
if source[cursor] == char:
break
cursor += 1
out.append(" ")
index = min(cursor + 1, length)
continue
if char == "`":
index += 1
while index < length:
if source[index] == "\\":
index += 2
continue
if source[index] == "`":
index += 1
break
if source[index] == "$" and index + 1 < length and source[index + 1] == "{":
depth = 1
index += 2
start = index
while index < length and depth:
if source[index] == "{":
depth += 1
elif source[index] == "}":
depth -= 1
if depth == 0:
break
index += 1
out.append(" " + source[start:index] + " ")
index += 1
continue
index += 1
out.append(" ")
continue
out.append(char)
index += 1
return "".join(out)
def exported_names(path: Path) -> set[str]:
source = path.read_text(encoding="utf-8")
names: set[str] = set()
for match in EXPORT_RE.finditer(source):
if match.group("name"):
names.add(match.group("name"))
if match.group("list"):
for piece in match.group("list").split(","):
piece = piece.strip()
if not piece:
continue
names.add(piece.split(" as ")[-1].strip())
return names
def resolve_import(source: Path, spec: str) -> Path | None:
"""相对 import 才能解析到本地文件;`/static/...` 这类站内绝对路径跳过。"""
if not spec.startswith("."):
return None
target = (source.parent / spec.split("?")[0]).resolve()
return target if target.is_file() else None
def check_syntax(paths: list[Path]) -> list[str]:
"""用 node 按 ES module 解析。装不了 node 就跳过(不让环境问题变成假失败)。"""
node = shutil.which("node")
if node is None:
return []
problems: list[str] = []
with tempfile.TemporaryDirectory() as tmp:
for path in paths:
# `node --check` 按扩展名决定模块类型,所以复制成 .mjs 再检查。
probe = Path(tmp) / (path.stem + ".mjs")
probe.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
result = subprocess.run(
[node, "--check", str(probe)], capture_output=True, text=True, check=False
)
if result.returncode != 0:
tail = (result.stderr or "").strip().splitlines()
problems.append(f"{path.name}: 语法错误 {tail[-1] if tail else ''}")
return problems
def main() -> int:
paths = [DASHBOARD_DIR / name for name in MODULES]
missing = [p.name for p in paths if not p.is_file()]
if missing:
print(f"[失败] 缺少文件:{missing}")
return 1
problems = check_syntax(paths)
for path in paths:
source = path.read_text(encoding="utf-8")
# ---- import 的目标必须存在,且名字必须真被导出 ----
imported: set[str] = set()
for match in IMPORT_RE.finditer(source):
spec = match.group("src")
if match.group("names"):
for piece in match.group("names").strip("{}").split(","):
piece = piece.strip()
if piece:
imported.add(piece.split(" as ")[-1].strip())
if match.group("default"):
imported.add(match.group("default"))
target = resolve_import(path, spec)
if target is None:
if spec.startswith("."):
problems.append(f"{path.name}: import '{spec}' 指向的文件不存在")
continue
available = exported_names(target)
wanted = set()
if match.group("names"):
for piece in match.group("names").strip("{}").split(","):
piece = piece.strip()
if piece:
wanted.add(piece.split(" as ")[0].strip())
for name in wanted - available:
problems.append(
f"{path.name}: 从 {target.name} 导入了 '{name}',但那边没有导出它"
)
# ---- 用到的全大写常量必须有来源 ----
declared = set(re.findall(r"(?:const|let|var|function|class)\s+([A-Z][A-Z0-9_]{2,})\b", source))
declared |= exported_names(path)
# 先清掉注释 / 字符串 / 模板文字,再排除对象字面量的 key。
stripped = OBJECT_KEY_RE.sub(" ", code_only(source))
for match in CONST_USE_RE.finditer(stripped):
name = match.group(1)
if name in imported or name in declared or name in KNOWN_GLOBALS:
continue
problems.append(f"{path.name}: 使用了 '{name}',但既没 import 也没在本文件声明")
print(f"检查 {len(paths)} 个模块:{', '.join(p.name for p in paths)}")
if problems:
print(f"\n发现 {len(problems)} 个问题:")
for problem in problems:
print(f" · {problem}")
print(
"\n提示:拆分模块时最容易漏 import —— 定义搬走了、使用处还留在原文件,"
"\n 浏览器里表现为页面白板 + ReferenceError,而 Python 测试全绿。"
)
return 1
print("全部通过:语法正确、import 可解析、常量都有来源。")
return 0
if __name__ == "__main__":
sys.exit(main())