This commit is contained in:
toom1996
2026-10-19 00:33:53 +08:00
parent 335e264e45
commit 549d025afe
8 changed files with 80 additions and 209 deletions

View File

@ -1,21 +1,17 @@
# ---- 前端构建阶段 ---- # ---- 构建阶段 ----
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
# 构建期 SSG 拉数据用的「后端 SSG 内部地址」。 # 构建期 SSG 取数地址:走宿主回环的代理 18091(deploy.sh 传 build-arg,配合 build.network=host)。
# 在 docker-compose 里通过 build.args.BASE_API_SSG 传入(= http://localhost:8091, # 由代理转发到「当前活跃的后端槽」,所以构建期不受正在更新哪个槽影响。
# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器的 SSG 内部端口)。 ARG BASE_API_SSG=http://127.0.0.1:18091
# 该端口只绑宿主回环(127.0.0.1:8091),nginx 绝不反代,因此 SSG 全量数据不会外泄。
# BASE_API_SSG 与运行期 BASE_API(8090)完全分离,二者指向不同端口。
ARG BASE_API_SSG=http://localhost:8091
ENV BASE_API_SSG=$BASE_API_SSG ENV BASE_API_SSG=$BASE_API_SSG
# SSG 内部端口的可选访问令牌(防御纵深)。仅构建期 node fetch 使用,且不带 PUBLIC_ 前缀, # SSG 内部接口可选令牌(无 PUBLIC_ 前缀,绝不会进入前端 bundle)
# 故绝不会进入前端 bundle。置空时后端不校验(依赖回环绑定即可)。
ARG SSG_TOKEN="" ARG SSG_TOKEN=""
ENV SSG_TOKEN=$SSG_TOKEN ENV SSG_TOKEN=$SSG_TOKEN
# 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090) # 运行期浏览器调用地址:空 = 同源相对 /api(由 proxy 反代到后端)
ENV BASE_API="" ENV BASE_API=""
COPY package*.json ./ COPY package*.json ./
@ -23,15 +19,22 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# ---- 运行阶段 ---- # ---- 运行阶段:只跑 Astro node server,nginx 已独立成 proxy 容器 ----
# 前端以 @astrojs/node standalone server 运行(监听 3000),nginx 反代 / 到该端口、
# 反代 /api 到 backend:8090。这样 on-demand SSR 详情页 / sitemap / robots 才能正确产出 HTML。
FROM node:22-alpine FROM node:22-alpine
RUN apk add --no-cache nginx
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /entrypoint.sh EXPOSE 3000
RUN chmod +x /entrypoint.sh
EXPOSE 80 # 只探端口存活:SSR 取数临时失败不应该把容器判死(否则蓝绿切换会误判)
ENTRYPOINT ["/entrypoint.sh"] HEALTHCHECK --interval=15s --timeout=3s --start-period=15s --retries=3 \
CMD nc -z 127.0.0.1 3000 || exit 1
CMD ["node", "./dist/server/entry.mjs"]

View File

