docs: 清掉三处"会静默过期"的小账 + check-test-profile 加固(F-98)
一、附录 A 从"手抄表"改成"可再生成的命令"
check-api-parity.py 加 --extra,现算"代码有、契约 §4 没有"的接口 = 41 个
(旧手抄表写 83/62/21,已差近一倍,而且没人会记得回来改它)。
并按"谁去改什么"分六类 —— 只有 E 类(8 个)要客户定口径,其余是文档侧补记。
二、F-6 已修:frontend-pc 的 Mock 说明
AGENTS.md 那句改正之外,连带修了 .agents/skills/project-api/SKILL.md ——
它整篇仍以 vite-plugin-mock / mock/** 为主线,而该项目里 mock/ 目录与插件都已删除。
只改指路牌、不改路的尽头,等于没改。
三、docs/03 两处文档内部口径冲突加 ⚠️ 注(不改口径,只把冲突摆明)
§2 notifyBizType 的 5/6 与 §13.5、落库值不一致 → 实现以 §13.5 为准(催报去重按 5);
§3.6/§9.5 的 collect_file.source 字段表(入口来源)与建表注释(谁传的)冲突 →
实现以建表口径为准。两处都写明"待客户确认",见 F-30 / F-38 / F-39。
四、check-test-profile.py 加固(新增 F-98)
· 子进程输出落盘(不再用没人读的 PIPE):失败时能看见"停在哪一行",
成功时打出启动用时(基线 ~10 秒 / 72 行 / 9.5KB);
· 开跑前按 jar + profile 清残留 JVM,且保留 dev 后端(E2E 正打着它);
· 等待期间每 15 秒报一次进度,卡住时不留沉默。
教训:随机端口这个防撞设计把残留 JVM 藏起来了 —— 撞不到端口就永远发现不了,
于是表现成"时好时坏",而失败时连日志都读不到。
验证:python scripts/verify-all.py --with-e2e → 通过 8 / 失败 0 / 跳过 0
(E2E 36 脚本 / 1548 断言 / 278s;prodguard 9/9;test profile 能起)
python scripts/verify-docs.py → 22 项全部通过
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
用法:
|
||||
python scripts/check-api-parity.py # 打印逐分类实现度 + 缺口清单
|
||||
python scripts/check-api-parity.py --quiet # 只打印汇总(CI 里用)
|
||||
python scripts/check-api-parity.py --extra # 反向:代码有、契约 §4 清单没有的(F-4 底稿)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -88,9 +89,11 @@ def code_endpoints() -> list[tuple[str, str, str]]:
|
||||
def main() -> int:
|
||||
quiet = "--quiet" in sys.argv
|
||||
only_missing = "--missing" in sys.argv
|
||||
only_extra = "--extra" in sys.argv
|
||||
doc = doc_overview()
|
||||
code = code_endpoints()
|
||||
code_keys = {(m, norm(p)) for _, m, p in code}
|
||||
doc_keys = {(m, norm(p)) for eps in doc.values() for m, p in eps}
|
||||
|
||||
claimed = implemented = 0
|
||||
missing: list[tuple[str, str, str]] = []
|
||||
@@ -104,6 +107,27 @@ def main() -> int:
|
||||
missing += [(cat, m, p) for m, p in miss]
|
||||
rows.append((cat, len(uniq), len(done), len(miss)))
|
||||
|
||||
# ── 反向差集:代码注册了、而契约 §4 清单里没有 ──
|
||||
# ⚠️ 这个清单**必须由工具生成**:它以前是人工做一次差集后手抄进文档的,
|
||||
# 于是每加一个接口就悄悄过期一次(数字 83/62/21 与实际差了 3 倍)。
|
||||
# 手抄件会过期,命令不会。
|
||||
extra = [(f, m, p) for f, m, p in code if (m, norm(p)) not in doc_keys]
|
||||
|
||||
if only_extra:
|
||||
print("=" * 78)
|
||||
print("代码有、契约 §4 清单没有:%d 个" % len(extra))
|
||||
print("(代码共注册 %d 个;契约声称 %d 个,其中 %d 个已在代码里)"
|
||||
% (len(code), claimed, implemented))
|
||||
print("=" * 78)
|
||||
cur = None
|
||||
for f, m, p in extra:
|
||||
if f != cur:
|
||||
print("\n【%s】" % f)
|
||||
cur = f
|
||||
print(" %-6s %s" % (m, p))
|
||||
print()
|
||||
return 0
|
||||
|
||||
if not quiet and not only_missing:
|
||||
print("=" * 78)
|
||||
print("代码注册接口:%d 个" % len(code))
|
||||
|
||||
+101
-18
@@ -16,8 +16,17 @@
|
||||
不验任何业务 —— 业务由 `scripts/e2e-*.py` 打真实接口验。
|
||||
这里只回答一个问题:**"CI 那套配置能不能把服务拉起来"**。
|
||||
|
||||
## 两个"时好时坏"的坑(都踩过)
|
||||
|
||||
1. **就绪判据不能用日志行**:日志行会随版本变(改个措辞就失效),
|
||||
而且子进程输出走管道时是块缓冲的,`readline()` 一阻塞就轮不到超时判断。
|
||||
所以判据是 **HTTP 探活**,并且子进程输出**落盘**(`backend/_test-profile.txt`)——
|
||||
失败时能看见它停在哪一行,而不是只剩一句"180 秒没起来"。
|
||||
2. **随机端口带来一个反直觉的副作用**:残留 JVM 永远不会撞端口,于是会静静攒着,
|
||||
直到某一次"怎么都起不来"。所以开跑前按 jar + profile 清残留(**保留 dev 后端**)。
|
||||
|
||||
跑法:python scripts/check-test-profile.py
|
||||
(前提:本地 PG/Redis 在跑、已 `mvn package`)
|
||||
(前提:本地 PG/Redis 在跑、已 `mvn package`;可用 `XT_TEST_PROFILE_WAIT` 调超时)
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
@@ -92,6 +101,42 @@ def preclean_port(port):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def preclean_stale_jvms():
|
||||
"""
|
||||
开跑前清掉**本项目的**残留 JVM(只留 dev 那个)。
|
||||
|
||||
⚠️ 为什么 preclean_port 不够:本脚本用的是随机端口,所以残留 JVM **永远撞不到**
|
||||
这个端口,而它们会一直占着内存与 CPU —— 表现是"这次怎么 180 秒都没起来",
|
||||
下一次又好了(**时好时坏正是资源被占的特征**)。实测就是这样:
|
||||
连着跑几轮之后本机攒了 5~7 个 java,之后 JVM 启动从 10 秒变成起不来。
|
||||
|
||||
判据要窄,否则会误杀:
|
||||
· 命令行里必须是**本项目的 jar**(别的项目的 java 一律不碰);
|
||||
· **必须留 dev**(`--spring.profiles.active=dev`)—— E2E 正打着它,
|
||||
顺手杀掉等于把后面的检查全废掉。
|
||||
"""
|
||||
try:
|
||||
ps = ("Get-CimInstance Win32_Process -Filter \"Name='java.exe'\" | "
|
||||
"ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }")
|
||||
r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
for line in (r.stdout or "").split("\n"):
|
||||
if "\t" not in line:
|
||||
continue
|
||||
pid, cmd = line.split("\t", 1)
|
||||
pid = pid.strip()
|
||||
if not pid.isdigit() or "xt-server" not in cmd:
|
||||
continue
|
||||
if "--spring.profiles.active=dev" in cmd:
|
||||
print(" (保留 dev 后端 pid %s —— E2E 要用它)" % pid)
|
||||
continue
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", pid],
|
||||
capture_output=True, timeout=20)
|
||||
print(" ⚠️ 清掉残留 JVM pid %s(上一次跑 test/prodguard 留下的)" % pid)
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
"""
|
||||
要一个空闲端口。
|
||||
@@ -108,24 +153,48 @@ def free_port() -> int:
|
||||
|
||||
PORT = free_port()
|
||||
print("用端口 %d(每次随机取,避免重复跑时撞端口)" % PORT)
|
||||
preclean_stale_jvms()
|
||||
preclean_port(PORT)
|
||||
|
||||
# ⚠️ 子进程输出**必须落盘**,不能用 PIPE:
|
||||
# ① 没人读 PIPE 时,管道缓冲区(Windows 约 64KB)写满就会**把 JVM 阻塞在写日志上** ——
|
||||
# 表现是"启动到一半不动了、进程还活着",与配置对不对毫无关系;
|
||||
# ② 失败时想读那段输出,而它要么还在缓冲区里、要么已被 kill 截断,
|
||||
# 于是只剩下一句"180 秒内没探活成功"这种没法排查的结论。
|
||||
# 落盘之后才能回答"它到底停在哪一行"。文件名用 .txt 是有意的:
|
||||
# .gitignore 里 `backend/_*.txt` 已经忽略它。
|
||||
# 实测基线:正常启动 ~10 秒(72 行 / 9.5KB 日志)——
|
||||
# 哪天变成几十秒,先怀疑本机负载与残留 JVM,而不是配置。
|
||||
LOG = os.path.join(ROOT, "backend", "_test-profile.txt")
|
||||
logf = open(LOG, "w", encoding="utf-8", errors="replace")
|
||||
|
||||
started = time.time()
|
||||
p = subprocess.Popen(
|
||||
["java", "-jar", JAR, "--spring.profiles.active=test",
|
||||
"--server.port=%d" % PORT],
|
||||
cwd=os.path.join(ROOT, "backend"), env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
stdout=logf, stderr=subprocess.STDOUT)
|
||||
|
||||
|
||||
def log_lines():
|
||||
"""读日志文件当前的内容(进程还开着也能读)。"""
|
||||
try:
|
||||
logf.flush()
|
||||
with open(LOG, encoding="utf-8", errors="replace") as f:
|
||||
return [x.rstrip() for x in f if x.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
ok = False
|
||||
reason = ""
|
||||
body = ""
|
||||
try:
|
||||
# ⚠️ 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行":
|
||||
# 子进程 stdout 在管道里是**块缓冲**的,启动日志常常不足一个缓冲区,
|
||||
# 于是一行都读不到;而且 readline() 一旦阻塞,超时的判断就轮不到。
|
||||
# 实测:单独跑能过、紧跟在几个 JVM 之后跑就失败 —— 时好时坏正是它的特征。
|
||||
deadline = time.time() + 180
|
||||
# 就绪判据是 **HTTP 探活**,不是"日志里出现了某一行":
|
||||
# 日志行会随版本变(改个措辞就失效),而"接口能回答"才是这件事本身。
|
||||
WAIT = float(os.environ.get("XT_TEST_PROFILE_WAIT", "180"))
|
||||
deadline = time.time() + WAIT
|
||||
next_report = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
if p.poll() is not None:
|
||||
reason = "进程自己退了(退出码 %s)" % p.returncode
|
||||
@@ -138,37 +207,51 @@ try:
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
reason = str(e)
|
||||
# 每 15 秒报一次"等到哪一步了" —— 卡住时最需要的是进度,不是沉默
|
||||
if time.time() >= next_report:
|
||||
next_report = time.time() + 15
|
||||
tail = log_lines()
|
||||
print(" …… 已等 %ds,日志停在:%s" % (
|
||||
int(time.time() - started), tail[-1][:110] if tail else "(还没有输出)"))
|
||||
time.sleep(1)
|
||||
if not ok and not reason:
|
||||
reason = "180 秒内没探活成功"
|
||||
reason = "%d 秒内没探活成功" % int(WAIT)
|
||||
finally:
|
||||
# ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程,
|
||||
# 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。**
|
||||
kill_tree(p)
|
||||
try:
|
||||
p.wait(timeout=15)
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
logf.close()
|
||||
|
||||
text = "\n".join(log_lines())
|
||||
|
||||
if ok:
|
||||
took = time.time() - started
|
||||
print("健康检查:", body[:200])
|
||||
print("启动用时:%.1f 秒(基线约 10 秒)" % took)
|
||||
if took > 60:
|
||||
print(" ⚠️ 比基线慢很多 —— 大概率是**本机负载或残留 JVM**,不是配置问题")
|
||||
print(" 查残留:Get-CimInstance Win32_Process -Filter \"Name='java.exe'\"")
|
||||
print("\n结果:test profile **能起来**")
|
||||
else:
|
||||
print("\n结果:test profile **起不来**")
|
||||
print(" 原因:" + reason)
|
||||
try:
|
||||
text = (p.stdout.read() or "") if p.stdout else ""
|
||||
except Exception:
|
||||
text = ""
|
||||
print(" 完整日志:%s(%d 行)" % (os.path.relpath(LOG, ROOT), len(log_lines())))
|
||||
if "already in use" in text or ("Port" in text and "in use" in text):
|
||||
print(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题")
|
||||
for kw in ("ERROR", "Caused by", "Failed", "migration", "Flyway", "Cannot"):
|
||||
# 关键字先扫一遍:起不来时真正的原因往往只有一行,而尾部 30 行可能全是无关的重试
|
||||
for kw in ("APPLICATION FAILED TO START", "Caused by", "ERROR", "Waiting", "lock",
|
||||
"Flyway", "migration", "Cannot", "Exception"):
|
||||
for ln in text.split("\n"):
|
||||
if kw in ln:
|
||||
print(" " + ln.strip()[:160])
|
||||
print(" [%s] %s" % (kw, ln.strip()[:150]))
|
||||
break
|
||||
print("---- 尾部 ----")
|
||||
print(text[-1500:])
|
||||
print("---- 日志尾部 20 行 ----")
|
||||
for ln in log_lines()[-20:]:
|
||||
print(" " + ln[:150])
|
||||
|
||||
sys.stdout.flush()
|
||||
raise SystemExit(0 if ok else 1)
|
||||
|
||||
Reference in New Issue
Block a user