Files
MailUI4Agents/deploy/redeploy-gateway.sh
JianFeeeee ca96f77a4b fix(appearance): 浏览器里同步从来没跑起来(三处叠加)+ 部署链加"前端不得比源码旧"闸门
用户说「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 包、打包一致性全绿
2026-09-14 08:59:32 +08:00

255 lines
9.9 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.

#!/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. 停服 → 原子替换 → 起服"
# 为什么仍要 stopSQLite 单写者,且换掉二进制后旧进程还在跑旧代码,
# 与新库 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