@ -1,19 +1,20 @@
# Xisoa — 时尚档案(Fashion Archive) # Xisoa — 时尚档案(Fashion Archive)
记录走秀(runway looks)与街拍(street style)的时尚档案站。前端为 **Astro 静态站**, 记录走秀(runway looks)与街拍(street style)的时尚档案站。前端为 **Astro**(列表静态生成,详情页走按需 SSR),
数据来自独立的 **Go + MySQL 后端**(走秀/街拍列表、详情、收藏、历史等)。 数据来自独立的 **Go 后端 + PostgreSQL(pgvector)**,两者各自独立仓库、通过 Docker Compose 一起上线。
## 技术栈 ## 技术栈
| 层 | 选型 | | 层 | 选型 |
| --- | --- | | --- | --- |
| 前端框架 | Astro 7(静态输出,详情页走 `@astrojs/node` 按需 SSR) | | 前端框架 | Astro 7(`@astrojs/node` standalone,详情页按需 SSR) |
| 构建 | Vite 8(Rolldown) | | 构建 | Vite 8(Rolldown) |
| 样式 | Tailwind CSS v4 | | 样式 | Tailwind CSS v4 |
| 交互 | Alpine.js(导航菜单、登录弹窗、收藏、灯箱等轻量交互) | | 交互 | Alpine.js(导航菜单、登录弹窗、收藏、灯箱等轻量交互) |
| 轮播 | Swiper | | 轮播 | Swiper |
| 后端 | Go + MySQL(独立仓库,不在本仓库内) | | 后端 | Go + Gin + GORM + PostgreSQL 16 + pgvector(独立仓库 `../backend`) |
| 部署 | Docker Compose + nginx(见 `DEPLOY.md`) | | 图片存储 | 缤纷云 S4(对象存储,不落服务器磁盘) |
| 部署 | Docker Compose,蓝绿双槽 + 常驻 nginx,零停机发布(**部署入口在后端仓**) |
## 目录结构 ## 目录结构
@ -37,28 +38,32 @@ src/
└── env.d.ts └── env.d.ts
``` ```
本仓库只负责前端构建产物(`Dockerfile` 只跑 Astro node server)。代理、数据库、发布脚本都在后端仓。
## 环境与配置 ## 环境与配置
- **非密配置**:`src/lib/config/env.dev.ts`(dev)与 `env.prod.ts`(prod),按 `import.meta.env.PROD` - **非密配置**:`src/lib/config/env.dev.ts`(dev)与 `env.prod.ts`(prod),按 `import.meta.env.PROD` 自动选择。
自动选择,缺项即抛异常。字段说明见 `src/lib/config/types.ts`:
- `baseApi`:浏览器运行期 API 基址(生产为空串 = 同源 `/api`)。 - `baseApi`:浏览器运行期 API 基址(生产为空串 = 同源 `/api`)。
- `baseApiSsr`:运行期按需 SSR 取数基址(Node fetch 需绝对 URL)。 - `baseApiSsr`:运行期 SSR 取数基址,可用环境变量 `BASE_API_SSR` 覆盖(容器内必须是 `http://proxy:18090`)。
- `baseApiSsg`:构建期 SSG 内部端口基址(仅回环,不对公网暴露)。 - `baseApiSsg`:构建期 SSG 内部端口基址,可用 `BASE_API_SSG` 覆盖(Docker 构建时走 `127.0.0.1:18091`)。
- `idleLogoutDays` / `siteName`。 - `idleLogoutDays` / `siteName`。
- **密钥**:不进 `config`(会打包进客户端 bundle),仅走构建期 env,例如 `SSG_TOKEN`。 - **密钥**:不进 `config`(会打包进客户端 bundle),仅走构建期 env,例如 `SSG_TOKEN`。
## 常用命令 ## 本地开发
```bash
npm install
npm run dev # http://localhost:4321
```
> 本地需先启动后端:接口默认 `localhost:8090`,SSG 内部端口 `localhost:8091`(见 `env.dev.ts`)。
| 命令 | 说明 | | 命令 | 说明 |
| --- | --- | | --- | --- |
| `npm install` | 安装依赖 | | `npm run dev` | 本地开发 |
| `npm run dev` | 本地开发,`http://localhost:4321` | | `npm run build` | 构建到 `./dist/` |
| `npm run build` | 构建静态产物到 `./dist/` | | `npm run preview` | 预览构建产物 |
| `npm run preview` | 本地预览构建产物 | | `npm run check` | 类型 / 模板检查 |
| `npm run check` | `astro check`:类型 / 模板检查 |
| `npm run astro ...` | 直接调用 Astro CLI(如 `astro add`) |
> 本地运行需先启动后端:接口默认落在 `localhost:8090`,SSG 内部端口 `localhost:8091`(见 `env.dev.ts`)。
## 国际化 ## 国际化
@ -68,4 +73,9 @@ src/
## 部署 ## 部署
Docker Compose + Gitea Actions,前后端两个仓库联动上线。完整步骤见 [`DEPLOY.md`](./DEPLOY.md)。 部署入口在**后端仓**,完整说明见 `../backend/DEPLOY.md`:
```bash
cd /opt/fashion/backend
./deploy.sh all # 会自动把前端仓一起拉到位并构建
```

