174 lines
8.9 KiB
Python
174 lines
8.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""页面文案守卫:枚举字段不许直接印到页面上(静态扫描,不连库、不起浏览器)。
|
||
|
||
## 为什么要有它
|
||
|
||
app/web/static/index.html 是三千行的单文件页面,**没有任何单测**。改错了不会有
|
||
东西报警。而它上面最容易出的错,是把后端送来的枚举值直接塞进模板——
|
||
页面上就出现 COMMAND_HALT、REJECT、issue_command:EXIT_STOCK 这种东西。
|
||
|
||
页面里其实早就有翻译层(T 表加 tx 函数),问题是它被系统性地绕开了:
|
||
2026-09-04 实测,同一份文件里 :899 走了 tx('frozen', …)、:1533 没走,印的是
|
||
同一个字段。这不是没想到,是执行漏网。所以需要一道机器检查。
|
||
|
||
## 它检查什么
|
||
|
||
一、**枚举字段直出**:模板里形如 {{ x.frozen_reason }} 的插值,字段名落在
|
||
ENUM_FIELDS 里,而外面没有包 tx( 或别的翻译函数,就报。
|
||
二、**判据码直出**:failed_checks、reason 这类会带 `CODE: ` 前缀的字段,
|
||
必须过 plainCheck 才能显示。
|
||
三、**整个对象打给人看**:JSON.stringify 出现在给交易员看的区域就报。
|
||
运维面板那几处是有意的,登记在 JSON_OK 里放行。
|
||
四、**翻译表兜底不许裸奔**:tx 找不到码时必须把码退到括号里,
|
||
不能让一个英文词单独立在页面上当句子。
|
||
|
||
## 怎么维护
|
||
|
||
加了新的枚举字段就往 ENUM_FIELDS 里加一行。这份名单是这道检查的全部依据,
|
||
不加就等于不检查——所以它和 T 表要一起改。
|
||
|
||
跑法:python3 scripts/test_page_enum_guard.py
|
||
"""
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
PAGE = os.path.join(os.path.dirname(HERE), "app", "web", "static", "index.html")
|
||
|
||
# 后端会送到页面的枚举字段。取值全集在 T 表里,两者要一起改。
|
||
# 只列**枚举**字段:后端已经写成中文句子的自由文本(basis、why、note 这类)不在此列,
|
||
# 那些本来就该原样显示。
|
||
# 字段名到翻译组的对应。**同名不等于同组**:全局参数的 autonomy 是
|
||
# full / propose_only / off(param_store.py:142),而个股交易方案的 autonomy 是
|
||
# auto / confirm(strategy_service.py:23),两套取值完全不同。所以这份对应表里
|
||
# 按"字段在哪个对象上"区分,不能只按字段名。
|
||
FIELD_GROUP = {
|
||
"frozen_reason": "frozen", "cushion_state": "cushion", "lot_kind": "lot",
|
||
"arbiter": "arbiter", "stype": "stype", "ref_source": "refSource",
|
||
"autonomy": "autonomy", # 全局参数那一套
|
||
"strategy_autonomy": "stratAutonomy", # 个股交易方案那一套,取值不同
|
||
"judge_verdict": "judgeVerdict", "origin_type": "originType",
|
||
"cancel_state": "cancelState", "conn_state": "connState",
|
||
"proc_state": "inboxState",
|
||
}
|
||
|
||
ENUM_FIELDS = [
|
||
"frozen_reason", # 冻结来路:COMMAND_HALT / BRAKE / MANUAL / NONE
|
||
"cushion_state", # 安全垫:SOLID / THIN / NONE
|
||
"lot_kind", # 批次:BASE / FILL / ADD / DCA / T0 / RECON
|
||
"arbiter", # 谁裁的:rule / judge / user / system
|
||
"stype", # 交易方案类型:T0 / GRID / TRAIL
|
||
"autonomy", # 全局自主档:full / propose_only / off
|
||
"ref_source", # 参考位来源:bionic / self_calc / user / none
|
||
"judge_verdict", # 研判闸结论:PASS / REJECT / UNAVAILABLE / STRATEGY / STRATEGY_RISK
|
||
"origin_type", # 提议来路:plan / proposal / system / strategy
|
||
"cancel_state", # 撤单进度:NONE / REQUESTED / SENT
|
||
"conn_state", # 通道状态:INIT / CONNECTING / ONLINE / OFFLINE / STOPPED
|
||
]
|
||
|
||
# 这三个字段的取值是**开放集**,不许声称全集,也不许在兜底里吞掉原值:
|
||
# reject_code 由交易对端下发,协议文档明写可以补充
|
||
# level 上游信号原样透传,本仓库没有产出方
|
||
# accum_state 决策系统给的中文原文,本来就不需要翻译
|
||
# 它们不进上面的名单,但在这里登记一笔,免得以后有人当成漏网又去补翻译。
|
||
OPEN_SET_FIELDS = ["reject_code", "level", "accum_state", "msg_type"]
|
||
|
||
# 这些字段带 `CODE: ` 前缀,显示前必须剥掉。
|
||
CODED_FIELDS = ["failed_checks"]
|
||
|
||
# 把整个对象打给人看,只有落在**交易员会看的区域**才算问题。两类不算:
|
||
# 一是存本地状态(sessionStorage / localStorage),那不是给人看的;
|
||
# 二是运维面板里那几个"原样看接口返回"的框,读者是运维不是交易员,
|
||
# 它们本来就该显示原始数据。
|
||
# 判定靠变量名:写进 opsResult / planResult 这类运维面板的框,或者进浏览器存储,
|
||
# 都放行。别的地方出现 JSON.stringify 就报。
|
||
JSON_SKIP_MARKS = (
|
||
"sessionStorage", "localStorage", # 存本地状态,不是给人看的
|
||
"opsResult", "planResult", # 运维面板:有意显示接口原文
|
||
"console.", # 控制台
|
||
# 下面几处都在运维面板里,读者是运维不是交易员,本来就该看原始数据。
|
||
# 每加一条都要写清它在哪个面板、为什么放行。
|
||
"planStatus.theme_sync", # 上游计划诊断行:主题映射,排查用
|
||
"planStatus.query", # 上游计划诊断行:本次请求参数,排查用
|
||
"chgLog", # 运维面板「快照台账」:整行原文
|
||
"report.report", # 运维面板「快照台账」:日报原文
|
||
"wsc.queue", # 运维面板「ws 通道」:出口队列计数
|
||
"s.row.payload", # 运维面板「ws 通道」:报文原文
|
||
"s.row.hard_numbers", # 只在悬浮提示里作原文备查,正文已是中文
|
||
"s.row)", # 运维面板「快照台账」:整行原文
|
||
)
|
||
|
||
# 允许包住枚举字段的翻译函数。
|
||
WRAPPERS = ("tx(", "opCn(", "plainCheck(", "propVerdictLine(")
|
||
|
||
|
||
def fail(msgs):
|
||
for m in msgs:
|
||
print(" FAIL", m)
|
||
|
||
|
||
def main():
|
||
src = open(PAGE, encoding="utf-8").read()
|
||
lines = src.split("\n")
|
||
bad = []
|
||
|
||
# 一、枚举字段直出
|
||
for i, line in enumerate(lines, 1):
|
||
for f in ENUM_FIELDS:
|
||
# 只看插值与属性绑定里出现的字段引用
|
||
for m in re.finditer(r"[\w.\[\]]*\.%s\b" % re.escape(f), line):
|
||
seg = line[max(0, m.start() - 60):m.end() + 20]
|
||
if any(w in seg for w in WRAPPERS):
|
||
continue
|
||
# v-if 这类判断用途不算显示
|
||
if re.search(r'v-(if|else-if|show)="[^"]*\.%s\b' % re.escape(f), line):
|
||
continue
|
||
# JS 里做比较也不算显示
|
||
if re.search(r"\.%s\s*(===|!==|==|!=|&&|\|\||\?)" % re.escape(f), seg):
|
||
continue
|
||
if "{{" not in line and ":title=" not in line and ":content=" not in line:
|
||
continue
|
||
bad.append(f"第 {i} 行:枚举字段 {f} 直接印到页面上,没走翻译。\n"
|
||
f" {line.strip()[:120]}")
|
||
|
||
# 二、判据码直出
|
||
for i, line in enumerate(lines, 1):
|
||
for f in CODED_FIELDS:
|
||
if f"{f}" in line and "{{" in line and "plainCheck" not in line:
|
||
bad.append(f"第 {i} 行:{f} 带大写判据码前缀,显示前必须过 plainCheck。\n"
|
||
f" {line.strip()[:120]}")
|
||
|
||
# 三、整个对象打给人看
|
||
for i, line in enumerate(lines, 1):
|
||
if "JSON.stringify" in line:
|
||
if not any(k in line for k in JSON_SKIP_MARKS):
|
||
bad.append(f"第 {i} 行:把整个对象打给交易员看。要么改成中文说明,"
|
||
f"要么说明它落在运维面板并登记进 JSON_SKIP_MARKS。\n"
|
||
f" {line.strip()[:120]}")
|
||
|
||
# 四、兜底不许裸奔
|
||
m = re.search(r"const tx = \(g, c\) => \{(.*?)\n \};", src, re.S)
|
||
if not m:
|
||
bad.append("找不到 tx 函数,这道检查的前提没了")
|
||
elif "return String(c);" in m.group(1):
|
||
bad.append("tx 的兜底是原样返回,会让一个英文词单独立在页面上当句子。"
|
||
"改成把码退到括号里。")
|
||
|
||
# 五、翻译表与本文件的字段名单要对得上
|
||
for f in ENUM_FIELDS:
|
||
group = FIELD_GROUP.get(f)
|
||
if group and f" {group}: {{" not in src and f" {group}:{{" not in src:
|
||
bad.append(f"字段 {f} 在名单里,但 T 表里没有对应的 {group} 组")
|
||
|
||
if bad:
|
||
print(f"页面文案守卫:发现 {len(bad)} 处")
|
||
fail(bad)
|
||
sys.exit(1)
|
||
print(f"ALL OK — 页面文案守卫:{len(ENUM_FIELDS)} 个枚举字段、"
|
||
f"{len(CODED_FIELDS)} 个带判据码的字段、整对象直出、翻译兜底 全部通过")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|