update
This commit is contained in:
78
DEPLOY.md
78
DEPLOY.md
@ -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(`*`)。
|
||||
14
Dockerfile
14
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"]
|
||||
|
||||
@ -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。
|
||||
});
|
||||
@ -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
18
entrypoint.sh
Normal 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
|
||||
11
nginx.conf
11
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 不反代)。
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
22
src/pages/404.astro
Normal 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>
|
||||
Reference in New Issue
Block a user