This commit is contained in:
toom1996
2026-09-14 00:42:45 +08:00
parent d6cb616337
commit 548155625d
22 changed files with 707 additions and 454 deletions

View File

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

View File

@ -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 并开层

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", () => ({

View File

@ -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 ?? "",

View File

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

View 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
View 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
View 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
}

View File

@ -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(() => {
/* 失败不回滚本地 */
})
}

View File

@ -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(() => {
/* 失败不回滚本地 */
})
}

View File

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

View File

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

View File

@ -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", () => {

View File

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

View File

@ -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", () => {