refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态

This commit is contained in:
ddCat
2026-09-09 22:02:10 +08:00
parent bacd502f5c
commit b5f3413ded
298 changed files with 12681 additions and 26428 deletions

View File

@@ -1,6 +1,5 @@
import { getActiveUserScope } from "@/lib/user-scope";
import axios from "axios";
import { apiBaseURL, apiClient, request, type BackendEnvelope } from "@/services/api/request";
import { http, apiBaseURL, ApiError } from "@/services/api/request";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
export type RemoteResource = {
@@ -73,7 +72,27 @@ export type ResourceUploadMeta = {
idempotencyKey?: string;
};
const api = apiClient;
/**
* 资源直传失败。
*
* `permanent` 是这条边界上唯一重要的信息:媒体直传失败后,调用方默认会把文件留在本机
* IndexedDB,并由云端数据同步用同一幂等键重传(见 user-data-sync 的 uploadLocalStorageKey)。
* 但鉴权失效、越权、请求本身不合法这几类失败重传多少次都是同样结果,把它们也归入
* "稍后自动同步" 等于向用户撒谎,必须当场抛出。
*/
export class ResourceUploadError extends Error {
readonly status?: number;
/** true 表示重试不会自愈,调用方不得降级为本地暂存。 */
readonly permanent: boolean;
constructor(message: string, options: { status?: number; permanent: boolean; cause?: unknown }) {
super(message, options.cause === undefined ? undefined : { cause: options.cause });
this.name = "ResourceUploadError";
this.status = options.status;
this.permanent = options.permanent;
}
}
const resourceCache = new Map<string, RemoteResource>();
const resourceRequests = new Map<string, Promise<RemoteResource>>();
const missingResourceIds = new Set<string>();
@@ -83,24 +102,24 @@ export function resourceStorageKey(id: string) {
}
export function getUserOSSSetting() {
return request<{ setting: UserOSSSetting }>(api.get("/settings/oss"));
return http.get<{ setting: UserOSSSetting }>("/settings/oss");
}
export function updateUserOSSSetting(input: UserOSSSettingInput) {
return request<{ setting: UserOSSSetting }>(api.patch("/settings/oss", input));
return http.patch<{ setting: UserOSSSetting }>("/settings/oss", input);
}
export function testUserOSSConnection(input: OSSConnectionTestInput) {
return request<OSSConnectionTestResult>(api.post("/settings/oss/test", input));
return http.post<OSSConnectionTestResult>("/settings/oss/test", input);
}
export async function getAccountFileStorageUsage() {
const data = await request<{ usage: AccountFileStorageUsage }>(api.get("/resources/storage-usage"));
const data = await http.get<{ usage: AccountFileStorageUsage }>("/resources/storage-usage");
return data.usage;
}
export async function syncResourceToArkPrivateAsset(id: string) {
const data = await request<{ sync: ArkPrivateAssetSync }>(api.post(`/resources/${encodeURIComponent(id)}/ark-private-asset`));
const data = await http.post<{ sync: ArkPrivateAssetSync }>(`/resources/${encodeURIComponent(id)}/ark-private-asset`);
return data.sync;
}
@@ -125,24 +144,26 @@ export async function uploadResourceFile(
onProgress?: (uploadedBytes: number, totalBytes: number) => void,
): Promise<RemoteResource> {
const name = meta?.fileName || (file instanceof File ? file.name : `${kind}.${extensionFromMime(file.type, kind)}`);
if (file.size > CHUNK_UPLOAD_THRESHOLD) {
const resource = await uploadFileInChunks(file, name, kind, meta, onProgress);
resourceCache.set(resourceCacheKey(resource.id), resource);
return resource;
}
const formData = new FormData();
formData.append("kind", kind);
formData.append("file", file, name);
if (meta?.width) formData.append("width", String(Math.round(meta.width)));
if (meta?.height) formData.append("height", String(Math.round(meta.height)));
if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs)));
// 分片与 multipart 两条路径的失败都要归一成 ResourceUploadError,
// 否则调用方只能靠文案猜测该重试还是该报错。
try {
const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData, {
if (file.size > CHUNK_UPLOAD_THRESHOLD) {
const resource = await uploadFileInChunks(file, name, kind, meta, onProgress);
resourceCache.set(resourceCacheKey(resource.id), resource);
return resource;
}
const formData = new FormData();
formData.append("kind", kind);
formData.append("file", file, name);
if (meta?.width) formData.append("width", String(Math.round(meta.width)));
if (meta?.height) formData.append("height", String(Math.round(meta.height)));
if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs)));
const data = await http.post<{ resource: RemoteResource }>("/resources", formData, {
...uploadRequestConfig(meta?.idempotencyKey),
onUploadProgress: onProgress ? ({ loaded, total }) => {
if (total && total > 0) onProgress(Math.min(file.size, file.size * loaded / total), file.size);
} : undefined,
}));
});
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
return data.resource;
} catch (error) {
@@ -165,9 +186,7 @@ async function uploadFileInChunks(file: Blob, name: string, kind: "image" | "vid
}
async function runChunkedUpload(file: Blob, name: string, kind: "image" | "video" | "audio" | "file", meta: ResourceUploadMeta | undefined, onProgress?: (uploadedBytes: number, totalBytes: number) => void) {
const session = await request<{ uploadId: string; chunkSize: number; chunkCount: number }>(
api.post("/resources/uploads", { fileName: name, kind, size: file.size, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey)),
);
const session = await http.post<{ uploadId: string; chunkSize: number; chunkCount: number }>("/resources/uploads", { fileName: name, kind, size: file.size, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey));
for (let index = 0; index < session.chunkCount; index++) {
const start = index * session.chunkSize;
const end = Math.min(file.size, start + session.chunkSize);
@@ -175,33 +194,39 @@ async function runChunkedUpload(file: Blob, name: string, kind: "image" | "video
const data = new FormData();
data.append("chunk", blob);
// raw 二进制直传,与后端按裸 body 逐片落盘对齐(勿设手动 Content-Type,让 axios 处理)。
await request<{ index: number }>(api.put(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, {
await http.put<{ index: number }>(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, {
headers: { "Content-Type": "application/octet-stream" },
onUploadProgress: onProgress ? ({ loaded }) => onProgress(start + Math.min(loaded, blob.size), file.size) : undefined,
}));
});
onProgress?.(Math.min(end, file.size), file.size);
}
const complete = await request<{ resource: RemoteResource }>(api.post(`/resources/uploads/${encodeURIComponent(session.uploadId)}/complete`));
const complete = await http.post<{ resource: RemoteResource }>(`/resources/uploads/${encodeURIComponent(session.uploadId)}/complete`);
return complete.resource;
}
function normalizeUploadError(error: unknown): Error {
// 兜底:即使误超 50MB multipart 上限(http.MaxBytesError),也给出可读中文而非英文裸错。
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data as { msg?: string } | undefined;
const msg = message?.msg || "";
if (status === 400 && /body too large|MaxBytes/i.test(msg)) {
return new Error("文件过大,请使用小于 50MB 的文件或稍后重试");
// 失败分类直接复用 request() 已经算好的 ApiError.retryable(408/425/429/5xx 可重试),
// 不在这里另起一套响应码判断,避免两处规则漂移。
// 差别只有一处:没有拿到任何 HTTP 响应(断网、超时)时 retryable 为 false,
// 但这种失败恰恰是最该退回本机等待重传的,因此单独按瞬时处理。
function normalizeUploadError(error: unknown): ResourceUploadError {
if (error instanceof ResourceUploadError) return error;
// 请求取消不是上传失败,保持原始语义交给调用方。
if (error instanceof DOMException && error.name === "AbortError") throw error;
if (error instanceof ApiError) {
const status = error.status;
const permanent = status !== undefined && !error.retryable;
// 即使误超 50MB multipart 上限(后端 http.MaxBytesError),也给出可读中文而非英文裸错。
if (status === 400 && /body too large|MaxBytes/i.test(error.message)) {
return new ResourceUploadError("文件过大,请使用小于 50MB 的文件或稍后重试", { status, permanent: true, cause: error });
}
if (msg) return new Error(msg);
if (status === 413) return new Error("文件过大,无法上传");
if (status === 413) return new ResourceUploadError("文件过大,无法上传", { status, permanent: true, cause: error });
return new ResourceUploadError(error.message || "上传失败", { status, permanent, cause: error });
}
return error instanceof Error ? error : new Error("上传失败");
return new ResourceUploadError(error instanceof Error ? error.message : "上传失败", { permanent: false, cause: error });
}
export async function importResourceFromUrl(url: string, kind: "image" | "video" | "audio" | "file", meta?: Omit<ResourceUploadMeta, "fileName">) {
const data = await request<{ resource: RemoteResource }>(api.post("/resources/import", { url, kind, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey)));
const data = await http.post<{ resource: RemoteResource }>("/resources/import", { url, kind, width: meta?.width, height: meta?.height, durationMs: meta?.durationMs }, uploadRequestConfig(meta?.idempotencyKey));
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
return data.resource;
}
@@ -218,13 +243,13 @@ export function getResource(id: string): Promise<RemoteResource> {
if (missingResourceIds.has(cacheKey)) return Promise.reject(new Error("资源不存在或已被删除"));
const pending = resourceRequests.get(cacheKey);
if (pending) return pending;
const task = request<{ resource: RemoteResource }>(api.get(`/resources/${encodeURIComponent(id)}`))
const task = http.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`)
.then((data) => {
resourceCache.set(cacheKey, data.resource);
return data.resource;
})
.catch((error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) missingResourceIds.add(cacheKey);
if (error instanceof ApiError && error.status === 404) missingResourceIds.add(cacheKey);
throw error;
})
.finally(() => resourceRequests.delete(cacheKey));
@@ -235,7 +260,7 @@ export function getResource(id: string): Promise<RemoteResource> {
// refreshResource 绕过缓存强制拉取资源最新状态(转码副本就绪轮询用),并回写缓存。
export function refreshResource(id: string): Promise<RemoteResource> {
const cacheKey = resourceCacheKey(id);
return request<{ resource: RemoteResource }>(api.get(`/resources/${encodeURIComponent(id)}`))
return http.get<{ resource: RemoteResource }>(`/resources/${encodeURIComponent(id)}`)
.then((data) => {
resourceCache.set(cacheKey, data.resource);
missingResourceIds.delete(cacheKey);
@@ -247,11 +272,11 @@ export async function getResourceOSSUrl(storageKey?: string) {
const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
try {
const data = await request<{ url: string }>(api.get(`/resources/${encodeURIComponent(id)}/oss-url`));
const data = await http.get<{ url: string }>(`/resources/${encodeURIComponent(id)}/oss-url`);
if (!data.url) throw new Error("后端未返回对象存储地址");
return data.url;
} catch (error) {
if (axios.isAxiosError<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data.msg || error.message || "获取对象存储地址失败");
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");
throw error;
}
}