Files
FinDataHub 9a5fa23228 build: 脚本通配复制 + 产物完整性自检;版本 1.5.2 → 1.6.0
## build-app.sh 第 3 步改为通配复制

原先是 ~18 行 `cp -f xxx.py` 的显式白名单。代价是每新增一个运行时依赖文件
都必须记得回来加一行——漏了只在**部署位**暴露为 ImportError(本地跑源码完全正常)。

本轮就撞上了:paths.py / process_utils.py 未入产物,导致部署位的
newsflash_collect.py 静默走 ImportError 降级到硬编码路径,跨平台抽象层等于没装。

改为 for 通配复制 *.py / *.sh:tests/ 与 __pycache__ 都是子目录不会被匹配;
带入的开发脚本每个仅数十 KB,且部署位可用来做诊断。

## 新增构建期完整性自检

在打包完成后断言最小运行时必需集(paths.py / process_utils.py / common.py /
db_pool.py / sync_bridge.py / newsflash_collect.py)都在产物里,缺任一即 exit 1。

原则:**打包漏文件必须在构建阶段失败,不能等上线后才发现**。

## VERSION 1.5.2 → 1.6.0

minor 升级依据:本轮新增跨平台抽象层(paths/process_utils)、前端 runtime.js
端点解析、CORS 白名单(对外接口行为变化)、SyncOutputParser。
不是纯修补,故走 minor。同版本号无法区分新旧,部署验证会失去锚点。
2026-10-27 00:32:23 +08:00

