update
This commit is contained in:
294
src/lib/api.ts
294
src/lib/api.ts
@ -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: [],
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user