This commit is contained in:
toom1996
2026-09-29 19:59:22 +08:00
parent b4fa41338b
commit 335e264e45
12 changed files with 143 additions and 103 deletions

View File

@ -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(`*`)。

View File

@ -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"]

View File

@ -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。
});

View File

@ -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: .

18
entrypoint.sh Normal file
View File

@ -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

View File

@ -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 不反代)。

View File

@ -19,7 +19,7 @@
<div class="showscard-left">
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
<img :src="toAbsUrl(it.cover)" :alt="cardTitle(it)" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
<img :src="toAbsUrl(it.cover)" :srcset="toAbsSrcset(it.cover)" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" :alt="cardTitle(it)" loading="lazy" decoding="async" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
</div>
</div>
<div class="showscard-right">
@ -31,7 +31,7 @@
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
<img x-show="slot.src" :src="toAbsUrl(slot.src)" :srcset="toAbsSrcset(slot.src)" sizes="80px" alt="" loading="lazy" decoding="async" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
</div>
</template>

View File

@ -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 = {
<title>{siteTitle}</title>
<meta name="description" content={metaDescription} />
<meta name="keywords" content={metaKeywords} />
<link rel="canonical" href={Astro.url.href} />
<!-- Open Graph -->
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:type" content="website" />
<meta property="og:title" content={siteTitle} />
<meta property="og:description" content={metaDescription} />
<meta property="og:url" content={Astro.url.href} />
<meta property="og:locale" content={locale === "cn" ? "zh_CN" : "en_US"} />
<meta property="og:image" content={OG_IMAGE} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={siteTitle} />
<meta name="twitter:description" content={metaDescription} />
<meta name="twitter:image" content={OG_IMAGE} />
<!-- 结构化数据 -->
<script type="application/ld+json" is:inline set:html={JSON.stringify(ldJson)}></script>
</head>
<body class="bg-white text-black min-h-screen flex flex-col px-5 py-5 md:px-[50px] md:py-[30px] font-sans">

View File

@ -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)

View File

@ -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(/\/$/, "")

22
src/pages/404.astro Normal file
View File

@ -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
---
<Layout title={t('Page not found')}>
<div class="flex flex-1 flex-col items-center justify-center min-h-[50vh] text-center gap-6">
<h1 class="text-6xl font-black tracking-tight">404</h1>
<p class="text-sm text-[var(--c-gray-700)] uppercase tracking-[0.2em]">{t('Page not found')}</p>
<a
href={href(locale, ROUTES.home)}
class="px-5 py-2 border border-black text-[12px] uppercase tracking-[0.5px] hover:bg-black hover:text-white transition-colors"
>
{t('Home')}
</a>
</div>
</Layout>