Browse Source

refactor(asset): 资产选择不再调用图片详情接口,缩略图统一走地址拼接

- popiAssetApi 的 resolve* 直接使用列表项已有的真实地址,不再请求
  /api/popi/asset/detail、/api/popi/media/detail
- previewImageUrl 统一由 buildPreviewImageUrl 现拼(OSS imageMogr2 参数)
- 删除已无引用的 asset/detail、media/detail 代理路由及测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feature-img
Luckyu_js 2 weeks ago
parent
commit
82d5eaecee
  1. 64
      src/app/api/popi/asset/detail/__tests__/route.test.ts
  2. 69
      src/app/api/popi/asset/detail/route.ts
  3. 69
      src/app/api/popi/media/detail/route.ts
  4. 121
      src/lib/popiAssetApi.ts

64
src/app/api/popi/asset/detail/__tests__/route.test.ts

@ -1,64 +0,0 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "../route";
const originalFetch = global.fetch;
const originalBaseUrl = process.env.POPIART_API_BASE_URL;
function createRequest(url = "http://localhost/api/popi/asset/detail?id=42"): NextRequest {
return new NextRequest(url, {
method: "GET",
headers: new Headers({
Authorization: "Bearer user-token",
}),
});
}
describe("/api/popi/asset/detail route", () => {
beforeEach(() => {
process.env.POPIART_API_BASE_URL = "https://popi.example";
});
afterEach(() => {
global.fetch = originalFetch;
process.env.POPIART_API_BASE_URL = originalBaseUrl;
vi.restoreAllMocks();
});
it("proxies asset detail requests", async () => {
const data = {
id: 42,
title: "Asset",
images: ["https://cdn.example/full.png"],
thumbs: ["https://cdn.example/thumb.png"],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ status: "0000", message: "ok", data }), { status: 200 })
);
global.fetch = fetchMock;
const response = await GET(createRequest());
await expect(response.json()).resolves.toEqual({ success: true, data });
expect(String(fetchMock.mock.calls[0][0])).toBe("https://popi.example/api_client/anime/asset/detail?id=42");
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer user-token",
token: "user-token",
},
cache: "no-store",
});
});
it("rejects invalid ids", async () => {
const response = await GET(createRequest("http://localhost/api/popi/asset/detail?id=abc"));
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "Asset id is invalid.",
});
});
});

69
src/app/api/popi/asset/detail/route.ts