View File

@ -1,38 +0,0 @@
#!/usr/bin/env bash
#
# 一键上线脚本(在服务器上执行,或本地 SSH 调它)
# 服务器目录约定(见 DEPLOY.md):
# /opt/fashion/test -> 前端仓(本脚本所在)
# /opt/fashion/admin/backend -> 后端仓
#
# 流程:拉代码 -> 起 db+backend -> 等后端健康 -> 构建前端(SSR 拉数据) -> 起前端
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
echo "==> [1/5] 拉取最新代码"
git pull --ff-only
git -C ../admin/backend pull --ff-only || true
echo "==> [2/5] 启动 db + backend(供前端构建期 SSR 取数)"
docker compose up -d db backend
echo "==> [3/5] 等待 backend :8090 健康"
for i in $(seq 1 60); do
if curl -fsS "http://localhost:8090/api/public/brands?size=1" >/dev/null 2>&1; then
echo " backend 已就绪"
break
fi
echo " 等待中... ($i/60)"
sleep 2
done
echo "==> [4/5] 构建前端镜像(构建期网络=host,经 BASE_API_SSG 拉取 SSG 数据)"
docker compose build frontend
echo "==> [5/5] 启动前端"
docker compose up -d frontend
echo "==> 部署完成,当前状态:"
docker compose ps

View File

@ -1,71 +0,0 @@
# 生产部署 compose(放在前端仓库根;服务器结构见仓库 README)
# 前端仓: /opt/fashion/test (本文件所在)
# 后端仓: /opt/fashion/backend (已对齐到当前活跃开发的后端,仅支持 PostgreSQL + pgvector)
services:
# ---- 数据库 (PostgreSQL + pgvector) ----
# 注意:后端代码仅支持 PostgreSQL + pgvector(gorm pgx 驱动),切勿换回 MySQL。
db:
image: ankane/pgvector:latest # 预编译好 pgvector 的 PG 镜像(latest 当前底层为 PG17)
restart: unless-stopped
environment:
POSTGRES_USER: fashion
POSTGRES_PASSWORD: ${DB_PASSWORD:-fashion_dev_2026} # 生产务必换强密码
POSTGRES_DB: fashion
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fashion -d fashion"]
interval: 10s
timeout: 5s
retries: 10
# ---- 后端 (Go) ----
backend:
build:
context: ../backend
restart: unless-stopped
environment:
SERVER_PORT: 8090
SSG_PORT: 8091 # SSG 内部端口(见下 ports 绑定)
SSG_TOKEN: ${SSG_TOKEN:-} # 可选:非空则 SSG 接口需带 token(防御纵深)
HASHID_SECRET: ${HASHID_SECRET:-} # 公开 ID 混淆盐值:生产务必注入随机串(改值会使旧链接失效,谨慎变更)
GIN_MODE: release
JWT_SECRET: ${JWT_SECRET:-change-me-in-prod}
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-https://xisoa.com,https://www.xisoa.com}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-false}
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:-fashion_dev_2026}
DB_NAME: ${DB_NAME:-fashion}
ports:
- "8090:8090" # 对外公开 API(运行期由 nginx 反代);生产可去掉此发布,改由前端 nginx 直接反代 backend:8090
- "127.0.0.1:8091:8091" # ⚠️ SSG 内部端口:只绑宿主回环,外部网络不可达;nginx 也绝不反代它
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/v1/public/brands?size=1"]
interval: 10s
timeout: 5s
retries: 10
# ---- 前端 (Astro node server + nginx 反代) ----
frontend:
build:
context: .
args:
BASE_API_SSG: http://localhost:8091 # 构建期 SSG 拉数据:指向 backend 的 SSG 内部端口(配合 network: host 访问回环绑定的 8091)
SSG_TOKEN: ${SSG_TOKEN:-} # 与 backend 的 SSG_TOKEN 保持一致;为空则构建期无需携带 token
network: host
restart: unless-stopped
ports:
- "80:80"
depends_on:
backend:
condition: service_healthy
volumes:
db_data:

View File

@ -1,18 +0,0 @@
#!/bin/sh
# 前端运行期入口:同容器内先后拉起
# 1) node standalone server(Astro 构建产物,监听 127.0.0.1:3000,承载 SSR 详情页/静态资源)
# 2) nginx(监听 80,反代 / 到 node、/api 到 backend:8090)
set -e
# node server 后台运行;一旦退出,整个容器随之退出(便于编排器感知故障)
node ./dist/server/entry.mjs &
NODE_PID=$!
# 捕获 SIGTERM/SIGINT 优雅关闭 node 与 nginx
trap 'kill -TERM $NODE_PID 2>/dev/null; nginx -s stop 2>/dev/null; exit 0' TERM INT
# nginx 前台运行(容器主进程)
nginx -g 'daemon off;' &
NGINX_PID=$!
wait -n

View File

@ -1,29 +0,0 @@
server {
listen 80;
server_name _;
# 前端 node server(Astro standalone,同容器内 127.0.0.1:3000):承载 SSR 详情页 / 静态资源。
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
# 网关隔离:SSG 内部接口只存在于 backend 的 8091 端口(回环绑定,nginx 不反代)。
# 若有人从公网域名探测 /api/internal/ssg/*(或旧 /api/v1/ssg/*),此处直接 404,
# 确保构建期全量数据绝不从公网出口。
location ~ ^/api/(internal/ssg/|v1/ssg/) {
return 404;
}
# 反向代理后端 API:浏览器走同源 /api,由 nginx 转发到 backend 容器(仅 8090 公开端口)
location /api/ {
proxy_pass http://backend:8090/;
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;
}
}

View File

@ -1,13 +1,22 @@
// src/lib/config/env.prod.ts — 生产环境配置(astro build / MODE=production) // src/lib/config/env.prod.ts — 生产环境配置(astro build / MODE=production)
import type { AppConfig } from "./types" import type { AppConfig } from "./types"
// SSR 取数地址:容器里 127.0.0.1/localhost 指的是容器自己,不是后端。
// docker-compose 会注入 BASE_API_SSR=http://backend:8090(服务名解析);
// 非容器部署不注入时回落到本机回环,行为与之前一致。
// 用 globalThis 取 process,避免把 Node 专有的 process 直接写进浏览器 bundle。
const ssrBaseApi: string =
(globalThis as any)?.process?.env?.BASE_API_SSR || "http://localhost:8090"
const config: AppConfig = { const config: AppConfig = {
// 空串 = 同源相对:生产由 nginx 把 /api 反代到后端,浏览器直接打同源 /api/v1/... // 空串 = 同源相对:生产由 nginx 把 /api 反代到后端,浏览器直接打同源 /api/v1/...
baseApi: "", baseApi: "",
// 按需渲染 SSR 在 Node 里 fetch,必须是绝对 URL(后端在本机/Docker 内部)。 // 按需渲染 SSR 在 Node 里 fetch,必须是绝对 URL(后端在本机/Docker 内部)。
baseApiSsr: "http://localhost:8090", baseApiSsr: ssrBaseApi,
// 构建期 SSG 内部端口:绑定回环,绝不对公网暴露。 // 构建期 SSG 内部端口:绑定回环,绝不对公网暴露。
baseApiSsg: "http://127.0.0.1:8091", // Docker 构建时由 build-arg BASE_API_SSG 注入(配合 build.network=host 访问宿主回环)。
baseApiSsg:
(globalThis as any)?.process?.env?.BASE_API_SSG || "http://127.0.0.1:8091",
idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出) idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出)
siteName: "Xisoa", // 站点名(TDK 的 title 后缀 / description 前缀统一来源) siteName: "Xisoa", // 站点名(TDK 的 title 后缀 / description 前缀统一来源)
} }

