Files
group_fqcd_jr/tools/make_launcher_bat.py
lzf_0626 404f8f6aa5 固化接口体检脚本,并交付双击即用的启动 bat
## tools/portal_api_check.py(新)

把 2026-09-13 那轮"照着接口内容把前端测一遍"的验证固化成可重复跑的脚本。
当时靠这套验证查出 6 个真缺陷,但它们只存在于一次会话里,下次改动没人会重跑:

- `K002`/`K003` 是裸信封,前端按 `payload.data` 取 -> 知识库页显示"已入库 0 块 / 为空"
- 委托/成交详情页照着建表字段写,而接口返回视图没带那些列 -> 5 行永远显示 `--`
- 配置项/路由规则的 `PUT` 要 `If-Match`,却没有端点能返回该 digest -> 首次编辑必然 409
- 回复模板 `scene` 只校验长度不校验枚举 -> 非法值撞数据库 CHECK、冒成 500
- 路由规则表单固定 `max_attempts=2` 且 `fallbacks` 为空 -> 后端必然 422
- 模型端点手填 ID -> 未激活的 ID 直接 422

与 `e2e_smoke_test.py` 分工互补:后者测**业务链路通不通**,本脚本测**接口契约对不对**
(状态码、信封形状、字段名与前端期望是否一致)。

三档,默认只跑第一档:
- 默认只读 41 项(不动数据)
- `--write` 加测写操作:配置项 ETag 链路、`max_attempts` 越界、知识库上传+失效、
  回复模板非法枚举、敏感词、推广物料全链路
- `--dangerous` 再加测会改生效配置的操作(激活配置版本会整版本替换,默认不跑,
  脚本内注明了恢复办法)

实测:只读 39/39、写入档 54/54 全绿。

## 桌面一键启动(双击即用)

桌面 `启动金融Agent平台.bat` + 仓库 `启动平台.bat`,由
`tools/make_launcher_bat.py` 生成,只负责"双击"这一层,启动逻辑复用 `start.ps1`
(不重复实现,避免两边漂移)。参数可透传:`-Port 8100` / `-NoBrowser` / `-SkipPriceSync`。

不要手写这个 bat:必须同时满足 **GBK 编码 + CRLF 换行 + 无 BOM**。生成器自己
读回来校验这三条。踩过的坑:用 `write_bytes` 直接落盘时没转 CRLF,cmd 对 LF-only
批处理会行边界错乱,把 `echo` 的说明文字当命令执行(实测报
`AT 命令已弃用`、`']' 不是内部或外部命令`)。

## start.ps1 的三处修复

1. **解释器探测选错环境(真机双击失败的主因)**
   原先只验证 `--version` 成功就选中,结果挑到一个 Python 3.10 环境:
   本项目用了 `datetime.UTC`(3.11+)且依赖 `asyncmy`,启动当场 ImportError ——
   而报错发生在**行情刷新**那一步,看起来像"行情源坏了"。
   现在实测两项:**版本 >= 3.11** + `import fastapi, sqlalchemy, asyncmy, pydantic` 通过,
   并在跳过时打印具体原因。

2. **`Select-Object -First 1` 掐断 native 管道**
   `& $exe -c ... 2>&1 | Select-Object -First 1` 拿到首个对象后停掉上游管道,
   等于把进程掐了、`$LASTEXITCODE` 变脏,于是**每个候选都被误判成"无法执行"**
   (连装好的 jr_py313 也被跳过)。改为先整体接住输出、再在结果上取行。

3. **幂等 + 就绪探测 + Docker 自动拉起**
   重复双击不再起第二个 API/Worker(API 按端口、Worker 按进程判断);
   起完等 `/internal/health/ready` 真正应答才报成功;Milvus 不可达时尝试拉起
   Docker Desktop 并最多等 60 秒(它不常驻,是"知识检索静默降级"最常见的原因)。

## 实测

- `启动平台.bat` 重复执行:解释器正确选中 jr_py313、依赖检查全 OK、行情已刷新、
  两个服务识别为已在跑并跳过、就绪探测 1 秒
- `-Port 8199` 冷启动:新起 API 窗口,8199 的 `/internal/health/ready` 与 `/portal/` 均 200,
  测试后已清理
- `ruff check app tests tools alembic hq.py` -> All checks passed
- `start.ps1` 语法解析通过(BOM 已保留)、bat 三项编码约束校验通过

文档同步:`AGENTS.md`(启动方式、bat 生成器、解释器探测两个坑)、
`docs/44-演示流程.md` §0.3/§0.4(双击启动、两条自检线的分工)。
2026-09-14 01:59:46 +08:00