@ -1,69 +0,0 @@
import { fetchPopiserverGateway as fetch } from "@/app/api/_popiserverFetch";
import { NextRequest, NextResponse } from "next/server";
import { requireLogin } from "@/app/api/_auth";
import { ApiError } from "@/app/api/_errors";
const POPI_ASSET_DETAIL_PATH = "/api_client/anime/asset/detail";
type PopiserverResponse<T> = {
status?: string;
message?: string;
data?: T;
};
function getPopiBaseUrl(request: NextRequest): string {
const baseUrl =
process.env.POPIART_API_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
request.nextUrl.origin;
return baseUrl.replace(/\/+$/, "");
}
function readAssetId(request: NextRequest): number {
const rawId = request.nextUrl.searchParams.get("id");
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
throw ApiError.badRequest("Asset id is invalid.");
}
return id;
}
export async function GET(request: NextRequest) {
try {
const login = requireLogin(request);
const id = readAssetId(request);
const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_ASSET_DETAIL_PATH);
upstreamUrl.searchParams.set("id", String(id));
const upstream = await fetch(upstreamUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${login.token}`,
token: login.token,
},
cache: "no-store",
});
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<unknown> | null;
if (!upstream.ok) {
throw ApiError.provider(payload?.message || "Popiserver asset detail failed.", upstream.status);
}
if (!payload || payload.status !== "0000") {
throw ApiError.provider(payload?.message || "Popiserver asset detail returned an error.", 502);
}
return NextResponse.json({
success: true,
data: payload.data,
});
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status });
}
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Asset detail failed." },
{ status: 500 }
);
}
}

69
src/app/api/popi/media/detail/route.ts

@ -1,69 +0,0 @@
import { fetchPopiserverGateway as fetch } from "@/app/api/_popiserverFetch";
import { NextRequest, NextResponse } from "next/server";
import { requireLogin } from "@/app/api/_auth";
import { ApiError } from "@/app/api/_errors";
const POPI_MEDIA_DETAIL_PATH = "/api_client/media/detail";
type PopiserverResponse<T> = {
status?: string;
message?: string;
data?: T;
};
function getPopiBaseUrl(request: NextRequest): string {
const baseUrl =
process.env.POPIART_API_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
request.nextUrl.origin;
return baseUrl.replace(/\/+$/, "");
}
function readMediaId(request: NextRequest): number {
const rawId = request.nextUrl.searchParams.get("id");
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
throw ApiError.badRequest("Media id is invalid.");
}
return id;
}
export async function GET(request: NextRequest) {
try {
const login = requireLogin(request);
const id = readMediaId(request);
const upstreamUrl = new URL(getPopiBaseUrl(request) + POPI_MEDIA_DETAIL_PATH);
upstreamUrl.searchParams.set("id", String(id));
const upstream = await fetch(upstreamUrl, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${login.token}`,
token: login.token,
},
cache: "no-store",
});
const payload = (await upstream.json().catch(() => null)) as PopiserverResponse<unknown> | null;
if (!upstream.ok) {
throw ApiError.provider(payload?.message || "Popiserver media detail failed.", upstream.status);
}
if (!payload || payload.status !== "0000") {
throw ApiError.provider(payload?.message || "Popiserver media detail returned an error.", 502);
}
return NextResponse.json({
success: true,
data: payload.data,
});
} catch (error) {
if (error instanceof ApiError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status });
}
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : "Media detail failed." },
{ status: 500 }
);
}
}

121
src/lib/popiAssetApi.ts

