This commit is contained in:
toom1996
2026-08-29 15:47:58 +08:00
parent eee2265e15
commit 3c7c5d8855
15 changed files with 1369 additions and 733 deletions

View File

@ -11,7 +11,6 @@
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
//
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
// - publicBase():客户端运行期 base,仅 BASE_API(空 → 同源相对 /api)。
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
// - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。
@ -34,7 +33,6 @@ export interface BrandEntry {
name: string // 英文名(name_en 回落 name)
nameCn?: string // 中文名(name_cn,可能为空)
display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名
article_count: number
}
export interface ArticleItem {
@ -43,49 +41,33 @@ export interface ArticleItem {
title: string
cover: string
brand_name: string
year: number
image_count: number
published_at: number
collection_type?: string
season?: string
season_code?: string
gallery: string[]
image_count: number // 该走秀图片总数(列表卡片 "N+" 角标用)
gallery: string[] // 由 images 映射而来的附图 URL 列表
}
export interface ArticleQuery {
page?: number
size?: number
sort?: string
brand_ids?: string[] // 编码串数组,透传给后端解码
collection_type?: string[]
season?: string[]
year?: string[]
with_images?: number
brandId?: string // 编码串;传入则附加 ?brand_id 查询参数
collection?: string // 单值:rtw/menswear/couture/resort/pre_fall
season?: string // 单值:spring/fall
year?: string | number
}
export interface ArticlePage {
data: ArticleItem[]
total: number
last_page: number
}
// 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。
export type RawArticle = {
id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字
id: string | number // 后端为 hashid 编码串(string)
title: string
summary?: string
description?: string
description?: string // 详情正文(仅详情接口返回)
cover: string
brand_name: string
brand_id?: string | number // hashid 编码串
year: number
image_count: number
published_at: number
created_at?: number
source_url?: string
collection_type?: string
season?: string
season_code?: string
image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回)
images?: { id?: number; image: string; name?: string }[]
}
@ -120,17 +102,6 @@ export function toAbs(url: string): string {
return /^https?:\/\//i.test(url) ? url : getBase() + url
}
// 客户端运行期 base:仅 BASE_API(空 → 同源相对 /api)。
export function publicBase(): string {
return (BASE_API || "").replace(/\/$/, "")
}
export function toAbsPublic(url: string): string {
if (!url) return ""
if (/^https?:\/\//i.test(url)) return url
return publicBase() + url
}
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
export function ssrBase(): string {
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
@ -166,59 +137,60 @@ function withLocale(params?: URLSearchParams, override?: string): URLSearchParam
return p
}
// ===================== 前端 JS 请求签名(反爬一层) =====================
// 注意:这是「提高成本」而非「加密」。CLIENT_SIGN_SECRET 必须出现在前端 bundle 才能签名,
// 对浏览器可见;有决心的爬虫可反编译复刻。它用于拖慢 casual 爬虫,配合后端按 IP 限流才有效。
// 必须与后端 client_sign.secret 完全一致。生产请改用随机长串并同步两端。
const CLIENT_SIGN_SECRET = "dev-client-sign-secret-change-me"
const CLIENT_SIGN_TTL = 30
// 计算 HMAC-SHA256 签名。canonical = METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce
// 与后端 middleware.ClientSign 的拼接方式严格一致(换行符分隔,顺序固定)。
async function clientSign(method: string, fullPath: string, rawQuery: string): Promise<{ sig: string; ts: string; nonce: string }> {
const ts = String(Math.floor(Date.now() / 1000))
const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36)
const msg = [method, fullPath, rawQuery, ts, nonce].join("\n")
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
"raw",
enc.encode(CLIENT_SIGN_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
)
const buf = await crypto.subtle.sign("HMAC", key, enc.encode(msg))
const sig = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
return { sig, ts, nonce }
}
// ===================== 内部请求原语 =====================
// 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。
// 自动追加 ?locale=(当前语言),让后端按语言选列返回。
// 每个请求附带前端 JS 签名(X-Sign / X-Sign-Ts / X-Sign-Nonce),供后端 ClientSign 中间件校验。
async function request<T>(path: string, params?: URLSearchParams): Promise<T> {
const p = withLocale(params)
const qs = p.toString() ? "?" + p.toString() : ""
const url = getBase() + "/api/v1" + path + qs
const res = await fetch(url)
const fullPath = "/api/v1" + path
const { sig, ts, nonce } = await clientSign("GET", fullPath, p.toString())
const url = getBase() + fullPath + qs
const res = await fetch(url, {
headers: {
"X-Sign": sig,
"X-Sign-Ts": ts,
"X-Sign-Nonce": nonce,
},
})
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
return (await res.json()) as T
}
// ===================== 文章(/api/public/articles) =====================
// ===================== 走秀(/api/public/runway-looks) =====================
// 接口:GET /api/public/articles
// 原始文章列表(不分页映射),供数据层与各类 getter 复用。
export async function getArticlesRaw(opts?: {
page?: number
size?: number
sort?: string
brand_ids?: string[]
brand_id?: string
collection_type?: string[]
season?: string[]
year?: string[]
with_images?: number
keyword?: string
}): Promise<{ data: RawArticle[]; total: number; last_page: number }> {
const p = new URLSearchParams()
p.set("page", String(opts?.page ?? 1))
p.set("size", String(opts?.size ?? 24))
if (opts?.sort) p.set("sort", opts.sort)
if (opts?.keyword) p.set("keyword", opts.keyword)
if (opts?.with_images) p.set("with_images", String(opts.with_images))
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
if (opts?.brand_ids?.length) p.set("brand_ids", opts.brand_ids.join(","))
for (const v of opts?.collection_type ?? []) p.append("collection_type", v)
for (const v of opts?.season ?? []) p.append("season", v)
for (const v of opts?.year ?? []) p.append("year", v)
const j = await request<{ data?: any[]; total?: number; last_page?: number }>(
"/public/articles",
p
)
return {
data: (j.data ?? []) as RawArticle[],
total: Number(j.total ?? (j.data?.length ?? 0)),
last_page: Number(j.last_page ?? 1),
}
}
// 接口:GET /api/public/articles/:id
// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// 接口:GET /api/public/runway-looks/:id
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
export async function fetchArticleById(
id: number | string,
@ -230,7 +202,7 @@ export async function fetchArticleById(
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/articles/${id}${qs}`)
const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`)
if (!res.ok) return null
const json: any = await res.json()
return (json?.data ?? null) as RawArticle | null
@ -261,65 +233,36 @@ export async function getSsrArticle(
}
}
// 接口:GET /api/public/articles(经 getArticlesRaw 透传)
// 文章列表(RunwayLooks / shows 用):多选用 repeated params,并把 images 映射成 gallery。
// 接口:GET /api/public/runway-looks
// 走秀列表(RunwayLooks / shows 用):单值筛选,brandId 走 ?brand_id 查询参数;
// 把后端 images[{image,name}] 映射成前端卡片用的 gallery(纯 URL 数组)。
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
const { data, total, last_page } = await getArticlesRaw({
page: q.page,
size: q.size,
sort: q.sort,
with_images: q.with_images,
brand_ids: q.brand_ids,
collection_type: q.collection_type,
season: q.season,
year: q.year,
})
const mapped: ArticleItem[] = data.map((it: any) => ({
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
if (q.sort) p.set("sort", q.sort)
if (q.brandId) p.set("brand_id", q.brandId)
if (q.collection) p.set("collection", q.collection)
if (q.season) p.set("season", q.season)
if (q.year) p.set("year", String(q.year))
const j = await request<{ data?: any[]; last_page?: number }>("/public/runway-looks", p)
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
...it,
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
}))
return { data: mapped, total: Number(total ?? mapped.length), last_page: Number(last_page ?? 1) }
}
// 接口:GET /api/public/articles(客户端运行期,base 走 BASE_API)
// articles.astro 客户端按需拉取:保留该页独有的单值参数形态。
export async function fetchArticlesList(opts?: {
page?: number
size?: number
collection_type?: string
season?: string
brand_id?: string
}): Promise<any[]> {
const base = publicBase()
if (!base) return []
const p = new URLSearchParams({ page: String(opts?.page ?? 1), size: String(opts?.size ?? 30) })
if (opts?.collection_type) p.set("collection_type", opts.collection_type)
if (opts?.season) p.set("season", opts.season)
if (opts?.brand_id) p.set("brand_id", opts.brand_id)
// 浏览器端按 URL path 自动推断当前语言并追加 ?locale=
p.set("locale", currentLocale())
const res = await fetch(`${base}/api/v1/public/articles?${p.toString()}`)
if (!res.ok) throw new Error(`list ${res.status}`)
const json = await res.json()
return json?.data ?? []
return { data: mapped, last_page: Number(j.last_page ?? 1) }
}
// ===================== 品牌(/api/public/brands) =====================
// 接口:GET /api/public/brands
// size 已写死 30(后端 brandDefaultSize),only_with_articles 已删除(字母索引始终只返回有档案品牌),
// 前端均不传;关键词搜索仍不过滤"必须有走秀档案"(后端对 keyword 跳过该限制)。
export async function getBrands(opts?: {
size?: number
only_with_articles?: number
featured?: number
keyword?: string
letter?: string
}): Promise<BrandEntry[]> {
const qs = new URLSearchParams()
if (opts?.size) qs.set("size", String(opts.size))
// 关键词搜索:用户按名字找品牌,不应被"必须有走秀档案"过滤。
// 后端已对 keyword 跳过该限制,这里再显式置 0 双保险,使正在运行的后端也立即生效。
const onlyWA = opts?.keyword ? 0 : (opts?.only_with_articles ?? 1)
qs.set("only_with_articles", String(onlyWA))
if (opts?.featured != null) qs.set("featured", String(opts.featured))
if (opts?.keyword) qs.set("keyword", opts.keyword)
if (opts?.letter) qs.set("letter", opts.letter)
@ -334,7 +277,6 @@ export async function getBrands(opts?: {
name: nameEn,
nameCn,
display,
article_count: Number(b.article_count ?? 0),
}
})
}
@ -447,10 +389,110 @@ export async function getSsgHotBrands(): Promise<BrandEntry[]> {
name: nameEn,
nameCn: (b.name_cn || "").toString(),
display: nameEn,
article_count: Number(b.article_count ?? 0),
}
})
}
// ===================== 街拍(/api/public/street-snaps) =====================
export interface StreetSnapItem {
id: string // hashid 编码串
title: string // 含字面 \t 分隔:地点 \t 场次
cover: string
image_count: number
images: { image: string; name: string }[] // 列表附带的每篇前 N 张缩略图
}
// 列表响应(与走秀 ArticlePage 同构)
export interface StreetSnapPage {
data: StreetSnapItem[]
last_page: number
}
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
export interface RawStreetSnap {
id: string
title: string
cover: string
images?: { id?: number; image: string; name?: string }[]
}
// 接口:GET /api/public/street-snaps
// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)。
// 分页 size 服务端固定 24,images 服务端固定 6,前端均不传。
export async function getStreetSnaps(q: {
page?: number
year?: number | string
sort?: string
} = {}): Promise<StreetSnapPage> {
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
if (q.sort) p.set("sort", q.sort)
if (q.year) p.set("year", String(q.year))
const j = await request<{ data?: any[]; last_page?: number }>("/public/street-snaps", p)
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
id: String(it.id),
title: it.title ?? "",
cover: it.cover ?? "",
image_count: Number(it.image_count ?? 0),
images: Array.isArray(it.images) ? it.images.map((im: any) => ({ image: im.image || im, name: im.name || "" })) : [],
}))
return { data: mapped, last_page: Number(j.last_page ?? 1) }
}
// 接口:GET /api/public/street-snaps/:id
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
export async function fetchStreetSnapById(
id: number | string,
base: string = ssrBase(),
locale?: string
): Promise<RawStreetSnap | null> {
const b = base.replace(/\/$/, "")
if (!b) return null
const p = withLocale(undefined, locale)
const qs = p.toString() ? "?" + p.toString() : ""
try {
const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`)
if (!res.ok) return null
const json: any = await res.json()
return (json?.data ?? null) as RawStreetSnap | null
} catch {
return null
}
}
export type SsrStreetSnapResult = {
snap: RawStreetSnap | null
error: "missing" | "failed" | null
}
// 服务端按需渲染(on-demand)取街拍详情:在 fetchStreetSnapById 之上包揽
// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。
export async function getSsrStreetSnap(
id: string | null | undefined,
locale?: string
): Promise<SsrStreetSnapResult> {
if (!id) return { snap: null, error: "missing" }
try {
const snap = await fetchStreetSnapById(id, ssrBase(), locale)
return { snap, error: snap ? null : "failed" }
} catch {
return { snap: null, error: "failed" }
}
}
// 接口:GET /api/v1/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
const j = await ssgRequest<{ data?: any[] }>(`/api/v1/ssg/street-snaps/popular?limit=${limit}`)
return (j?.data ?? []).map((b: any) => ({
id: String(b.id),
title: b.title ?? "",
cover: b.cover ?? "",
image_count: Number(b.image_count ?? 0),
images: [],
}))
}