Files
group_fqcd_jr/tools/check_portal_modules.py
T
Windows 2fe7d0c506 feat(投顾): 客户主动申报投顾方案 + 投顾受理自动生成方案草稿
客户在自己主页提交申报 → 投顾工作台受理 → 自动跑既有推荐逻辑生成一份待审核草稿 →
投顾再走既有的「审核通过 → 发送给客户」。补上原先「客户只能被动等方案」的缺口。

后端
- 新增表 advisor_service_request(本轮新建,带 AUTO_INCREMENT)+ 迁移
  20260916_advisor_service_request(幂等:先查表再建,兼容本库 alembic 指针滞后)
- 新增 AdvisorServiceRequestService:create / list_mine / queue / review
  · 申报前置:风险测评必须存在且未失效(FM-03,12 个月),服务端判
  · 队列复用 ProductRecommendationService._visible_customer_ids(本人 + 归属),待受理排前
  · 受理即调用推荐逻辑生成草稿并把 content_id 回填;生成前置失败给出人话原因并保持待受理
  · 「已发送客户」不落库,由关联方案 published_at 推导,避免两处状态各写各的
- 权限码 9071-9074(客户 write/read:self、投顾 read/review),已进种子与授权工具

前端
- 投顾工作台新增「客户申报」面板(受理 / 驳回,驳回理由客户可见)
- 客户页新增申报表单(金额/期限/风险偏好/备注)与「我的申报」列表

客户自助路由刻意不挂投顾灰度闸门:那是投顾业务的灰度,客户提交自己的申请不该被它拦下。
2026-09-16 18:17:46 +08:00

254 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",
"history-module.js",
"service-request-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())