diff --git a/.env.example b/.qenv.example similarity index 100% rename from .env.example rename to .qenv.example diff --git a/DEPLOY.md b/DEPLOY.md deleted file mode 100644 index 132374a..0000000 --- a/DEPLOY.md +++ /dev/null @@ -1,78 +0,0 @@ -# 部署与 CI/CD 说明 - -项目结构:前端 Astro 静态站 + 后端 Go 服务 + MySQL。采用 **Docker 容器化 + Gitea Actions** 上线。 - -## 一、架构 - -``` -浏览器 - │ HTTP (80) - ▼ -[frontend] nginx:80 ── 静态资源 / , 反代 /api ──▶ [backend] Go :8090 - │ - ▼ - [db] MySQL 8.0 -``` - -- **前端**:Astro 构建为静态 `dist/`,用 nginx 托管;`/api/v1` 由 nginx 反代到 `backend:8090`(同源,无跨域)。 -- **后端**:Go 二进制,监听 `8090`,连 `db` 服务。 -- **构建期取数**:品牌页等通过 SSG 内部接口(`/api/v1/ssg/*`,基址 `BASE_API_SSG`,默认 `http://localhost:8091`)在构建时拉数据;运行期浏览器走同源 `/api/v1`(基址 `BASE_API` 置空)。 - -## 二、服务器目录约定 - -```bash -/opt/fashion/ -├── test/ # 前端仓 git clone(含 docker-compose.yml / deploy.sh / Dockerfile / nginx.conf) -└── admin/ - └── backend/ # 后端仓 git clone(含 Dockerfile) -``` - -初始化(在服务器上): - -```bash -mkdir -p /opt/fashion && cd /opt/fashion -git clone <前端仓地址> test -git clone <后端仓地址> admin/backend -cd test -cp .env.example .env # 按需修改 JWT_SECRET / DB_PASSWORD -``` - -前置:服务器已安装 Docker Engine + Docker Compose v2,且当前用户可免 sudo 执行 docker。 - -## 三、手动一键上线 - -在服务器上(或本地 SSH 过去)执行: - -```bash -cd /opt/fashion/test -./deploy.sh -``` - -`deploy.sh` 会:① 拉两个仓库最新代码 → ② `docker compose up -d db backend` → ③ 轮询等待 backend 健康 → ④ `docker compose build frontend`(构建期 SSR 拉数据)→ ⑤ `docker compose up -d frontend`。 - -完成后访问服务器 `http://<服务器IP>/` 即可。 - -## 四、CI/CD(Gitea Actions) - -仓库根已放入 `.gitea/workflows/deploy.yml`。push 到 `main`/`master` 即自动 SSH 到服务器执行 `./deploy.sh`。 - -前置(Gitea 仓库 → Settings → Secrets / Actions): - -| Secret | 说明 | -| ------------- | ---------------------------- | -| `DEPLOY_HOST` | 服务器 IP / 域名 | -| `DEPLOY_USER` | SSH 用户名 | -| `DEPLOY_KEY` | 服务器私钥(对应公钥已放入 `~/.ssh/authorized_keys`) | - -> 后端仓库也放一份相同的 `.gitea/workflows/deploy.yml`,这样前后端任意仓库 push 都会触发一次完整上线(deploy.sh 会同时 pull 两个仓)。 - -## 五、HTTPS(可选) - -在服务器前置一个反向代理(如 Caddy / 已有 nginx)终止 TLS,把 80 转发到 frontend 容器,并加 `X-Forwarded-Proto`。或把 frontend 的 `ports` 换成 `443` + 挂载证书。此处从简,未内置。 - -## 六、注意事项 - -- 后端环境变量名(`DB_HOST`/`JWT_SECRET` 等)需与实际 `config` 读取一致;如后端用别的变量名,改 `docker-compose.yml` 的 `environment` 即可。 -- `JWT_SECRET` 生产务必改成随机长串;默认值是占位的。 -- 生产可去掉 `backend.ports` 的 `8090:8090` 暴露,仅由前端 nginx 在同一 compose 网络内反代访问,更安全。 -- 前端 `BASE_API` 在 Dockerfile 中置空(同源 `/api`);如需独立域名,改为绝对地址并在后端已开启 CORS(`*`)。 diff --git a/Dockerfile b/Dockerfile index b6454b0..c142459 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,15 @@ RUN npm ci COPY . . RUN npm run build -# ---- 静态托管阶段 ---- -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html +# ---- 运行阶段 ---- +# 前端以 @astrojs/node standalone server 运行(监听 3000),nginx 反代 / 到该端口、 +# 反代 /api 到 backend:8090。这样 on-demand SSR 详情页 / sitemap / robots 才能正确产出 HTML。 +FROM node:22-alpine +RUN apk add --no-cache nginx +WORKDIR /app +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 -CMD ["nginx", "-g", "daemon off;"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/astro.config.mjs b/astro.config.mjs index b8af0c3..1c48b2d 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -36,6 +36,13 @@ export default defineConfig({ mode: 'standalone' }), + // 运行期 node server 监听地址(容器内部)。nginx 反代到该端口。 + // host: true 监听 0.0.0.0,便于同容器内 nginx 以 127.0.0.1:3000 转发。 + server: { + host: true, + port: 3000, + }, + // base 地址配置已迁到 src/lib/config(dev/prod 两份 TS,缺则抛异常)。 // 仅「密钥」类仍走构建期 env(如 SSG_TOKEN),不在此声明,也不进客户端 bundle。 }); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9b608e3..b05b23e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,20 @@ -# 生产部署 compose(放在前端仓库根,服务器结构见 DEPLOY.md) +# 生产部署 compose(放在前端仓库根;服务器结构见仓库 README) # 前端仓: /opt/fashion/test (本文件所在) -# 后端仓: /opt/fashion/backend (注意:已对齐到当前活跃开发的后端,原 admin/backend 为旧扁平版) +# 后端仓: /opt/fashion/backend (已对齐到当前活跃开发的后端,仅支持 PostgreSQL + pgvector) services: - # ---- 数据库 ---- + # ---- 数据库 (PostgreSQL + pgvector) ---- + # 注意:后端代码仅支持 PostgreSQL + pgvector(gorm pgx 驱动),切勿换回 MySQL。 db: - image: mysql:8.0 + image: ankane/pgvector:latest # 预编译好 pgvector 的 PG 镜像(latest 当前底层为 PG17) restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-root} - MYSQL_DATABASE: ${DB_NAME:-fashionadmin} + POSTGRES_USER: fashion + POSTGRES_PASSWORD: ${DB_PASSWORD:-fashion_dev_2026} # 生产务必换强密码 + POSTGRES_DB: fashion volumes: - - db_data:/var/lib/mysql + - db_data:/var/lib/postgresql/data healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_PASSWORD:-root}"] + test: ["CMD-SHELL", "pg_isready -U fashion -d fashion"] interval: 10s timeout: 5s retries: 10 @@ -29,11 +31,15 @@ services: 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: 3306 - DB_USER: ${DB_USER:-root} - DB_PASSWORD: ${DB_PASSWORD:-root} - DB_NAME: ${DB_NAME:-fashionadmin} + 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 也绝不反代它 @@ -46,7 +52,7 @@ services: timeout: 5s retries: 10 - # ---- 前端 (Astro 静态 + nginx) ---- + # ---- 前端 (Astro node server + nginx 反代) ---- frontend: build: context: . diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..4dd1ed2 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,18 @@ +#!/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 index e751ed6..5b6f4a3 100644 --- a/nginx.conf +++ b/nginx.conf @@ -2,12 +2,13 @@ server { listen 80; server_name _; - root /usr/share/nginx/html; - index index.html; - - # 前端静态资源 / SPA 回退 + # 前端 node server(Astro standalone,同容器内 127.0.0.1:3000):承载 SSR 详情页 / 静态资源。 location / { - try_files $uri $uri/ /index.html; + 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 不反代)。 diff --git a/src/components/CollectionCard.astro b/src/components/CollectionCard.astro index 9243cf0..7bd6a5a 100644 --- a/src/components/CollectionCard.astro +++ b/src/components/CollectionCard.astro @@ -19,7 +19,7 @@
- +
@@ -31,7 +31,7 @@ diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index dced358..a06da2e 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -36,6 +36,18 @@ const defaultKeywords = const metaDescription = description ?? defaultDescription const metaKeywords = keywords ?? defaultKeywords +// 社交分享默认图(站方应提供一张 1200x630 的公开图放 public/og-default.jpg)。 +const OG_IMAGE = "/og-default.jpg" + +// 站点级结构化数据(WebSite),帮助搜索引擎理解站点身份与语言。 +const ldJson = { + "@context": "https://schema.org", + "@type": "WebSite", + name: SITE_NAME, + url: Astro.url.href, + inLanguage: locale === "cn" ? "zh-CN" : "en", +} + // 把当前语言注入 API 请求层:所有服务端(SSG/SSR)取数统一走 X-Locale 头, // 后端据此选 *_en / *_cn 列。客户端 fetch 则自行按 URL path 推断,无需此处。 setApiLocale(locale) @@ -99,6 +111,25 @@ const headers = { {siteTitle} + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/collection-page.ts b/src/lib/collection-page.ts index f305a6e..7fdb037 100644 --- a/src/lib/collection-page.ts +++ b/src/lib/collection-page.ts @@ -16,7 +16,7 @@ * 所有方法内的 `this` 在运行时都指向 Alpine 组件实例。 */ -import { toAbs } from "@/lib/request" +import { toAbs, toSrcset } from "@/lib/request" import { getUser } from "@/lib/auth" import { clientHref, ROUTES } from "@/lib/routes" import { buildPageList, SPIN_SVG } from "@/lib/ui" @@ -99,6 +99,7 @@ export function createCollectionPage(opts: CollectionPageOptions) { nextPage() { this.goPage(this.page + 1) }, toAbsUrl(url: string) { return toAbs(url) }, + toAbsSrcset(url: string) { return toSrcset(url) }, itemHref(it: { id: string | number }) { // 按当前列表模块的收藏类型选对应类型化路由(与列表页 slug 一致)。 const route = opts.favType === "street" ? ROUTES.streetSnap(it.id) : ROUTES.runwayLook(it.id) diff --git a/src/lib/request.ts b/src/lib/request.ts index 9dcd2da..122abaa 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -37,6 +37,32 @@ export function toAbs(url: string): string { return /^https?:\/\//i.test(url) ? url : baseUrl() + url } +// 响应式 srcset:基于同一图片 URL 生成多档宽度的候选集,交给浏览器按设备 DPR/视口挑选, +// 避免「一张大图在所有屏幕都下载」造成的带宽浪费(图片站性能关键项)。 +// +// 适配本站两类远程图 URL: +// - 缤纷云 S4 CoreIX 样式串:key!style:high → key!w:480 / !w:768 ...(替换 style 段为宽度段) +// - 带查询串:?w=1080&q=80 → 改写 w= 参数 +// 本地 /uploads 没有缩放服务,返回空(不生成 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}`) + try { + const u2 = new URL(u) + u2.searchParams.set("w", String(w)) + return u2.toString() + } catch { + return u + (u.includes("?") ? "&" : "?") + "w=" + w + } + } + return SRCSET_WIDTHS.map((w) => `${at(abs, w)} ${w}w`).join(", ") +} + // 按需渲染(on-demand SSR)详情 base:必须是绝对 URL(Node fetch 不吃相对路径)。 export function ssrBase(): string { return config.baseApiSsr.replace(/\/$/, "") diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..92814be --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,22 @@ +--- +import Layout from '@/layouts/Layout.astro' +import { getI18n } from '@/lib/i18n' +import { href, ROUTES } from '@/lib/routes' + +const { t, locale } = getI18n(Astro) +// 显式返回 404 状态码,避免被当作 200 软 404 损害 SEO。 +Astro.response.status = 404 +--- + + +
+

404

+

{t('Page not found')}

+ + {t('Home')} + +
+