Compare commits

..

1 Commits

Author SHA1 Message Date
818562ebdd Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-10-19 19:13:23 +08:00
13 changed files with 167 additions and 654 deletions

View File

@ -5,18 +5,10 @@
# ============================================================ # ============================================================
# ---- 数据库 ---- # ---- 数据库 ----
# 生产复用宿主机上的 Postgres(如 1Panel 自带),容器内经 host-gateway 访问
# 下列 DB_* 同时用于容器与 init-db.sh / backup-db.sh(脚本走 --network host)
DB_USER=fashion DB_USER=fashion
DB_NAME=fashion DB_NAME=fashion
# 上线必改:不要沿用开发库口令 # 上线必改:不要沿用开发库口令
DB_PASSWORD= DB_PASSWORD=
# 容器内访问宿主的固定写法;若数据库在另一台机器,改成那台的地址
DB_HOST=host.docker.internal
DB_PORT=5432
# 1Panel 管理的 Postgres 容器名(init-db.sh / backup-db.sh 直接 docker exec 进去)
# 查法:docker ps --format '{{.Names}}' | grep -i postgres
PG_CONTAINER=1Panel-postgresql-4Ndp
# ---- 后端密钥(必填,缺了容器拒绝启动)---- # ---- 后端密钥(必填,缺了容器拒绝启动)----
# 公开 ID 混淆盐:上线后不要再改,改了所有旧链接失效 # 公开 ID 混淆盐:上线后不要再改,改了所有旧链接失效
@ -39,8 +31,8 @@ S4_REGION=cn-east-1
S4_BASE_URL= S4_BASE_URL=
# ---- 目录与访问 ---- # ---- 目录与访问 ----
# 前端仓库位置(服务器 /opt/fashion 下是 ../frontend_v2) # 前端仓库位置(服务器 /opt/fashion 下默认 ../test)
WEB_DIR=../frontend_v2 WEB_DIR=../test
# 前端 nginx 监听端口(有宿主机 nginx/宝塔占用 80 时改成 8080 再由外层反代) # 前端 nginx 监听端口(有宿主机 nginx/宝塔占用 80 时改成 8080 再由外层反代)
HTTP_PORT=80 HTTP_PORT=80
CORS_ALLOW_ORIGINS=https://xisoa.com,https://www.xisoa.com CORS_ALLOW_ORIGINS=https://xisoa.com,https://www.xisoa.com

11
.gitattributes vendored
View File

@ -1,11 +0,0 @@
# Shell 脚本必须保持 LF:CRLF 会让 Linux 上的 bash 直接报语法错误
# (典型报错:syntax error near unexpected token `$'do\r'')
*.sh text eol=lf
# Dockerfile 同理(CRLF 会导致某些解析器把续行符后的 \r 当成参数)
Dockerfile text eol=lf
Dockerfile.* text eol=lf
# compose / nginx 配置也统一 LF
*.yml text eol=lf
*.conf text eol=lf

View File

