调整skill位置

This commit is contained in:
马宝龙
2026-07-28 15:58:05 +08:00
parent 5c3b306aae
commit 92b718c096
7 changed files with 112 additions and 36 deletions

View File

@@ -16,14 +16,14 @@ description: 启动本地项目的前端和后端服务,并通过进程状态
- 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。 - 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。
2. 执行启动前检查。 2. 执行启动前检查。
- 确认所需运行时和依赖目录可用。 - 确认所需运行时和依赖目录可用。前端已有 `node_modules` 时直接启动,不要每次重复执行 `npm ci`;仅在依赖缺失或用户明确要求重装时安装。
- 检查计划使用的端口。只有在进程和健康响应明确属于当前项目时才复用服务,绝不要结束未知进程。 - 检查计划使用的端口。只有在进程和健康响应明确属于当前项目时才复用服务,绝不要结束未知进程。
- 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。 - 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。
- 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。 - 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。
3. 使用 `scripts/start_and_verify.py` 启动两个应用服务。 3. 使用 `scripts/start_and_verify.py` 启动两个应用服务。脚本默认使用 0.25 秒快速轮询,并行等待两个服务就绪。
- 从项目根目录运行脚本。 - 从项目根目录运行脚本。
- 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加检查 - 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加只读检查;不要重复添加已经作为 `--backend-url` 的健康地址
- 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。 - 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。
- 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running` - 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running`
- 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。 - 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。
@@ -34,22 +34,22 @@ description: 启动本地项目的前端和后端服务,并通过进程状态
python skills/fullstack-start-verify/scripts/start_and_verify.py ` python skills/fullstack-start-verify/scripts/start_and_verify.py `
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" ` --backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
--backend-dir . ` --backend-dir . `
--backend-url http://127.0.0.1:8080/api/health ` --backend-url http://127.0.0.1:8080/api/v1/health `
--frontend-cmd "npm run dev -- --host 127.0.0.1" ` --frontend-cmd "npm run dev -- --host 127.0.0.1" `
--frontend-dir frontend ` --frontend-dir frontend `
--frontend-url http://127.0.0.1:5173/ ` --frontend-url http://127.0.0.1:5173/ `
--check-url health=http://127.0.0.1:8080/api/health --check-url summary=http://127.0.0.1:8080/api/v1/users/summary
``` ```
4. 按递进层级验证应用。 4. 按递进层级验证应用。
- 确认两个健康检查地址都返回可接受的 HTTP 状态,并确认对应进程仍在运行 - 快速路径先确认两个健康检查地址和 1 至 2 个主要只读 API脚本会并行执行等待和额外检查
- 针对主要用户流程执行聚焦的后端或 API 检查。除非用户明确要求写入流程且测试数据安全,否则优先执行只读检查 - 只有代码变更、用户明确要求回归,或快速路径暴露问题时,才执行前端构建、后端测试等较慢命令
- 在相关且不会重复更权威检查的情况下,执行前端构建或项目已有测试命令 - 只有涉及页面或交互变更且有浏览器工具时,才执行浏览器冒烟:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果
- 如果有 Playwright 或其他浏览器工具,打开前端地址并执行主要用户路径:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果 - 除非用户明确要求写入流程且测试数据安全,否则不执行新增、修改、删除或数据库迁移
- 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。 - 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。
5. 汇报并清理环境。 5. 汇报并清理环境。
- 汇报具体命令、地址、执行的检查、通过或失败状态,以及第一个可执行的失败原因。 - 汇报验证任务 ID、具体命令、地址、执行的检查、通过或失败状态,以及第一个可执行的失败原因。
- 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。 - 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。
- 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。 - 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。
- 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。 - 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。
@@ -64,4 +64,4 @@ description: 启动本地项目的前端和后端服务,并通过进程状态
## 内置脚本 ## 内置脚本
`scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的启动和 HTTP 检查。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。 `scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的启动和 HTTP 检查。脚本会为检查请求添加 `X-Request-Id`,输出验证任务 ID并行等待服务与执行额外检查。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。错误日志摘要会脱敏,但保留日志前仍需确认服务自身没有输出敏感信息。

View File

