update
This commit is contained in:
@ -1,5 +1,5 @@
|
||||
// @ts-check
|
||||
import { defineConfig, envField } from 'astro/config';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
@ -41,17 +41,6 @@ export default defineConfig({
|
||||
mode: 'standalone'
|
||||
}),
|
||||
|
||||
// 环境变量声明:统一命名规范
|
||||
// BASE_API —— 公开/运行期 API 基址(浏览器 + 按需渲染共用,空 = 同源 /api)
|
||||
// BASE_API_SSG —— 构建期 SSG 内部端口基址(仅服务端,绝不进前端 bundle)
|
||||
// SSG_TOKEN —— SSG 内部接口可选访问令牌(仅服务端)
|
||||
// 注意:Astro 仅自动把 PUBLIC_ 前缀变量暴露给客户端,BASE_API 没有该前缀,
|
||||
// 故必须在 schema 中声明为 access:'public',否则浏览器端 import.meta.env.BASE_API 为 undefined。
|
||||
env: {
|
||||
schema: {
|
||||
BASE_API: envField.string({ context: 'client', access: 'public', optional: true }),
|
||||
BASE_API_SSG: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
SSG_TOKEN: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
},
|
||||
},
|
||||
// base 地址配置已迁到 src/lib/config(dev/prod 两份 TS,缺则抛异常)。
|
||||
// 仅「密钥」类仍走构建期 env(如 SSG_TOKEN),不在此声明,也不进客户端 bundle。
|
||||
});
|
||||
@ -66,7 +66,8 @@ const fullLoginLabel = t('go to full login')
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getUser, login } from "@/lib/auth"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
import ComingSoon from "@/components/ComingSoon.astro"
|
||||
import { toAbs, getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/api"
|
||||
import { getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from "@/lib/routes"
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { RawArticle } from "@/lib/api"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrBase } from "@/lib/api"
|
||||
import { ssrBase } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
article: RawArticle | null
|
||||
@ -585,7 +585,8 @@ const homeHref = href(locale, ROUTES.home)
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getArticleDetailAuthed, toAbs } from "@/lib/api"
|
||||
import { getArticleDetailAuthed } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
|
||||
|
||||
|
||||
@ -265,7 +265,8 @@ const seasonOptions: { value: string; label: string }[] = [
|
||||
|
||||
|
||||
<script>
|
||||
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
|
||||
import { getArticles, getBrands, type ArticleItem, type BrandEntry } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||||
|
||||
@ -3,7 +3,7 @@ import type { RawStreetSnap } from "@/lib/api"
|
||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||
import { getI18n } from "@/i18n/utils"
|
||||
import { href, ROUTES } from '@/lib/routes'
|
||||
import { ssrBase } from "@/lib/api"
|
||||
import { ssrBase } from "@/lib/request"
|
||||
|
||||
interface Props {
|
||||
snap: RawStreetSnap | null
|
||||
@ -351,7 +351,8 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
|
||||
import { getStreetSnapDetailAuthed } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
|
||||
|
||||
|
||||
@ -221,7 +221,8 @@ const cityOptions: { value: string; label: string }[] = [
|
||||
|
||||
|
||||
<script>
|
||||
import { getStreetSnaps, toAbs, type StreetSnapItem } from "@/lib/api"
|
||||
import { getStreetSnaps, type StreetSnapItem } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
|
||||
|
||||
@ -179,7 +179,11 @@ const headers = {
|
||||
|
||||
<footer class="relative z-[1] bg-white mt-20 pt-10 border-t-2 border-black text-black w-full font-sans">
|
||||
<script>
|
||||
import { getUser, fetchMe, logout } from "@/lib/auth"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { fetchMe, logout, enforceIdleLogout } from "@/lib/api"
|
||||
|
||||
// 空闲登出:超过 config.idleLogoutDays 天没访问则登出(在 Alpine 初始化前先跑,避免登录态闪烁)
|
||||
enforceIdleLogout()
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("authMenu", () => ({
|
||||
|
||||
505
src/lib/api.ts
505
src/lib/api.ts
@ -1,30 +1,27 @@
|
||||
// src/lib/api.ts — 后端「业务数据」API 统一客户端(所有业务取数的唯一出入口)
|
||||
// src/lib/api.ts — 后端接口层(纯接口)
|
||||
//
|
||||
// 集中封装全部 /api/v1 业务查询:公开查询(/api/v1/public/*)。
|
||||
// 鉴权 / 会话相关(登录、令牌、受保护写请求)已拆到 auth.ts;
|
||||
// 请求签名(clientSign)已拆到 crypto.ts;请求语言透传已拆到 locale.ts。
|
||||
// 任何模块需要后端业务数据,都只从这里取,不要再自己写 fetch。
|
||||
// 全站对后端的唯一出入口:公开业务查询 + SSG 构建接口 + 鉴权 + 收藏 + 历史。
|
||||
// 本文件只定义「调哪个端点、参数、返回类型」,一律经 request.ts 的统一入口 request() 发出
|
||||
// (签名 / locale / Bearer / 401 续期都在那一层处理),会话令牌的本地读写见 auth.ts。
|
||||
//
|
||||
// base URL 解析规则(全项目统一两个基准地址,命名见下):
|
||||
// BASE_API —— 公开/运行期 API 基址(浏览器运行期 + 按需渲染 SSR)。
|
||||
// 空值 = 同源相对 /api;DEV 构建期固定走线上域名。
|
||||
// BASE_API_SSG —— 构建期 Node 调 /api/internal/ssg/* 的「内部端口」(默认 8091),
|
||||
// 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。
|
||||
// 路径一律写「含版本前缀的完整路径」(如 /api/v1/public/runway-looks)——
|
||||
// 版本前缀直接落在各接口上;将来整体升 /api/v2 时全局替换 /api/v1 即可。
|
||||
//
|
||||
// - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。
|
||||
// - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。
|
||||
// 分组:
|
||||
// - 公开业务:走秀 / 品牌 / 街拍 列表与详情(/api/v1/public/*)
|
||||
// - 鉴权 :登录 / 登出 / 取当前用户(/api/v1/auth/*、/api/v1/me)
|
||||
// - 收藏 :增删 + 分页拉取 + 批量校验(/api/v1/me/favorites*)
|
||||
// - 历史 :记录 + 分页拉取 + 删除 + 清空(/api/v1/me/history*)
|
||||
// - SSG :构建期内部端口取数(/api/internal/ssg/*,仅 Node 构建期)
|
||||
|
||||
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
||||
import "@/lib/http"
|
||||
import { request, ssrBase, requestSsg } from "./request"
|
||||
import { config } from "./config"
|
||||
import { saveSession, clearSession, getUser, getAccessToken, getRefreshToken, getLastActiveAt, touchActivity } from "./auth"
|
||||
import { withLocale } from "./locale"
|
||||
import type { AuthUser } from "./auth"
|
||||
|
||||
// BASE_API:Astro 7 中声明在 env.schema(access:public)的变量,浏览器端须从 astro:env/client 读取。
|
||||
// 该模块在 SSR(服务端)也会回退到 process.env,因此服务端/客户端共用同一份值。
|
||||
import { BASE_API } from "astro:env/client"
|
||||
|
||||
// 跨模块依赖(已拆分的关注点):
|
||||
import { clientSign } from "./crypto" // 公开请求签名(X-Sign* 头)
|
||||
import { getAccessToken, getRefreshToken, refreshSession } from "./auth" // 令牌读取 / 续期
|
||||
import { withLocale, currentLocale } from "./locale" // 请求语言透传
|
||||
// 刷新令牌的实现在 request.ts(属 transport 的 401 重试机制),此处再导出以便「接口集中在 api.ts」。
|
||||
export { refreshSession } from "./request"
|
||||
|
||||
// ===================== 类型 =====================
|
||||
|
||||
@ -102,68 +99,88 @@ export interface SsgPopularBrand {
|
||||
title: string
|
||||
}
|
||||
|
||||
// ===================== base 解析 =====================
|
||||
|
||||
// 仅本模块内部使用(toAbs / request 等),不对外导出。
|
||||
function getBase(): string {
|
||||
// 运行期/预渲染统一走 BASE_API(空则同源相对 /api)
|
||||
return (BASE_API || "").replace(/\/$/, "")
|
||||
// 浏览历史单篇回查结果(账户页 My History 渲染用)。
|
||||
export interface HistoryMeta {
|
||||
title: string
|
||||
cover: string
|
||||
brand: string
|
||||
type: "street" | "runway"
|
||||
// 后端回传的**规范 id**(带类型前缀,如 r0008CZYI)。历史/收藏里可能存在早期无前缀的 id,
|
||||
// 后端对无前缀串也能解析并回传规范 id,故用它做「同一篇文章」的判据与链接目标。
|
||||
id: string
|
||||
}
|
||||
|
||||
// 相对图片路径补成绝对(基于 getBase())。
|
||||
export function toAbs(url: string): string {
|
||||
if (!url) return ""
|
||||
return /^https?:\/\//i.test(url) ? url : getBase() + url
|
||||
export interface StreetSnapItem {
|
||||
id: string // hashid 编码串
|
||||
title: string // 含字面 \t 分隔:地点 \t 场次
|
||||
cover: string
|
||||
image_count: number
|
||||
images: { id?: string; image: string; name?: string }[] // 列表附带的每篇前 N 张缩略图(id 为图片自身的编码串)
|
||||
}
|
||||
|
||||
// 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。
|
||||
export function ssrBase(): string {
|
||||
return (BASE_API || "http://localhost:8090").replace(/\/$/, "")
|
||||
// 街拍列表响应(与走秀 ArticlePage 同构)
|
||||
export interface StreetSnapPage {
|
||||
data: StreetSnapItem[]
|
||||
last_page: number
|
||||
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
|
||||
}
|
||||
|
||||
// ===================== 内部请求原语 =====================
|
||||
|
||||
// 通用请求原语:走 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 fullPath = "/api/v1" + path
|
||||
const { sig, ts, nonce } = await clientSign("GET", fullPath, p.toString())
|
||||
const url = getBase() + fullPath + qs
|
||||
const headers: Record<string, string> = {
|
||||
"X-Sign": sig,
|
||||
"X-Sign-Ts": ts,
|
||||
"X-Sign-Nonce": nonce,
|
||||
}
|
||||
// 已登录则自动附带 Bearer(翻页/筛选等需鉴权的列表请求才能通过后端校验)。
|
||||
// 未登录时不带,首页(page<=1)仍可直接公开访问。
|
||||
const token = getAccessToken()
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`
|
||||
let res = await fetch(url, { headers })
|
||||
// 登录态下 access 过期:用 refresh 续期一次后重试(与 authJson 行为一致)。
|
||||
if (res.status === 401 && getRefreshToken()) {
|
||||
try {
|
||||
await refreshSession()
|
||||
const newToken = getAccessToken()
|
||||
if (newToken) headers["Authorization"] = `Bearer ${newToken}`
|
||||
res = await fetch(url, { headers })
|
||||
} catch {
|
||||
/* 刷新失败:保留原 401,下面统一抛出,由调用方处理 */
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw new Error(`API ${res.status} ${url}`)
|
||||
return (await res.json()) as T
|
||||
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
|
||||
export interface RawStreetSnap {
|
||||
id: string
|
||||
title: string
|
||||
cover: string
|
||||
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
|
||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||
}
|
||||
|
||||
// ===================== 走秀(/api/public/runway-looks) =====================
|
||||
// ===================== 类型:收藏 / 历史(线格式) =====================
|
||||
|
||||
// 后端收藏项(/me/favorites 解包后的数组元素)。
|
||||
export interface ServerFavorite {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
cover?: string
|
||||
brand?: string
|
||||
added_at?: number
|
||||
kind?: string
|
||||
image_url?: string
|
||||
parent_uid?: string
|
||||
}
|
||||
|
||||
export interface FavoritePage {
|
||||
items: ServerFavorite[]
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
|
||||
// 收藏写入负载(POST /me/favorites)。
|
||||
export interface FavoritePayload {
|
||||
target_type: string
|
||||
target_uid: string
|
||||
title: string
|
||||
cover: string
|
||||
brand: string
|
||||
kind: string
|
||||
image_url: string
|
||||
parent_uid: string
|
||||
}
|
||||
|
||||
// 后端历史项(/me/history 解包后的数组元素,仅 id + viewed_at)。
|
||||
export interface ServerHistory {
|
||||
id: string
|
||||
viewed_at?: number
|
||||
}
|
||||
|
||||
export interface HistoryPage {
|
||||
items: ServerHistory[]
|
||||
total: number
|
||||
last_page: number
|
||||
}
|
||||
|
||||
// ===================== 公开业务:走秀 =====================
|
||||
|
||||
// 接口:GET /api/public/runway-looks/:id
|
||||
// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。
|
||||
// locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。
|
||||
// 对外导出:详情取数 / 个人中心逐篇回查(浏览器端按需取 title/cover 渲染历史卡片)。
|
||||
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
export type SsrArticleResult = {
|
||||
article: RawArticle | null
|
||||
error: "missing" | "failed" | null
|
||||
@ -171,6 +188,10 @@ export type SsrArticleResult = {
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/public/runway-looks/:id
|
||||
// 服务端按需渲染(on-demand)取文章详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
// 注意:此处在**服务端**按需渲染时调用,走 ssrBase()(运行期 8090),属可信内部取数,不经前端签名。
|
||||
// locale 显式传入时优先(避免并发串 locale)。
|
||||
export async function getSsrArticle(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
@ -195,22 +216,20 @@ export async function getSsrArticle(
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户详情取数:走 request()(自动附带 Bearer + locale 签名),
|
||||
// 后端据此返回完整图片集(未登录截断到 PreviewLimit 张)。详情页未登录→登录后客户端补拉全量用。
|
||||
export async function getArticleDetailAuthed(
|
||||
id: string,
|
||||
locale?: string
|
||||
): Promise<RawArticle | null> {
|
||||
// 接口:GET /api/v1/public/runway-looks/:id(已登录)
|
||||
// 走统一 request()(自动带 Bearer + 签名 + locale),后端据此返回完整图片集。
|
||||
// 详情页未登录 → 登录后客户端补拉全量用。
|
||||
export async function getArticleDetailAuthed(id: string): Promise<RawArticle | null> {
|
||||
try {
|
||||
const json: any = await request(`/public/runway-looks/${encodeURIComponent(id)}`)
|
||||
const json: any = await request("GET", `/api/v1/public/runway-looks/${encodeURIComponent(id)}`)
|
||||
return (json?.data ?? null) as RawArticle | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 接口:GET /api/public/runway-looks
|
||||
// 走秀列表(RunwayLooks / shows 用):单值筛选,brandId 走 ?brand_id 查询参数;
|
||||
// 接口:GET /api/v1/public/runway-looks
|
||||
// 走秀列表:单值筛选,brandId 走 ?brand_id 查询参数;
|
||||
// 把后端 images[{image,name}] 映射成前端卡片用的 gallery(纯 URL 数组)。
|
||||
export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
||||
const p = new URLSearchParams()
|
||||
@ -220,7 +239,11 @@ export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
||||
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; preview?: boolean }>("/public/runway-looks", p)
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>(
|
||||
"GET",
|
||||
"/api/v1/public/runway-looks",
|
||||
{ params: p }
|
||||
)
|
||||
const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({
|
||||
...it,
|
||||
gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [],
|
||||
@ -228,9 +251,9 @@ export async function getArticles(q: ArticleQuery): Promise<ArticlePage> {
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
|
||||
}
|
||||
|
||||
// ===================== 品牌(/api/public/brands) =====================
|
||||
// ===================== 公开业务:品牌 =====================
|
||||
|
||||
// 接口:GET /api/public/brands
|
||||
// 接口:GET /api/v1/public/brands
|
||||
// size 已写死 30(后端 brandDefaultSize),only_with_articles 已删除(字母索引始终只返回有档案品牌),
|
||||
// 前端均不传;关键词搜索仍不过滤"必须有走秀档案"(后端对 keyword 跳过该限制)。
|
||||
export async function getBrands(opts?: {
|
||||
@ -242,47 +265,33 @@ export async function getBrands(opts?: {
|
||||
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)
|
||||
const j = await request<{ data?: any[] }>("/public/brands", qs)
|
||||
const j = await request<{ data?: any[] }>("GET", "/api/v1/public/brands", { params: qs })
|
||||
return (j.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
const nameCn = (b.name_cn || "").toString()
|
||||
// 品牌名始终展示英文(即便是中文语境),不再拼接中文名,保持英文观感。
|
||||
const display = nameEn
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn,
|
||||
display,
|
||||
display: nameEn,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 浏览历史:单篇详情回查(账户页 My History 渲染用) =====================
|
||||
// ===================== 公开业务:浏览历史单篇回查 =====================
|
||||
|
||||
// 历史只存文章对外编码串,展示时逐篇回查公开详情接口取 title/cover/brand。
|
||||
// 编码串首字符即类型:s=… 实为 s 开头(如 s000zAwwQ)代表街拍,其余(r 开头等)为走秀。
|
||||
// 走 request():自动带 locale 与前端签名,登录态附带 Bearer。
|
||||
|
||||
export interface HistoryMeta {
|
||||
title: string
|
||||
cover: string
|
||||
brand: string
|
||||
type: "street" | "runway"
|
||||
// 后端回传的**规范 id**(带类型前缀,如 r0008CZYI)。历史/收藏里可能存在早期无前缀的 id,
|
||||
// 后端对无前缀串也能解析并回传规范 id,故用它做「同一篇文章」的判据与链接目标。
|
||||
id: string
|
||||
}
|
||||
|
||||
// 单篇回查:按编码串首字符判断类型(s 开头 = 街拍,其余 = 走秀)。失败返回 null,由调用方容忍。
|
||||
// 编码串首字符即类型:s 开头代表街拍,其余(r 开头等)为走秀。
|
||||
export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
|
||||
const sid = String(id)
|
||||
// 编码串类型前缀是单个字符(如 s000zAwwQ),与 /item/[id] 路由判断一致(r= 走秀 / s= 街拍)
|
||||
const type = sid.charAt(0) === "s" ? ("street" as const) : ("runway" as const)
|
||||
const path =
|
||||
type === "street"
|
||||
? `/public/street-snaps/${encodeURIComponent(sid)}`
|
||||
: `/public/runway-looks/${encodeURIComponent(sid)}`
|
||||
? `/api/v1/public/street-snaps/${encodeURIComponent(sid)}`
|
||||
: `/api/v1/public/runway-looks/${encodeURIComponent(sid)}`
|
||||
try {
|
||||
const j = await request<{ data?: any }>(path)
|
||||
const j = await request<{ data?: any }>("GET", path)
|
||||
const d = j?.data
|
||||
if (!d) return null
|
||||
return {
|
||||
@ -297,92 +306,9 @@ export async function getHistoryMeta(id: string): Promise<HistoryMeta | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== SSG 构建专用(/api/internal/ssg/*,仅 Node 构建期) =====================
|
||||
// ===================== 公开业务:街拍 =====================
|
||||
|
||||
function ssgEnv(name: string): string | undefined {
|
||||
const e = import.meta.env as any
|
||||
if (e[name] != null && e[name] !== "") return e[name] as string
|
||||
if (typeof process !== "undefined" && process.env[name]) return process.env[name] as string
|
||||
return undefined
|
||||
}
|
||||
|
||||
// SSG 内部接口 base:只认 BASE_API_SSG 专用端口(默认 8091),不回落 BASE_API/运行期端口。
|
||||
// 绝不可回落到 BASE_API(8090)——那会把内部全量接口暴露到公网端口。
|
||||
function ssgBase(): string {
|
||||
return (ssgEnv("BASE_API_SSG") || "http://localhost:8091").replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function ssgToken(): string {
|
||||
return (ssgEnv("SSG_TOKEN") || "").toString().trim()
|
||||
}
|
||||
|
||||
async function ssgRequest<T>(path: string): Promise<T | null> {
|
||||
// 组合 SSG_TOKEN 与 ?locale=(构建期按当前语言选列,后端 parseLocale 已支持)。
|
||||
const t = ssgToken()
|
||||
const parts: string[] = []
|
||||
if (t) parts.push("token=" + encodeURIComponent(t))
|
||||
const loc = currentLocale()
|
||||
if (loc) parts.push("locale=" + encodeURIComponent(loc))
|
||||
const url = ssgBase() + path + (parts.length ? (path.includes("?") ? "&" : "?") + parts.join("&") : "")
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页(/index/)runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
|
||||
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
|
||||
const j = await ssgRequest<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/internal/ssg/home/runway`)
|
||||
return j?.data ?? { hotBrand: [] }
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// SSG 构建期:走秀页热门品牌(按图片总数排名,结果按热度顺序)。
|
||||
// 后端固定返回前 30 个;侧栏 filter 展示前 10,品牌弹窗 HOT 标签展示全部。
|
||||
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
||||
const j = await ssgRequest<{ data?: any[] }>("/api/internal/ssg/brands/popular")
|
||||
return (j?.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn: (b.name_cn || "").toString(),
|
||||
display: nameEn,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===================== 街拍(/api/public/street-snaps) =====================
|
||||
|
||||
export interface StreetSnapItem {
|
||||
id: string // hashid 编码串
|
||||
title: string // 含字面 \t 分隔:地点 \t 场次
|
||||
cover: string
|
||||
image_count: number
|
||||
images: { id?: string; image: string; name?: string }[] // 列表附带的每篇前 N 张缩略图(id 为图片自身的编码串)
|
||||
}
|
||||
|
||||
// 列表响应(与走秀 ArticlePage 同构)
|
||||
export interface StreetSnapPage {
|
||||
data: StreetSnapItem[]
|
||||
last_page: number
|
||||
preview?: boolean // 未登录且总量超预览上限时为 true,前端据此弹登录门禁
|
||||
}
|
||||
|
||||
// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。
|
||||
export interface RawStreetSnap {
|
||||
id: string
|
||||
title: string
|
||||
cover: string
|
||||
images?: { id?: string; image: string; name?: string }[] // id 为图片自身的编码串(i/j 前缀),单图收藏用
|
||||
favorited?: boolean // 已登录时该图集是否已收藏(随详情接口返回)
|
||||
}
|
||||
|
||||
// 接口:GET /api/public/street-snaps
|
||||
// 接口:GET /api/v1/public/street-snaps
|
||||
// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)+ 地区(city 精确匹配)。
|
||||
// 分页 size 服务端固定 24,images 服务端固定 6,前端均不传。
|
||||
export async function getStreetSnaps(q: {
|
||||
@ -396,7 +322,11 @@ export async function getStreetSnaps(q: {
|
||||
if (q.sort) p.set("sort", q.sort)
|
||||
if (q.year) p.set("year", String(q.year))
|
||||
if (q.city) p.set("city", q.city)
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>("/public/street-snaps", p)
|
||||
const j = await request<{ data?: any[]; last_page?: number; preview?: boolean }>(
|
||||
"GET",
|
||||
"/api/v1/public/street-snaps",
|
||||
{ params: p }
|
||||
)
|
||||
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
title: it.title ?? "",
|
||||
@ -410,17 +340,15 @@ export async function getStreetSnaps(q: {
|
||||
return { data: mapped, last_page: Number(j.last_page ?? 1), preview: !!j.preview }
|
||||
}
|
||||
|
||||
// 接口:GET /api/public/street-snaps/:id
|
||||
// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。
|
||||
// 对外导出:详情取数 / 个人中心逐篇回查。
|
||||
export type SsrStreetSnapResult = {
|
||||
snap: RawStreetSnap | null
|
||||
error: "missing" | "failed" | null
|
||||
preview?: boolean // 未登录且图片数超过预览上限时为 true,详情页据此弹「登录查看全部图片」门禁
|
||||
imageTotal?: number // 图片集真实总数(后端截断前),详情页渲染「剩余 N 张」占位遮罩用
|
||||
preview?: boolean
|
||||
imageTotal?: number
|
||||
}
|
||||
|
||||
// 服务端按需渲染(on-demand)取街拍详情:直接 fetch 详情接口并透传 preview 标记。
|
||||
// 接口:GET /api/v1/public/street-snaps/:id
|
||||
// 服务端按需渲染(on-demand)取街拍详情:经 ssrBase() 直接 fetch(可信内部取数,不经签名)。
|
||||
export async function getSsrStreetSnap(
|
||||
id: string | null | undefined,
|
||||
locale?: string
|
||||
@ -445,23 +373,184 @@ export async function getSsrStreetSnap(
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户街拍详情取数:走 request()(自动附带 Bearer + locale 签名),返回完整图片集。
|
||||
export async function getStreetSnapDetailAuthed(
|
||||
id: string,
|
||||
locale?: string
|
||||
): Promise<RawStreetSnap | null> {
|
||||
// 接口:GET /api/v1/public/street-snaps/:id(已登录)
|
||||
// 走统一 request()(自动带 Bearer + 签名 + locale),返回完整图片集。
|
||||
export async function getStreetSnapDetailAuthed(id: string): Promise<RawStreetSnap | null> {
|
||||
try {
|
||||
const json: any = await request(`/public/street-snaps/${encodeURIComponent(id)}`)
|
||||
const json: any = await request("GET", `/api/v1/public/street-snaps/${encodeURIComponent(id)}`)
|
||||
return (json?.data ?? null) as RawStreetSnap | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 鉴权 =====================
|
||||
|
||||
// 接口:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
|
||||
// 返回完整响应 json(access_token / refresh_token / user 都在顶层,非 envelope 的 data)。
|
||||
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
|
||||
let json: any
|
||||
try {
|
||||
json = await request("POST", "/api/v1/auth/login", { body: input, auth: false })
|
||||
} catch {
|
||||
clearSession()
|
||||
throw new Error("login failed")
|
||||
}
|
||||
const access: string | undefined = json?.access_token
|
||||
const refresh: string | undefined = json?.refresh_token
|
||||
const user: any = json?.user
|
||||
if (!access || !refresh || !user) {
|
||||
clearSession()
|
||||
throw new Error("login failed")
|
||||
}
|
||||
// 登录响应带 expires_in(access 剩余秒数),记录到期时刻供「提前刷新」用。
|
||||
saveSession(access, refresh, user, Number(json?.expires_in) || undefined)
|
||||
touchActivity() // 记录本次活动(空闲登出用)
|
||||
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:login"))
|
||||
}
|
||||
return user as AuthUser
|
||||
}
|
||||
|
||||
// 接口:DELETE /api/v1/auth/sessions/current(吊销当前 refresh_token),随后清本地态。
|
||||
export async function logout(): Promise<void> {
|
||||
const refresh = getRefreshToken()
|
||||
if (refresh) {
|
||||
try {
|
||||
// 会话即 refresh token:单设备登出 = 吊销当前会话
|
||||
await request("DELETE", "/api/v1/auth/sessions/current", { body: { refresh_token: refresh }, auth: false })
|
||||
} catch {
|
||||
/* 忽略网络错误,仍清本地态 */
|
||||
}
|
||||
}
|
||||
clearSession()
|
||||
}
|
||||
|
||||
// 空闲登出:应用启动(Layout)时调用。若「上次访问」距今超过 config.idleLogoutDays 天,则登出;
|
||||
// 否则刷新「最后活动时间」。返回是否因空闲被登出。
|
||||
// 说明:这只是前端 UX 层(refresh token 在服务端仍有效);要真正强制需后端按 last_used 校验。
|
||||
export function enforceIdleLogout(): boolean {
|
||||
if (typeof localStorage === "undefined") return false
|
||||
if (!getUser()) return false // 未登录:无会话可清
|
||||
const days = config.idleLogoutDays
|
||||
if (!days || days <= 0) return false // 0 = 关闭
|
||||
const last = getLastActiveAt()
|
||||
if (last != null && Date.now() - last > days * 24 * 3600 * 1000) {
|
||||
// 超窗:后台吊销 refresh(不阻塞),立即清本地会话(同步,避免登录态闪烁)
|
||||
const rt = getRefreshToken()
|
||||
if (rt) {
|
||||
request("DELETE", "/api/v1/auth/sessions/current", { body: { refresh_token: rt }, auth: false }).catch(() => {})
|
||||
}
|
||||
clearSession()
|
||||
return true
|
||||
}
|
||||
touchActivity()
|
||||
return false
|
||||
}
|
||||
|
||||
// 接口:GET /api/v1/me。无 token 或失败回落 null(调用方容忍)。
|
||||
// 响应为顶层 { user }(无 data 信封),但兼容 { data: { user } } 形态。
|
||||
export async function fetchMe(): Promise<AuthUser | null> {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
if (!getAccessToken()) return null
|
||||
try {
|
||||
const j = await request<any>("GET", "/api/v1/me")
|
||||
const user = j?.data?.user ?? j?.user
|
||||
return (user as AuthUser) ?? null
|
||||
} catch {
|
||||
clearSession()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 收藏(/api/v1/me/favorites*) =====================
|
||||
|
||||
// 新增收藏(已收藏则由后端幂等处理)。
|
||||
export async function addFavorite(payload: FavoritePayload): Promise<void> {
|
||||
await request("POST", "/api/v1/me/favorites", { body: payload })
|
||||
}
|
||||
|
||||
// 取消收藏(按 target_uid)。
|
||||
export async function deleteFavorite(id: string): Promise<void> {
|
||||
await request("DELETE", `/api/v1/me/favorites/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
// 分页拉取收藏;kind 传 gallery / image 可只取对应粒度(服务端过滤),留空为全部。
|
||||
export async function fetchFavorites(
|
||||
page = 1,
|
||||
perPage = 24,
|
||||
kind?: string
|
||||
): Promise<FavoritePage> {
|
||||
const params = new URLSearchParams()
|
||||
params.set("page", String(page))
|
||||
params.set("per_page", String(perPage))
|
||||
if (kind) params.set("kind", kind)
|
||||
const j = await request<{ data: FavoritePage }>("GET", "/api/v1/me/favorites", { params })
|
||||
return j.data
|
||||
}
|
||||
|
||||
// 批量校验当前页的收藏态:只对传入的 id 做 WHERE IN 查询(复杂度与收藏总量无关)。
|
||||
export async function checkFavorites(ids: string[]): Promise<string[]> {
|
||||
const j = await request<{ data: string[] }>("POST", "/api/v1/me/favorites/checks", { body: { ids } })
|
||||
const arr = j?.data
|
||||
return Array.isArray(arr) ? arr.map(String) : []
|
||||
}
|
||||
|
||||
// ===================== 历史(/api/v1/me/history*) =====================
|
||||
|
||||
// 记录一次浏览(upsert 刷新时间)。
|
||||
export async function addHistory(targetUid: string): Promise<void> {
|
||||
await request("POST", "/api/v1/me/history", { body: { target_uid: targetUid } })
|
||||
}
|
||||
|
||||
// 分页拉取浏览历史(仅 id + viewed_at)。
|
||||
export async function fetchHistory(page = 1, perPage = 24): Promise<HistoryPage> {
|
||||
const params = new URLSearchParams()
|
||||
params.set("page", String(page))
|
||||
params.set("per_page", String(perPage))
|
||||
const j = await request<{ data: HistoryPage }>("GET", "/api/v1/me/history", { params })
|
||||
return j.data
|
||||
}
|
||||
|
||||
// 删除一条历史(按文章 id)。
|
||||
export async function deleteHistory(id: string): Promise<void> {
|
||||
await request("DELETE", `/api/v1/me/history/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
// 清空全部历史。
|
||||
export async function clearAllHistory(): Promise<void> {
|
||||
await request("DELETE", "/api/v1/me/history")
|
||||
}
|
||||
|
||||
// ===================== SSG 构建专用(/api/internal/ssg/*,仅 Node 构建期) =====================
|
||||
|
||||
// 接口:GET /api/internal/ssg/home/runway(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页 runway 区块数据,含多个模块;hotBrand 为其中之一(当前仅此模块)。
|
||||
export async function getSsgIndexRunway(): Promise<{ hotBrand: SsgPopularBrand[] }> {
|
||||
const j = await requestSsg<{ data?: { hotBrand?: SsgPopularBrand[] } }>(`/api/internal/ssg/home/runway`)
|
||||
return j?.data ?? { hotBrand: [] }
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/brands/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// 走秀页热门品牌(按图片总数排名,结果按热度顺序)。后端固定返回前 30 个。
|
||||
export async function getSsgHotBrands(): Promise<BrandEntry[]> {
|
||||
const j = await requestSsg<{ data?: any[] }>("/api/internal/ssg/brands/popular")
|
||||
return (j?.data ?? []).map((b: any) => {
|
||||
const nameEn = (b.name_en || b.name || "").toString()
|
||||
return {
|
||||
id: String(b.id),
|
||||
name: nameEn,
|
||||
nameCn: (b.name_cn || "").toString(),
|
||||
display: nameEn,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接口:GET /api/internal/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用)
|
||||
// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。
|
||||
export async function getSsgStreetSnapPopular(limit = 12): Promise<StreetSnapItem[]> {
|
||||
const j = await ssgRequest<{ data?: any[] }>(`/api/internal/ssg/street-snaps/popular?limit=${limit}`)
|
||||
const j = await requestSsg<{ data?: any[] }>(`/api/internal/ssg/street-snaps/popular?limit=${limit}`)
|
||||
return (j?.data ?? []).map((b: any) => ({
|
||||
id: String(b.id),
|
||||
title: b.title ?? "",
|
||||
|
||||
193
src/lib/auth.ts
193
src/lib/auth.ts
@ -1,18 +1,15 @@
|
||||
// src/lib/auth.ts — 账号体系与鉴权(登录 / 会话 / 令牌 / 受保护请求)
|
||||
// src/lib/auth.ts — 会话状态存储(localStorage)
|
||||
//
|
||||
// 本文件只管「我是谁」的本地状态:access / refresh 令牌、access 到期时刻、最后活动时间、用户信息。
|
||||
// 不含任何网络请求(登录/登出/刷新等接口见 api.ts,401 续期见 request.ts)。
|
||||
//
|
||||
// 双令牌:access_token(短命,请求鉴权)+ refresh_token(长命,落库可吊销)。
|
||||
// 登录写两者;authJson 等受保护请求遇 401 自动用 refresh 续期一次;登出吊销 refresh。
|
||||
//
|
||||
// 本文件只管「我是谁 / 怎么登录 / 怎么带令牌发请求」,不含任何业务数据取数
|
||||
// (业务数据见 api.ts,SSG 数据见 ssg.ts)。请求语言透传见 locale.ts。
|
||||
|
||||
import { BASE_API } from "astro:env/client"
|
||||
import { currentLocale } from "./locale"
|
||||
|
||||
const BASE_API_URL = (BASE_API || "").replace(/\/$/, "")
|
||||
const TOKEN_KEY = "fa_token" // access token
|
||||
const REFRESH_KEY = "fa_refresh" // refresh token
|
||||
const USER_KEY = "fa_user"
|
||||
const EXPIRES_KEY = "fa_expires" // access token 到期时刻(epoch ms,来自登录/刷新响应的 expires_in)
|
||||
const LAST_ACTIVE_KEY = "fa_last_active" // 最后一次「有访问」的时刻(epoch ms),空闲登出用
|
||||
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
@ -22,7 +19,7 @@ export interface AuthUser {
|
||||
tier: "free" | "vip"
|
||||
}
|
||||
|
||||
// 注意:getUser / clearSession 只读写 localStorage,不发网络请求。
|
||||
// 读取当前登录用户(仅读 localStorage,不发网络请求)。
|
||||
export function getUser(): AuthUser | null {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
try {
|
||||
@ -43,143 +40,65 @@ export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY)
|
||||
}
|
||||
|
||||
// access token 到期时刻(epoch ms)。未记录(老会话 / 未登录)返回 null。
|
||||
export function getAccessExpiresAt(): number | null {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
const raw = localStorage.getItem(EXPIRES_KEY)
|
||||
if (!raw) return null
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
// access 是否「已到期或临近到期」(阈值默认 60s)——供 request.ts 提前刷新用。
|
||||
// - 无 access token:视为需要(若有 refresh token 则应先刷)。
|
||||
// - 有 token 但到期时刻未知:返回 false(不主动刷,交给 401 重试兜底)。
|
||||
export function isAccessExpiring(withinMs = 60_000): boolean {
|
||||
if (!getAccessToken()) return true
|
||||
const exp = getAccessExpiresAt()
|
||||
if (exp == null) return false
|
||||
return Date.now() >= exp - withinMs
|
||||
}
|
||||
|
||||
// 最后一次「有访问」的时刻(epoch ms)。未记录返回 null(视为无活动记录)。
|
||||
export function getLastActiveAt(): number | null {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
const raw = localStorage.getItem(LAST_ACTIVE_KEY)
|
||||
if (!raw) return null
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
// 记录一次「有访问」(页面加载 / 登录时调用),供空闲登出比较。
|
||||
export function touchActivity(): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.setItem(LAST_ACTIVE_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(EXPIRES_KEY)
|
||||
localStorage.removeItem(LAST_ACTIVE_KEY)
|
||||
}
|
||||
|
||||
// 写登录态:登录成功后把 access + refresh + user 写入 localStorage。
|
||||
export function saveSession(access: string, refresh: string, user: AuthUser): void {
|
||||
// 写登录态:登录 / 续期成功后把 access + refresh + user(+ 到期时刻)写入 localStorage。
|
||||
// expiresInSeconds = 登录/刷新响应里的 expires_in(access 剩余秒数);可选,
|
||||
// 给了就据此记录 access 到期时刻(供 isAccessExpiring 提前刷新用)。
|
||||
export function saveSession(
|
||||
access: string,
|
||||
refresh: string,
|
||||
user: AuthUser,
|
||||
expiresInSeconds?: number,
|
||||
): void {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.setItem(TOKEN_KEY, access)
|
||||
localStorage.setItem(REFRESH_KEY, refresh)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
// 刷新 token:用 refresh_token 换新的 access_token(refresh 复用、不轮换)。
|
||||
export async function refreshSession(): Promise<string> {
|
||||
const refresh = getRefreshToken()
|
||||
if (!refresh) throw new Error("no refresh token")
|
||||
const res = await fetch(`${BASE_API_URL}/api/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearSession()
|
||||
throw new Error("refresh failed")
|
||||
}
|
||||
const json: any = await res.json()
|
||||
const access: string | undefined = json?.access_token
|
||||
if (!access) throw new Error("refresh failed")
|
||||
// refresh 复用:仅更新 access,保留原 refresh。
|
||||
const user = getUser()
|
||||
if (user) saveSession(access, refresh, user)
|
||||
return access
|
||||
}
|
||||
|
||||
// 带鉴权的请求:自动附带 Bearer,遇 401 静默刷新一次后重试。
|
||||
async function authedFetch(url: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const headers = new Headers(opts.headers)
|
||||
const token = getAccessToken()
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`)
|
||||
let res = await fetch(url, { ...opts, headers })
|
||||
if (res.status === 401 && getRefreshToken()) {
|
||||
try {
|
||||
await refreshSession()
|
||||
const newToken = getAccessToken()
|
||||
if (newToken) headers.set("Authorization", `Bearer ${newToken}`)
|
||||
res = await fetch(url, { ...opts, headers })
|
||||
} catch {
|
||||
/* 刷新失败:返回原始 401,由调用方处理 */
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// 带鉴权的 JSON 请求:自动附 Bearer、遇 401 静默刷新一次后重试,
|
||||
// 成功返回响应体中的 data 字段;非 2xx 抛出(含后端 message)。
|
||||
// 收藏/个人中心等需要登录的写操作统一走这里。
|
||||
export async function authJson<T = any>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const url = `${BASE_API_URL}/api/v1${path}`
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
||||
const res = await authedFetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let msg = `API ${res.status} ${method} ${path}`
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j?.message) msg = String(j.message)
|
||||
} catch {
|
||||
/* 响应体非 JSON,沿用默认错误信息 */
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const json: any = await res.json()
|
||||
return (json?.data ?? null) as T
|
||||
}
|
||||
|
||||
// 取当前登录用户:GET /api/v1/me。无 token 或失败回落 null(调用方容忍)。
|
||||
export async function fetchMe(): Promise<AuthUser | null> {
|
||||
if (typeof localStorage === "undefined") return null
|
||||
if (!getAccessToken()) return null
|
||||
try {
|
||||
const res = await authedFetch(`${BASE_API_URL}/api/v1/me?locale=${encodeURIComponent(currentLocale())}`)
|
||||
if (!res.ok) {
|
||||
clearSession()
|
||||
return null
|
||||
}
|
||||
const json: any = await res.json()
|
||||
const user = json?.data?.user ?? json?.user
|
||||
return (user as AuthUser) ?? null
|
||||
} catch {
|
||||
return null
|
||||
if (typeof expiresInSeconds === "number" && expiresInSeconds > 0) {
|
||||
localStorage.setItem(EXPIRES_KEY, String(Date.now() + expiresInSeconds * 1000))
|
||||
} else {
|
||||
localStorage.removeItem(EXPIRES_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// 登录:POST /api/v1/auth/login(公开端点)。成功后写 access+refresh+user 进 localStorage。
|
||||
export async function login(input: { account: string; password: string }): Promise<AuthUser> {
|
||||
const res = await fetch(`${BASE_API_URL}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!res.ok) {
|
||||
clearSession()
|
||||
throw new Error("login failed")
|
||||
}
|
||||
const json: any = await res.json()
|
||||
const access: string | undefined = json?.access_token
|
||||
const refresh: string | undefined = json?.refresh_token
|
||||
const user: any = json?.user
|
||||
if (!access || !refresh || !user) throw new Error("login failed")
|
||||
saveSession(access, refresh, user)
|
||||
// 登录成功广播:列表页在未登录时进页没记图集,登录后由监听补记(与「进页面就记」一致)。
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:login"))
|
||||
}
|
||||
return user as AuthUser
|
||||
}
|
||||
|
||||
// 登出:吊销当前 refresh_token,再清本地态。
|
||||
export async function logout(): Promise<void> {
|
||||
const refresh = getRefreshToken()
|
||||
if (refresh) {
|
||||
try {
|
||||
// 会话即 refresh token:单设备登出 = 吊销当前会话
|
||||
await fetch(`${BASE_API_URL}/api/v1/auth/sessions/current`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
})
|
||||
} catch {
|
||||
/* 忽略网络错误,仍清本地态 */
|
||||
}
|
||||
}
|
||||
clearSession()
|
||||
}
|
||||
|
||||
11
src/lib/config/env.dev.ts
Normal file
11
src/lib/config/env.dev.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// src/lib/config/env.dev.ts — 开发环境配置(astro dev / MODE=development)
|
||||
import type { AppConfig } from "./types"
|
||||
|
||||
const config: AppConfig = {
|
||||
baseApi: "http://localhost:8090", // 浏览器运行期直连本地后端
|
||||
baseApiSsr: "http://localhost:8090", // 按需渲染 SSR 取数(绝对 URL)
|
||||
baseApiSsg: "http://localhost:8091", // 构建期 SSG 内部端口
|
||||
idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出)
|
||||
}
|
||||
|
||||
export default config
|
||||
14
src/lib/config/env.prod.ts
Normal file
14
src/lib/config/env.prod.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// src/lib/config/env.prod.ts — 生产环境配置(astro build / MODE=production)
|
||||
import type { AppConfig } from "./types"
|
||||
|
||||
const config: AppConfig = {
|
||||
// 空串 = 同源相对:生产由 nginx 把 /api 反代到后端,浏览器直接打同源 /api/v1/...
|
||||
baseApi: "",
|
||||
// 按需渲染 SSR 在 Node 里 fetch,必须是绝对 URL(后端在本机/Docker 内部)。
|
||||
baseApiSsr: "http://localhost:8090",
|
||||
// 构建期 SSG 内部端口:绑定回环,绝不对公网暴露。
|
||||
baseApiSsg: "http://127.0.0.1:8091",
|
||||
idleLogoutDays: 7, // 超过 7 天没访问则登出(0 = 关闭空闲登出)
|
||||
}
|
||||
|
||||
export default config
|
||||
46
src/lib/config/index.ts
Normal file
46
src/lib/config/index.ts
Normal file
@ -0,0 +1,46 @@
|
||||
// src/lib/config/index.ts — 配置入口
|
||||
//
|
||||
// 按环境选择 dev / prod 两份配置,并做「必填校验」:任何必填项缺失即抛异常
|
||||
// (构建/启动期立刻暴露,避免静默用到错环境的地址)。
|
||||
//
|
||||
// 选择依据 import.meta.env.PROD:
|
||||
// - astro dev → MODE=development → PROD=false → env.dev.ts
|
||||
// - astro build → MODE=production → PROD=true → env.prod.ts
|
||||
//
|
||||
// 新增配置项时:在 types.ts 加字段 → 两个 env.*.ts 都补上 → 在下述 REQUIRED 里登记必填。
|
||||
import type { AppConfig } from "./types"
|
||||
import dev from "./env.dev"
|
||||
import prod from "./env.prod"
|
||||
|
||||
export type { AppConfig }
|
||||
|
||||
const isProd: boolean = import.meta.env.PROD
|
||||
|
||||
// 必填项:缺失(undefined / 非字符串 / 空串)即抛异常。
|
||||
// 注意 baseApi 允许空串(= 同源相对),故单独放行,不做「非空」要求。
|
||||
const REQUIRED_NON_EMPTY: (keyof AppConfig)[] = ["baseApiSsr", "baseApiSsg"]
|
||||
|
||||
function validate(cfg: Partial<AppConfig> | undefined, env: string): AppConfig {
|
||||
const where = `src/lib/config/env.${env}.ts`
|
||||
if (!cfg) throw new Error(`[config] 未找到 ${env} 配置(${where})`)
|
||||
|
||||
if (typeof cfg.baseApi !== "string") {
|
||||
throw new Error(`[config] ${where} 缺少 baseApi(浏览器运行期基址;同源请显式写空串 "")`)
|
||||
}
|
||||
for (const key of REQUIRED_NON_EMPTY) {
|
||||
const v = cfg[key]
|
||||
if (typeof v !== "string" || v.trim() === "") {
|
||||
throw new Error(`[config] ${where} 缺少必填项 ${key}(不能为空)`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof cfg.idleLogoutDays !== "number" ||
|
||||
!Number.isFinite(cfg.idleLogoutDays) ||
|
||||
cfg.idleLogoutDays < 0
|
||||
) {
|
||||
throw new Error(`[config] ${where} 缺少/非法 idleLogoutDays(空闲登出天数,>=0;0 = 关闭)`)
|
||||
}
|
||||
return cfg as AppConfig
|
||||
}
|
||||
|
||||
export const config: AppConfig = validate(isProd ? prod : dev, isProd ? "prod" : "dev")
|
||||
15
src/lib/config/types.ts
Normal file
15
src/lib/config/types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// src/lib/config/types.ts — 应用配置的结构定义
|
||||
//
|
||||
// 只放「非密」的 URL/开关配置。密钥类(如 SSG_TOKEN)不进这里——本 config 会被打包进客户端
|
||||
// bundle,放密钥等于泄漏;密钥仍走构建期 env(见 request.ts 的 ssgToken)。
|
||||
|
||||
export interface AppConfig {
|
||||
/** 运行期「浏览器」公开 API 基址(origin)。空串 = 同源相对(请求发往 /api/v1/...)。 */
|
||||
baseApi: string
|
||||
/** 服务端「按需渲染(on-demand SSR)」取数基址(Node fetch 需绝对 URL,不能是相对路径)。 */
|
||||
baseApiSsr: string
|
||||
/** 构建期「SSG 内部端口」基址(仅构建期 Node 用;绝不回落公开端口)。 */
|
||||
baseApiSsg: string
|
||||
/** 空闲登出:超过这么多天没有访问则登出(0 = 关闭空闲登出)。 */
|
||||
idleLogoutDays: number
|
||||
}
|
||||
@ -1,25 +1,29 @@
|
||||
// src/lib/favorites.ts — 收藏(服务端持久化 + 本地缓存兜底)
|
||||
// src/lib/favorites.ts — 收藏(本地缓存兜底 + 服务端持久化)
|
||||
//
|
||||
// 职责:收藏的本地缓存、乐观更新、状态判定,以及「服务端调用」的编排。
|
||||
// 真正的 HTTP 端点(/me/favorites*)在 api.ts;本文件只负责本地与业务逻辑。
|
||||
//
|
||||
// 设计要点(应对"收藏量极大"场景):
|
||||
// - 列表卡片打勾:只把「当前可见页的 id」发给后端 /me/favorites/checks,
|
||||
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦,
|
||||
// 即便收藏 10 万也只传输约 24~50 个 id(常数级)。
|
||||
// - 个人中心 Saved looks:走 /me/favorites 的「服务端分页」,每次只取一页(默认 24 条),
|
||||
// DOM 永远轻量,不存在一次渲染上万张卡片的问题。
|
||||
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到 /api/v1/me/favorites(POST/DELETE),
|
||||
// 即便网络失败也不回滚本地,保证前端不"卡死"。
|
||||
// 问哪些已收藏。复杂度与「一屏多少张」成正比,与「用户收藏了多少」彻底解耦。
|
||||
// - 个人中心 Saved looks:走 /me/favorites 的「服务端分页」,每次只取一页(默认 24 条)。
|
||||
// - 收藏/取消:本地先乐观更新(UI 即时),再后台同步到服务端,失败不回滚本地。
|
||||
// - 未登录:纯本地 localStorage,行为与旧版一致(按当前用户名隔离)。
|
||||
//
|
||||
// 两种收藏粒度(共用一张 favorites 表,后端以 kind 区分):
|
||||
// - gallery:整篇图集。id = 图集 uid(r/s 前缀),type = runway / street。
|
||||
// - image :单张图片。id = 图片自身的编码 id(i/j 前缀),type = runway_image / snap_image,
|
||||
// 额外带 imageUrl(这一张图)与 parentUid(所属图集,供从收藏回跳原图集)。
|
||||
// 两种粒度的 id 前缀互不冲突,因此可放在同一张表、同一套接口里。
|
||||
//
|
||||
// 对外函数:getFavorites / isFavorited / toggleFavorite / toggleImageFavorite / removeFavorite /
|
||||
// fetchFavoritesPage / checkFavorited(列表与个人中心按需调用)。
|
||||
|
||||
import { getUser, getAccessToken, authJson } from "./auth"
|
||||
import { getUser, getAccessToken } from "./auth"
|
||||
import {
|
||||
addFavorite,
|
||||
deleteFavorite,
|
||||
fetchFavorites,
|
||||
checkFavorites,
|
||||
type ServerFavorite,
|
||||
type FavoritePayload,
|
||||
} from "./api"
|
||||
|
||||
export type FavoriteType = "runway" | "street" | "runway_image" | "snap_image"
|
||||
export type FavoriteKind = "gallery" | "image"
|
||||
@ -36,19 +40,6 @@ export interface Favorite {
|
||||
parentUid?: string // kind=image:所属图集 uid
|
||||
}
|
||||
|
||||
// 后端 FavoriteItem 响应结构({data:{items:[...]}} 解包后)
|
||||
interface ServerFavorite {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
cover?: string
|
||||
brand?: string
|
||||
added_at?: number
|
||||
kind?: string
|
||||
image_url?: string
|
||||
parent_uid?: string
|
||||
}
|
||||
|
||||
// ---------- 本地存储(兜底 / 离线) ----------
|
||||
function storageKey(): string | null {
|
||||
const u = getUser()
|
||||
@ -148,7 +139,7 @@ export function toggleImageFavorite(args: {
|
||||
export function removeFavorite(id: string): void {
|
||||
setLocal(getLocal().filter((f) => f.id !== id))
|
||||
if (serverMode()) {
|
||||
authJson("DELETE", `/me/favorites/${encodeURIComponent(id)}`).catch(() => {
|
||||
deleteFavorite(id).catch(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
}
|
||||
@ -178,12 +169,8 @@ export async function fetchFavoritesPage(
|
||||
return { items, total: items.length, lastPage: 1 }
|
||||
}
|
||||
try {
|
||||
const qs = kind ? `&kind=${kind}` : ""
|
||||
const r = await authJson<{ items: ServerFavorite[]; total: number; last_page: number }>(
|
||||
"GET",
|
||||
`/me/favorites?page=${page}&per_page=${perPage}${qs}`,
|
||||
)
|
||||
const items = (r?.items ?? []).map((it) => ({
|
||||
const r = await fetchFavorites(page, perPage, kind)
|
||||
const items = (r?.items ?? []).map((it: ServerFavorite) => ({
|
||||
id: String(it.id),
|
||||
type: normalizeType(it.type),
|
||||
title: it.title || "",
|
||||
@ -215,8 +202,8 @@ export async function fetchFavoritesPage(
|
||||
export async function checkFavorited(ids: string[]): Promise<Set<string>> {
|
||||
if (!serverMode() || ids.length === 0) return new Set()
|
||||
try {
|
||||
const arr = await authJson<string[]>("POST", "/me/favorites/checks", { ids })
|
||||
return new Set(Array.isArray(arr) ? arr.map(String) : [])
|
||||
const arr = await checkFavorites(ids)
|
||||
return new Set(arr.map(String))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
@ -249,7 +236,7 @@ export function hydrateFavorited(
|
||||
|
||||
// ---------- 内部:后台同步到服务端 ----------
|
||||
function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
|
||||
const payload = {
|
||||
const payload: FavoritePayload = {
|
||||
target_type: item.type,
|
||||
target_uid: item.id,
|
||||
title: item.title,
|
||||
@ -260,11 +247,11 @@ function syncServer(item: Omit<Favorite, "addedAt">, newState: boolean): void {
|
||||
parent_uid: item.parentUid || "",
|
||||
}
|
||||
if (newState) {
|
||||
authJson("POST", "/me/favorites", payload).catch(() => {
|
||||
addFavorite(payload).catch(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
} else {
|
||||
authJson("DELETE", `/me/favorites/${encodeURIComponent(item.id)}`).catch(() => {
|
||||
deleteFavorite(item.id).catch(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
// src/lib/history.ts — 浏览历史(服务端持久化 + 本地缓存兜底)
|
||||
// src/lib/history.ts — 浏览历史(本地缓存兜底 + 服务端持久化)
|
||||
//
|
||||
// 2026-09-02 语义收敛(用户拍板):
|
||||
// 职责:历史的本地缓存与去重、以及「服务端调用」的编排。
|
||||
// 真正的 HTTP 端点(/me/history*)与单篇回查(getHistoryMeta)在 api.ts。
|
||||
//
|
||||
// 语义(2026-09-02 用户拍板):
|
||||
// - 浏览历史 = 「点开过哪篇文章」,触发时机在 item 详情页(打开某篇走秀/街拍即记一条)。
|
||||
// 列表页(Runway Looks / Street Snaps)进入不再记。
|
||||
// - 后端 histories 表只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
|
||||
// (首字符区分类型:s 开头=街拍 / r 开头=走秀,编码串自带类型,故不再有 kind/target_type 概念),
|
||||
// 表已去掉 title/cover/brand 快照列。
|
||||
// - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand
|
||||
// (用户选「只存 id、展示时回查」),历史卡片点击跳回文章详情。
|
||||
// 列表页进入不记。
|
||||
// - 服务端只存 (user_id, target_uid, viewed_at):target_uid 为文章对外编码串
|
||||
// (首字符区分类型:s 开头=街拍 / r 开头=走秀)。
|
||||
// - 账户页 My History 展示:先取服务端 id 列表,再逐篇回查公开详情接口拿 title/cover/brand。
|
||||
// - 未登录:纯本地 localStorage(按用户名隔离);登录态写本地 + 后台 POST(失败不回滚本地)。
|
||||
|
||||
import { getHistoryMeta } from "./api"
|
||||
import { getUser, getAccessToken, authJson } from "./auth"
|
||||
import { getUser, getAccessToken } from "./auth"
|
||||
import {
|
||||
getHistoryMeta,
|
||||
addHistory,
|
||||
fetchHistory,
|
||||
deleteHistory,
|
||||
clearAllHistory,
|
||||
type ServerHistory,
|
||||
} from "./api"
|
||||
|
||||
export interface HistoryData {
|
||||
title: string
|
||||
@ -36,12 +44,6 @@ function dedupeKey(id: string): string {
|
||||
return /^[rsij]/.test(s) ? s.slice(1) : s
|
||||
}
|
||||
|
||||
// 后端 HistoryItem 响应结构({data:{items:[...]}} 解包后,仅 id + viewed_at)
|
||||
interface ServerHistory {
|
||||
id: string
|
||||
viewed_at?: number
|
||||
}
|
||||
|
||||
// ---------- 本地存储(兜底 / 离线) ----------
|
||||
function storageKey(): string | null {
|
||||
const u = getUser()
|
||||
@ -90,7 +92,7 @@ export function recordHistory(id: string): void {
|
||||
setLocal(list)
|
||||
|
||||
if (serverMode()) {
|
||||
authJson("POST", "/me/history", { target_uid: id }).catch(() => {
|
||||
addHistory(id).catch(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
}
|
||||
@ -125,12 +127,8 @@ export async function fetchHistoryPage(page = 1, perPage = 24): Promise<HistPage
|
||||
return { items: local, total: local.length, lastPage: 1 }
|
||||
}
|
||||
try {
|
||||
const qs = `page=${page}&per_page=${perPage}`
|
||||
const r = await authJson<{ items: ServerHistory[]; total: number; last_page: number }>(
|
||||
"GET",
|
||||
`/me/history?${qs}`,
|
||||
)
|
||||
const base: HistoryEntry[] = (r?.items ?? []).map((it) => ({
|
||||
const r = await fetchHistory(page, perPage)
|
||||
const base: HistoryEntry[] = (r?.items ?? []).map((it: ServerHistory) => ({
|
||||
id: String(it.id),
|
||||
viewedAt: Number(it.viewed_at) || 0,
|
||||
}))
|
||||
@ -163,7 +161,7 @@ export function removeHistory(id: string): void {
|
||||
const key = dedupeKey(id)
|
||||
setLocal(getLocal().filter((h) => dedupeKey(h.id) !== key))
|
||||
if (serverMode()) {
|
||||
authJson("DELETE", `/me/history/${encodeURIComponent(id)}`).catch(() => {
|
||||
deleteHistory(id).catch(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
}
|
||||
@ -173,7 +171,7 @@ export function removeHistory(id: string): void {
|
||||
export function clearHistory(): void {
|
||||
setLocal([])
|
||||
if (serverMode()) {
|
||||
authJson("DELETE", "/me/history").catch(() => {
|
||||
clearAllHistory().catch(() => {
|
||||
/* 失败不回滚本地 */
|
||||
})
|
||||
}
|
||||
|
||||
@ -5,8 +5,7 @@
|
||||
// `import "@/lib/http"`(本文件会在加载时自动 install),或直接调用 installApiLogger()。
|
||||
//
|
||||
// 日志去向:
|
||||
// - 浏览器(DEV 环境):console.debug 彩色输出,并收集到 window.__apiLog,
|
||||
// 可用 downloadApiLog() 一键导出为 api-debug.log 下载。
|
||||
// - 浏览器(DEV 环境):console.debug 彩色输出,并收集到 window.__apiLog(控制台可查)。
|
||||
// - Node(SSR / SSG 构建期):追加写入项目根 logs/api-debug.log。
|
||||
//
|
||||
// 开关:默认在 DEV 或 SSR(含 astro build)开启;生产环境客户端默认关闭,
|
||||
@ -188,21 +187,3 @@ export function installApiLogger(): void {
|
||||
|
||||
// 加载即自动安装:任何 import "@/lib/http" 的模块都会触发全局 fetch 拦截。
|
||||
installApiLogger()
|
||||
|
||||
// 浏览器端:把已收集的日志导出为 .log 文件下载(仅在浏览器可用)。
|
||||
export function downloadApiLog(): void {
|
||||
if (typeof document === "undefined") return
|
||||
const logs: any[] = g.__apiLog || []
|
||||
const text = logs
|
||||
.map(
|
||||
(e) =>
|
||||
`${e.method} ${e.url} -> ${e.status}\nREQ: ${e.req || ""}\nRESP: ${e.resp || ""}`
|
||||
)
|
||||
.join("\n\n")
|
||||
const blob = new Blob([text || "(暂无记录)"], { type: "text/plain" })
|
||||
const a = document.createElement("a")
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = "api-debug.log"
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
192
src/lib/request.ts
Normal file
192
src/lib/request.ts
Normal file
@ -0,0 +1,192 @@
|
||||
// src/lib/request.ts — HTTP 请求层(transport)
|
||||
//
|
||||
// 只负责「怎么发请求」:base 解析、请求签名、语言透传、令牌装配、错误处理、SSG 内部端口请求。
|
||||
// 不含任何业务端点定义(端点全部见 api.ts),不含会话存储(见 auth.ts)。
|
||||
//
|
||||
// 设计:**全站只有一个公开请求入口 `request()`**,统一附带:
|
||||
// - 前端反爬签名(X-Sign / X-Sign-Ts / X-Sign-Nonce)—— 所有请求一律带;
|
||||
// 后端只在「/public 列表」路由校验,其余路由忽略该头即可。
|
||||
// - ?locale=(当前语言)。
|
||||
// - 已登录时的 Bearer(opts.auth !== false 时经 fetchWithToken 附),遇 401 自动续期一次后重试。
|
||||
// base 地址全部来自 src/lib/config(dev/prod 两份,缺则抛);密钥不进 config。
|
||||
//
|
||||
// base 一览(均来自 config):
|
||||
// baseApi —— 浏览器运行期公开基址(空串 = 同源相对)。
|
||||
// baseApiSsr —— 服务端按需渲染取数基址(绝对 URL)。
|
||||
// baseApiSsg —— 构建期 SSG 内部端口基址。
|
||||
|
||||
// 加载即安装全局 fetch 拦截器:把 API 请求/响应记录到调试日志(控制台/文件)。
|
||||
import "@/lib/http"
|
||||
|
||||
import { config } from "./config" // dev/prod 配置(baseApi / baseApiSsr / baseApiSsg)
|
||||
import { clientSign } from "./crypto" // 公开请求签名(X-Sign* 头)
|
||||
import { getAccessToken, getRefreshToken, getUser, saveSession, clearSession, isAccessExpiring } from "./auth" // 令牌读取 / 写入
|
||||
import { withLocale, currentLocale } from "./locale" // 请求语言透传
|
||||
|
||||
// ===================== base 解析(全部来自 config) =====================
|
||||
|
||||
// 浏览器运行期公开 API base(空则同源相对 /api)。
|
||||
function baseUrl(): string {
|
||||
return config.baseApi.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
// 相对图片路径补成绝对(基于 baseUrl())。
|
||||
export function toAbs(url: string): string {
|
||||
if (!url) return ""
|
||||
return /^https?:\/\//i.test(url) ? url : baseUrl() + url
|
||||
}
|
||||
|
||||
// 按需渲染(on-demand SSR)详情 base:必须是绝对 URL(Node fetch 不吃相对路径)。
|
||||
export function ssrBase(): string {
|
||||
return config.baseApiSsr.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
// ===================== 令牌原语(Bearer + 401 续期重试) =====================
|
||||
|
||||
// 唯一实现「附加 Bearer(若有)+ 遇 401 用 refresh 续期一次后重试」的底层 fetch。
|
||||
async function fetchWithToken(url: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const headers = new Headers(opts.headers)
|
||||
const token = getAccessToken()
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`)
|
||||
let res = await fetch(url, { ...opts, headers })
|
||||
if (res.status === 401 && getRefreshToken()) {
|
||||
try {
|
||||
await refreshSession()
|
||||
const newToken = getAccessToken()
|
||||
if (newToken) headers.set("Authorization", `Bearer ${newToken}`)
|
||||
res = await fetch(url, { ...opts, headers })
|
||||
} catch {
|
||||
/* 刷新失败:返回原始 401,由调用方处理 */
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// 提前刷新:若 access 已到期/临近到期且有 refresh token,则先刷一次(单飞),
|
||||
// 避免先吃一个 401 再刷。刷不动就照原样发请求,交给 fetchWithToken 的 401 重试兜底。
|
||||
async function ensureFreshToken(): Promise<void> {
|
||||
if (!getRefreshToken()) return
|
||||
if (!isAccessExpiring()) return
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch {
|
||||
/* 刷新失败:保持原状态,后续 401 会让调用方按未登录处理 */
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 唯一请求入口 =====================
|
||||
|
||||
// 全站统一请求:走 baseUrl() + path(path 为含 /api/v1 前缀的完整路径)。
|
||||
// 一律附带「反爬签名 + ?locale=(当前语言)」。
|
||||
// opts.auth 默认 true:附 Bearer + 遇 401 续期重试;显式 false 时走裸 fetch
|
||||
// (用于登录/刷新/登出等 auth 端点:它们本就不需要 Bearer,且可避免「刷新请求触发刷新」的递归)。
|
||||
// 查询参数走 opts.params(URLSearchParams),不要拼进 path。
|
||||
// 成功返回**完整响应 json**;非 2xx 抛出(尽量带上后端 message)。
|
||||
export async function request<T = any>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { params?: URLSearchParams; body?: unknown; auth?: boolean },
|
||||
): Promise<T> {
|
||||
const params = withLocale(opts?.params)
|
||||
const query = params.toString()
|
||||
const fullPath = path
|
||||
const { sig, ts, nonce } = await clientSign(method, fullPath, query)
|
||||
const url = baseUrl() + fullPath + (query ? "?" + query : "")
|
||||
const headers: Record<string, string> = {
|
||||
"X-Sign": sig,
|
||||
"X-Sign-Ts": ts,
|
||||
"X-Sign-Nonce": nonce,
|
||||
}
|
||||
const body = opts?.body
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json"
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}
|
||||
// 带令牌语义的请求先做「提前刷新」:access 快到期就先刷,省掉 401 那一跳。
|
||||
if (opts?.auth !== false) await ensureFreshToken()
|
||||
const res = opts?.auth === false ? await fetch(url, init) : await fetchWithToken(url, init)
|
||||
if (!res.ok) {
|
||||
let msg = `API ${res.status} ${method} ${path}`
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j?.message) msg = String(j.message)
|
||||
} catch {
|
||||
/* 响应体非 JSON,沿用默认错误信息 */
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
// ===================== 令牌续期(401 重试机制的内部依赖) =====================
|
||||
|
||||
// 单飞(single-flight):同一时刻只允许一个刷新在途,其余并发调用共享同一个 Promise。
|
||||
// 「access 过期后多个接口一起 401」时只会发一次 /auth/refresh,不会各自刷一遍。
|
||||
let refreshInFlight: Promise<string> | null = null
|
||||
|
||||
// 刷新 token:用 refresh_token 换新的 access_token(refresh 复用、不轮换)。
|
||||
// 由 fetchWithToken 在遇 401 时内部调用;失败即清会话并抛出。并发调用共享同一次刷新。
|
||||
export function refreshSession(): Promise<string> {
|
||||
if (!refreshInFlight) {
|
||||
refreshInFlight = doRefreshSession().finally(() => {
|
||||
refreshInFlight = null
|
||||
})
|
||||
}
|
||||
return refreshInFlight
|
||||
}
|
||||
|
||||
async function doRefreshSession(): Promise<string> {
|
||||
const refresh = getRefreshToken()
|
||||
if (!refresh) throw new Error("no refresh token")
|
||||
let json: any
|
||||
try {
|
||||
// auth: false —— 刷新请求本身不走「Bearer + 401 重试」,否则 401 会再次触发刷新(递归)。
|
||||
json = await request("POST", "/api/v1/auth/refresh", {
|
||||
body: { refresh_token: refresh },
|
||||
auth: false,
|
||||
})
|
||||
} catch {
|
||||
clearSession()
|
||||
throw new Error("refresh failed")
|
||||
}
|
||||
const access: string | undefined = json?.access_token
|
||||
if (!access) {
|
||||
clearSession()
|
||||
throw new Error("refresh failed")
|
||||
}
|
||||
// refresh 复用:仅更新 access,保留原 refresh。
|
||||
const user = getUser()
|
||||
// 刷新响应带 expires_in(新 access 的剩余秒数),据此更新到期时刻。
|
||||
if (user) saveSession(access, refresh, user, Number(json?.expires_in) || undefined)
|
||||
return access
|
||||
}
|
||||
|
||||
// ===================== SSG 内部接口(/api/internal/ssg/*,仅 Node 构建期) =====================
|
||||
|
||||
// SSG 内部可选访问令牌:**属密钥**,不进 public config(config 会进客户端 bundle),
|
||||
// 仅构建期从 env 读取(例:SSG_TOKEN=xxx npm run build)。默认空 = 不带 token。
|
||||
function ssgToken(): string {
|
||||
const e = import.meta.env as any
|
||||
const v = e?.SSG_TOKEN ?? (typeof process !== "undefined" ? process.env?.SSG_TOKEN : "")
|
||||
return String(v ?? "").trim()
|
||||
}
|
||||
|
||||
export async function requestSsg<T>(path: string): Promise<T | null> {
|
||||
// 组合 SSG_TOKEN 与 ?locale=(构建期按当前语言选列,后端 parseLocale 已支持)。
|
||||
const t = ssgToken()
|
||||
const parts: string[] = []
|
||||
if (t) parts.push("token=" + encodeURIComponent(t))
|
||||
const loc = currentLocale()
|
||||
if (loc) parts.push("locale=" + encodeURIComponent(loc))
|
||||
const base = config.baseApiSsg.replace(/\/$/, "")
|
||||
const url = base + path + (parts.length ? (path.includes("?") ? "&" : "?") + parts.join("&") : "")
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -181,7 +181,7 @@ const browseLabel = t("browse the archive")
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { toAbs } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
|
||||
|
||||
@ -124,7 +124,8 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { login, getUser } from "@/lib/auth"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
|
||||
@ -182,7 +182,7 @@ const browseLabel = t("browse the archive")
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { toAbs } from "@/lib/api"
|
||||
import { toAbs } from "@/lib/request"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { getFavorites, removeFavorite, fetchFavoritesPage } from "@/lib/favorites"
|
||||
import { fetchHistoryPage, removeHistory, clearHistory } from "@/lib/history"
|
||||
|
||||
@ -124,7 +124,8 @@ const pageTitle = `${loginLabel} — Portfolio Studio`
|
||||
</main>
|
||||
|
||||
<script>
|
||||
import { login, getUser } from "@/lib/auth"
|
||||
import { getUser } from "@/lib/auth"
|
||||
import { login } from "@/lib/api"
|
||||
import { clientHref, ROUTES } from "@/lib/routes"
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
|
||||
Reference in New Issue
Block a user