View File

@ -40,27 +40,32 @@ export function toAbs(url: string): string {
// 响应式 srcset:基于同一图片 URL 生成多档宽度的候选集,交给浏览器按设备 DPR/视口挑选, // 响应式 srcset:基于同一图片 URL 生成多档宽度的候选集,交给浏览器按设备 DPR/视口挑选,
// 避免「一张大图在所有屏幕都下载」造成的带宽浪费(图片站性能关键项)。 // 避免「一张大图在所有屏幕都下载」造成的带宽浪费(图片站性能关键项)。
// //
// 适配本站两类远程图 URL: // 仅对「带查询串」的远程图 URL 生效(?w=1080&q=80 → 改写 w= 参数);
// - 缤纷云 S4 CoreIX 样式串:key!style:high → key!w:480 / !w:768 ...(替换 style 段为宽度段) // 其余一律返回空(不生成 srcset,退回单图):
// - 带查询串:?w=1080&q=80 → 改写 w= 参数 // - 本地 /uploads:没有缩放服务;
// 本地 /uploads 没有缩放服务,返回空(不生成 srcset,退回单图)。 // - 缤纷云 S4:**桶上没有开启动态缩放**,只有命名样式(!style:thumb / !style:high),
// 任何 !w:480 这类宽度变体都会 404。
//
// ⚠️ 历史坑(2026-10-15):曾把列表图的 !style:thumb 改写成 !w:480/768/1080/1600 四档,
// 而桶上并未配置动态缩放 → 四档全部 404。关键点是 `<img srcset>` 一旦有候选集,浏览器
// 只从候选里挑、**不会回退到 src**,于是列表整片裂图(封面与缩略图都裂)。
// 故带 !xxx: 样式段的 URL 一律不产出 srcset,直接用后端给的单图。
const SRCSET_WIDTHS = [480, 768, 1080, 1600] const SRCSET_WIDTHS = [480, 768, 1080, 1600]
export function toSrcset(url: string): string { export function toSrcset(url: string): string {
if (!url) return "" if (!url) return ""
const abs = toAbs(url) const abs = toAbs(url)
if (!/^https?:\/\//i.test(abs)) return "" if (!/^https?:\/\//i.test(abs)) return ""
const at = (u: string, w: number): string => { // 缤纷云样式串(!style:…)与历史宽度串(!w:…)都不支持按宽度改写,退回单图。
if (/\!style:/.test(u)) return u.replace(/\!style:[^!]+/, `!w:${w}`) if (/![a-z]+:/i.test(abs)) return ""
if (/\!w:\d+/.test(u)) return u.replace(/\!w:\d+/, `!w:${w}`) return SRCSET_WIDTHS.map((w) => {
try { try {
const u2 = new URL(u) const u2 = new URL(abs)
u2.searchParams.set("w", String(w)) u2.searchParams.set("w", String(w))
return u2.toString() return `${u2.toString()} ${w}w`
} catch { } catch {
return u + (u.includes("?") ? "&" : "?") + "w=" + w return `${abs + (abs.includes("?") ? "&" : "?") + "w=" + w} ${w}w`
} }
} }).join(", ")
return SRCSET_WIDTHS.map((w) => `${at(abs, w)} ${w}w`).join(", ")
} }
// 按需渲染(on-demand SSR)详情 base:必须是绝对 URL(Node fetch 不吃相对路径)。 // 按需渲染(on-demand SSR)详情 base:必须是绝对 URL(Node fetch 不吃相对路径)。