@ -1,39 +0,0 @@
name: deploy
on:
<<<<<<< HEAD
# 手动触发:在 Gitea 仓库 Actions 页面点「Run workflow」才部署,
# 避免每次 push 到 main 的半成品代码被自动上线。
=======
>>>>>>> 98284c59e89856adf5a09ec675185b157e98d442
workflow_dispatch:
jobs:
deploy:
runs-on: docker
steps:
<<<<<<< HEAD
- name: SSH 到服务器执行上线脚本
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
cd /opt/fashion/test
./deploy.sh
=======
- name: SSH deploy
shell: sh
env:
KEY_B64: ${{ secrets.DEPLOY_KEY_B64 }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
set -e
apk add --no-cache openssh-client >/dev/null 2>&1 || true
mkdir -p "$HOME/.ssh"
echo "$KEY_B64" | base64 -d > "$HOME/.ssh/id"
chmod 600 "$HOME/.ssh/id"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "$HOME/.ssh/id" \
"$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/fashion/backend_v2 && ./deploy.sh backend"
>>>>>>> 98284c59e89856adf5a09ec675185b157e98d442

3
.gitignore vendored
View File

@ -52,6 +52,3 @@ admin_cookies.txt
# 部署状态与备份(deploy.sh 生成,不进版本库) # 部署状态与备份(deploy.sh 生成,不进版本库)
.deploy/ .deploy/
backups/ backups/
# 本地预构建产物(scripts/push-build.sh 生成)
/server-linux-amd64

416
DEPLOY.md
View File

@ -1,365 +1,175 @@
# 部署手册(1Panel 单机 · Docker 蓝绿 · Gitea Actions) # 部署手册(Docker · 蓝绿零停机)
> 本文记录**当前实际线上环境**的部署方式。与老模板的差异见文末「与默认模板的差异」。
>
> 最后更新:2026-10-20
---
## 0. 一图看懂 ## 0. 一图看懂
``` ```
浏览器 浏览器 ──▶ proxy(nginx:80,常驻,发布时从不重建)
│ https://fashion.23cm.cn (443, Let's Encrypt) ├─ /api ──▶ backend_a 或 backend_b
▼ ├─ / ──▶ web_a 或 web_b
1Panel OpenResty (80/443,宿主机,已服务 git.23cm.cn 等站点) ├─ 18090(仅容器网络)SSR 取数 → 当前活跃后端
│ proxy_pass 127.0.0.1:8080 ├─ 127.0.0.1:18091 前端构建期取 SSG
▼ └─ 127.0.0.1:18092 管理后台(SSH 隧道)
proxy 容器 (nginx:1.27-alpine,常驻,发布时不重建)
├─ /api/ ──▶ backend_a 或 backend_b :8090
├─ / ──▶ web_a 或 web_b :3000
├─ :18090(仅容器网络)SSR 取数 → 当前活跃后端
├─ 宿主 127.0.0.1:18091 → 后端 SSG :8091(构建期取数)
└─ 宿主 127.0.0.1:18092 → 后端管理后台 :8092
│
▼
1Panel PostgreSQL 容器 (5432, 库 fashion, 用户 user_JA83HF)
``` ```
发布时只重建**空闲槽**,等它 healthy 后改写切换文件并 `nginx -s reload`,在途请求由旧槽跑完。 发布时只重建**空闲槽**,等它健康后改写切换文件并 `nginx -s reload`,在途请求由旧槽跑完。
镜像以 `RELEASE`(`时间戳-后端提交号`)打标签,不可变,历史版本不被覆盖。 镜像以 `RELEASE`(`时间戳-后端提交号`)打标签,**不可变**,历史版本不会被覆盖。
部署入口在本仓库(后端仓 `deploy.sh`),脚本会同时同步前端仓。 部署入口在本仓库(后端仓),脚本会自动把前端仓也拉到位。
--- ---
## 1. 服务器现状(只做一次,已就绪) ## 1. 服务器准备(只做一次)
| 项 | 值 |
| --- | --- |
| 主机 | `43.161.253.59`,Ubuntu 24.04,**2 核 / 3.6G 内存 / 1.9G swap** |
| 面板 | 1Panel v2.0.3(已装 Docker 28.3 + Compose v2.38) |
| Gitea | 容器 `1Panel-gitea-1fEW`(EE 24.2.3,Web `:3000` / SSH `:222`,域名 `git.23cm.cn`) |
| Gitea Runner | 容器 `gitea-runner`(act_runner,label `docker`,job 镜像 `node:22-alpine`) |
| 数据库 | 容器 `1Panel-postgresql-4Ndp`(postgres:18.6,`0.0.0.0:5432`,库 `fashion`,用户 `user_JA83HF`) |
| 反向代理 | 容器 `1Panel-openresty-2mkA`(占 80/443,站点配置在 `/opt/1panel/www/conf.d`) |
| 其它 | MySQL / MariaDB / Redis / PHP-FPM×2(供其它站点用,勿动) |
### 目录
```
/opt/fashion/
├── backend_v2/ # 后端仓(部署入口,含 deploy.sh / docker-compose.yml / nginx/)
├── frontend_v2/ # 前端仓(Astro)
└── spider/ # 爬虫仓(Go CLI,当前暂不部署)
```
三个仓库都以 **`dev`** 分支为准(不是 main)。
### 初始化命令(仅重建机器时参考)
```bash ```bash
# Ubuntu 22.04 / 24.04,2 核 4G 起
curl -fsSL https://get.docker.com | sh
apt install -y docker-compose-plugin git
mkdir -p /opt/fashion && cd /opt/fashion mkdir -p /opt/fashion && cd /opt/fashion
git clone -b dev https://git.23cm.cn/toom1996/backend_v2.git backend_v2 git clone <后端仓库> backend
git clone -b dev https://git.23cm.cn/toom1996/frontend_v2.git frontend_v2 git clone <前端仓库> test # 必须与 backend 同级
git clone -b dev https://git.23cm.cn/toom1996/spider.git spider
cd backend_v2 cd backend
cp .env.example .env # 按第 3 节填写 cp .env.example .env && vi .env
chmod +x deploy.sh chmod +x deploy.sh init-db.sh
``` ```
> 数据库已存在且有数据,**不需要** `init-db.sh` / 导入 dump。 私有仓库需要配好拉取凭据:SSH deploy key,或 HTTPS token + `git config credential.helper store`。
--- ---
## 2. 关键配置(已改好,重建机器时照抄) ## 2. 首次部署(只做一次)
### 2.1 docker-compose.yml
相对上游模板做了 3 处适配:
1. **删掉自带 `db` 服务**:改用 1Panel 现有 Postgres(不再另起 pgvector)。
2. **后端连库走宿主网关**:
```yaml
DB_HOST: host.docker.internal
# backend-base 下:
extra_hosts:
- "host.docker.internal:host-gateway"
```
3. **proxy 只绑宿主回环**(80/443 已被 OpenResty 占用):
```yaml
ports:
- "127.0.0.1:${HTTP_PORT:-8080}:80"
- "127.0.0.1:18091:18091"
- "127.0.0.1:18092:18092"
```
### 2.2 nginx/default.conf(重要)
容器内的 18091 / 18092 **必须监听 0.0.0.0**,不能监听 `127.0.0.1`:
```nginx
listen 18091; # 容器内回环监听会导致 docker 端口发布不可达
listen 18092;
```
原因:Docker 端口发布是转发到**容器网卡**,不是容器回环。监听 `127.0.0.1` 时,
宿主机 `127.0.0.1:18091` 会连接被重置,导致**前端构建期 SSG 取数为空、首页无数据**。
宿主机侧仍只发布到 `127.0.0.1`,不会外泄。
### 2.3 deploy.sh(相对模板新增)
- 首次部署顺序修复:先 `render_active` 再 `--no-deps` 起代理(否则 map 为空 nginx 起不来)。
- 新增 `--no-cache`:强制前端不用构建缓存(见第 5 节 SSG)。
---
## 3. `.env`(敏感值只放服务器,600 权限)
路径:`/opt/fashion/backend_v2/.env`(已 gitignore,**绝不入库**)。
```ini
# 数据库(复用 1Panel 现有 Postgres)
DB_USER=user_JA83HF
DB_NAME=fashion
DB_PASSWORD=<真实口令,见下方说明>
# 密钥
HASHID_SECRET= # 留空=内置默认盐,必须与爬虫 ingest.hashid_secret 一致
JWT_SECRET=<随机长串>
INGEST_SECRET=<与爬虫 ingest.secret 一致>
SSG_TOKEN=<随机串,构建期取 SSG 用>
# 对象存储(当前关闭;现有图片走 CDN 公开桶,仍可正常显示)
S4_ENABLED=false
S4_AK=disabled
S4_SK=disabled
# 目录 / 端口
WEB_DIR=../frontend_v2
HTTP_PORT=8080
CORS_ALLOW_ORIGINS=https://fashion.23cm.cn,https://test.23cm.cn
# 其它
GIT_BRANCH=dev
GOPROXY=https://goproxy.cn,direct
```
> **DB 密码从哪来**:以 Postgres 容器的环境变量为准,而不是旧 `config.yml`:
> ```bash
> docker exec 1Panel-postgresql-4Ndp printenv POSTGRES_PASSWORD
> ```
> 旧 `/opt/fashionapi/config.yml` 里的密码被行尾 `#` 注释截断过,是错的(28P01 的根因)。
---
## 4. 数据库
- 复用 1Panel 的 `postgres:18.6`,库 `fashion`,用户 `user_JA83HF`。
- 已开启 pgvector(`vector` 扩展)。
- 主要表:`brands` / `brand_runways` / `brand_runway_images` / `street_snaps` / `street_snap_images` / `users` / `favorites` / `histories` / `ingest_jobs` …
- 后端不再自动迁移,表结构变更走 `cmd/dbtool`。
---
## 5. 部署方式 A:Gitea Actions(手动触发)
三个仓库的 `.gitea/workflows/deploy.yml` 都是 `workflow_dispatch`,**不会 push 自动上线**。
### 使用
1. 打开 `https://git.23cm.cn/toom1996/<repo>`
2. 顶部 **Actions → deploy → Run workflow**
3. 分支选 **`dev`** → Run
各仓行为:
| 仓库 | 实际执行 |
| --- | --- |
| `backend_v2` | `cd /opt/fashion/backend_v2 && ./deploy.sh backend` |
| `frontend_v2` | `cd /opt/fashion/backend_v2 && ./deploy.sh web --no-cache` |
| `spider` | `git pull` + `go build -o bin/spider .` |
### 原理与 Secrets
- Runner 与服务器同机,job 容器用 `node:22-alpine`,通过 SSH 连 `127.0.0.1` 执行部署。
- 仓库 Secrets:`DEPLOY_HOST=127.0.0.1`、`DEPLOY_USER=root`、`DEPLOY_KEY_B64`(SSH 私钥 base64)。
- 注意:**不要用公网 IP 43.161.253.59 做 DEPLOY_HOST**,云主机常拦截「访问自己公网 IP」的 hairpin,会卡住。
---
## 6. 部署方式 B:手动 SSH
```bash ```bash
ssh -i ~/.ssh/id_rsa_hongkong root@43.161.253.59 ./init-db.sh /path/to/your.sql # 建库(传入与当前代码匹配的 SQL)
cd /opt/fashion/backend_v2 ./deploy.sh all # 启动全部服务
curl http://127.0.0.1/api/health # {"status":"ok"}
./deploy.sh # 只更新后端(默认)
./deploy.sh web # 只更新前端
./deploy.sh all # 前后端都更新
./deploy.sh all --no-pull
./deploy.sh web --no-cache # 强制重建前端(刷新首页 SSG 数据)
./deploy.sh --rollback # 秒级回滚到上一槽
./deploy.sh --prune # 清理旧镜像(默认保留最近 5 个)
GIT_BRANCH=dev ./deploy.sh all
``` ```
**发布流程**: `.env` 必填 6 项:
| 变量 | 说明 |
| --- | --- |
| `DB_PASSWORD` | 数据库口令,别沿用开发库的 |
| `HASHID_SECRET` | 公开 ID 混淆盐,**上线后不要改**(改了所有旧链接失效) |
| `JWT_SECRET` | 登录令牌密钥 |
| `INGEST_SECRET` | 爬虫上报密钥,须与爬虫端一致 |
| `S4_AK` / `S4_SK` | 对象存储密钥,缺了图片全 404 |
---
## 3. 日常发布
```bash
./deploy.sh # 拉 main + 只更新后端(默认)
./deploy.sh all # 拉 main + 前后端都更新
./deploy.sh web # 只更新前端
./deploy.sh all --no-pull # 不拉代码,用服务器现有代码构建
GIT_BRANCH=dev ./deploy.sh all # 临时指定分支(优先于 .env)
```
**拉代码**(`.env` 的 `GIT_BRANCH`,默认 `main`):
- 后端仓 `.` 和前端仓 `$WEB_DIR`(默认 `../test`)都会 `fetch` → 必要时 `checkout` → `pull --ff-only`
- **不能快进就报错退出**,不会硬覆盖(有本地改动时会提示 `reset --hard` 命令)
- 每次打印实际提交:`后端:main @ a1b2c3d fix: xxx`
**发布过程**:
| 步 | 动作 | 用户感受 | | 步 | 动作 | 用户感受 |
| --- | --- | --- | | --- | --- | --- |
| 1 | 生成 `RELEASE`,`git pull --ff-only`(后端 + 前端) | 无感 | | 1 | 生成 `RELEASE`,拉代码 | 无感 |
| 2 | `docker compose build` 构建 `fashion-*:$RELEASE` | 无感(旧槽在跑) | | 2 | `docker compose build` 构建 `fashion-*:$RELEASE` | 无感(旧槽在跑) |
| 3 | 重建空闲槽 | 无感 | | 3 | 重建空闲槽 | 无感 |
| 4 | 轮询 health,未通过则退出 | 旧槽继续服务 | | 4 | 轮询 health,未通过则**直接退出** | 旧槽继续服务 |
| 5 | 改切换文件 → `nginx -t` → `nginx -s reload` | 在途请求不丢 | | 5 | 改切换文件 → `nginx -t` → `nginx -s reload` | 在途请求不丢 |
| 6 | 写 `.deploy/releases.log` | — | | 6 | 写 `.deploy/releases.log` | — |
--- ---
## 7. 域名与 HTTPS ## 4. 回滚
| 域名 | 指向 | 说明 |
| --- | --- | --- |
| `fashion.23cm.cn` | 127.0.0.1:8080 | **正式入口**,Let's Encrypt 证书,80/443 都通 |
| `test.23cm.cn` | 127.0.0.1:8080 | 同一应用;原为旧静态站,旧配置备份在 `test.23cm.cn.conf.bak` |
- OpenResty 站点配置:`/opt/1panel/www/conf.d/<domain>.conf`
- 证书目录:`/opt/1panel/www/sites/<domain>/ssl/{fullchain.pem,privkey.pem}`
- `fashion.23cm.cn` 证书由 **certbot(webroot)** 签发,自动续期;续期 hook:
`/etc/letsencrypt/renewal-hooks/deploy/fashion-23cm.sh`(复制证书 + `nginx -s reload`)
- 验证:`curl -I https://fashion.23cm.cn/`
> 注意:1Panel 面板里的证书是自签(`CN=localhost`),与 certbot 那张不是同一张;面板「证书」列表里看不到 fashion 的证书属正常。
还原 `test.23cm.cn` 旧静态站:
```bash ```bash
cp /opt/1panel/www/conf.d/test.23cm.cn.conf.bak /opt/1panel/www/conf.d/test.23cm.cn.conf ./deploy.sh --rollback
docker exec 1Panel-openresty-2mkA nginx -s reload # 回滚 backend:b → a(该槽镜像 fashion-backend:20261018-2210-9f3e1a2)
```
不动代码、不重建容器,只改 nginx 指向 + reload,秒级生效。
前提是该槽容器还在(正常交替发布时,非活跃槽就是上一版)。
---
## 5. 版本与镜像管理
```bash
cat .deploy/releases.log # 发布历史
docker inspect -f '{{.Config.Image}}' $(docker compose ps -q backend_a) # 线上版本
./deploy.sh --prune # 清理旧镜像
KEEP_IMAGES=3 ./deploy.sh --prune # 只留 3 个
```
镜像标签形如 `fashion-backend:20261019-153012-a1b2c3d`(时间 + 提交号),不会被自动清理。
`latest` 只是手动执行 compose 命令的别名,`deploy.sh` 从不依赖它。
---
## 6. 必须记住的规则
1. **默认发布 `main`,日常开发在 `dev`** —— 直接 `./deploy.sh all` 会部署 main 上的旧代码。要么先 merge,要么 `GIT_BRANCH=dev ./deploy.sh all`。
2. **改表要向后兼容** —— 切换的几十秒里新旧代码同时在跑。加字段/加表可以;改类型、删字段要拆两次发布。
3. **`HASHID_SECRET` 上线后别动**。
4. **镜像会占磁盘**(前端约 250MB/版),每 5 版跑一次 `./deploy.sh --prune`。
---
## 7. 管理后台
```bash
ssh -L 18092:127.0.0.1:18092 user@server
# 本机浏览器打开 http://localhost:18092/admin
``` ```
--- ---
## 8. 爬虫(当前暂不部署) ## 8. 域名 / HTTPS
- 已构建:`/opt/fashion/spider/bin/spider` 前端只监听 80,二选一:
- 生产配置:`/opt/fashion/spider/config.prod.yml`(ingest 指向 `http://127.0.0.1:18092`)
- 是 **CLI**,不是守护进程,跑完即退出:
```bash - **宿主机 nginx / 宝塔**:`.env` 里 `HTTP_PORT=8080`,外层反代到 `127.0.0.1:8080`,证书在外层做
cd /opt/fashion/spider - **Caddy(自动证书)**:同 compose 加 caddy 容器,`reverse_proxy 127.0.0.1:80`
./bin/spider -c config.prod.yml vogue -b 1
./bin/spider -c config.prod.yml theimpression -n 5
```
- 若要定时执行,可加 systemd timer(需要时再配)。
--- ---
## 9. 备份 ## 9. 备份
数据库在 1Panel Postgres 容器里,备份命令:
```bash ```bash
docker exec 1Panel-postgresql-4Ndp pg_dump -U user_JA83HF fashion \ docker compose exec -T db pg_dump -U fashion fashion | gzip > backups/db-$(date +%F).sql.gz
| gzip > /opt/fashion/backups/db-$(date +%F).sql.gz
``` ```
每日 4 点、保留 7 天(示例): 每天 4 点自动备份并保留 7 天:
```cron ```
0 4 * * * docker exec 1Panel-postgresql-4Ndp pg_dump -U user_JA83HF fashion | gzip > /opt/fashion/backups/db-$(date +\%F).sql.gz && find /opt/fashion/backups -name 'db-*.sql.gz' -mtime +7 -delete 0 4 * * * cd /opt/fashion/backend && docker compose exec -T db pg_dump -U fashion fashion | gzip > backups/db-$(date +\%F).sql.gz && find backups -name 'db-*.sql.gz' -mtime +7 -delete
``` ```
--- ---
## 10. 本地构建发布(前后端都不在服务器编译) ## 10. 排障
服务器资源紧张时用这条路径:**构建全在本机完成**,服务器只负责打镜像层 + 蓝绿切换。
```bash ```bash
# 本机(后端仓)执行,一条命令跑完 docker compose ps # 各容器状态与健康
SERVER=user@server ./scripts/push-build.sh docker compose logs -f proxy # 切换失败先看这里
``` docker compose logs -f backend_a
脚本做五件事:
| 步 | 动作 | 说明 |
|---|---|---|
| 1 | 建 SSH 隧道 `本地18091 → 服务器 127.0.0.1:18091` | 前端构建期靠它读**线上** SSG 数据,公网零暴露 |
| 2 | 本机 `npm run build` | 静态页内容与线上一致,不吃服务器内存 |
| 3 | 本机 `CGO_ENABLED=0 GOOS=linux go build` | 产出 35MB 的 `server-linux-amd64` |
| 4 | `tar`/`scp` 上传 `dist/` 与二进制 | 只传产物,不传源码 |
| 5 | 远程 `./deploy.sh all --prebuilt` | 用 `Dockerfile.prebuilt` 打层,蓝绿切换 |
前置条件:
- 本机有 `node` / `npm` / `go` / `ssh` / `scp` / `tar`
- 本机后端仓放一份 `.env`(复制服务器那份,用于取 `SSG_TOKEN`)
- 服务器上 `proxy` 正在跑(18091 是它的端口)
与「服务器构建」的区别:
| | 服务器构建 | 本地构建(push-build) |
|---|---|---|
| 服务器 CPU/内存 | 前端 2~4 分钟 + 1GB 内存 | 只 `COPY` 产物,秒级 |
| 构建期数据 | 线上(天然一致) | 线上(走隧道,同样一致) |
| git 拉取 | 会拉两个仓库 | **跳过**(`--prebuilt` 自动 `--no-pull`) |
| 发布号 | 服务器仓库的 sha | 本机 sha,通过 `RELEASE` 传入 |
两种方式可以混用:后端改动小、想走服务器构建,就 `./deploy.sh backend`;想省资源就 `push-build.sh`。
## 11. 排障(真实案例)
```bash
cd /opt/fashion/backend_v2
docker compose ps
docker compose logs --tail 100 proxy
docker compose logs --tail 100 backend_a
docker compose logs --tail 100 web_b docker compose logs --tail 100 web_b
``` ```
| 现象 | 原因 / 处理 | | 现象 | 原因 |
| --- | --- | | --- | --- |
| 连库报 `28P01 password authentication failed` | 密码不对。以容器 `POSTGRES_PASSWORD` 为准;旧 config 里的可能被 `#` 截断 |
| 首页只有 `Loading`、没有图集 | 构建期 SSG 取数失败。确认 nginx 内部 `listen 18091`(不是 `127.0.0.1:18091`)、proxy 在跑、`curl 127.0.0.1:18091/api/internal/ssg/home/runway?token=…` 有数据;再 `./deploy.sh web --no-cache` |
| 改了内容但首页没变 | 首页是构建期 SSG,需重新部署前端(workflow 已带 `--no-cache`) |
| `nginx: bind() to 0.0.0.0:18091 failed (Address in use)` | 旧进程还占着回环端口,`docker compose restart proxy` |
| 全站接口 404 | `nginx/default.conf` 的 `proxy_pass` 带了 URI/斜杠(会剥掉 `/api` 前缀) | | 全站接口 404 | `nginx/default.conf` 的 `proxy_pass` 带了 URI/斜杠(会剥掉 `/api` 前缀) |
| 详情页 500 | 前端 `BASE_API_SSR` 必须是 `http://proxy:18090`(容器里的 localhost 是它自己) | | 详情页 500 | 前端 `BASE_API_SSR` 必须是 `http://proxy:18090`(容器里的 localhost 是它自己) |
| 图片全 404 | S4 关闭但现有图片走 CDN 正常;新入库图片需补 `S4_AK/S4_SK` 再开 `S4_ENABLED` | | 图片全 404 | `S4_AK/S4_SK` 没填或填错 |
| 发布后仍是旧版本 | 分支不对(用 `dev`);或看 `docker compose logs proxy` 的 reload 报错 | | 前端构建失败 | 构建期要连 18091 取 SSG:确认 `proxy` 在跑、`backend_a` healthy |
| 回滚提示「槽不可用」 | 该槽被更早发布重建过,回滚只能退一格 | | 发布后仍是旧版本 | 分支不对(默认 `main`,日常在 `dev`);或 `docker compose logs proxy` 里 reload 报错 |
| 磁盘涨得快 | `./deploy.sh --prune`;`docker builder prune -f` | | 回滚提示"槽不可用" | 该槽被更早的发布重建过,回滚只能退一格 |
| 登录后跳回登录页 | `JWT_SECRET` 改过,旧 token 失效 |
--- | 磁盘涨得快 | `./deploy.sh --prune` |
## 11. 性能说明(当前瓶颈)
服务器 **2 核 / 3.6G**,且已常驻 Gitea、Postgres、MySQL、MariaDB、Redis、PHP×2、OpenResty、两个 Go 后端、Node 前端。体检时 **swap 1.9G 已用 1.7G**,构建期容易换页,`npm ci + astro build` 明显慢。
建议(按性价比):
1. **长期(推荐)**:构建移到本地 **WSL2 + Docker Engine**,推送到 Gitea 容器仓库,服务器只 `pull + up`。
- 不要把构建目录放在 `/mnt/d/...`(9p 极慢),放 WSL 自己的 `~/project`。
2. **短期(零成本)**:
- 前端用 `--no-cache` 只用于刷新 SSG;进一步可改 `CACHEBUST` 构建参数,只重跑 `npm run build`,`npm ci` 走缓存。
- Dockerfile 加 BuildKit cache mount(npm/go 依赖缓存)。
- `docker builder prune -f` 回收构建缓存;`./deploy.sh --prune` 控制镜像数。
- 可再加 4G swap 降低 OOM 风险。
---
## 12. 与默认模板的差异(为什么这么改)
| 模板假设 | 实际 |
| --- | --- |
| 自带 `db` 服务 + `init-db.sh` | 复用 1Panel 现有 Postgres,库已有数据 |
| 目录 `backend` / `test` | `backend_v2` / `frontend_v2` |
| 默认分支 `main` | `dev` |
| 外层 Caddy / 宿主机 nginx 空着 | 1Panel OpenResty 已占 80/443,应用 proxy 改绑 `127.0.0.1:8080` |
| `S4_AK/S4_SK` 必填 | 当前 `S4_ENABLED=false`,图片走 CDN 公开桶 |
| push 自动部署 | 改为 `workflow_dispatch` 手动触发 |
| nginx 内部 `listen 127.0.0.1:18091` | 容器内必须 `listen 18091`(0.0.0.0),否则端口发布不可达 |

View File

@ -1,27 +0,0 @@
# 预构建镜像:二进制在本机(GOOS=linux)编好,服务器只负责打层。
# 用途:服务器资源紧张时,跳过容器内的 go build。
# 由 scripts/push-build.sh 与 deploy.sh --prebuilt 使用;构建上下文里的
# server-linux-amd64 由本机交叉编译产出(已在 .gitignore / .dockerignore 中)。
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY server-linux-amd64 /app/server
# 从 Windows 传上来的文件没有执行位(644),必须显式补上,否则容器启动报
# exec: "/app/server": permission denied
RUN chmod +x /app/server
COPY configs ./configs
RUN mkdir -p /app/uploads
ENV CONFIG_PATH=/app/configs/config.yml \
GIN_MODE=release \
UPLOAD_DIR=/app/uploads
EXPOSE 8090 8091 8092
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://127.0.0.1:8090/api/health || exit 1
CMD ["/app/server"]

View File

@ -212,15 +212,3 @@ cp .env.example .env # 填 6 个必填密钥
镜像按 `RELEASE=时间戳-提交号` 打不可变标签,历史版本不被覆盖。 镜像按 `RELEASE=时间戳-提交号` 打不可变标签,历史版本不被覆盖。
完整步骤、发布流程、镜像管理、数据库兼容约束与排障见 **[`DEPLOY.md`](./DEPLOY.md)**。 完整步骤、发布流程、镜像管理、数据库兼容约束与排障见 **[`DEPLOY.md`](./DEPLOY.md)**。
### 本地构建(服务器不编译)
服务器资源紧张时,前后端都可以在本机构建,只把产物传上去:
```bash
SERVER=user@server ./scripts/push-build.sh
```
它会建 SSH 隧道让前端读**线上** SSG 数据 → 本机 `astro build` → 本机交叉编译后端 →
上传 `dist/` 与 `server-linux-amd64` → 远程 `deploy.sh all --prebuilt`。
详见 [`DEPLOY.md` 第 10 节](./DEPLOY.md)。

57
deploy.sh Executable file → Normal file
View File

@ -6,9 +6,7 @@
# ./deploy.sh web 只更新前端 # ./deploy.sh web 只更新前端
# ./deploy.sh --rollback 切回上一个槽(不动代码,秒级) # ./deploy.sh --rollback 切回上一个槽(不动代码,秒级)
# ./deploy.sh all --no-pull 用服务器上现有代码构建 # ./deploy.sh all --no-pull 用服务器上现有代码构建
# ./deploy.sh web --no-cache 强制不用构建缓存(前端 SSG 依赖后端数据,数据变了要强制重建)
# ./deploy.sh --prune 只清理旧镜像(保留最近 KEEP_IMAGES 个,默认 5) # ./deploy.sh --prune 只清理旧镜像(保留最近 KEEP_IMAGES 个,默认 5)
# ./deploy.sh all --prebuilt 用已上传的预构建产物(不拉代码、不在容器内编译)
# GIT_BRANCH=dev ./deploy.sh all 临时指定分支(优先于 .env) # GIT_BRANCH=dev ./deploy.sh all 临时指定分支(优先于 .env)
# #
# 每次发布生成 RELEASE=<时间戳>-<后端提交号>,镜像以此打标签(不可变,不覆盖历史)。 # 每次发布生成 RELEASE=<时间戳>-<后端提交号>,镜像以此打标签(不可变,不覆盖历史)。
@ -18,18 +16,14 @@ cd "$(dirname "$0")"
TARGET="backend" TARGET="backend"
NO_PULL=0 NO_PULL=0
NO_CACHE=0
PRUNE_ONLY=0 PRUNE_ONLY=0
PREBUILT=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
backend|web|all) TARGET="$arg" ;; backend|web|all) TARGET="$arg" ;;
--rollback) TARGET="rollback" ;; --rollback) TARGET="rollback" ;;
--no-pull) NO_PULL=1 ;; --no-pull) NO_PULL=1 ;;
--no-cache) NO_CACHE=1 ;;
--prune) PRUNE_ONLY=1 ;; --prune) PRUNE_ONLY=1 ;;
--prebuilt) PREBUILT=1 ;; *) echo "用法: ./deploy.sh [backend|web|all|--rollback|--prune] [--no-pull]"; exit 1 ;;
*) echo "用法: ./deploy.sh [backend|web|all|--rollback|--prune] [--no-pull] [--no-cache] [--prebuilt]"; exit 1 ;;
esac esac
done done
@ -41,7 +35,7 @@ set -a; . ./.env; set +a
[ -n "$CLI_GIT_BRANCH" ] && GIT_BRANCH="$CLI_GIT_BRANCH" [ -n "$CLI_GIT_BRANCH" ] && GIT_BRANCH="$CLI_GIT_BRANCH"
GIT_BRANCH="${GIT_BRANCH:-main}" GIT_BRANCH="${GIT_BRANCH:-main}"
KEEP_IMAGES="${KEEP_IMAGES:-5}" KEEP_IMAGES="${KEEP_IMAGES:-5}"
WEB_DIR="${WEB_DIR:-../frontend_v2}" WEB_DIR="${WEB_DIR:-../test}"
STATE_DIR=".deploy" STATE_DIR=".deploy"
ACTIVE_CONF="$STATE_DIR/nginx/active.conf" ACTIVE_CONF="$STATE_DIR/nginx/active.conf"
@ -154,9 +148,7 @@ roll() { # $1=component(backend|web)
old=$(cur_slot "$comp"); idle=$(other_slot "$old") old=$(cur_slot "$comp"); idle=$(other_slot "$old")
echo "==> $comp:活跃槽 $old,更新空闲槽 $idle(镜像 $RELEASE)" echo "==> $comp:活跃槽 $old,更新空闲槽 $idle(镜像 $RELEASE)"
local build_flag="" docker compose build "${comp}_a" >/dev/null
[ "$NO_CACHE" = "1" ] && build_flag="--no-cache"
docker compose build $build_flag "${comp}_a" >/dev/null
# latest 只是给手动执行 compose 命令用的便利别名,deploy 自己从不依赖它 # latest 只是给手动执行 compose 命令用的便利别名,deploy 自己从不依赖它
docker tag "fashion-${comp}:${RELEASE}" "fashion-${comp}:latest" 2>/dev/null || true docker tag "fashion-${comp}:${RELEASE}" "fashion-${comp}:latest" 2>/dev/null || true
@ -204,45 +196,12 @@ prune_images() {
done done
} }
# ---- 预构建产物校验 ----
# 本地构建 + 上传的路径:镜像只负责打包,不在服务器上编译。
# 同时强制 --no-pull:代码已在本地构建完毕,服务器仓库不需要(也不应该)再更新。
use_prebuilt() {
PREBUILT=1
NO_PULL=1
export BACKEND_DOCKERFILE=Dockerfile.prebuilt
export WEB_DOCKERFILE=Dockerfile.prebuilt
echo "==> 使用预构建产物(不在服务器编译)"
case "$TARGET" in
backend|all)
[ -f server-linux-amd64 ] || {
echo "!! 缺少 server-linux-amd64:先在本机执行 scripts/push-build.sh"
exit 1
}
echo " 后端二进制:$(du -h server-linux-amd64 | cut -f1)"
;;
esac
case "$TARGET" in
web|all)
[ -f "$WEB_DIR/dist/server/entry.mjs" ] || {
echo "!! 缺少 $WEB_DIR/dist/server/entry.mjs:前端还没构建或没上传"
exit 1
}
echo " 前端产物:$WEB_DIR/dist"
;;
esac
}
# ---- 主流程 ---- # ---- 主流程 ----
if [ "$PRUNE_ONLY" = "1" ]; then if [ "$PRUNE_ONLY" = "1" ]; then
prune_images prune_images
exit 0 exit 0
fi fi
if [ "$PREBUILT" = "1" ] && [ "$TARGET" != "rollback" ]; then
use_prebuilt
fi
if [ "$TARGET" != "rollback" ]; then if [ "$TARGET" != "rollback" ]; then
export RELEASE="${RELEASE:-$(new_release)}" export RELEASE="${RELEASE:-$(new_release)}"
echo "==> 发布号 $RELEASE" echo "==> 发布号 $RELEASE"
@ -255,14 +214,12 @@ else
export RELEASE="${RELEASE:-latest}" export RELEASE="${RELEASE:-latest}"
fi fi
# 首次部署:先写切换点,再只启动代理。 # 首次部署先让代理起来(前端构建期要用 18091 取 SSG 数据)
# - render_active 必须先于 proxy 启动,否则 include 的 map 为空,nginx 会因未知变量启动失败。
# - --no-deps 避免首次起代理时顺带构建前端(此时 18091 尚未就绪,SSG 取数会失败)。
render_active
if ! healthy proxy; then if ! healthy proxy; then
echo "==> 启动代理(首次只起代理,槽位由 roll 流程构建)" echo "==> 启动代理(首次会一并拉起 db 与 a 槽)"
docker compose up -d --no-deps proxy docker compose up -d proxy
fi fi
render_active
if [ "$TARGET" = "rollback" ]; then if [ "$TARGET" = "rollback" ]; then
do_rollback do_rollback