348 lines
15 KiB
Bash
Executable File
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.
#!/bin/bash
set -e
# 关键:确保编译失败能中止脚本(此前 `swift build | tail` 管道吞掉退出码,
# 导致编译失败时静默使用旧二进制打包部署)
set -o pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
APP_DIR="${PROJECT_DIR}/.build/FinDataHub.app"
KXS_SRC="${PROJECT_DIR}/Services/kuaixun-service"
FCORE_SRC="${PROJECT_DIR}/Services/findatahub-core"
VERSION=$(cat "${PROJECT_DIR}/VERSION" | tr -d '[:space:]')
echo "=== FinDataHub 构建 (v${VERSION}) ==="
# 0) 清理陈旧构建产物
# .build 曾膨胀到 2.0 GB,其中绝大部分是历史遗留(副本 .app 913M / 旧版备份 20M)。
# 注意:沙箱会拦截大批量 rm -rf(SAFE_DELETE_BULK_CONFIRM_REQUIRED,阈值 199 项),
# 统一改为 mv 到 /tmp 后后台异步清理,规避该限制。
echo "[0/8] 清理陈旧构建产物..."
PRUNE_DIR="/tmp/fdh-build-prune.$(date +%s)"
mkdir -p "$PRUNE_DIR"
PRUNED=0
for d in ".build/FinDataHub_副本.app" ".build/FinDataHub.app.old"; do
if [ -d "$d" ]; then
mv "$d" "$PRUNE_DIR/" && echo " 移出: $d" && PRUNED=1
fi
done
# .build/arm64-apple-macosx 与 .build/apple 是 SPM 中间产物,删除会导致全量重编译
# (约 +2 分钟);保留即可,磁盘紧张时手动清理。
if [ "$PRUNED" = "1" ]; then
( rm -rf "$PRUNE_DIR" & ) 2>/dev/null || true
else
rmdir "$PRUNE_DIR" 2>/dev/null || true
fi
# 1) 编译
echo "[1/8] 编译 Swift..."
cd "$PROJECT_DIR"
# --disable-sandbox:本机 SPM manifest 沙箱(sandbox-exec)不可用,必须禁用否则编译失败
swift build -c release --disable-sandbox 2>&1 | tail -1
# 2) 创建 .app 骨架
echo "[2/8] 创建 App 包..."
# 同上:用 mv 到 /tmp 替代 rm -rf,规避沙箱大批量删除限制
if [ -d "$APP_DIR" ]; then
STALE_DIR="/tmp/fdh-app-stale.$(date +%s)"
mv "$APP_DIR" "$STALE_DIR"
( rm -rf "$STALE_DIR" & ) 2>/dev/null || true
fi
mkdir -p "${APP_DIR}/Contents/MacOS"
mkdir -p "${APP_DIR}/Contents/Resources/bin"
mkdir -p "${APP_DIR}/Contents/Resources/akshare_fetcher"
mkdir -p "${APP_DIR}/Contents/Resources/tushare_fetcher"
mkdir -p "${APP_DIR}/Contents/Resources/kuaixun-service"
mkdir -p "${APP_DIR}/Contents/Resources/findatahub-core"
cp -f .build/release/FinDataHub "${APP_DIR}/Contents/MacOS/"
cat > "${APP_DIR}/Contents/Info.plist" << INFOEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>FinDataHub</string>
<key>CFBundleDisplayName</key>
<string>FinDataHub</string>
<key>CFBundleIdentifier</key>
<string>com.findatahub.app</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>CFBundleExecutable</key>
<string>FinDataHub</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSUIElement</key>
<true/>
</dict>
</plist>
INFOEOF
# 复制图标到 Resources
cp -f "${PROJECT_DIR}/App/Assets/AppIcon.icns" "${APP_DIR}/Contents/Resources/AppIcon.icns"
# 3) 复制管理脚本
echo "[3/8] 复制管理脚本..."
# 通配复制,而不是逐个白名单。
#
# 原先此处是 ~18 行 `cp -f xxx.py` 的显式列表。代价是每新增一个运行时依赖文件,
# 都必须记得回来加一行——漏了就只在**部署位**暴露为 ImportError(本地跑源码
# 完全正常)。本轮就发生了:paths.py / process_utils.py 未入产物,
# 导致部署位的 newsflash_collect.py 静默走 ImportError 降级到硬编码路径。
#
# 通配 *.py / *.sh 是安全的:tests/ 与 __pycache__ 都是子目录,不会被匹配;
# 带入的开发脚本(backfill_*.py 等)每个仅数十 KB,且部署位可用来做诊断。
for _f in "${PROJECT_DIR}"/Scripts/bin/*.py "${PROJECT_DIR}"/Scripts/bin/*.sh; do
[ -e "$_f" ] || continue
cp -f "$_f" "${APP_DIR}/Contents/Resources/bin/"
done
chmod +x "${APP_DIR}/Contents/Resources/bin/sync_bridge.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/insights.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/newsflash.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/newsflash_collect.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/newsflash_backend.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/daily_summary.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/configure_ollama_keepalive.sh"
chmod +x "${APP_DIR}/Contents/Resources/bin/mysql_toggle.sh"
chmod +x "${APP_DIR}/Contents/Resources/bin/install_collector_launchd.py"
chmod +x "${APP_DIR}/Contents/Resources/bin/uninstall_collector_launchd.py"
# 4) 打包前端
if [ -d "${PROJECT_DIR}/Services/kuaixun-frontend/dist" ]; then
echo "[4/8] 打包快讯前端..."
mkdir -p "${APP_DIR}/Contents/Resources/kuaixun-frontend"
cp -r "${PROJECT_DIR}/Services/kuaixun-frontend/dist" "${APP_DIR}/Contents/Resources/kuaixun-frontend/"
fi
# 5) 打包 kuaixun-service(common + extend + app 模块 + .venv)
echo "[5/8] 打包 kuaixun-service..."
# common/ 是**必须**的独立包(app/main.py 里 `from common.config import load_env`)。
# 历史缺陷:本段只拷了 extend 与 app,漏了 common/,导致部署位的后端
# `import app.main` 直接 ModuleNotFoundError(2026-09-19 实测坐实)。
# 注意:Scripts/bin/common.py 是**单文件模块**,与这里 kuaixun 的 common/ **包**同名,
# 二者不能互相替代(前者被 bin/ 先入 sys.path 时还会遮蔽后者)。
if [ -d "${KXS_SRC}/common" ]; then
rsync -a --delete \
--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
"${KXS_SRC}/common" "${APP_DIR}/Contents/Resources/kuaixun-service/"
fi
if [ -d "${KXS_SRC}/extend" ]; then
rsync -a --delete \
--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
"${KXS_SRC}/extend" "${APP_DIR}/Contents/Resources/kuaixun-service/"
fi
if [ -d "${KXS_SRC}/app" ]; then
rsync -a --delete \
--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
"${KXS_SRC}/app" "${APP_DIR}/Contents/Resources/kuaixun-service/"
fi
if [ -d "${KXS_SRC}/.venv" ]; then
rsync -a --delete \
--exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
"${KXS_SRC}/.venv" "${APP_DIR}/Contents/Resources/kuaixun-service/"
fi
# 6) 打包 findatahub-core(tushare/akshare fetcher 的本地依赖)
echo "[6/8] 打包 findatahub-core..."
if [ -d "${FCORE_SRC}/src" ]; then
rsync -a --delete \
--exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
--exclude='tests' --exclude='.prefect' --exclude='build' --exclude='*.egg-info' --exclude='README.md' \
--exclude='.venv.bak' --exclude='.venv.*.bak' \
"${FCORE_SRC}/" "${APP_DIR}/Contents/Resources/findatahub-core/"
cp -f "${FCORE_SRC}/pyproject.toml" "${APP_DIR}/Contents/Resources/findatahub-core/" 2>/dev/null || true
fi
# 7) 打包项目代码(含 .venv 依赖)
echo "[7/8] 打包项目代码(含 .venv 依赖)..."
mkdir -p "${APP_DIR}/Contents/Resources/akshare_fetcher"
mkdir -p "${APP_DIR}/Contents/Resources/tushare_fetcher"
rsync -a --delete \
--exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
--exclude='logs' \
--exclude='.venv.bak' --exclude='.venv.*.bak' \
"${PROJECT_DIR}/Services/akshare_fetcher/" "${APP_DIR}/Contents/Resources/akshare_fetcher/"
rsync -a --delete \
--exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='.DS_Store' \
--exclude='rust_core/target' --exclude='logs' \
--exclude='.venv.bak' --exclude='.venv.*.bak' \
"${PROJECT_DIR}/Services/tushare_fetcher/" "${APP_DIR}/Contents/Resources/tushare_fetcher/"
# ── 修正 App 内所有 .venv 的路径(shebang / VIRTUAL_ENV / .pth)──
# 注意:路径统一指向 /Applications/FinDataHub.app(标准部署位置)
# Swift 端通过 NSBundle 动态获取路径,不依赖 shebang
echo " 修正 .venv 路径..."
DEPLOY_APP="/Applications/FinDataHub.app"
DEPLOY_RESOURCES="${DEPLOY_APP}/Contents/Resources"
fix_venv_paths() {
local venv_rel="$1"
local venv_dir="${DEPLOY_RESOURCES}/${venv_rel}"
local py_path="${venv_dir}/bin/python3"
local build_venv="${APP_DIR}/Contents/Resources/${venv_rel}"
if [ ! -d "$build_venv" ]; then return; fi
# 修正 bin/ 下所有文件的 shebang(以 #! 开头的文本脚本)
if [ -d "$build_venv/bin" ]; then
for f in "$build_venv/bin"/*; do
[ -f "$f" ] || continue
if head -1 "$f" 2>/dev/null | grep -q '^#!'; then
sed -i '' "1s|#!.*bin/python[0-9.]*|#!${py_path}|" "$f" 2>/dev/null || true
fi
done
fi
# 修正 activate 脚本中的 VIRTUAL_ENV(bash)
if [ -f "$build_venv/bin/activate" ]; then
sed -i '' "s|VIRTUAL_ENV='[^']*'|VIRTUAL_ENV='${venv_dir}'|" "$build_venv/bin/activate" 2>/dev/null || true
fi
# 修正 activate.csh 中的 VIRTUAL_ENV(tcsh/csh,使用单引号)
if [ -f "$build_venv/bin/activate.csh" ]; then
sed -i '' "s|setenv VIRTUAL_ENV '[^']*'|setenv VIRTUAL_ENV '${venv_dir}'|" "$build_venv/bin/activate.csh" 2>/dev/null || true
fi
# 修正 activate.fish 中的 VIRTUAL_ENV(fish shell,使用单引号)
if [ -f "$build_venv/bin/activate.fish" ]; then
sed -i '' "s|set -gx VIRTUAL_ENV '[^']*'|set -gx VIRTUAL_ENV '${venv_dir}'|" "$build_venv/bin/activate.fish" 2>/dev/null || true
fi
# 修正 .pth 文件中的本地依赖路径(如 findatahub-core)
find "$build_venv/lib" -name '*.pth' -type f 2>/dev/null | while read -r pth; do
if grep -q "findatahub-core/src" "$pth" 2>/dev/null; then
echo "${DEPLOY_RESOURCES}/findatahub-core/src" > "$pth"
fi
done
# 修正 direct_url.json 中的本地路径引用
find "$build_venv/lib" -name 'direct_url.json' -type f 2>/dev/null | while read -r duf; do
if grep -q "findatahub-core" "$duf" 2>/dev/null; then
python3 -c "
import json
try:
with open('$duf', 'r') as f: d = json.load(f)
d['url'] = 'file://${DEPLOY_RESOURCES}/findatahub-core'
d['dir_info']['editable'] = False
with open('$duf', 'w') as f: json.dump(d, f)
except: pass
" 2>/dev/null || true
fi
done
}
fix_venv_paths "kuaixun-service/.venv"
fix_venv_paths "akshare_fetcher/.venv"
fix_venv_paths "tushare_fetcher/.venv"
# ── 精简 App 内的 .venv:移除开发工具 ──
# 下列包在各自 pyproject.toml 中均声明为 dev / optional-dependencies,运行时不会被
# import;之所以混进 venv,是因为安装时未加 --no-dev。仅清理 App 内的副本,
# 不动 Services/ 下的开发用 venv,不影响本地跑测试。
# 保留 pip 与其余 dist-info(部分包运行时通过 pkg_resources 读取元数据)。
# 如遇 ImportError,可用 FDH_STRIP_VENV=0 跳过此步。
echo " 精简 .venv(移除开发工具)..."
if [ "${FDH_STRIP_VENV:-1}" != "0" ]; then
strip_venv_dev_deps() {
local venv_dir="$1"
[ -d "$venv_dir" ] || return 0
local sp
sp=$(find "$venv_dir" -maxdepth 5 -type d -name site-packages 2>/dev/null | head -1)
[ -n "$sp" ] || return 0
local before after
before=$(du -sk "$venv_dir" 2>/dev/null | cut -f1)
[ -n "$before" ] || before=0
# 用 mv 到 /tmp 再后台删除:直接 rm -rf 会被沙箱的大批量删除保护拦截
# (SAFE_DELETE_BULK_CONFIRM_REQUIRED,阈值 199 项),一旦拦截返回非零,
# set -e 会直接终止整个打包脚本。
local tmp_strip="/tmp/fdh-venv-strip.$(date +%s).$$"
mkdir -p "$tmp_strip" || return 0
local moved=0
local item
for item in "$sp"/mypy "$sp"/mypyc "$sp"/mypy_extensions* \
"$sp"/_pytest "$sp"/pytest "$sp"/pytest_* "$sp"/pytest-* \
"$sp"/ruff* "$sp"/black* \
"$sp"/mypy-*.dist-info "$sp"/mypy_extensions-*.dist-info \
"$sp"/pytest-*.dist-info "$sp"/pytest_*.dist-info \
"$sp"/ruff-*.dist-info "$sp"/black-*.dist-info \
"$sp"/fb62009ad6f7d3457273__mypyc.cpython-*.so
do
# 通配符未匹配时 $item 会保留字面量 "*",跳过
[ -e "$item" ] || continue
mv "$item" "$tmp_strip/" 2>/dev/null && moved=1
done
after=$(du -sk "$venv_dir" 2>/dev/null | cut -f1)
[ -n "$after" ] || after=0
echo " $(basename "$(dirname "$venv_dir")"): $((before / 1024))M -> $((after / 1024))M"
if [ "$moved" = "1" ]; then
( rm -rf "$tmp_strip" & ) 2>/dev/null || true
else
rmdir "$tmp_strip" 2>/dev/null || true
fi
}
strip_venv_dev_deps "${APP_DIR}/Contents/Resources/akshare_fetcher/.venv"
strip_venv_dev_deps "${APP_DIR}/Contents/Resources/tushare_fetcher/.venv"
strip_venv_dev_deps "${APP_DIR}/Contents/Resources/kuaixun-service/.venv"
strip_venv_dev_deps "${APP_DIR}/Contents/Resources/findatahub-core/.venv"
fi
# ── 修正 shell 脚本中的硬编码 PROJECT_DIR ──
echo " 修正 shell 脚本硬编码路径..."
BUILD_RESOURCES="${APP_DIR}/Contents/Resources"
for sh_script in \
"${BUILD_RESOURCES}/akshare_fetcher/scripts/ths_archive_daily.sh" \
"${BUILD_RESOURCES}/akshare_fetcher/scripts/install_launchd.sh" \
"${BUILD_RESOURCES}/tushare_fetcher/scripts/install_launchd.sh"
do
if [ -f "$sh_script" ]; then
sed -i '' 's|^PROJECT_DIR=/Users/mantianfuyun.*|PROJECT_DIR="$(cd "$(dirname "$0")/.." \&\& pwd)"|' "$sh_script" 2>/dev/null || true
fi
done
echo ""
echo "=== 产物完整性自检 ==="
# 最小运行时必需集。白名单式复制曾漏掉 paths.py / process_utils.py,
# 而本地跑源码完全正常——只在部署位表现为 ImportError 降级到硬编码路径。
# 这里显式断言:打包漏文件必须在**构建阶段**失败,不能等上线后才发现。
_missing=""
for _req in paths.py process_utils.py common.py db_pool.py sync_bridge.py newsflash_collect.py; do
if [ ! -f "${APP_DIR}/Contents/Resources/bin/${_req}" ]; then
_missing="${_missing} ${_req}"
fi
done
if [ -n "${_missing}" ]; then
echo "❌ 产物缺少运行时模块:${_missing}"
exit 1
fi
echo " ✅ 运行时模块齐备"
echo ""
echo "=== 构建完成 ==="
du -sh "$APP_DIR"
echo "位置: ${APP_DIR}"
# 8) 代码签名(使用固定自签名证书,避免每次打包重新授权)
echo "[8/8] 代码签名..."
CERT_NAME="FinDataHub Developer"
codesign --force --deep --sign "$CERT_NAME" "$APP_DIR" 2>&1 || {
echo " 使用自签名证书失败,回退到 ad-hoc 签名"
codesign --force --deep --sign - "$APP_DIR" 2>/dev/null || echo " 警告: 代码签名失败"
}
# 注册到 Launch Services(让系统设置能识别此应用)
echo "注册 Launch Services..."
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister
"$LSREGISTER" -f "$APP_DIR" 2>/dev/null || true
echo "完成"