## 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。同版本号无法区分新旧,部署验证会失去锚点。
348 lines
15 KiB
Bash
Executable File
348 lines
15 KiB
Bash
Executable File
#!/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 "完成"
|