@ -1,6 +1,7 @@
"use client";
import { authFetch } from "@/utils/authFetch";
import { useAppConfigStore } from "@/store/appConfigStore";
import { buildPreviewImageUrl } from "@/utils/previewImageUrl";
export interface PopiAssetMediaItem {
id: number;
@ -18,25 +19,6 @@ export interface PopiAssetMediaItem {
duration?: number;
}
export interface PopiAssetDetail extends PopiAssetMediaItem {
createTime?: string;
}
export interface PopiMediaDetail {
id: number;
type?: number;
name?: string;
path?: string;
url?: string;
thumbPath?: string;
thumbUrl?: string;
originPath?: string;
originUrl?: string;
relatedNum?: number;
createTime?: string;
updateTime?: string;
}
export interface PopiMediaItem {
id: number;
name?: string;
@ -44,18 +26,6 @@ export interface PopiMediaItem {
thumbUrl?: string;
}
interface PopiAssetDetailResponse {
success?: boolean;
data?: PopiAssetDetail;
error?: string;
}
interface PopiMediaDetailResponse {
success?: boolean;
data?: PopiMediaDetail;
error?: string;
}
export interface ResolvedPopiImageAsset {
assetId: number;
imageUrl: string | null;
@ -85,56 +55,31 @@ function filenameFromAsset(asset: PopiAssetMediaItem): string {
return asset.title || asset.name || `asset-${asset.mediaId ?? asset.id}`;
}
function mediaUrlFromDetail(detail: PopiMediaDetail): string | null {
return detail.originUrl || detail.url || detail.originPath || detail.path || null;
}
function mediaPreviewUrlFromDetail(detail: PopiMediaDetail, imageUrl: string): string {
return detail.thumbUrl || detail.url || imageUrl;
}
export async function getPopiAssetDetail(id: number): Promise<PopiAssetDetail> {
const response = await authFetch(`/api/popi/asset/detail?id=${encodeURIComponent(String(id))}`, {
method: "GET",
});
const payload = (await response.json().catch(() => null)) as PopiAssetDetailResponse | null;
if (!response.ok || !payload?.success || !payload.data) {
throw new Error(payload?.error || "Failed to load asset detail.");
}
return payload.data;
}
export async function getPopiMediaDetail(id: number): Promise<PopiMediaDetail> {
const response = await authFetch(`/api/popi/media/detail?id=${encodeURIComponent(String(id))}`, {
method: "GET",
});
const payload = (await response.json().catch(() => null)) as PopiMediaDetailResponse | null;
if (!response.ok || !payload?.success || !payload.data) {
throw new Error(payload?.error || "Failed to load media detail.");
}
return payload.data;
/**
* URLOSS/imageMogr2
* thumbs/
*/
export function toPreviewImageUrl(originalUrl: string | null | undefined): string {
const suffix = useAppConfigStore.getState().previewImageSuffix;
return buildPreviewImageUrl(originalUrl, suffix) ?? originalUrl ?? "";
}
export function getPopiImageAssetPreview(asset: PopiAssetMediaItem): ResolvedPopiImageAsset | null {
const imageUrl = firstString(asset.images);
const previewImageUrl = firstString(asset.thumbs) || imageUrl;
if (!previewImageUrl) return null;
const imageUrl = firstString(asset.images) || firstString(asset.thumbs);
if (!imageUrl) return null;
return {
assetId: asset.id,
imageUrl,
previewImageUrl,
previewImageUrl: toPreviewImageUrl(imageUrl),
filename: filenameFromAsset(asset),
dimensions: dimensionsFromAsset(asset),
};
}
export function getPopiVideoAssetPreview(asset: PopiAssetMediaItem): ResolvedPopiVideoAsset | null {
const previewPosterUrl = asset.coverThumb || asset.cover || null;
const poster = asset.coverThumb || asset.cover || null;
const previewPosterUrl = poster ? toPreviewImageUrl(poster) : null;
if (!asset.video && !previewPosterUrl) return null;
return {
@ -147,45 +92,27 @@ export function getPopiVideoAssetPreview(asset: PopiAssetMediaItem): ResolvedPop
};
}
/**
* /使
* async
*/
export async function resolvePopiImageAsset(asset: PopiAssetMediaItem): Promise<ResolvedPopiImageAsset | null> {
const detail = await getPopiAssetDetail(asset.id);
const imageUrl = firstString(detail.images) || firstString(asset.images);
if (!imageUrl) return null;
return {
assetId: detail.id || asset.id,
imageUrl,
previewImageUrl: firstString(detail.thumbs) || firstString(asset.thumbs) || imageUrl,
filename: detail.title || filenameFromAsset(asset),
dimensions: dimensionsFromAsset(detail) || dimensionsFromAsset(asset),
};
return getPopiImageAssetPreview(asset);
}
export async function resolvePopiImageMedia(media: PopiMediaItem): Promise<ResolvedPopiImageAsset | null> {
const detail = await getPopiMediaDetail(media.id);
const imageUrl = mediaUrlFromDetail(detail) || media.url || null;
const imageUrl = media.url || media.thumbUrl || null;
if (!imageUrl) return null;
return {
assetId: detail.id || media.id,
assetId: media.id,
imageUrl,
previewImageUrl: mediaPreviewUrlFromDetail(detail, media.thumbUrl || imageUrl),
filename: detail.name || media.name || `media-${media.id}`,
previewImageUrl: toPreviewImageUrl(imageUrl),
filename: media.name || `media-${media.id}`,
dimensions: null,
};
}
export async function resolvePopiVideoAsset(asset: PopiAssetMediaItem): Promise<ResolvedPopiVideoAsset | null> {
const detail = await getPopiAssetDetail(asset.id);
const videoUrl = detail.video || asset.video || null;
if (!videoUrl) return null;
return {
assetId: detail.id || asset.id,
videoUrl,
previewPosterUrl: detail.coverThumb || detail.cover || asset.coverThumb || asset.cover || null,
filename: detail.title || asset.title || `asset-${asset.id}`,
duration: detail.duration || asset.duration || null,
dimensions: dimensionsFromAsset(detail) || dimensionsFromAsset(asset),
};
return getPopiVideoAssetPreview(asset);
}

Loading…
Cancel
Save