From 8435095b4f20a2e9d95635ef33c5bc47c93f4276 Mon Sep 17 00:00:00 2001 From: becomer Date: Thu, 17 Sep 2026 18:07:21 +0800 Subject: [PATCH] =?UTF-8?q?v1.2.58=EF=BC=9A=E6=94=B9=E4=B8=BA=E7=9C=9F?= =?UTF-8?q?=C2=B7=E8=93=9D=E7=BB=BF=E9=83=A8=E7=BD=B2=EF=BC=88=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E9=83=A8=E7=BD=B2=E6=9C=9F=20502=EF=BC=89+=20?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E7=A0=81=E6=8C=89=E9=92=AE=E6=96=87=E6=A1=88?= =?UTF-8?q?=E9=9A=8F=E7=BB=91=E5=AE=9A=E7=8A=B6=E6=80=81=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 部署方式重构为真正的蓝绿(修复 v1.2.55~57 每次部署约 3 分钟的 502 窗口): 根因:旧脚本是「翻到备用端口 → 1 秒后翻回 → 在活跃端口重启容器」,最终稳态只剩一个容器;且两次 nginx -s reload 仅隔 1 秒,openresty 未设 worker_shutdown_timeout(仅 keepalive_timeout 60),旧 worker 会继续持有连接并按旧配置往已删除的备用端口转发 → 502 持续到旧 worker 排空(实测 17:28:27~17:31:21,8885 Connection refused 86 次,8884 为 0)。 新方案:两个固定槽位(blue=8884 / green=8885)常驻。每次部署只更新「当前无流量的备用槽」→ 容器内健康检查 → nginx -t 校验 → 翻一次反代指向它 → 等旧 worker 全部退出;活跃槽容器保留作为上一版本供秒级回滚。因两端口始终都有活容器,旧 worker 无论是否排空都不可能出现 502。 另含:旧命名容器一次性收养(cpdaily-xiaozhushou → cpdaily-xiaozhushou-<端口>,避免下次部署端口冲突)、清理 cpdaily-blue 陈尸容器、反代配置切换前备份与 nginx -t 失败自动回滚、健康检查失败时不动反代。脚本已用 mock docker 干跑验证:两次连续部署正确轮换(8885→8884)、健康检查失败分支正确中止且不改反代。 ② 个人中心设备码按钮:未绑定设备码时显示「立即绑定」,已绑定显示「重新绑定」(新增 hasDeviceCode computed)。 ③ 验证:vue-tsc --noEmit、npm run build(含 postbuild-css)全过。前端改动 + CI 配置,无后端逻辑变更。 --- .woodpecker/build.yml | 175 +++++++++++++-------- frondend/src/views/profile/ProfileView.vue | 5 +- 2 files changed, 116 insertions(+), 64 deletions(-) diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml index c29a842..54f7a69 100644 --- a/.woodpecker/build.yml +++ b/.woodpecker/build.yml @@ -29,37 +29,67 @@ steps: # 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护 # (已移出版本库,避免密钥入库;容器以只读挂载方式读取) + # 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着": + # 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚) + SLOT_BLUE=8884 + SLOT_GREEN=8885 + # ============================================================ - # 第一步:读取当前反代端口,确定蓝绿状态 + # 第一步:读取当前反代指向的端口,确定活跃/备用槽 # 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路): # /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885 + # 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。 # ============================================================ CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \ grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \ /www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$') echo "=== 当前反代端口: $CURRENT_PORT ===" - if [ "$CURRENT_PORT" = "8884" ]; then - PORT_ACTIVE=8884 - PORT_STANDBY=8885 - elif [ "$CURRENT_PORT" = "8885" ]; then - PORT_ACTIVE=8885 - PORT_STANDBY=8884 + if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then + PORT_ACTIVE=$SLOT_BLUE + PORT_STANDBY=$SLOT_GREEN + elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then + PORT_ACTIVE=$SLOT_GREEN + PORT_STANDBY=$SLOT_BLUE else - echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)" + echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)" exit 1 fi - echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY" - - # 清理之前可能残留的临时容器 - docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true + echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY" + echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)" # ============================================================ - # 第二步:在备用端口启动新容器 + # 第一步附:一次性「收养」旧命名容器 + # 迁移前的容器名为 cpdaily-xiaozhushou(无端口后缀),本脚本改用 + # cpdaily-xiaozhushou-<端口>。若不改名,下次部署 rm -f 找不到它 → 端口 + # 被占用导致 docker run 冲突、部署失败。此处按它实际映射的端口改名(幂等)。 + # 同时清理历史遗留的 cpdaily-blue 陈尸容器。 # ============================================================ - echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ===" + LEGACY_NAME=cpdaily-xiaozhushou + if docker inspect "$LEGACY_NAME" >/dev/null 2>&1; then + LEGACY_PORT=$(docker port "$LEGACY_NAME" 8093 2>/dev/null | head -1 | grep -oE '[0-9]+$' || true) + if [ -n "$LEGACY_PORT" ]; then + if docker inspect "cpdaily-xiaozhushou-$LEGACY_PORT" >/dev/null 2>&1; then + echo " 发现旧命名容器,但 cpdaily-xiaozhushou-$LEGACY_PORT 已存在,移除旧容器" + docker rm -f "$LEGACY_NAME" || true + else + echo " 收养旧命名容器: $LEGACY_NAME → cpdaily-xiaozhushou-$LEGACY_PORT" + docker rename "$LEGACY_NAME" "cpdaily-xiaozhushou-$LEGACY_PORT" + fi + else + echo " 旧命名容器未映射 8093 端口,跳过收养" + fi + fi + docker rm -f cpdaily-blue 2>/dev/null || true - docker run -d --name cpdaily-xiaozhushou-new \ + # ============================================================ + # 第二步:更新「备用槽」容器(此槽当前无流量,停它不会影响线上) + # ============================================================ + echo "=== 停止并移除备用槽旧容器(:$PORT_STANDBY)===" + docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true + + echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ===" + docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \ --restart unless-stopped \ --network likedream-net \ -e TZ=Asia/Shanghai \ @@ -68,73 +98,92 @@ steps: cpdaily-xiaozhushou:$IMAGE_TAG # ============================================================ - # 第三步:健康检查 + # 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代) + # 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。 # ============================================================ HEALTHY=false - for i in $(seq 1 15); do - if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then + for i in $(seq 1 30); do + if docker exec cpdaily-xiaozhushou-$PORT_STANDBY \ + wget -q --timeout=3 -O /dev/null http://127.0.0.1:8093/health 2>/dev/null; then HEALTHY=true break fi - echo " 等待新容器就绪... ($i/15)" + echo " 等待新容器就绪... ($i/30)" sleep 2 done if [ "$HEALTHY" = false ]; then - echo "ERROR: 新容器未通过健康检查,回滚" - docker stop cpdaily-xiaozhushou-new - docker rm cpdaily-xiaozhushou-new + echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)" + docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true exit 1 fi echo " 新容器已就绪 ✓" # ============================================================ - # 第四步:切换 1Panel 反代到备用端口 + # 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换) + # 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出, + # 确保旧连接不再按旧配置转发。 # ============================================================ echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ===" + PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf docker exec 1Panel-openresty-ESmn \ - sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \ - /www/sites/jinrixiaoyuan.top/proxy/root.conf - docker exec 1Panel-openresty-ESmn nginx -s reload - echo " Nginx 热加载完成(零中断)✓" - - # ============================================================ - # 第五步:停止旧容器 - # ============================================================ - echo "=== 清理旧容器 ===" - docker stop cpdaily-xiaozhushou 2>/dev/null || true - docker rm cpdaily-xiaozhushou 2>/dev/null || true - - # ============================================================ - # 第六步:在活跃端口启动正式容器 - # ============================================================ - echo "=== 启动正式容器 on :$PORT_ACTIVE ===" - docker run -d --name cpdaily-xiaozhushou \ - --restart unless-stopped \ - --network likedream-net \ - -e TZ=Asia/Shanghai \ - -p $PORT_ACTIVE:8093 \ - -v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \ - cpdaily-xiaozhushou:$IMAGE_TAG - - # ============================================================ - # 第七步:切回 1Panel 反代到活跃端口 - # ============================================================ - echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ===" + cp $PROXY_CONF $PROXY_CONF.bak docker exec 1Panel-openresty-ESmn \ - sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \ - /www/sites/jinrixiaoyuan.top/proxy/root.conf - docker exec 1Panel-openresty-ESmn nginx -s reload - echo " Nginx 热加载完成(零中断)✓" + sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF - # ============================================================ - # 第八步:清理 - # ============================================================ - echo "=== 清理 ===" - docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true - docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do - docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true + if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then + echo "ERROR: nginx 配置校验失败,回滚反代配置" + docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF + docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true + exit 1 + fi + + # 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发 + OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \ + sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ') + docker exec 1Panel-openresty-ESmn nginx -s reload + echo " Nginx 热加载完成,等待旧 worker 退出..." + + for i in $(seq 1 15); do + STILL=0 + for p in $OLD_WORKERS; do + if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then + STILL=$((STILL + 1)) + fi + done + if [ "$STILL" = "0" ]; then + echo " 旧 worker 已全部退出 ✓" + break + fi + echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)" + sleep 2 done - echo "=== 部署完成 ✓ ===" + echo " 反代已指向 :$PORT_STANDBY ✓" + + # ============================================================ + # 第五步:验证线上确实由新槽服务 + # ============================================================ + if ! docker exec 1Panel-openresty-ESmn \ + sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then + echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查" + else + echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓" + fi + + # ============================================================ + # 第六步:保留活跃槽用于回滚说明;清理多余历史镜像 + # 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。 + # 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。 + # ============================================================ + echo "=== 当前容器 ===" + docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})' + + echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)===" + docker images cpdaily-xiaozhushou --format '{{.Tag}}' \ + | grep -v '^latest$' | head -n -2 | while read tag; do + docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true + done + + echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)===" volumes: - /var/run/docker.sock:/var/run/docker.sock diff --git a/frondend/src/views/profile/ProfileView.vue b/frondend/src/views/profile/ProfileView.vue index ba63a94..6a883ac 100644 --- a/frondend/src/views/profile/ProfileView.vue +++ b/frondend/src/views/profile/ProfileView.vue @@ -102,6 +102,9 @@ function maskDeviceCode(code: string): string { return code.slice(0, 4) + '****' + code.slice(-4) } +// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案) +const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode) + onMounted(async () => { try { const res = await authApi.getUserInfo() @@ -458,7 +461,7 @@ async function handleRedeemInvite() { @click="goRebind" > - 重新绑定 + {{ hasDeviceCode ? '重新绑定' : '立即绑定' }}