diff --git a/Dockerfile b/Dockerfile index c142459..1e56c1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,17 @@ -# ---- 前端构建阶段 ---- +# ---- 构建阶段 ---- FROM node:22-alpine AS build WORKDIR /app -# 构建期 SSG 拉数据用的「后端 SSG 内部地址」。 -# 在 docker-compose 里通过 build.args.BASE_API_SSG 传入(= http://localhost:8091, -# 配合 build.network: host,让前端构建容器能访问已启动的 backend 容器的 SSG 内部端口)。 -# 该端口只绑宿主回环(127.0.0.1:8091),nginx 绝不反代,因此 SSG 全量数据不会外泄。 -# BASE_API_SSG 与运行期 BASE_API(8090)完全分离,二者指向不同端口。 -ARG BASE_API_SSG=http://localhost:8091 +# 构建期 SSG 取数地址:走宿主回环的代理 18091(deploy.sh 传 build-arg,配合 build.network=host)。 +# 由代理转发到「当前活跃的后端槽」,所以构建期不受正在更新哪个槽影响。 +ARG BASE_API_SSG=http://127.0.0.1:18091 ENV BASE_API_SSG=$BASE_API_SSG -# SSG 内部端口的可选访问令牌(防御纵深)。仅构建期 node fetch 使用,且不带 PUBLIC_ 前缀, -# 故绝不会进入前端 bundle。置空时后端不校验(依赖回环绑定即可)。 +# SSG 内部接口可选令牌(无 PUBLIC_ 前缀,绝不会进入前端 bundle) ARG SSG_TOKEN="" ENV SSG_TOKEN=$SSG_TOKEN -# 运行期浏览器调用地址:置空 = 同源相对路径 /api(由下方 nginx 反代到 backend:8090) +# 运行期浏览器调用地址:空 = 同源相对 /api(由 proxy 反代到后端) ENV BASE_API="" COPY package*.json ./ @@ -23,15 +19,22 @@ RUN npm ci COPY . . RUN npm run build -# ---- 运行阶段 ---- -# 前端以 @astrojs/node standalone server 运行(监听 3000),nginx 反代 / 到该端口、 -# 反代 /api 到 backend:8090。这样 on-demand SSR 详情页 / sitemap / robots 才能正确产出 HTML。 +# ---- 运行阶段:只跑 Astro node server,nginx 已独立成 proxy 容器 ---- FROM node:22-alpine -RUN apk add --no-cache nginx 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 nginx.conf /etc/nginx/conf.d/default.conf -COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -EXPOSE 80 -ENTRYPOINT ["/entrypoint.sh"] + +EXPOSE 3000 + +# 只探端口存活:SSR 取数临时失败不应该把容器判死(否则蓝绿切换会误判) +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"] diff --git a/README.md b/README.md index 214ff27..47397d2 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,20 @@ # Xisoa — 时尚档案(Fashion Archive) -记录走秀(runway looks)与街拍(street style)的时尚档案站。前端为 **Astro 静态站**, -数据来自独立的 **Go + MySQL 后端**(走秀/街拍列表、详情、收藏、历史等)。 +记录走秀(runway looks)与街拍(street style)的时尚档案站。前端为 **Astro**(列表静态生成,详情页走按需 SSR), +数据来自独立的 **Go 后端 + PostgreSQL(pgvector)**,两者各自独立仓库、通过 Docker Compose 一起上线。 ## 技术栈 | 层 | 选型 | | --- | --- | -| 前端框架 | Astro 7(静态输出,详情页走 `@astrojs/node` 按需 SSR) | +| 前端框架 | Astro 7(`@astrojs/node` standalone,详情页按需 SSR) | | 构建 | Vite 8(Rolldown) | | 样式 | Tailwind CSS v4 | | 交互 | Alpine.js(导航菜单、登录弹窗、收藏、灯箱等轻量交互) | | 轮播 | Swiper | -| 后端 | Go + MySQL(独立仓库,不在本仓库内) | -| 部署 | Docker Compose + nginx(见 `DEPLOY.md`) | +| 后端 | Go + Gin + GORM + PostgreSQL 16 + pgvector(独立仓库 `../backend`) | +| 图片存储 | 缤纷云 S4(对象存储,不落服务器磁盘) | +| 部署 | Docker Compose,蓝绿双槽 + 常驻 nginx,零停机发布(**部署入口在后端仓**) | ## 目录结构 @@ -30,35 +31,39 @@ src/ │ ├── routes.ts # 前端页面路由(带 locale 前缀) │ ├── config/ # dev / prod 两份非密配置 + 必填校验 │ ├── i18n/ # 国际化:语言解析、t()、字典 -│ ├── ssg.ts / ssr.ts # 构建期 SSG / 运行期 SSR 取数 +│ ├── ssg.ts / ssr.ts # 构建期 SSG / 运行期 SSR 取数 │ └── gallery.ts / favorites.ts / collection-page.ts / ... # 业务逻辑 ├── pages/ # Astro 路由(en / cn 两套 + 根重定向) ├── styles/ # global.css / look-grid.css └── 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/types.ts`: +- **非密配置**:`src/lib/config/env.dev.ts`(dev)与 `env.prod.ts`(prod),按 `import.meta.env.PROD` 自动选择。 - `baseApi`:浏览器运行期 API 基址(生产为空串 = 同源 `/api`)。 - - `baseApiSsr`:运行期按需 SSR 取数基址(Node fetch 需绝对 URL)。 - - `baseApiSsg`:构建期 SSG 内部端口基址(仅回环,不对公网暴露)。 + - `baseApiSsr`:运行期 SSR 取数基址,可用环境变量 `BASE_API_SSR` 覆盖(容器内必须是 `http://proxy:18090`)。 + - `baseApiSsg`:构建期 SSG 内部端口基址,可用 `BASE_API_SSG` 覆盖(Docker 构建时走 `127.0.0.1:18091`)。 - `idleLogoutDays` / `siteName`。 - **密钥**:不进 `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` | 本地开发,`http://localhost:4321` | -| `npm run build` | 构建静态产物到 `./dist/` | -| `npm run preview` | 本地预览构建产物 | -| `npm run check` | `astro check`:类型 / 模板检查 | -| `npm run astro ...` | 直接调用 Astro CLI(如 `astro add`) | - -> 本地运行需先启动后端:接口默认落在 `localhost:8090`,SSG 内部端口 `localhost:8091`(见 `env.dev.ts`)。 +| `npm run dev` | 本地开发 | +| `npm run build` | 构建到 `./dist/` | +| `npm run preview` | 预览构建产物 | +| `npm run check` | 类型 / 模板检查 | ## 国际化 @@ -68,4 +73,9 @@ src/ ## 部署 -Docker Compose + Gitea Actions,前后端两个仓库联动上线。完整步骤见 [`DEPLOY.md`](./DEPLOY.md)。 +部署入口在**后端仓**,完整说明见 `../backend/DEPLOY.md`: + +```bash +cd /opt/fashion/backend +./deploy.sh all # 会自动把前端仓一起拉到位并构建 +``` diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index beced48..0000000 --- a/deploy.sh +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b05b23e..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 4dd1ed2..0000000 --- a/entrypoint.sh +++ /dev/null @@ -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 diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index 5b6f4a3..0000000 --- a/nginx.conf +++ /dev/null @@ -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; - } -} diff --git a/src/lib/config/env.prod.ts b/src/lib/config/env.prod.ts index 7bf5458..7904b87 100644 --- a/src/lib/config/env.prod.ts +++ b/src/lib/config/env.prod.ts @@ -1,13 +1,22 @@ // src/lib/config/env.prod.ts — 生产环境配置(astro build / MODE=production) 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 = { // 空串 = 同源相对:生产由 nginx 把 /api 反代到后端,浏览器直接打同源 /api/v1/... baseApi: "", // 按需渲染 SSR 在 Node 里 fetch,必须是绝对 URL(后端在本机/Docker 内部)。 - baseApiSsr: "http://localhost:8090", + baseApiSsr: ssrBaseApi, // 构建期 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 = 关闭空闲登出) siteName: "Xisoa", // 站点名(TDK 的 title 后缀 / description 前缀统一来源) } diff --git a/src/lib/request.ts b/src/lib/request.ts index 122abaa..528bdda 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -40,27 +40,32 @@ export function toAbs(url: string): string { // 响应式 srcset:基于同一图片 URL 生成多档宽度的候选集,交给浏览器按设备 DPR/视口挑选, // 避免「一张大图在所有屏幕都下载」造成的带宽浪费(图片站性能关键项)。 // -// 适配本站两类远程图 URL: -// - 缤纷云 S4 CoreIX 样式串:key!style:high → key!w:480 / !w:768 ...(替换 style 段为宽度段) -// - 带查询串:?w=1080&q=80 → 改写 w= 参数 -// 本地 /uploads 没有缩放服务,返回空(不生成 srcset,退回单图)。 +// 仅对「带查询串」的远程图 URL 生效(?w=1080&q=80 → 改写 w= 参数); +// 其余一律返回空(不生成 srcset,退回单图): +// - 本地 /uploads:没有缩放服务; +// - 缤纷云 S4:**桶上没有开启动态缩放**,只有命名样式(!style:thumb / !style:high), +// 任何 !w:480 这类宽度变体都会 404。 +// +// ⚠️ 历史坑(2026-10-15):曾把列表图的 !style:thumb 改写成 !w:480/768/1080/1600 四档, +// 而桶上并未配置动态缩放 → 四档全部 404。关键点是 `` 一旦有候选集,浏览器 +// 只从候选里挑、**不会回退到 src**,于是列表整片裂图(封面与缩略图都裂)。 +// 故带 !xxx: 样式段的 URL 一律不产出 srcset,直接用后端给的单图。 const SRCSET_WIDTHS = [480, 768, 1080, 1600] export function toSrcset(url: string): string { if (!url) return "" const abs = toAbs(url) if (!/^https?:\/\//i.test(abs)) return "" - const at = (u: string, w: number): string => { - if (/\!style:/.test(u)) return u.replace(/\!style:[^!]+/, `!w:${w}`) - if (/\!w:\d+/.test(u)) return u.replace(/\!w:\d+/, `!w:${w}`) + // 缤纷云样式串(!style:…)与历史宽度串(!w:…)都不支持按宽度改写,退回单图。 + if (/![a-z]+:/i.test(abs)) return "" + return SRCSET_WIDTHS.map((w) => { try { - const u2 = new URL(u) + const u2 = new URL(abs) u2.searchParams.set("w", String(w)) - return u2.toString() + return `${u2.toString()} ${w}w` } catch { - return u + (u.includes("?") ? "&" : "?") + "w=" + w + return `${abs + (abs.includes("?") ? "&" : "?") + "w=" + w} ${w}w` } - } - return SRCSET_WIDTHS.map((w) => `${at(abs, w)} ${w}w`).join(", ") + }).join(", ") } // 按需渲染(on-demand SSR)详情 base:必须是绝对 URL(Node fetch 不吃相对路径)。