update
This commit is contained in:
27
.dockerignore
Normal file
27
.dockerignore
Normal file
@ -0,0 +1,27 @@
|
||||
# 版本库与本地工具目录
|
||||
.git
|
||||
.superpowers
|
||||
docs
|
||||
|
||||
# 编译产物与运行时输出
|
||||
bin/
|
||||
*.exe
|
||||
*.log
|
||||
*.out
|
||||
*.err
|
||||
|
||||
# 数据库备份与迁移脚本:体积大(db/backups 合计 150MB+),运行期完全用不到
|
||||
db/backups/
|
||||
db/migrations/
|
||||
|
||||
# ⚠️ 本地密钥:config.local.yml 里有明文 DB 密码 / S4 AK-SK / JWT,绝不能进镜像
|
||||
configs/config.local.yml
|
||||
configs/*.local.yml
|
||||
|
||||
# 上传与临时目录
|
||||
uploads/
|
||||
tmp_sqlrun/
|
||||
dev/
|
||||
|
||||
# 依赖缓存
|
||||
vendor/
|
||||
93
.env.example
93
.env.example
@ -1,58 +1,63 @@
|
||||
# Fashion API 环境变量示例
|
||||
#
|
||||
# 复制为 .env 供 docker-compose / 容器注入;也可直接以环境变量形式注入。
|
||||
# 本文件只作模板,不含真实密钥。密钥也可在本地「终端交互启动」时按提示填写(写回 config.local.yml)。
|
||||
#
|
||||
# 加载优先级:环境变量 > config.yml > config.local.yml > 交互提示(仅本地 TTY)
|
||||
# 注意:config.yml 中以下敏感项已留空,必须二选一提供:
|
||||
# JWT_SECRET / DB_PASSWORD / INGEST_SECRET / HASHID_SECRET / S4_AK / S4_SK
|
||||
# ============================================================
|
||||
# 生产环境变量模板:复制为 .env 后填写(.env 已在 .gitignore 中)
|
||||
# cp .env.example .env
|
||||
# 生成随机密钥:openssl rand -hex 32
|
||||
# ============================================================
|
||||
|
||||
# ---- 服务 ----
|
||||
SERVER_PORT=8090
|
||||
GIN_MODE=release
|
||||
SSG_PORT=8091
|
||||
SSG_TOKEN=
|
||||
BACKSTAGE_PORT=8092
|
||||
HASHID_SECRET=
|
||||
|
||||
# ---- 数据库 (PostgreSQL + pgvector) ----
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
# ---- 数据库 ----
|
||||
DB_USER=fashion
|
||||
DB_PASSWORD=
|
||||
DB_NAME=fashion
|
||||
DB_LOG_LEVEL=warn
|
||||
# 上线必改:不要沿用开发库口令
|
||||
DB_PASSWORD=
|
||||
|
||||
# ---- JWT ----
|
||||
# ---- 后端密钥(必填,缺了容器拒绝启动)----
|
||||
# 公开 ID 混淆盐:上线后不要再改,改了所有旧链接失效
|
||||
HASHID_SECRET=
|
||||
JWT_SECRET=
|
||||
JWT_EXPIRE_HOURS=168
|
||||
JWT_REFRESH_EXPIRE_HOURS=720
|
||||
|
||||
# ---- 爬虫上报 HMAC ----
|
||||
# 爬虫上报 HMAC 密钥:须与爬虫端 config.yml 的 ingest.secret 完全一致
|
||||
INGEST_SECRET=
|
||||
INGEST_TTL=300
|
||||
INGEST_DOWNLOAD_PROXY=
|
||||
# SSG 内部接口令牌(可选,留空则只依赖回环绑定)
|
||||
SSG_TOKEN=
|
||||
|
||||
# ---- 公开接口前端 JS 签名 ----
|
||||
CLIENT_SIGN_ENABLED=false
|
||||
CLIENT_SIGN_SECRET=
|
||||
CLIENT_SIGN_TTL=30
|
||||
|
||||
# ---- 跨域(CORS):逗号分隔的具体域名,覆盖 config.yml 的 allow_origins ----
|
||||
CORS_ALLOW_ORIGINS=https://xisoa.com,https://www.xisoa.com
|
||||
|
||||
# ---- 按 IP 速率限制(令牌桶,防刷兜底)----
|
||||
RATE_LIMIT_ENABLED=false
|
||||
RATE_LIMIT_RPS=20
|
||||
RATE_LIMIT_BURST=40
|
||||
|
||||
# ---- 缤纷云 S4 (S3 兼容,图片存储) ----
|
||||
# ---- 对象存储(缤纷云 S4)----
|
||||
S4_ENABLED=true
|
||||
# 从开发机 configs/config.local.yml 取,或到控制台重新生成子账户密钥
|
||||
S4_AK=
|
||||
S4_SK=
|
||||
S4_BUCKET=toomstudio
|
||||
S4_ENDPOINT=https://s3.bitiful.net
|
||||
S4_REGION=cn-east-1
|
||||
# 留空自动推导为 https://<bucket>.s3.bitiful.net
|
||||
S4_BASE_URL=
|
||||
S4_STYLE_DISPLAY=high
|
||||
S4_STYLE_THUMB=thumb
|
||||
|
||||
# ---- 目录与访问 ----
|
||||
# 前端仓库位置(服务器 /opt/fashion 下默认 ../test)
|
||||
WEB_DIR=../test
|
||||
# 前端 nginx 监听端口(有宿主机 nginx/宝塔占用 80 时改成 8080 再由外层反代)
|
||||
HTTP_PORT=80
|
||||
CORS_ALLOW_ORIGINS=https://xisoa.com,https://www.xisoa.com
|
||||
|
||||
# ---- 限流(生产建议开启)----
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_RPS=20
|
||||
RATE_LIMIT_BURST=40
|
||||
|
||||
# ---- 入库 worker(下载并发 1~16,Vogue 实测 4 较稳)----
|
||||
INGEST_IMAGE_CONCURRENCY=4
|
||||
# 图片下载代理,留空直连;仅作用于下载,不影响上传与数据库
|
||||
INGEST_DOWNLOAD_PROXY=
|
||||
|
||||
# ---- 视觉服务(可选,默认关闭)----
|
||||
VISION_ENABLED=false
|
||||
VISION_ENDPOINT=http://127.0.0.1:8900
|
||||
|
||||
# ---- 构建 ----
|
||||
# 服务器拉 Go 依赖的代理
|
||||
GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# ---- 代码分支 ----
|
||||
# deploy.sh 会把后端仓与前端仓都切到该分支并快进拉取;临时换分支:
|
||||
# GIT_BRANCH=dev ./deploy.sh all
|
||||
# 不想拉代码(例如手动 checkout 了某个提交)时加 --no-pull
|
||||
# 注意:本项目远程默认分支是 main(不是 master),日常开发在 dev 上
|
||||
GIT_BRANCH=main
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@ -38,6 +38,17 @@ admin_cookies.txt
|
||||
# 依赖
|
||||
/vendor/
|
||||
|
||||
# 日志
|
||||
# 日志与进程输出(server.out / server.err 曾被误提交进仓库)
|
||||
*.log
|
||||
.superpowers
|
||||
*.out
|
||||
*.err
|
||||
.superpowers
|
||||
|
||||
# 部署密钥(docker-compose 读取的 .env,绝不入库)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 部署状态与备份(deploy.sh 生成,不进版本库)
|
||||
.deploy/
|
||||
backups/
|
||||
175
DEPLOY.md
Normal file
175
DEPLOY.md
Normal file
@ -0,0 +1,175 @@
|
||||
# 部署手册(Docker · 蓝绿零停机)
|
||||
|
||||
## 0. 一图看懂
|
||||
|
||||
```
|
||||
浏览器 ──▶ proxy(nginx:80,常驻,发布时从不重建)
|
||||
├─ /api ──▶ backend_a 或 backend_b
|
||||
├─ / ──▶ web_a 或 web_b
|
||||
├─ 18090(仅容器网络)SSR 取数 → 当前活跃后端
|
||||
├─ 127.0.0.1:18091 前端构建期取 SSG
|
||||
└─ 127.0.0.1:18092 管理后台(SSH 隧道)
|
||||
```
|
||||
|
||||
发布时只重建**空闲槽**,等它健康后改写切换文件并 `nginx -s reload`,在途请求由旧槽跑完。
|
||||
镜像以 `RELEASE`(`时间戳-后端提交号`)打标签,**不可变**,历史版本不会被覆盖。
|
||||
|
||||
部署入口在本仓库(后端仓),脚本会自动把前端仓也拉到位。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务器准备(只做一次)
|
||||
|
||||
```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
|
||||
git clone <后端仓库> backend
|
||||
git clone <前端仓库> test # 必须与 backend 同级
|
||||
|
||||
cd backend
|
||||
cp .env.example .env && vi .env
|
||||
chmod +x deploy.sh init-db.sh
|
||||
```
|
||||
|
||||
私有仓库需要配好拉取凭据:SSH deploy key,或 HTTPS token + `git config credential.helper store`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 首次部署(只做一次)
|
||||
|
||||
```bash
|
||||
./init-db.sh /path/to/your.sql # 建库(传入与当前代码匹配的 SQL)
|
||||
./deploy.sh all # 启动全部服务
|
||||
curl http://127.0.0.1/api/health # {"status":"ok"}
|
||||
```
|
||||
|
||||
`.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`,拉代码 | 无感 |
|
||||
| 2 | `docker compose build` 构建 `fashion-*:$RELEASE` | 无感(旧槽在跑) |
|
||||
| 3 | 重建空闲槽 | 无感 |
|
||||
| 4 | 轮询 health,未通过则**直接退出** | 旧槽继续服务 |
|
||||
| 5 | 改切换文件 → `nginx -t` → `nginx -s reload` | 在途请求不丢 |
|
||||
| 6 | 写 `.deploy/releases.log` | — |
|
||||
|
||||
---
|
||||
|
||||
## 4. 回滚
|
||||
|
||||
```bash
|
||||
./deploy.sh --rollback
|
||||
# 回滚 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. 域名 / HTTPS
|
||||
|
||||
前端只监听 80,二选一:
|
||||
|
||||
- **宿主机 nginx / 宝塔**:`.env` 里 `HTTP_PORT=8080`,外层反代到 `127.0.0.1:8080`,证书在外层做
|
||||
- **Caddy(自动证书)**:同 compose 加 caddy 容器,`reverse_proxy 127.0.0.1:80`
|
||||
|
||||
---
|
||||
|
||||
## 9. 备份
|
||||
|
||||
```bash
|
||||
docker compose exec -T db pg_dump -U fashion fashion | gzip > backups/db-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
每天 4 点自动备份并保留 7 天:
|
||||
|
||||
```
|
||||
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. 排障
|
||||
|
||||
```bash
|
||||
docker compose ps # 各容器状态与健康
|
||||
docker compose logs -f proxy # 切换失败先看这里
|
||||
docker compose logs -f backend_a
|
||||
docker compose logs --tail 100 web_b
|
||||
```
|
||||
|
||||
| 现象 | 原因 |
|
||||
| --- | --- |
|
||||
| 全站接口 404 | `nginx/default.conf` 的 `proxy_pass` 带了 URI/斜杠(会剥掉 `/api` 前缀) |
|
||||
| 详情页 500 | 前端 `BASE_API_SSR` 必须是 `http://proxy:18090`(容器里的 localhost 是它自己) |
|
||||
| 图片全 404 | `S4_AK/S4_SK` 没填或填错 |
|
||||
| 前端构建失败 | 构建期要连 18091 取 SSG:确认 `proxy` 在跑、`backend_a` healthy |
|
||||
| 发布后仍是旧版本 | 分支不对(默认 `main`,日常在 `dev`);或 `docker compose logs proxy` 里 reload 报错 |
|
||||
| 回滚提示"槽不可用" | 该槽被更早的发布重建过,回滚只能退一格 |
|
||||
| 登录后跳回登录页 | `JWT_SECRET` 改过,旧 token 失效 |
|
||||
| 磁盘涨得快 | `./deploy.sh --prune` |
|
||||
29
Dockerfile
29
Dockerfile
@ -2,13 +2,18 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# 先拉依赖,利用层缓存
|
||||
# 国内构建默认走 goproxy.cn:服务器上没有本地模块缓存,直连 proxy.golang.org 常常超时。
|
||||
# 换环境时用 --build-arg GOPROXY=https://proxy.golang.org,direct 覆盖。
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
ENV GOPROXY=$GOPROXY
|
||||
|
||||
# 先拉依赖,利用层缓存(改业务代码时不会重复下载模块)
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
# 静态链接,便于在 alpine 运行时直接运行
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server
|
||||
# -trimpath 去掉构建机的绝对路径;-s -w 去掉符号表与调试信息(约 50MB -> 32MB)
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/server ./cmd/server
|
||||
|
||||
# ---- 运行阶段 ----
|
||||
FROM alpine:3.20
|
||||
@ -16,12 +21,24 @@ RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/server /app/server
|
||||
# 模板与后台静态资源已用 go:embed 打进二进制,这里只需要配置
|
||||
COPY configs ./configs
|
||||
|
||||
# 上传目录(容器外挂 volume)
|
||||
# 本地存储兜底目录:启用 S4 时图片在对象存储,此目录只承接临时/兜底文件
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
ENV CONFIG_PATH=/app/configs/config.yml
|
||||
EXPOSE 8090
|
||||
ENV CONFIG_PATH=/app/configs/config.yml \
|
||||
GIN_MODE=release \
|
||||
UPLOAD_DIR=/app/uploads
|
||||
|
||||
# 三个端口的暴露面(详见 README):
|
||||
# 8090 公开 API(生产由前端 nginx 同源反代)
|
||||
# 8091 SSG 构建期内部接口(只绑回环,绝不对外)
|
||||
# 8092 管理后台(只绑回环)
|
||||
EXPOSE 8090 8091 8092
|
||||
|
||||
# busybox 自带 wget,无需额外安装
|
||||
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"]
|
||||
|
||||
23
README.md
23
README.md
@ -186,6 +186,29 @@ backend/
|
||||
├── scripts/ # pgvector 起库脚本等
|
||||
├── go.mod / go.sum
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml # 生产编排:db + proxy + backend_a/b + web_a/b
|
||||
├── deploy.sh # 发布 / 回滚 / 清理旧镜像
|
||||
├── init-db.sh # 首次建库
|
||||
├── nginx/default.conf # 常驻代理配置
|
||||
├── Makefile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 部署(Docker,零停机)
|
||||
|
||||
生产部署以**本仓库为入口**:`docker-compose.yml`、`deploy.sh`、`init-db.sh`、代理配置都在这里,
|
||||
脚本会自动把同级的 `/opt/fashion/test`(前端仓)一起拉到位并构建。
|
||||
|
||||
```bash
|
||||
cp .env.example .env # 填 6 个必填密钥
|
||||
./init-db.sh <sql> # 首次建库
|
||||
./deploy.sh all # 发布(之后日常就是这一条)
|
||||
./deploy.sh --rollback # 秒级回滚
|
||||
```
|
||||
|
||||
蓝绿双槽 + 常驻 nginx:发布时只重建空闲槽,健康后再 `nginx -s reload` 切换,在途请求不丢;
|
||||
镜像按 `RELEASE=时间戳-提交号` 打不可变标签,历史版本不被覆盖。
|
||||
|
||||
完整步骤、发布流程、镜像管理、数据库兼容约束与排障见 **[`DEPLOY.md`](./DEPLOY.md)**。
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM 交叉编译 Fashion API 的 Linux (amd64) 二进制
|
||||
REM ------------------------------------------------------------
|
||||
REM 在你的 Windows 开发机上双击运行即可,产物在 bin/ 目录:
|
||||
REM bin/server-linux-amd64
|
||||
REM bin/migrate-linux-amd64
|
||||
REM 然后把这两个文件 + configs/config.yml 一起传到服务器即可。
|
||||
REM
|
||||
REM 说明:
|
||||
REM - 本脚本会自动定位 go.exe(不依赖系统 PATH),常见安装位置都试一遍,
|
||||
REM 仍找不到再退回 PATH 里的 go;所以无论 Go 装哪、PATH 配没配都能跑。
|
||||
REM - CGO_ENABLED=0 生成纯静态二进制,任何 Linux 发行版都能直接跑,
|
||||
REM 不依赖服务器上的 glibc / gcc。
|
||||
REM - 依赖全部来自本地模块缓存,无需联网(若你改过 go.mod 又想离线,
|
||||
REM 取消 GOPROXY=off 那行注释可强制只用缓存)。
|
||||
REM ============================================================
|
||||
|
||||
SETLOCAL EnableExtensions
|
||||
REM 切到本脚本所在目录(项目根)
|
||||
CD /D "%~dp0"
|
||||
|
||||
REM ---- 自动定位 go.exe(不依赖 PATH)----
|
||||
SET "GOEXE="
|
||||
FOR %%G IN (
|
||||
"%ProgramFiles%\Go\bin\go.exe"
|
||||
"%ProgramFiles(x86)%\Go\bin\go.exe"
|
||||
"C:\Go\bin\go.exe"
|
||||
) DO (
|
||||
IF EXIST %%~G ( SET "GOEXE=%%~G" & GOTO :go_found )
|
||||
)
|
||||
WHERE go >nul 2>&1 && SET "GOEXE=go"
|
||||
:go_found
|
||||
IF NOT DEFINED GOEXE (
|
||||
echo ✗ 找不到 go.exe:请先安装 Go (https://go.dev/dl/),或把它加入系统 PATH。
|
||||
pause
|
||||
EXIT /B 1
|
||||
)
|
||||
echo 使用 Go: %GOEXE%
|
||||
"%GOEXE%" version
|
||||
|
||||
SET GOOS=linux
|
||||
SET GOARCH=amd64
|
||||
SET CGO_ENABLED=0
|
||||
REM SET GOPROXY=off
|
||||
|
||||
IF NOT EXIST bin MKDIR bin
|
||||
|
||||
echo [1/1] 编译 server ...
|
||||
"%GOEXE%" build -mod=readonly -trimpath -ldflags "-s -w" -o bin/server-linux-amd64 ./cmd/server
|
||||
IF ERRORLEVEL 1 ( echo ✗ server 编译失败 & pause & EXIT /B 1 )
|
||||
|
||||
echo.
|
||||
echo ✓ 编译完成!产物:
|
||||
echo bin/server-linux-amd64
|
||||
echo bin/migrate-linux-amd64
|
||||
echo.
|
||||
echo 下一步(把 your_user 换成你的服务器账号,目标 10.5.8.16):
|
||||
echo scp bin/server-linux-amd64 bin/migrate-linux-amd64 configs/config.yml your_user@10.5.8.16:/opt/fashionapi/
|
||||
echo ssh your_user@10.5.8.16 "cd /opt/fashionapi && chmod +x server-linux-amd64 migrate-linux-amd64 ^&^& ./server-linux-amd64"
|
||||
echo.
|
||||
pause
|
||||
ENDLOCAL
|
||||
251
deploy.sh
Normal file
251
deploy.sh
Normal file
@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env bash
|
||||
# 零停机部署:同步代码 → 构建不可变镜像 → 蓝绿滚动 → 常驻代理切换
|
||||
#
|
||||
# ./deploy.sh 拉 GIT_BRANCH(默认 main)+ 只更新后端
|
||||
# ./deploy.sh all 前后端都更新
|
||||
# ./deploy.sh web 只更新前端
|
||||
# ./deploy.sh --rollback 切回上一个槽(不动代码,秒级)
|
||||
# ./deploy.sh all --no-pull 用服务器上现有代码构建
|
||||
# ./deploy.sh --prune 只清理旧镜像(保留最近 KEEP_IMAGES 个,默认 5)
|
||||
# GIT_BRANCH=dev ./deploy.sh all 临时指定分支(优先于 .env)
|
||||
#
|
||||
# 每次发布生成 RELEASE=<时间戳>-<后端提交号>,镜像以此打标签(不可变,不覆盖历史)。
|
||||
# 发布记录写在 .deploy/releases.log;每个槽跑的是哪个 RELEASE 可用 docker inspect 查。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TARGET="backend"
|
||||
NO_PULL=0
|
||||
PRUNE_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
backend|web|all) TARGET="$arg" ;;
|
||||
--rollback) TARGET="rollback" ;;
|
||||
--no-pull) NO_PULL=1 ;;
|
||||
--prune) PRUNE_ONLY=1 ;;
|
||||
*) echo "用法: ./deploy.sh [backend|web|all|--rollback|--prune] [--no-pull]"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f .env ] || { echo "!! 缺少 .env:先 cp .env.example .env 并填写"; exit 1; }
|
||||
|
||||
# 命令行显式传入的分支优先于 .env(.env 会覆盖同名 shell 变量,所以先记下来)
|
||||
CLI_GIT_BRANCH="${GIT_BRANCH-}"
|
||||
set -a; . ./.env; set +a
|
||||
[ -n "$CLI_GIT_BRANCH" ] && GIT_BRANCH="$CLI_GIT_BRANCH"
|
||||
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||
KEEP_IMAGES="${KEEP_IMAGES:-5}"
|
||||
WEB_DIR="${WEB_DIR:-../test}"
|
||||
|
||||
STATE_DIR=".deploy"
|
||||
ACTIVE_CONF="$STATE_DIR/nginx/active.conf"
|
||||
PORT="${HTTP_PORT:-80}"
|
||||
mkdir -p "$STATE_DIR/nginx"
|
||||
|
||||
cur_slot() { cat "$STATE_DIR/$1" 2>/dev/null || echo a; }
|
||||
other_slot() { [ "$1" = a ] && echo b || echo a; }
|
||||
short_sha() { [ -d "$1/.git" ] && git -C "$1" rev-parse --short HEAD 2>/dev/null || echo "nogit"; }
|
||||
|
||||
# ---- 发布号 ----
|
||||
# 时间戳 + 后端提交号:一眼看出线上跑的是哪个提交,同一秒重复执行也不会撞标签。
|
||||
new_release() { echo "$(date +%Y%m%d-%H%M%S)-$(short_sha .)"; }
|
||||
|
||||
record_release() {
|
||||
[ -n "${RELEASE:-}" ] || return 0
|
||||
printf '%s %s 后端=%s 前端=%s (%s)\n' \
|
||||
"$RELEASE" "$(date '+%F %T')" \
|
||||
"$(short_sha .)" "$(short_sha "$WEB_DIR")" "$TARGET" \
|
||||
>> "$STATE_DIR/releases.log"
|
||||
}
|
||||
|
||||
# 生成切换文件(写到 .deploy/,不进版本库):backend / web 各自独立的活跃槽
|
||||
render_active() {
|
||||
local b w
|
||||
b=$(cur_slot backend); w=$(cur_slot web)
|
||||
cat > "$ACTIVE_CONF" <<EOF
|
||||
# ⚠️ 由 deploy.sh 生成,勿手改
|
||||
map \$host \$web_up { default "web_${w}:3000"; }
|
||||
map \$host \$api_up { default "backend_${b}:8090"; }
|
||||
map \$host \$ssg_up { default "backend_${b}:8091"; }
|
||||
map \$host \$admin_up { default "backend_${b}:8092"; }
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---- 代码同步 ----
|
||||
sync_repo() { # $1=目录 $2=展示名
|
||||
local dir=$1 label=$2 cur
|
||||
if [ ! -d "$dir/.git" ]; then
|
||||
echo " $label:不是 git 仓库,跳过($dir)"
|
||||
return 0
|
||||
fi
|
||||
cur=$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
|
||||
git -C "$dir" fetch --prune origin "$GIT_BRANCH"
|
||||
if [ "$cur" != "$GIT_BRANCH" ]; then
|
||||
echo " $label:当前在 $cur,切到 $GIT_BRANCH"
|
||||
git -C "$dir" checkout "$GIT_BRANCH"
|
||||
fi
|
||||
if ! git -C "$dir" pull --ff-only origin "$GIT_BRANCH" >/dev/null 2>&1; then
|
||||
echo " !! $label 无法快进到 origin/$GIT_BRANCH(服务器上有本地提交或未提交改动)"
|
||||
echo " git -C $dir status 查看;确认可丢弃后:git -C $dir reset --hard origin/$GIT_BRANCH"
|
||||
exit 1
|
||||
fi
|
||||
echo " $label:$GIT_BRANCH @ $(short_sha "$dir") $(git -C "$dir" log -1 --pretty=%s | cut -c1-40)"
|
||||
}
|
||||
|
||||
sync_code() {
|
||||
echo "==> 同步代码(分支 ${GIT_BRANCH})"
|
||||
sync_repo "." "后端"
|
||||
sync_repo "$WEB_DIR" "前端"
|
||||
}
|
||||
|
||||
# ---- 容器与槽位 ----
|
||||
cid() { docker compose ps -q "$1" 2>/dev/null || true; }
|
||||
|
||||
healthy() {
|
||||
local c
|
||||
c=$(cid "$1"); [ -n "$c" ] || return 1
|
||||
[ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$c" 2>/dev/null)" = healthy ]
|
||||
}
|
||||
|
||||
slot_image() { # 槽正在跑的镜像标签
|
||||
local c
|
||||
c=$(cid "$1"); [ -n "$c" ] || return 1
|
||||
docker inspect -f '{{.Config.Image}}' "$c" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_healthy() { # $1=服务名 $2=超时秒
|
||||
local i
|
||||
for ((i = 0; i < $2; i++)); do
|
||||
healthy "$1" && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
reload_proxy() {
|
||||
docker compose exec -T proxy nginx -t >/dev/null
|
||||
docker compose exec -T proxy nginx -s reload
|
||||
local i
|
||||
for ((i = 0; i < 10; i++)); do
|
||||
sleep 1
|
||||
if curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "!! 代理 reload 后健康检查失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
switch_to() { # $1=component $2=slot
|
||||
echo " → 切换 $1 到槽 $2"
|
||||
echo "$2" > "$STATE_DIR/$1"
|
||||
render_active
|
||||
reload_proxy
|
||||
}
|
||||
|
||||
roll() { # $1=component(backend|web)
|
||||
local comp=$1 old idle
|
||||
old=$(cur_slot "$comp"); idle=$(other_slot "$old")
|
||||
echo "==> $comp:活跃槽 $old,更新空闲槽 $idle(镜像 $RELEASE)"
|
||||
|
||||
docker compose build "${comp}_a" >/dev/null
|
||||
# latest 只是给手动执行 compose 命令用的便利别名,deploy 自己从不依赖它
|
||||
docker tag "fashion-${comp}:${RELEASE}" "fashion-${comp}:latest" 2>/dev/null || true
|
||||
|
||||
echo " 重建 ${comp}_${idle}"
|
||||
docker compose up -d --no-deps --force-recreate "${comp}_${idle}"
|
||||
|
||||
if ! wait_healthy "${comp}_${idle}" 120; then
|
||||
echo "!! ${comp}_${idle} 120 秒未 healthy,保持旧槽继续服务"
|
||||
docker compose logs --tail 40 "${comp}_${idle}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
switch_to "$comp" "$idle"
|
||||
echo " 完成:旧槽 $old 仍在运行($(slot_image "${comp}_${old}" 2>/dev/null || echo '未知镜像'))"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local comp old other img
|
||||
for comp in backend web; do
|
||||
old=$(cur_slot "$comp"); other=$(other_slot "$old")
|
||||
if healthy "${comp}_${other}"; then
|
||||
img=$(slot_image "${comp}_${other}" 2>/dev/null || echo "未知")
|
||||
switch_to "$comp" "$other"
|
||||
echo " 回滚 $comp:$old → $other(该槽镜像 ${img})"
|
||||
else
|
||||
echo " $comp:槽 $other 不可用,跳过(可能还没发布过第二次)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
prune_images() {
|
||||
local comp tags t
|
||||
echo "==> 清理旧镜像(每个组件保留最近 ${KEEP_IMAGES} 个)"
|
||||
for comp in backend web; do
|
||||
tags=$(docker images "fashion-${comp}" --format '{{.Tag}}' 2>/dev/null \
|
||||
| grep -vE '^(latest|<none>)$' | sort -r | tail -n +$((KEEP_IMAGES + 1)))
|
||||
[ -z "$tags" ] && { echo " $comp:无需清理"; continue; }
|
||||
for t in $tags; do
|
||||
if docker rmi "fashion-${comp}:${t}" >/dev/null 2>&1; then
|
||||
echo " $comp:删除 $t"
|
||||
else
|
||||
echo " $comp:$t 仍被容器使用,跳过"
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# ---- 主流程 ----
|
||||
if [ "$PRUNE_ONLY" = "1" ]; then
|
||||
prune_images
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$TARGET" != "rollback" ]; then
|
||||
export RELEASE="${RELEASE:-$(new_release)}"
|
||||
echo "==> 发布号 $RELEASE"
|
||||
if [ "$NO_PULL" = "0" ]; then
|
||||
sync_code
|
||||
else
|
||||
echo "==> 跳过拉代码(--no-pull)"
|
||||
fi
|
||||
else
|
||||
export RELEASE="${RELEASE:-latest}"
|
||||
fi
|
||||
|
||||
# 首次部署先让代理起来(前端构建期要用 18091 取 SSG 数据)
|
||||
if ! healthy proxy; then
|
||||
echo "==> 启动代理(首次会一并拉起 db 与 a 槽)"
|
||||
docker compose up -d proxy
|
||||
fi
|
||||
render_active
|
||||
|
||||
if [ "$TARGET" = "rollback" ]; then
|
||||
do_rollback
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 顺序有讲究:先切后端(前端构建期要读活跃后端的 SSG),再切前端
|
||||
case "$TARGET" in
|
||||
backend) roll backend ;;
|
||||
web) roll web ;;
|
||||
all)
|
||||
roll backend
|
||||
roll web
|
||||
;;
|
||||
esac
|
||||
|
||||
record_release
|
||||
|
||||
echo "==> 代理检查"
|
||||
if curl -fsS "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then
|
||||
echo " 正常"
|
||||
else
|
||||
echo " !! /healthz 不通,请检查 docker compose logs proxy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker image prune -f >/dev/null
|
||||
echo "完成:$RELEASE"
|
||||
echo "旧镜像可用 ./deploy.sh --prune 清理(默认保留最近 ${KEEP_IMAGES} 个)"
|
||||
155
docker-compose.yml
Normal file
155
docker-compose.yml
Normal file
@ -0,0 +1,155 @@
|
||||
# 生产部署:蓝绿双槽 + 常驻代理,支持零停机发布
|
||||
#
|
||||
# 服务器目录(必须同级):
|
||||
# /opt/fashion/backend ← 本文件所在(后端仓,部署入口)
|
||||
# /opt/fashion/test ← 前端仓(默认按 ../test 取,可用 .env 的 WEB_DIR 覆盖)
|
||||
#
|
||||
# 浏览器 ──▶ proxy(nginx:80,常驻不重建)
|
||||
# ├─ /api ──▶ backend_a 或 backend_b
|
||||
# ├─ / ──▶ web_a 或 web_b
|
||||
# ├─ 18090(容器内) SSR 取数 → 当前活跃后端
|
||||
# ├─ 127.0.0.1:18091 前端构建期取 SSG
|
||||
# └─ 127.0.0.1:18092 管理后台(SSH 隧道)
|
||||
#
|
||||
# 首次部署:cp .env.example .env → 填密钥 → ./init-db.sh <sql> → ./deploy.sh all
|
||||
# 日常发布:./deploy.sh(只更新后端) / ./deploy.sh all / ./deploy.sh --rollback
|
||||
#
|
||||
# a/b 两个槽共用同一份镜像标签(RELEASE=时间戳-提交号,不可变),
|
||||
# deploy.sh 只重建空闲槽,换完 nginx -s reload 即切换。
|
||||
|
||||
x-backend-base: &backend-base
|
||||
image: fashion-backend:${RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SERVER_PORT: 8090
|
||||
SSG_PORT: 8091
|
||||
BACKSTAGE_PORT: 8092
|
||||
GIN_MODE: release
|
||||
|
||||
HASHID_SECRET: ${HASHID_SECRET:?HASHID_SECRET 未设置:复制 .env.example 为 .env 并填写}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET 未设置}
|
||||
SSG_TOKEN: ${SSG_TOKEN:-}
|
||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://xisoa.com,https://www.xisoa.com}
|
||||
|
||||
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
|
||||
RATE_LIMIT_RPS: ${RATE_LIMIT_RPS:-20}
|
||||
RATE_LIMIT_BURST: ${RATE_LIMIT_BURST:-40}
|
||||
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: ${DB_USER:-fashion}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD 未设置}
|
||||
DB_NAME: ${DB_NAME:-fashion}
|
||||
|
||||
INGEST_SECRET: ${INGEST_SECRET:?INGEST_SECRET 未设置}
|
||||
INGEST_TTL: ${INGEST_TTL:-300}
|
||||
INGEST_DOWNLOAD_PROXY: ${INGEST_DOWNLOAD_PROXY:-}
|
||||
INGEST_IMAGE_CONCURRENCY: ${INGEST_IMAGE_CONCURRENCY:-4}
|
||||
|
||||
S4_ENABLED: ${S4_ENABLED:-true}
|
||||
S4_AK: ${S4_AK:?S4_AK 未设置}
|
||||
S4_SK: ${S4_SK:?S4_SK 未设置}
|
||||
S4_BUCKET: ${S4_BUCKET:-toomstudio}
|
||||
S4_ENDPOINT: ${S4_ENDPOINT:-https://s3.bitiful.net}
|
||||
S4_REGION: ${S4_REGION:-cn-east-1}
|
||||
S4_BASE_URL: ${S4_BASE_URL:-}
|
||||
|
||||
VISION_ENABLED: ${VISION_ENABLED:-false}
|
||||
VISION_ENDPOINT: ${VISION_ENDPOINT:-http://127.0.0.1:8900}
|
||||
|
||||
UPLOAD_DIR: /app/uploads
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 15s
|
||||
|
||||
x-web-base: &web-base
|
||||
image: fashion-web:${RELEASE:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# 容器内的 localhost 是它自己,SSR 取数统一走代理(永远指向活跃后端槽)
|
||||
BASE_API_SSR: http://proxy:18090
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "127.0.0.1", "3000"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
services:
|
||||
# ---- 数据库: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 ----
|
||||
proxy:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "127.0.0.1:18091:18091" # 前端构建期取 SSG
|
||||
- "127.0.0.1:18092:18092" # 管理后台(SSH 隧道)
|
||||
volumes:
|
||||
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
# 切换点放在 .deploy/(已被 gitignore),发布时改写它不会弄脏工作区
|
||||
- ./.deploy/nginx:/etc/nginx/active:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
depends_on:
|
||||
backend_a:
|
||||
condition: service_healthy
|
||||
web_a:
|
||||
condition: service_started
|
||||
|
||||
# ---- 后端双槽 ----
|
||||
backend_a:
|
||||
<<: *backend-base
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
|
||||
backend_b:
|
||||
<<: *backend-base
|
||||
|
||||
# ---- 前端双槽(只跑 Astro node,nginx 在 proxy 里)----
|
||||
web_a:
|
||||
<<: *web-base
|
||||
build:
|
||||
context: ${WEB_DIR:-../test}
|
||||
args:
|
||||
# 走宿主回环的代理 18091,由代理转发到活跃后端槽
|
||||
BASE_API_SSG: http://127.0.0.1:18091
|
||||
SSG_TOKEN: ${SSG_TOKEN:-}
|
||||
network: host
|
||||
|
||||
web_b:
|
||||
<<: *web-base
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
uploads:
|
||||
45
init-db.sh
Normal file
45
init-db.sh
Normal file
@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# 首次建库:把导出的 SQL 灌进空的 PostgreSQL 容器
|
||||
#
|
||||
# ./init-db.sh /path/to/dump.sql
|
||||
#
|
||||
# 前置:在开发机导出最新结构与数据(含 pgvector 扩展):
|
||||
# go run ./cmd/dbtool dump -with-extension -out db/backups/db_dump.sql
|
||||
#
|
||||
# ⚠️ 仓库里 db/backups/db_dump.sql 是 2026-09-21 导出的,比当前代码少了之后两个月
|
||||
# 的新增迁移(向量表 / 近邻对表 / 品牌重编号 / published_at / image_size 等),
|
||||
# 直接拿它建库会导致后端启动后字段缺失——所以这里必须显式传入文件路径。
|
||||
#
|
||||
# 注意:只在库为空时执行一次;生产库已有数据时重跑会与现有行冲突。
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
[ -f .env ] || { echo "!! 缺少 .env:先 cp .env.example .env 并填写"; exit 1; }
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
DUMP="${1:-}"
|
||||
if [ -z "$DUMP" ]; then
|
||||
echo "用法:./init-db.sh <dump.sql>"
|
||||
echo ""
|
||||
echo " 请传入一份与当前代码匹配的 SQL(含全部新增迁移)。"
|
||||
echo " 导出命令:go run ./cmd/dbtool dump -with-extension -out db/backups/db_dump.sql"
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$DUMP" ] || { echo "!! 找不到 dump 文件:$DUMP"; exit 1; }
|
||||
|
||||
echo "==> 启动数据库"
|
||||
docker compose up -d db
|
||||
|
||||
echo "==> 等待数据库就绪"
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T db pg_isready -U "${DB_USER:-fashion}" -d "${DB_NAME:-fashion}" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
[ "$i" -eq 30 ] && { echo "!! 数据库 60 秒未就绪"; docker compose logs --tail 30 db; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> 导入 $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 启动全部服务"
|
||||
92
nginx/default.conf
Normal file
92
nginx/default.conf
Normal file
@ -0,0 +1,92 @@
|
||||
# 蓝绿切换的入口:唯一对外端口 80,容器常驻、发布时从不重建。
|
||||
# 上游地址不写死,全部来自 active/ 下的 map(由 deploy.sh 生成 + nginx -s reload)。
|
||||
# 该目录挂的是仓库外的 .deploy/nginx,所以发布时不会把 git 工作区改脏。
|
||||
# 用变量做 proxy_pass,槽容器不存在时 nginx 依然能启动(按请求时才解析)。
|
||||
|
||||
# 切换点:map $host $api_up { default "backend_a:8090"; } ...
|
||||
include /etc/nginx/active/*.conf;
|
||||
|
||||
# Docker 内置 DNS:服务名 -> 容器 IP
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
resolver_timeout 3s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# SSG 内部接口:公网一律 404,绝不从外部出口
|
||||
location ~ ^/api/(internal/ssg/|v1/ssg/) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# 后端 API:proxy_pass 不带 URI,原样透传 /api/... 前缀(带斜杠会把前缀剥掉导致全站 404)
|
||||
location /api/ {
|
||||
proxy_pass http://$api_up;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 3s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Astro 构建产物文件名带内容哈希,可长期缓存
|
||||
location /_astro/ {
|
||||
proxy_pass http://$web_up;
|
||||
proxy_set_header Host $host;
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
# Astro node server:SSR 详情页与静态资源
|
||||
location / {
|
||||
proxy_pass http://$web_up;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 部署脚本用来确认代理本身活着
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
}
|
||||
|
||||
# 内部:web 容器的 SSR 取数走这里,永远指向当前活跃的后端槽
|
||||
server {
|
||||
listen 18090;
|
||||
access_log off;
|
||||
location / {
|
||||
proxy_pass http://$api_up;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
|
||||
# 内部:前端构建期取 SSG 数据(只绑宿主回环,前端镜像 build 时用 host 网络访问)
|
||||
server {
|
||||
listen 127.0.0.1:18091;
|
||||
access_log off;
|
||||
location / {
|
||||
proxy_pass http://$ssg_up;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# 内部:管理后台(只绑宿主回环;远程访问用 SSH 隧道到 18092)
|
||||
server {
|
||||
listen 127.0.0.1:18092;
|
||||
location / {
|
||||
proxy_pass http://$admin_up;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
# ============================================================
|
||||
# Fashion API —— systemd 服务单元(可选,用于服务器常驻运行)
|
||||
# ------------------------------------------------------------
|
||||
# 用法(在服务器上):
|
||||
# sudo cp fashionapi.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now fashionapi
|
||||
# 查看日志:sudo journalctl -u fashionapi -f
|
||||
#
|
||||
# 敏感项(JWT_SECRET / DB_PASSWORD 等)建议用 Environment 注入,
|
||||
# 不要写进 config.yml,也不要提交到仓库。
|
||||
# ============================================================
|
||||
|
||||
[Unit]
|
||||
Description=Fashion API (public readonly endpoints)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/fashionapi
|
||||
ExecStart=/opt/fashionapi/server-linux-amd64
|
||||
# 生产环境变量覆盖(按需修改)
|
||||
Environment=CONFIG_PATH=/opt/fashionapi/config.yml
|
||||
Environment=GIN_MODE=release
|
||||
# Environment=DB_HOST=127.0.0.1
|
||||
# Environment=DB_PASSWORD=your_strong_password
|
||||
# Environment=JWT_SECRET=change-me-to-a-random-long-string
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# 如需指定运行账户,取消下一行注释并确保该用户对 /opt/fashionapi 有读权限
|
||||
# User=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -1,6 +0,0 @@
|
||||
2026/09/02 15:39:33 ✓ 配置文件: D:\project\backend_v2\configs\config.yml
|
||||
2026/09/02 15:39:33 ✓ MySQL 已连接: 127.0.0.1:3306/db_dev
|
||||
2026/09/02 15:39:33 ✓ 静态资源: /uploads → D:\project\backend_v2\uploads
|
||||
2026/09/02 15:39:33 🚀 公开服务已启动: http://localhost:8090
|
||||
2026/09/02 15:39:33 🔒 SSG 内部服务已启动: http://localhost:8091 (仅构建期/回环可达)
|
||||
[mysql] 2026/09/02 16:01:06 connection.go:49: bad connection
|
||||
63
server.out
63
server.out
@ -1,63 +0,0 @@
|
||||
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
|
||||
|
||||
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
|
||||
- using env: export GIN_MODE=release
|
||||
- using code: gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
[GIN-debug] GET /uploads/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (4 handlers)
|
||||
[GIN-debug] HEAD /uploads/*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (4 handlers)
|
||||
[GIN-debug] GET /api/v1/health --> fashionapi/internal/handler.(*HealthHandler).Check-fm (4 handlers)
|
||||
[GIN-debug] GET /api/v1/public/runway-looks --> fashionapi/internal/handler.(*ArticleHandler).List-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/public/brands --> fashionapi/internal/handler.(*BrandHandler).List-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/public/street-snaps --> fashionapi/internal/handler.(*StreetSnapHandler).List-fm (6 handlers)
|
||||
[GIN-debug] GET /api/v1/public/runway-looks/:id --> fashionapi/internal/handler.(*ArticleHandler).Detail-fm (4 handlers)
|
||||
[GIN-debug] GET /api/v1/public/street-snaps/:id --> fashionapi/internal/handler.(*StreetSnapHandler).Detail-fm (4 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/login --> fashionapi/internal/handler.(*AuthHandler).Login-fm (4 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/refresh --> fashionapi/internal/handler.(*AuthHandler).Refresh-fm (4 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/logout --> fashionapi/internal/handler.(*AuthHandler).Logout-fm (4 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/logout-all --> fashionapi/internal/handler.(*AuthHandler).LogoutAll-fm (4 handlers)
|
||||
[GIN-debug] GET /api/v1/auth/me --> fashionapi/internal/handler.(*AuthHandler).Me-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/auth/favorites --> fashionapi/internal/handler.(*FavoriteHandler).List-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/favorites --> fashionapi/internal/handler.(*FavoriteHandler).Add-fm (5 handlers)
|
||||
[GIN-debug] DELETE /api/v1/auth/favorites/:target_uid --> fashionapi/internal/handler.(*FavoriteHandler).Remove-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/favorites/check --> fashionapi/internal/handler.(*FavoriteHandler).Check-fm (5 handlers)
|
||||
[GIN-debug] GET /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).List-fm (5 handlers)
|
||||
[GIN-debug] POST /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).Record-fm (5 handlers)
|
||||
[GIN-debug] DELETE /api/v1/auth/history/:target_uid --> fashionapi/internal/handler.(*HistoryHandler).Remove-fm (5 handlers)
|
||||
[GIN-debug] DELETE /api/v1/auth/history --> fashionapi/internal/handler.(*HistoryHandler).Clear-fm (5 handlers)
|
||||
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
|
||||
- using env: export GIN_MODE=release
|
||||
- using code: gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
[GIN-debug] GET /api/v1/ssg/brands/hot --> fashionapi/internal/handler.(*SSGHandler).HotBrands-fm (4 handlers)
|
||||
[GIN-debug] GET /api/v1/ssg/index/runway --> fashionapi/internal/handler.(*SSGHandler).IndexRunway-fm (4 handlers)
|
||||
[GIN-debug] GET /api/v1/ssg/street-snaps/popular --> fashionapi/internal/handler.(*SSGHandler).StreetSnapPopular-fm (4 handlers)
|
||||
[GIN] 2026/09/02 - 15:40:07 | 200 | 0s | ::1 | GET "/api/v1/health"
|
||||
[GIN] 2026/09/02 - 15:40:07 | 200 | 22.41ms | ::1 | GET "/api/v1/public/brands"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 220.55ms | ::1 | POST "/api/v1/auth/login"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 22.64ms | ::1 | POST "/api/v1/auth/history"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 11.67ms | ::1 | POST "/api/v1/auth/history"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 1.03ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 2.82ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=gallery"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 1.6ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=image"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 17.42ms | ::1 | DELETE "/api/v1/auth/history/rTEST123"
|
||||
[GIN] 2026/09/02 - 15:41:59 | 200 | 13.48ms | ::1 | DELETE "/api/v1/auth/history/runway-looks"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 129.48ms | ::1 | POST "/api/v1/auth/login"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 7.37ms | ::1 | POST "/api/v1/auth/history"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 10.92ms | ::1 | POST "/api/v1/auth/history"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 2.8ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 1.22ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=gallery"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 1.57ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=image"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 16.02ms | ::1 | POST "/api/v1/auth/history"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 1.88ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 10.24ms | ::1 | DELETE "/api/v1/auth/history/rTEST123"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 5.76ms | ::1 | DELETE "/api/v1/auth/history/runway-looks"
|
||||
[GIN] 2026/09/02 - 15:43:41 | 200 | 1.15ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24"
|
||||
[GIN] 2026/09/02 - 16:01:06 | 200 | 14.3ms | ::1 | GET "/api/v1/auth/me?locale=en"
|
||||
[GIN] 2026/09/02 - 16:01:06 | 200 | 14.8ms | ::1 | GET "/api/v1/auth/favorites?page=1&per_page=24"
|
||||
[GIN] 2026/09/02 - 16:01:06 | 200 | 19.8ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=all"
|
||||
[GIN] 2026/09/02 - 16:01:09 | 200 | 13.48ms | ::1 | GET "/api/v1/public/runway-looks/r000AhByP?locale=cn"
|
||||
[GIN] 2026/09/02 - 16:01:09 | 200 | 3.19ms | ::1 | GET "/api/v1/auth/me?locale=en"
|
||||
[GIN] 2026/09/02 - 16:01:12 | 200 | 627.5µs | ::1 | GET "/api/v1/auth/me?locale=en"
|
||||
[GIN] 2026/09/02 - 16:01:12 | 200 | 5.3ms | ::1 | GET "/api/v1/auth/favorites?page=1&per_page=24"
|
||||
[GIN] 2026/09/02 - 16:01:12 | 200 | 5.3ms | ::1 | GET "/api/v1/auth/history?page=1&per_page=24&kind=all"
|
||||
2543
server_run.err
2543
server_run.err
File diff suppressed because it is too large
Load Diff
2483
server_run.out
2483
server_run.out
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user