@@ -4,7 +4,9 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import concurrent.futures
import os import os
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -13,6 +15,7 @@ import tempfile
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -28,6 +31,13 @@ class Service:
log_file: object | None = None log_file: object | None = None
SENSITIVE_VALUE_PATTERN = re.compile(
r"(?i)(password|token|secret|authorization|api[_-]?key)"
r"(\s*[\"']?\s*[:=]\s*[\"']?)([^\"'\s,;}\]]+)"
)
BEARER_PATTERN = re.compile(r"(?i)(Bearer\s+)[^\s,;]+")
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="启动前后端命令,检查 HTTP 地址并清理进程。" description="启动前后端命令,检查 HTTP 地址并清理进程。"
@@ -54,8 +64,14 @@ def parse_args() -> argparse.Namespace:
parser.add_argument( parser.add_argument(
"--interval", "--interval",
type=float, type=float,
default=1.0, default=0.25,
help="每次就绪检查的间隔秒数,默认 1", help="每次就绪检查的间隔秒数,默认 0.25",
)
parser.add_argument(
"--check-timeout",
type=float,
default=5.0,
help="额外接口检查的单请求超时时间,默认 5 秒",
) )
parser.add_argument( parser.add_argument(
"--log-dir", "--log-dir",
@@ -110,8 +126,14 @@ def start_service(service: Service, log_dir: Path) -> None:
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}") print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
def request(url: str, timeout: float) -> tuple[bool, str]: def request(url: str, timeout: float, request_id: str) -> tuple[bool, str]:
request_obj = urllib.request.Request(url, headers={"User-Agent": "fullstack-start-verify/1.0"}) request_obj = urllib.request.Request(
url,
headers={
"User-Agent": "fullstack-start-verify/1.1",
"X-Request-Id": request_id,
},
)
try: try:
with urllib.request.urlopen(request_obj, timeout=timeout) as response: with urllib.request.urlopen(request_obj, timeout=timeout) as response:
status = response.status status = response.status
@@ -123,7 +145,9 @@ def request(url: str, timeout: float) -> tuple[bool, str]:
return False, type(reason).__name__ return False, type(reason).__name__
def wait_for_service(service: Service, timeout: float, interval: float) -> None: def wait_for_service(
service: Service, timeout: float, interval: float, run_id: str
) -> None:
if service.process is None: if service.process is None:
raise RuntimeError(f"{service.name} 未启动") raise RuntimeError(f"{service.name} 未启动")
@@ -134,12 +158,13 @@ def wait_for_service(service: Service, timeout: float, interval: float) -> None:
if exit_code is not None: if exit_code is not None:
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}") raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
ok, reason = request(service.url, min(interval, 5.0)) request_timeout = min(max(interval * 2, 0.5), 5.0)
ok, reason = request(service.url, request_timeout, f"{run_id}-{service.name}")
last_reason = reason last_reason = reason
if ok: if ok:
print(f"通过 {service.name}{service.url} [{reason}]") print(f"通过 {service.name}{service.url} [{reason}]")
return return
time.sleep(max(interval, 0.05)) time.sleep(min(max(interval, 0.05), max(deadline - time.monotonic(), 0)))
raise RuntimeError(f"{service.name}{timeout:g} 秒内未就绪 [{last_reason}]") raise RuntimeError(f"{service.name}{timeout:g} 秒内未就绪 [{last_reason}]")
@@ -159,9 +184,43 @@ def parse_extra_checks(values: list[str]) -> list[tuple[str, str]]:
return checks return checks
def run_extra_checks(checks: list[tuple[str, str]]) -> None: def wait_for_services(
for label, url in checks: services: list[Service], timeout: float, interval: float, run_id: str
ok, reason = request(url, 10.0) ) -> None:
"""并行等待服务,整体耗时取最慢服务的就绪时间。"""
executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(services))
failed = True
try:
futures = [
executor.submit(wait_for_service, service, timeout, interval, run_id)
for service in services
]
done, _ = concurrent.futures.wait(
futures, return_when=concurrent.futures.FIRST_EXCEPTION
)
for future in done:
future.result()
failed = False
finally:
executor.shutdown(wait=not failed, cancel_futures=failed)
def run_extra_checks(
checks: list[tuple[str, str]], timeout: float, run_id: str
) -> None:
"""并行执行只读接口检查,避免多个接口检查串行等待。"""
if not checks:
return
def execute_check(
item: tuple[int, tuple[str, str]]
) -> tuple[str, str, tuple[bool, str]]:
index, (label, url) = item
return label, url, request(url, timeout, f"{run_id}-check-{index}")
with concurrent.futures.ThreadPoolExecutor(max_workers=len(checks)) as executor:
results = list(executor.map(execute_check, enumerate(checks, start=1)))
for label, url, (ok, reason) in results:
if not ok: if not ok:
raise RuntimeError(f"检查失败:{label} {url} [{reason}]") raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
print(f"通过 {label}{url} [{reason}]") print(f"通过 {label}{url} [{reason}]")
@@ -173,17 +232,24 @@ def stop_service(service: Service) -> None:
return return
if os.name == "nt": if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
try: try:
process.wait(timeout=5) subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=2,
)
except subprocess.TimeoutExpired:
pass
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
process.kill() process.kill()
process.wait(timeout=5) try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.returncode = -1
except OSError: except OSError:
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。 # taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
process.returncode = -1 process.returncode = -1
@@ -204,8 +270,19 @@ def tail(path: Path | None, lines: int = 20) -> list[str]:
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
def redact(line: str) -> str:
"""仅在终端输出日志时脱敏,避免错误摘要泄露凭据。"""
line = SENSITIVE_VALUE_PATTERN.sub(r"\1\2[REDACTED]", line)
return BEARER_PATTERN.sub(r"\1[REDACTED]", line)
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
if args.timeout <= 0 or args.interval <= 0 or args.check_timeout <= 0:
raise SystemExit("timeout、interval 和 check-timeout 必须大于 0")
run_id = uuid.uuid4().hex[:12]
print(f"验证任务 ID{run_id}")
checks = parse_extra_checks(args.check_url)
temp_log_dir: Path | None = None temp_log_dir: Path | None = None
log_dir = args.log_dir log_dir = args.log_dir
if log_dir is None: if log_dir is None:
@@ -223,17 +300,16 @@ def main() -> int:
try: try:
for service in services: for service in services:
start_service(service, log_dir) start_service(service, log_dir)
for service in services: wait_for_services(services, args.timeout, args.interval, run_id)
wait_for_service(service, args.timeout, args.interval) run_extra_checks(checks, args.check_timeout, run_id)
run_extra_checks(parse_extra_checks(args.check_url)) print(f"全栈验证通过FULLSTACK_VERIFY=PASS任务 ID{run_id}")
print("全栈验证通过FULLSTACK_VERIFY=PASS")
return 0 return 0
except (OSError, RuntimeError, ValueError) as error: except (OSError, RuntimeError, ValueError) as error:
failure = error failure = error
print(f"全栈验证失败:{error}", file=sys.stderr) print(f"全栈验证失败:{error}", file=sys.stderr)
for service in services: for service in services:
for line in tail(service.log_path): for line in tail(service.log_path):
print(f"日志 {service.name}{line}", file=sys.stderr) print(f"日志 {service.name}{redact(line)}", file=sys.stderr)
return 1 return 1
finally: finally:
if not args.keep_running: if not args.keep_running:

View File

@@ -100,7 +100,7 @@ mvn -f backend/pom.xml verify
从项目根目录执行: 从项目根目录执行:
```powershell ```powershell
python .codex/skills/fullstack-start-verify/scripts/start_and_verify.py ` python skills/fullstack-start-verify/scripts/start_and_verify.py `
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" ` --backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
--backend-dir . ` --backend-dir . `
--backend-url http://127.0.0.1:8080/api/v1/health ` --backend-url http://127.0.0.1:8080/api/v1/health `
@@ -108,7 +108,7 @@ python .codex/skills/fullstack-start-verify/scripts/start_and_verify.py `
--frontend-dir frontend ` --frontend-dir frontend `
--frontend-url http://127.0.0.1:5173/ ` --frontend-url http://127.0.0.1:5173/ `
--check-url summary=http://127.0.0.1:8080/api/v1/users/summary ` --check-url summary=http://127.0.0.1:8080/api/v1/users/summary `
--check-url "users=http://127.0.0.1:8080/api/v1/users?page=1& pageSize=50" --check-url "users=http://127.0.0.1:8080/api/v1/users?page=1&pageSize=50&keyword=&role=&status="
``` ```
验证期间只执行健康检查和 GET 查询,不执行新增、修改、删除或数据库迁移。脚本默认清理本次启动的进程;排查启动失败时增加 `--keep-logs` 保留日志,并确认日志中没有敏感信息。 验证期间只执行健康检查和 GET 查询,不执行新增、修改、删除或数据库迁移。脚本默认清理本次启动的进程;排查启动失败时增加 `--keep-logs` 保留日志,并确认日志中没有敏感信息。