db6a51d617
一、附录 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 项全部通过
258 lines
13 KiB
Python
258 lines
13 KiB
Python
# -*- 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)
|