Files
xiaotutongxue/scripts/check-test-profile.py
lovife db6a51d617 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 项全部通过
2026-09-30 09:10:34 +08:00

258 lines
13 KiB
Python
Raw Permalink 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 -*-
"""
用 `test` profile 真起一次 —— **CI 用的就是这个 profile**。
## 为什么单独一个检查
`application-test.yml` 是流水线里 `e2e` 阶段真正加载的配置,而它是"新写的配置"。
**新写的配置从没跑起来过**,正是流水线第一次上线时最典型的红法 ——
而且往往红在一个拼错的属性名上,排查半天。
所以这里用真实的环境变量起一次,确认三件事:
① 能起来;② 迁移跑得过;③ `/actuator/health` 可用。
## 它不验什么
不验任何业务 —— 业务由 `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`;可用 `XT_TEST_PROFILE_WAIT` 调超时)
"""
import base64
import os
import re
import socket
import subprocess
import sys
import time
import urllib.request
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RSA_KEY = 'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0='
JAR = os.path.join(ROOT, "backend", "xt-server", "target", "xt-server-1.0.0-SNAPSHOT.jar")
env = dict(os.environ)
env.update({
# 指到本地已有的库(与 dev 用的是同一个库,只验"能不能起来")
"PG_HOST": "localhost", "PG_PORT": "5432", "PG_DB": "filldb",
"PG_USER": "fill", "PG_PASSWORD": "fill_dev_pwd",
"REDIS_HOST": "localhost", "REDIS_PORT": "16379", "REDIS_PASSWORD": "redis_dev_pwd",
# ⚠️ 密钥一律用 base64(os.urandom(n)) 生成 —— 手写字面量连错两次:
# 一次是 AES 少了 2 字节,一次是 HMAC 那串根本不是合法 base64
"XT_JWT_SECRET": base64.b64encode(os.urandom(64)).decode(),
"XT_AES_KEY": base64.b64encode(os.urandom(32)).decode(),
"XT_HMAC_KEY": base64.b64encode(os.urandom(48)).decode(),
"XT_RSA_PRIVATE_KEY": RSA_KEY,
})
def kill_tree(proc):
"""
杀掉整个进程树(Windows 用 taskkill /T)。
⚠️ 不能用 `proc.kill()`:它只杀直接子进程,而 JVM 可能带着子进程;
更重要的是——**外层命令被超时掐掉时 finally 不一定会跑**,
所以残留会积累。用 `/T` 至少把"能跑到 finally"的那些清干净,
再靠下面的 preclean_port 兜住剩下的。
"""
if proc is None or proc.poll() is not None:
return
try:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
capture_output=True, timeout=20)
except Exception:
try:
proc.kill()
except Exception:
pass
def preclean_port(port):
"""
开跑前清掉这个端口上的残留 java。
⚠️ 为什么必须有它:脚本用的是**随机空闲端口**,所以下一次跑**不会撞端口** ——
残留的 JVM 于是静静地占着内存与 CPU,只在某次"起 jar 超时"时才隐约暴露。
不清端口 = 残留永远不会被发现。
"""
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-NetTCPConnection -State Listen -LocalPort %d -ErrorAction SilentlyContinue)"
".OwningProcess" % port],
capture_output=True, text=True, timeout=30)
for line in (r.stdout or "").split():
pid = line.strip()
if pid.isdigit():
subprocess.run(["taskkill", "/F", "/T", "/PID", pid],
capture_output=True, timeout=20)
print(" ⚠️ 端口 %d 上有残留进程 %s,已清掉" % (port, pid))
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:
"""
要一个空闲端口。
⚠️ 不要写死端口:写死的话**第二次跑就撞端口**,而报的错
(`Port xxx was already in use`)跟"配置对不对"毫无关系 ——
一个只能跑一次的检查等于没有检查。
"""
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
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=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 探活**,不是"日志里出现了某一行":
# 日志行会随版本变(改个措辞就失效),而"接口能回答"才是这件事本身。
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
break
try:
with urllib.request.urlopen(
"http://localhost:%d/actuator/health" % PORT, timeout=3) as r:
body = r.read().decode()
ok = True
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 = "%d 秒内没探活成功" % int(WAIT)
finally:
# ⚠️ 必须杀**进程树**(Windows 用 taskkill /T):JVM 可能带子进程,
# 而 p.kill() 只杀直接子进程。**失败的运行恰恰是最需要清理的那些。**
kill_tree(p)
try:
p.wait(timeout=15)
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)
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(" ⚠️ 端口被占 —— 这是**环境问题**,不是配置问题")
# 关键字先扫一遍:起不来时真正的原因往往只有一行,而尾部 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(" [%s] %s" % (kw, ln.strip()[:150]))
break
print("---- 日志尾部 20 行 ----")
for ln in log_lines()[-20:]:
print(" " + ln[:150])
sys.stdout.flush()
raise SystemExit(0 if ok else 1)