用户说「webui 你也没改呢」。查证:**部署是活的**(本地产物 = 线上产物、CSS 里壁纸 修复的规则都在、入口 `Cache-Control: no-cache`、资源哈希+immutable)——是我新加的 "外观存服务端"那套在**浏览器**里根本没生效。沿途挖出三处叠加缺陷 + 一处部署链真空子: ## ① 路径写成绝对 `/api/v1/...`(双前缀 ⇒ 404) `resolveBase()` 解析出来的 base 已经含 `/api/v1`(默认就是它),既有调用者传的都是 `/me/mail/inbox` 这种**相对基地址**的形状。我写成 `/api/v1/me/appearance` ⇒ 实际请求 `/api/v1/api/v1/me/appearance` ⇒ 404。 **单测全绿却没抓住**:我只断言了方法、报文,没断言 URL。现在补了 URL 判据 (含"不得出现 /api/v1/api/v1"这条)。 ## ② 浏览器密码登录只有 cookie、没有 Bearer ⇒ `currentAuth()` 直接短路 `currentAuth()` 原先要求 token 非空,而密码登录只建 cookie 会话(桌面端粘贴用户密钥 才设 Bearer)⇒ WebUI 里 `pull/push` 从来没跑过。已放宽为"只要有 base",并补了两条 判据(cookie 会话也要能拉、能推)。 ("完全没有网关地址 ⇒ local-only"这条判据删掉了:`resolveBase()` 总有默认值, 那个状态到不了 —— 判据不量够不着的对象。) ## ③ 服务端"无记录"时拿默认值覆盖本地 首次启用同步时每个老用户都会中招:服务端回默认值(theme=system / bg=none), 客户端照着应用 ⇒ **用户已有的主题与本地壁纸被静默重置**。现在改为"以本地为准、 推上去认领",并补判据(含"有记录时以服务端为准"的反向对照)。 ## ④ 部署链真空子:dist 比源码旧也能"同步成功" 改完源码忘了 `vite build`,`redeploy-gateway.sh` 照样把旧 dist 打进二进制 —— 这正是 ①在线上一直没被发现的直接原因。现在部署脚本会比对 `src/**` 与 `dist/index.html` 的 mtime,旧了就 **FAIL** 并提示先 build。 ## 顺带:我自己在真实账号上留的测试数据 线上 E2E 时我把 `theme=dark/bg=preset(dusk)/dim=35` PUT 到了 **jianf** 这个真实账号 (应该用测试账号)。已删掉那条记录(接口现在回 `saved:false`),配合 ③ 的修复, 用户本地那份外观会被认领上去而不会被覆盖。 ## 验证 - 浏览器实测(自带无头 Chromium + 真实功能,非注入 CSS): `200 GET /api/v1/me/appearance` → `data-bg=on`、`dark=true`、本地缓存写入 ✓ - 三张对比图(自定义图片档 / 关背景 / 预设渐变)已随邮件发给用户 - 前端 253 条(含新增 URL 判据与 cookie 会话判据)、server 10 包、打包一致性全绿
255 lines
9.9 KiB
Bash
Executable File
255 lines
9.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# redeploy-gateway.sh —— Gateway 二进制热替换(原子化,带备份与后置验证)
|
||
#
|
||
# 为什么需要这个脚本:日常改后端只需要换二进制,跑整个 install.sh 太重
|
||
# (它会重装 npm 依赖、重写 systemd 单元、重新生成 env)。而手工
|
||
# 「systemctl stop → cp → start」有两个隐患:
|
||
#
|
||
# 1. cp 是就地写入,会改坏**正在运行中进程**的可执行映像。
|
||
# 即使先 stop 了,中途失败也会留下一个半截二进制且旧的已被覆盖。
|
||
# install(1) 本质是 rename,是原子的 —— 要么完整换掉,要么原样不动。
|
||
#
|
||
# 2. 不备份数据库。SQLite 在 WAL 模式下 cp 会拿到不一致快照
|
||
# (主库文件与 -wal 不同步),恢复时可能丢最近写入甚至损坏。
|
||
# 必须用 sqlite3 .backup,它走的是官方在线备份 API。
|
||
#
|
||
# 纪律来自 git-release-discipline skill 第五章:
|
||
# 发布终点不是「推上去了」,而是后置验证清单全绿。任一项不过就回滚,
|
||
# 不要「先上着再修」。
|
||
#
|
||
# 用法:
|
||
# bash deploy/redeploy-gateway.sh # 完整流程(含测试)
|
||
# bash deploy/redeploy-gateway.sh --skip-tests # 跳过测试(急救时用)
|
||
# bash deploy/redeploy-gateway.sh --dry-run # 只打印将执行的动作
|
||
#
|
||
# 退出码: 0=成功 1=失败或验证不过(已尝试回滚) 2=参数/环境问题
|
||
|
||
set -uo pipefail
|
||
|
||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
PREFIX="${AGENTMAIL_PREFIX:-/opt/agentmail}"
|
||
TARGET="$PREFIX/agentmail-gateway"
|
||
DB="$PREFIX/data/agentmail.db"
|
||
SERVICE="agentmail-gateway"
|
||
HEALTH_URL="${AGENTMAIL_HEALTH_URL:-http://127.0.0.1:8180/health}"
|
||
TS="$(date +%Y%m%d-%H%M%S)"
|
||
|
||
SKIP_TESTS=0
|
||
DRY_RUN=0
|
||
SYNC_WEB=1
|
||
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--skip-tests) SKIP_TESTS=1; shift ;;
|
||
--skip-web) SYNC_WEB=0; shift ;;
|
||
--dry-run) DRY_RUN=1; shift ;;
|
||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
|
||
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||
|
||
say() { printf '\n=== %s\n' "$*"; }
|
||
ok() { printf ' [ OK ] %s\n' "$*"; }
|
||
bad() { printf ' [FAIL] %s\n' "$*"; }
|
||
warn() { printf ' [WARN] %s\n' "$*"; }
|
||
|
||
run() {
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] %s\n' "$*"
|
||
return 0
|
||
fi
|
||
printf ' $ %s\n' "$*"
|
||
eval "$@"
|
||
}
|
||
|
||
[ -d "$REPO/server" ] || { bad "找不到 $REPO/server"; exit 2; }
|
||
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3,无法安全备份数据库"; exit 2; }
|
||
|
||
say "0. 计划"
|
||
printf ' 仓库 : %s\n' "$REPO"
|
||
printf ' 目标 : %s\n' "$TARGET"
|
||
printf ' 数据库 : %s\n' "$DB"
|
||
printf ' 服务 : %s\n' "$SERVICE"
|
||
[ "$DRY_RUN" = 1 ] && printf ' 模式 : DRY-RUN(不落地)\n' || printf ' 模式 : 真实执行\n'
|
||
|
||
# ---------------------------------------------------------------- 1 前端产物
|
||
if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then
|
||
say "1. 同步前端产物进 go:embed 目录"
|
||
# 只清构建产物:placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
|
||
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库带走。
|
||
run "rm -rf '$REPO/server/internal/static/static/assets'"
|
||
run "rm -f '$REPO/server/internal/static/static/index.html'"
|
||
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'"
|
||
ok "前端产物已同步"
|
||
|
||
# 运行时脚本必须装在安装根下 —— 单元/drop-in 里引用的是
|
||
# /opt/agentmail/bin/service-failure-notify.mjs,不是仓库路径。
|
||
# 漏了这一步,故障通知会在"仓库被挪走/改名"时静默失效(2026-09-14 修的就是这个)。
|
||
# 前端产物必须比源码新,否则会把旧界面打进二进制。
|
||
# 2026-09-14 实测踩过:改了 src/lib/appearance.ts 的请求路径却没跑 vite build,
|
||
# 部署脚本照样"同步成功",服务出去的还是旧 bundle —— 表现为接口 404
|
||
# (/api/v1/api/v1/… 双前缀),而所有单测都是绿的。
|
||
if [ -d "$REPO/client/electron/src" ] && [ -d "$REPO/client/electron/dist" ]; then
|
||
newer=$(find "$REPO/client/electron/src" -type f -newer "$REPO/client/electron/dist/index.html" 2>/dev/null | head -3)
|
||
if [ -n "$newer" ]; then
|
||
echo " [FAIL] 前端 dist 比源码旧(先跑:cd client/electron && npm run build)"
|
||
echo " 更新的文件:$(echo "$newer" | tr '\n' ' ')"
|
||
exit 1
|
||
fi
|
||
echo " [ OK ] 前端产物比源码新"
|
||
fi
|
||
install -d /opt/agentmail/bin
|
||
install -m 0755 "$REPO/deploy/service-failure-notify.mjs" /opt/agentmail/bin/service-failure-notify.mjs
|
||
else
|
||
say "1. 跳过前端同步"
|
||
[ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "client/electron/dist 不存在,先跑 cd client/electron && npm run build"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 2 静态检查与测试
|
||
say "2. 构建前检查"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] go vet ./... && go test ./...\n'
|
||
else
|
||
( cd "$REPO/server" && go vet ./... ) || { bad "go vet 不过,终止"; exit 1; }
|
||
ok "go vet 通过"
|
||
if [ "$SKIP_TESTS" = 1 ]; then
|
||
warn "--skip-tests:跳过测试(急救模式,事后必须补跑)"
|
||
else
|
||
( cd "$REPO/server" && timeout 280 go test ./... -timeout 250s ) \
|
||
|| { bad "测试不过,终止部署"; exit 1; }
|
||
ok "go test 全包通过"
|
||
fi
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 3 构建
|
||
say "3. 构建二进制"
|
||
STAGE="/tmp/agentmail-gateway-build-$TS"
|
||
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
|
||
run "rm -f '$STAGE'"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
printf ' [dry-run] (cd server && go build -o %s ./cmd/server)\n' "$STAGE"
|
||
else
|
||
( cd "$REPO/server" && go build -o "$STAGE" ./cmd/server ) \
|
||
|| { bad "构建失败"; exit 1; }
|
||
ok "构建完成 $(du -h "$STAGE" | cut -f1)"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 4 备份数据库
|
||
say "4. 备份数据库(sqlite3 .backup,不能用 cp —— WAL 下 cp 会拿到不一致快照)"
|
||
DBBAK=""
|
||
if [ -f "$DB" ]; then
|
||
DBBAK="/tmp/agentmail-pre-deploy-$TS.db"
|
||
run "sqlite3 '$DB' \".backup '$DBBAK'\"" || { bad "备份失败,终止部署"; exit 1; }
|
||
if [ "$DRY_RUN" != 1 ]; then
|
||
integ="$(sqlite3 "$DBBAK" 'PRAGMA integrity_check;' 2>&1 | head -1)"
|
||
if [ "$integ" = "ok" ]; then
|
||
ok "备份完成 $DBBAK($(du -h "$DBBAK" | cut -f1)),integrity_check=ok"
|
||
else
|
||
bad "备份完整性异常: $integ"; exit 1
|
||
fi
|
||
fi
|
||
else
|
||
warn "数据库不存在(首次部署?): $DB"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 5 旧二进制留档
|
||
say "5. 旧二进制留档"
|
||
BINBAK=""
|
||
if [ -f "$TARGET" ]; then
|
||
BINBAK="${TARGET}.bak-${TS}"
|
||
run "install -m 0755 '$TARGET' '$BINBAK'"
|
||
ok "旧二进制留档 $BINBAK"
|
||
else
|
||
warn "目标不存在(首次安装)"
|
||
fi
|
||
|
||
# ---------------------------------------------------------------- 6 原子替换
|
||
say "6. 停服 → 原子替换 → 起服"
|
||
# 为什么仍要 stop:SQLite 单写者,且换掉二进制后旧进程还在跑旧代码,
|
||
# 与新库 schema 可能不一致。install 保证的是「文件替换本身」原子,
|
||
# 不代表可以热换正在服务的进程。
|
||
run "systemctl stop '$SERVICE'"
|
||
run "install -m 0755 '$STAGE' '$TARGET'" || {
|
||
bad "替换失败"
|
||
[ -n "$BINBAK" ] && run "install -m 0755 '$BINBAK' '$TARGET'"
|
||
run "systemctl start '$SERVICE'"
|
||
exit 1
|
||
}
|
||
run "systemctl start '$SERVICE'"
|
||
run "sleep 6"
|
||
|
||
# ---------------------------------------------------------------- 7 后置验证
|
||
say "7. 后置验证清单(发布终点是这张单子全绿,不是「换上去了」)"
|
||
if [ "$DRY_RUN" = 1 ]; then
|
||
cat <<'EOF'
|
||
[dry-run] 真实执行时逐项校验:
|
||
[ ] 服务 active
|
||
[ ] /health 可达
|
||
[ ] 近 2 分钟无 panic/fatal
|
||
[ ] 四个 Agent 桥的 SSE 重新连上
|
||
EOF
|
||
echo
|
||
echo " DRY-RUN 结束。确认无误后去掉 --dry-run 重跑。"
|
||
exit 0
|
||
fi
|
||
|
||
CHECK_FAIL=0
|
||
|
||
if systemctl is-active --quiet "$SERVICE"; then
|
||
ok "服务 $SERVICE active"
|
||
else
|
||
bad "服务未 active"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
if curl -sf -m 5 "$HEALTH_URL" >/dev/null 2>&1; then
|
||
ok "健康检查通过 $HEALTH_URL"
|
||
else
|
||
bad "健康检查失败 $HEALTH_URL"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
fc="$(journalctl -u "$SERVICE" --since '2 min ago' --no-pager 2>/dev/null \
|
||
| grep -icE 'panic|fatal|SIGSEGV' || true)"
|
||
if [ "${fc:-0}" = "0" ]; then
|
||
ok "近 2 分钟无 panic/fatal"
|
||
else
|
||
bad "近 2 分钟出现 $fc 条 panic/fatal"; CHECK_FAIL=$((CHECK_FAIL+1))
|
||
fi
|
||
|
||
# 桥重连:Gateway 重启会掐断所有 SSE,插件应当在几秒内自己回来。
|
||
# 一个都没回来通常意味着密钥被撤销(停用 Agent 会撤销密钥)或端口没起。
|
||
sse="$(journalctl -u "$SERVICE" --since '1 min ago' --no-pager 2>/dev/null \
|
||
| grep -c 'Client connected' || true)"
|
||
if [ "${sse:-0}" -gt 0 ]; then
|
||
ok "已有 $sse 个 SSE 客户端重新连上"
|
||
else
|
||
warn "暂未看到 SSE 重连 —— 若插件应当在线,检查密钥是否被撤销(停用会撤销密钥)"
|
||
fi
|
||
|
||
echo
|
||
echo " 仍需人工确认(脚本无法代替):"
|
||
echo " [ ] 真实发一封邮件端到端跑通(不是只看进程起来了)"
|
||
echo " [ ] WebUI 能登录且列表正常"
|
||
|
||
echo
|
||
if [ "$CHECK_FAIL" -gt 0 ]; then
|
||
say "结论: 验证有 $CHECK_FAIL 项失败 —— 正在回滚,不要「先上着再修」"
|
||
if [ -n "$BINBAK" ]; then
|
||
run "systemctl stop '$SERVICE'"
|
||
run "install -m 0755 '$BINBAK' '$TARGET'"
|
||
run "systemctl start '$SERVICE'"
|
||
ok "已回滚到 $BINBAK"
|
||
else
|
||
warn "无旧二进制可回滚"
|
||
fi
|
||
[ -n "$DBBAK" ] && echo " 如需恢复数据库(先停服务): install -m 0644 '$DBBAK' '$DB'"
|
||
exit 1
|
||
fi
|
||
|
||
say "结论: 自动项全绿"
|
||
echo " 回滚命令(留档 24 小时内有效):"
|
||
[ -n "$BINBAK" ] && echo " install -m 0755 '$BINBAK' '$TARGET' && systemctl restart '$SERVICE'"
|
||
[ -n "$DBBAK" ] && echo " install -m 0644 '$DBBAK' '$DB' # 先 systemctl stop"
|
||
exit 0
|