View File

@ -26,7 +26,7 @@ x-backend-base: &backend-base
BACKSTAGE_PORT: 8092 BACKSTAGE_PORT: 8092
GIN_MODE: release GIN_MODE: release
HASHID_SECRET: ${HASHID_SECRET:-} HASHID_SECRET: ${HASHID_SECRET:?HASHID_SECRET 未设置:复制 .env.example 为 .env 并填写}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 未设置} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 未设置}
SSG_TOKEN: ${SSG_TOKEN:-} SSG_TOKEN: ${SSG_TOKEN:-}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://xisoa.com,https://www.xisoa.com} CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://xisoa.com,https://www.xisoa.com}
@ -35,8 +35,7 @@ x-backend-base: &backend-base
RATE_LIMIT_RPS: ${RATE_LIMIT_RPS:-20} RATE_LIMIT_RPS: ${RATE_LIMIT_RPS:-20}
RATE_LIMIT_BURST: ${RATE_LIMIT_BURST:-40} RATE_LIMIT_BURST: ${RATE_LIMIT_BURST:-40}
# 复用 1Panel 现有 Postgres(宿主 5432)。host-gateway 让容器经宿主访问它。 DB_HOST: db
DB_HOST: host.docker.internal
DB_PORT: 5432 DB_PORT: 5432
DB_USER: ${DB_USER:-fashion} DB_USER: ${DB_USER:-fashion}
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 未设置} DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 未设置}
@ -61,8 +60,9 @@ x-backend-base: &backend-base
UPLOAD_DIR: /app/uploads UPLOAD_DIR: /app/uploads
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
extra_hosts: depends_on:
- "host.docker.internal:host-gateway" db:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"] test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"]
interval: 10s interval: 10s
@ -84,15 +84,29 @@ x-web-base: &web-base
start_period: 15s start_period: 15s
services: services:
# ---- 数据库:复用 1Panel 现有 Postgres(连接信息见 .env 的 DB_HOST/DB_PORT/DB_USER/DB_PASSWORD)---- # ---- 数据库:PostgreSQL 16 + pgvector ----
db:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-fashion}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 未设置}
POSTGRES_DB: ${DB_NAME:-fashion}
TZ: Asia/Shanghai
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fashion} -d ${DB_NAME:-fashion}"]
interval: 10s
timeout: 5s
retries: 10
# ---- 常驻代理:整个发布过程中不重建,切换只靠 reload ---- # ---- 常驻代理:整个发布过程中不重建,切换只靠 reload ----
proxy: proxy:
image: nginx:1.27-alpine image: nginx:1.27-alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
# 只绑宿主回环:80/443 由 1Panel OpenResty 占用,外层反代到 127.0.0.1:${HTTP_PORT} - "${HTTP_PORT:-80}:80"
- "127.0.0.1:${HTTP_PORT:-8080}:80"
- "127.0.0.1:18091:18091" # 前端构建期取 SSG - "127.0.0.1:18091:18091" # 前端构建期取 SSG
- "127.0.0.1:18092:18092" # 管理后台(SSH 隧道) - "127.0.0.1:18092:18092" # 管理后台(SSH 隧道)
volumes: volumes:
@ -116,8 +130,6 @@ services:
<<: *backend-base <<: *backend-base
build: build:
context: . context: .
# 默认在容器内编译;本地预构建时由 deploy.sh --prebuilt 切到 Dockerfile.prebuilt
dockerfile: ${BACKEND_DOCKERFILE:-Dockerfile}
args: args:
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
@ -129,8 +141,6 @@ services:
<<: *web-base <<: *web-base
build: build:
context: ${WEB_DIR:-../test} context: ${WEB_DIR:-../test}
# 默认在容器内 npm ci + astro build;本地预构建时切到 Dockerfile.prebuilt
dockerfile: ${WEB_DOCKERFILE:-Dockerfile}
args: args:
# 走宿主回环的代理 18091,由代理转发到活跃后端槽 # 走宿主回环的代理 18091,由代理转发到活跃后端槽
BASE_API_SSG: http://127.0.0.1:18091 BASE_API_SSG: http://127.0.0.1:18091
@ -141,4 +151,5 @@ services:
<<: *web-base <<: *web-base
volumes: volumes:
db_data:
uploads: uploads:

View File

@ -1,15 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 首次建库:把导出的 SQL 灌进 PostgreSQL # 首次建库:把导出的 SQL 灌进空的 PostgreSQL 容器
# #
# ./init-db.sh /path/to/dump.sql # ./init-db.sh /path/to/dump.sql
# #
# 数据库由 1Panel 管理(独立的 postgres 容器,端口不对外发布),所以直接 # 前置:在开发机导出最新结构与数据(含 pgvector 扩展):
# docker exec 进那个容器跑 psql —— 服务器上不需要装 psql,也不依赖宿主端口。 # go run ./cmd/dbtool dump -with-extension -out db/backups/db_dump.sql
# #
# 容器名在 .env 的 PG_CONTAINER 里;不确定就看: # ⚠️ 仓库里 db/backups/db_dump.sql 是 2026-09-21 导出的,比当前代码少了之后两个月
# docker ps --format '{{.Names}}' | grep -i postgres # 的新增迁移(向量表 / 近邻对表 / 品牌重编号 / published_at / image_size 等),
# 直接拿它建库会导致后端启动后字段缺失——所以这里必须显式传入文件路径。
# #
# 注意:只在库为空时执行一次;已有数据时重跑会与现有行冲突。 # 注意:只在库为空时执行一次;生产库已有数据时重跑会与现有行冲突。
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -21,39 +22,24 @@ if [ -z "$DUMP" ]; then
echo "用法:./init-db.sh <dump.sql>" echo "用法:./init-db.sh <dump.sql>"
echo "" echo ""
echo " 请传入一份与当前代码匹配的 SQL(含全部新增迁移)。" echo " 请传入一份与当前代码匹配的 SQL(含全部新增迁移)。"
echo " 导出命令(开发机):go run ./cmd/dbtool dump -with-extension -out db/backups/db_dump.sql" echo " 导出命令:go run ./cmd/dbtool dump -with-extension -out db/backups/db_dump.sql"
exit 1 exit 1
fi fi
[ -f "$DUMP" ] || { echo "!! 找不到 dump 文件:$DUMP"; exit 1; } [ -f "$DUMP" ] || { echo "!! 找不到 dump 文件:$DUMP"; exit 1; }
PG_CONTAINER="${PG_CONTAINER:?PG_CONTAINER 未设置:填 .env 里那个 postgres 容器名}" echo "==> 启动数据库"
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then docker compose up -d db
echo "!! 容器不存在:$PG_CONTAINER"
echo " docker ps --format '{{.Names}}' | grep -i postgres"
exit 1
fi
run_psql() { echo "==> 等待数据库就绪"
docker exec -i -e PGPASSWORD="${DB_PASSWORD:?DB_PASSWORD 未设置}" "$PG_CONTAINER" \ for i in $(seq 1 30); do
psql -U "${DB_USER:-fashion}" -d "${DB_NAME:-fashion}" "$@" if docker compose exec -T db pg_isready -U "${DB_USER:-fashion}" -d "${DB_NAME:-fashion}" >/dev/null 2>&1; then
} break
echo "==> 检查连接(容器 $PG_CONTAINER)"
if ! run_psql -c 'select 1' >/dev/null 2>&1; then
echo "!! 连不上。检查 DB_USER / DB_NAME / DB_PASSWORD 是否与该容器一致"
exit 1
fi
echo "==> 检查 pgvector"
if run_psql -tAc "select 1 from pg_available_extensions where name = 'vector'" 2>/dev/null | grep -q 1; then
echo " pgvector 可用"
else
echo " !! 该 Postgres 没有 pgvector:dump 里的 vector 类型会导入失败"
echo " 换用带 pgvector 的镜像,或先安装扩展后重试"
exit 1
fi fi
[ "$i" -eq 30 ] && { echo "!! 数据库 60 秒未就绪"; docker compose logs --tail 30 db; exit 1; }
sleep 2
done
echo "==> 导入 $DUMP" echo "==> 导入 $DUMP"
run_psql -v ON_ERROR_STOP=1 < "$DUMP" docker compose exec -T db psql -U "${DB_USER:-fashion}" -d "${DB_NAME:-fashion}" -v ON_ERROR_STOP=1 < "$DUMP"
echo "==> 完成。可以执行 ./deploy.sh all 启动全部服务" echo "==> 完成。可以执行 ./deploy.sh all 启动全部服务"