164 lines
6.4 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.
"""生成"双击即用"的启动 bat。
## 为什么用脚本生成而不是直接写 .bat 文件
两个编码陷阱,靠普通文本工具写必然踩:
1. **cmd 按系统 ANSI 解析批处理**(简体中文下是 GBK)。同样一段中文,
存成 UTF-8 后在 cmd 里就是乱码 —— 所以 bat 必须写成 **GBK**。
2. **`start.ps1` 必须带 BOM**(见该文件自己的 .NOTES)。本脚本顺带校验,
少了就补,避免"双击后满屏语法错误"。
## 为什么 bat 里只调 start.ps1
启动逻辑(解释器发现、依赖检查、行情刷新、幂等起窗口、就绪探测)都在
`start.ps1` 里,且已经能单独跑。bat 只负责"双击"这一层:
定位项目 → 切代码页 → 交给 PowerShell → 留住窗口让人看到账号和报错。
逻辑只有一份,不会两边漂移。
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
BAT_TEMPLATE = r"""@echo off
chcp 936 >nul
title 金融 Agent 平台 · 一键启动
rem ============================================================
rem 双击本文件即可启动平台:
rem 依赖检查(MySQL/Redis/Milvus) -> 刷新基金行情 -> API(含前端) -> Agent Worker
rem 真正的启动逻辑在项目里的 start.ps1,这里只做入口。
rem 如果项目换了位置,改下面这一行 PROJ 即可。
rem ============================================================
set "PROJ={project}"
echo ============================================================
echo 金融 Agent 平台 · 一键启动
echo ============================================================
echo.
if not exist "%PROJ%\start.ps1" (
echo [错误] 找不到启动脚本:
echo %PROJ%\start.ps1
echo.
echo 项目可能被移动或改名了。用记事本打开本文件,
echo 把 set "PROJ=..." 那一行改成实际路径。
echo.
pause
exit /b 1
)
cd /d "%PROJ%"
echo 项目目录:%PROJ%
echo 即将启动:API(前端+接口) 、Agent Worker 、并刷新基金行情
echo 就绪后会自动打开浏览器,请稍候...
echo.
rem %%* 让参数能透传给 start.ps1,例如:
rem 启动平台.bat -NoBrowser 不自动开浏览器
rem 启动平台.bat -Port 8100 换端口
rem 启动平台.bat -SkipPriceSync 不联网刷新行情
powershell -NoProfile -ExecutionPolicy Bypass -File "%PROJ%\start.ps1" %*
set "RC=%ERRORLEVEL%"
echo.
echo ============================================================
if "%RC%"=="0" (
echo 启动完成。服务在**独立窗口**里运行,关掉本窗口不影响它们。
) else (
echo 启动过程返回了错误码 %RC%,请往上翻看红色输出。
)
echo.
echo 服务窗口说明:
echo - 平台 API :接口 + 前端页面,地址 http://127.0.0.1:8000/portal/
echo - Agent Worker:客服对话、知识向量同步、风控扫描,必须一起开着
echo.
echo 常用命令(在本目录新开一个终端执行):
echo python tools\seed_demo_data.py 首次使用,准备演示数据
echo python tools\sync_market_prices.py 下单报"行情已过期"时补刷
echo python tools\e2e_smoke_test.py 交付自检
echo ============================================================
echo.
pause
"""
def build(project_root: Path) -> str:
return BAT_TEMPLATE.format(project=project_root)
def write_bat(path: Path, content: str) -> None:
"""按 GBK + CRLF 落盘:这是 cmd 能正确解析并显示中文的唯一组合。
⚠️ **换行必须是 CRLF**,这一点踩过坑:`cmd` 对 LF-only 的批处理会行边界错乱,
把本该 `echo` 出去的说明文字当成命令执行(实测出现 `AT 命令已弃用`、
`']' 不是内部或外部命令` 这种莫名其妙的报错,还会真的去跑脚本)。
"""
try:
encoded = content.encode("gbk")
except UnicodeEncodeError as exc: # pragma: no cover - 防御性
raise SystemExit(
f"[失败] 内容含 GBK 无法表示的字符({exc.object[exc.start:exc.end]!r})。"
"bat 里只能用 GBK 能表示的字符。"
) from exc
# 先把 CRLF 归一成 LF,再统一展开 —— 否则模板里已经是 CRLF 的行会变成 CRCRLF。
encoded = encoded.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
path.write_bytes(encoded)
# 校验(不是"我觉得写对了",是真的读回来验):无 BOM、纯 CRLF、GBK 可逆。
raw = path.read_bytes()
assert not raw.startswith(b"\xef\xbb\xbf"), "bat 不能带 BOM:cmd 会把 BOM 当命令的一部分"
assert b"\n" not in raw.replace(b"\r\n", b""), "存在裸 LF:cmd 会错乱解析"
# 比编码是否可逆时先把两边都归一成 LF,否则比的是"换行风格"而不是"内容"。
assert raw.decode("gbk").replace("\r\n", "\n") == content.replace("\r\n", "\n"), \
"GBK 往返不一致"
print(f"[OK] 已写入 {path}({len(raw)} 字节,GBK/CRLF,无 BOM)")
def ensure_ps1_bom(ps1_path: Path) -> None:
"""`start.ps1` 缺 BOM 会让 Windows PowerShell 5.1 按 GBK 解析、直接抛语法错。"""
raw = ps1_path.read_bytes()
if raw.startswith(b"\xef\xbb\xbf"):
print(f"[OK] {ps1_path.name} 已带 BOM")
return
ps1_path.write_bytes(b"\xef\xbb\xbf" + raw)
print(f"[修好] {ps1_path.name} 缺 BOM,已补上(否则 PowerShell 5.1 解析中文会报语法错)")
def main() -> int:
target_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.home() / "Desktop"
if not target_dir.is_dir():
print(f"[失败] 目标目录不存在:{target_dir}")
return 1
ps1_path = PROJECT_ROOT / "start.ps1"
if not ps1_path.is_file():
print(f"[失败] 找不到 {ps1_path}")
return 1
ensure_ps1_bom(ps1_path)
content = build(PROJECT_ROOT)
# 桌面:用户双击的那个
write_bat(target_dir / "启动金融Agent平台.bat", content)
# 项目根:随仓库走,别人 clone 下来也能双击(路径指向自己所在目录)
write_bat(PROJECT_ROOT / "启动平台.bat", content)
print()
print("完成。双击桌面的「启动金融Agent平台.bat」即可:")
print(" 依赖检查 → 刷新基金行情 → API(前端) → Agent Worker → 自动开浏览器")
print("重复双击是安全的:API 按端口判断、Worker 按进程判断,都不会起第二份。")
return 0
if __name__ == "__main__":
sys.exit(main())