View File

@ -71,7 +71,7 @@ server {
# 内部:前端构建期取 SSG 数据(只绑宿主回环,前端镜像 build 时用 host 网络访问) # 内部:前端构建期取 SSG 数据(只绑宿主回环,前端镜像 build 时用 host 网络访问)
server { server {
listen 18091; # 容器内必须监听 0.0.0.0,否则 docker 端口发布到容器网卡时连不上;宿主机只发布到 127.0.0.1 listen 127.0.0.1:18091;
access_log off; access_log off;
location / { location / {
proxy_pass http://$ssg_up; proxy_pass http://$ssg_up;
@ -81,7 +81,7 @@ server {
# 内部:管理后台(只绑宿主回环;远程访问用 SSH 隧道到 18092) # 内部:管理后台(只绑宿主回环;远程访问用 SSH 隧道到 18092)
server { server {
listen 18092; # 同上:容器内回环监听会导致 docker 端口发布不可达(管理后台/爬虫入库走此口) listen 127.0.0.1:18092;
location / { location / {
proxy_pass http://$admin_up; proxy_pass http://$admin_up;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@ -1,30 +0,0 @@
#!/usr/bin/env bash
# 备份 1Panel 管理的 Postgres(直接 docker exec 进它的容器,不依赖宿主端口)
#
# ./scripts/backup-db.sh # 输出 backups/db-YYYY-MM-DD-HHMMSS.sql.gz
# KEEP_DAYS=7 ./scripts/backup-db.sh # 顺手删掉 7 天前的备份
#
# cron(每天 4 点):
# 0 4 * * * cd /opt/fashion/backend && KEEP_DAYS=7 ./scripts/backup-db.sh >> .deploy/backup.log 2>&1
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] || { echo "!! 缺少 .env"; exit 1; }
set -a; . ./.env; set +a
PG_CONTAINER="${PG_CONTAINER:?PG_CONTAINER 未设置:填 .env 里那个 postgres 容器名}"
KEEP_DAYS="${KEEP_DAYS:-0}"
mkdir -p backups
OUT="backups/db-$(date +%F-%H%M%S).sql.gz"
echo "==> 备份到 $OUT"
docker exec -e PGPASSWORD="${DB_PASSWORD:?DB_PASSWORD 未设置}" "$PG_CONTAINER" \
pg_dump -U "${DB_USER:-fashion}" "${DB_NAME:-fashion}" | gzip > "$OUT"
echo " $(du -h "$OUT" | cut -f1)"
if [ "$KEEP_DAYS" -gt 0 ]; then
find backups -name 'db-*.sql.gz' -mtime +"$KEEP_DAYS" -print -delete
fi
echo "==> 完成"

View File

@ -1,121 +0,0 @@
#!/usr/bin/env bash
# 本地构建 + 上传 + 远程发布(前后端都走预构建,服务器不编译)
#
# SERVER=user@server ./scripts/push-build.sh
# SERVER=user@server REMOTE_DIR=/opt/fashion WEB_DIR=../test ./scripts/push-build.sh
#
# 流程:
# 1. 建 SSH 隧道(本地 18091 → 服务器回环 18091):前端构建期靠它读线上 SSG 数据
# 2. 本机 npm run build → 静态页内容与线上数据一致,且不占服务器资源
# 3. 本机交叉编译 linux/amd64 → 产出 server-linux-amd64
# 4. 上传 dist/ 与二进制
# 5. 远程 ./deploy.sh all --prebuilt → 服务器只打镜像 + 蓝绿切换
set -euo pipefail
cd "$(dirname "$0")/.." # 后端仓根
SERVER="${SERVER:-}"
if [ -z "$SERVER" ]; then
echo "用法: SERVER=user@host ./scripts/push-build.sh"
echo ""
echo "可选环境变量:"
echo " REMOTE_DIR 服务器上的项目根(默认 /opt/fashion)"
echo " WEB_DIR 本地前端仓(默认 ../test)"
echo " SSH_OPTS 额外 ssh 参数(默认 -o ConnectTimeout=10)"
exit 1
fi
REMOTE_DIR="${REMOTE_DIR:-/opt/fashion}"
REMOTE_BACKEND="${REMOTE_BACKEND:-${REMOTE_DIR}/backend_v2}"
REMOTE_FRONTEND="${REMOTE_FRONTEND:-${REMOTE_DIR}/frontend_v2}"
WEB_DIR="${WEB_DIR:-../frontend_v2}"
SSH_OPTS="${SSH_OPTS:--o ConnectTimeout=10}"
TUNNEL_PORT="${TUNNEL_PORT:-18091}"
# 本机也放一份 .env(复制服务器那份即可),用于取 SSG_TOKEN 等构建期密钥
SSG_TOKEN="${SSG_TOKEN:-}"
if [ -f .env ]; then
# shellcheck disable=SC1091
set -a; . ./.env; set +a
fi
# Git Bash 的 $HOME 常是 /home/<user>,而 Windows 上生成的密钥在 C:\Users\<user>\.ssh 下。
# 这里自动兜底,避免在 Git Bash 里 ssh 找不到私钥而报 publickey denied。
if [ -z "${SSH_KEY:-}" ]; then
# Windows 用户目录优先:Git Bash 的 $HOME 下可能还躺着一把无关的旧钥匙
for cand in "/c/Users/${USERNAME:-}/.ssh/id_ed25519" "${USERPROFILE:-}/.ssh/id_ed25519" "$HOME/.ssh/id_ed25519"; do
if [ -n "$cand" ] && [ -f "$cand" ]; then
SSH_KEY="$cand"
break
fi
done
fi
if [ -n "${SSH_KEY:-}" ]; then
SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
echo " 使用私钥:$SSH_KEY"
fi
TUNNEL_PID=""
cleanup() { [ -n "$TUNNEL_PID" ] && kill "$TUNNEL_PID" 2>/dev/null || true; }
trap cleanup EXIT
echo "==> 0/5 检查 SSH 免密登录"
# 隧道是后台起的(ssh -N ... &),一旦需要交互输密码就会静默卡住,所以必须免密。
if ! ssh $SSH_OPTS -o BatchMode=yes "$SERVER" true 2>/dev/null; then
echo "!! 无法免密登录 $SERVER"
echo ""
echo " Windows(PowerShell):"
echo " ssh-keygen -t ed25519 -C deploy@local"
echo " type \$env:USERPROFILE\\.ssh\\id_ed25519.pub | ssh $SERVER \"mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys\""
echo ""
echo " Linux / macOS / WSL:"
echo " ssh-keygen -t ed25519 -C deploy@local && ssh-copy-id $SERVER"
echo ""
echo " 配好后用这条确认:ssh $SERVER true && echo ok"
exit 1
fi
echo " 通过"
# 服务器若开了 SSG_TOKEN,构建期必须带同一个值。自动从服务器 .env 取,避免本机再维护一份密钥。
if [ -z "${SSG_TOKEN:-}" ]; then
SSG_TOKEN="$(ssh $SSH_OPTS -o BatchMode=yes "$SERVER" \
"grep -m1 '^SSG_TOKEN=' '${REMOTE_BACKEND}/.env' 2>/dev/null | cut -d= -f2-" 2>/dev/null || true)"
if [ -n "$SSG_TOKEN" ]; then
echo " 已从服务器读取 SSG_TOKEN(${#SSG_TOKEN} 字符)"
else
echo " 服务器未设置 SSG_TOKEN,构建期不带 token"
fi
fi
echo "==> 1/5 建立 SSH 隧道(本地 ${TUNNEL_PORT} → ${SERVER}:127.0.0.1:18091)"
ssh -N $SSH_OPTS -o ExitOnForwardFailure=yes \
-L "${TUNNEL_PORT}:127.0.0.1:18091" "$SERVER" &
TUNNEL_PID=$!
for i in $(seq 1 15); do
if (echo > "/dev/tcp/127.0.0.1/${TUNNEL_PORT}") >/dev/null 2>&1; then
break
fi
if [ "$i" -eq 15 ]; then
echo "!! 隧道 15 秒内没建起来(检查 SSH 连通性、服务器上 proxy 是否在跑)"
exit 1
fi
sleep 1
done
echo "==> 2/5 构建前端(构建期数据来自线上)"
(cd "$WEB_DIR" && BASE_API_SSG="http://127.0.0.1:${TUNNEL_PORT}" SSG_TOKEN="$SSG_TOKEN" npm run build)
[ -f "$WEB_DIR/dist/server/entry.mjs" ] || { echo "!! 前端产物缺失:$WEB_DIR/dist/server/entry.mjs"; exit 1; }
echo "==> 3/5 交叉编译后端(linux/amd64)"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o server-linux-amd64 ./cmd/server
echo "==> 4/5 上传产物到 ${SERVER}:${REMOTE_DIR}"
ssh $SSH_OPTS "$SERVER" "mkdir -p '${REMOTE_BACKEND}' '${REMOTE_FRONTEND}'"
tar -C "$WEB_DIR" -czf - dist | ssh $SSH_OPTS "$SERVER" "tar -xzf - -C '${REMOTE_FRONTEND}'"
scp $SSH_OPTS server-linux-amd64 "${SERVER}:${REMOTE_BACKEND}/server-linux-amd64"
echo "==> 5/5 远程发布(预构建 + 蓝绿切换)"
RELEASE="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD 2>/dev/null || echo nogit)"
ssh $SSH_OPTS "$SERVER" "cd '${REMOTE_BACKEND}' && RELEASE='${RELEASE}' ./deploy.sh all --prebuilt"
echo "完成:$RELEASE"