tosUpload.ts
7.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
import TOS from "@volcengine/tos-sdk";
import { getTosCredential } from "@/api/files";
const TOS_BUCKET = "linkmed";
const TOS_REGION = "cn-beijing";
interface TosCredentialCache {
client: TOS;
bucket: string;
region: string;
expiredTime: string; // ISO string
}
let credentialCache: TosCredentialCache | null = null;
function isCredentialExpired(expiredTime: string): boolean {
const expiry = new Date(expiredTime).getTime();
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
return now >= expiry - fiveMinutes;
}
export async function fetchTosCredential(
sessionName = "frontend-upload",
): Promise<TosCredentialCache> {
if (credentialCache && !isCredentialExpired(credentialCache.expiredTime)) {
return credentialCache;
}
const res = await getTosCredential(sessionName);
const data = (res as any).data ?? res;
const region: string = data.region ?? data.responseMetadata?.region ?? data.responseMetadata?.Region ?? TOS_REGION;
const creds = data.credentials ?? data;
const accessKeyId: string = creds.accessKeyId;
const accessKeySecret: string = creds.secretAccessKey;
const stsToken: string = creds.sessionToken;
const expiredTime: string = creds.expiredTime ?? creds.expiration ?? "";
const client = new TOS({
accessKeyId,
accessKeySecret,
stsToken,
region,
bucket: TOS_BUCKET,
endpoint: `tos-${region}.volces.com`,
});
credentialCache = { client, bucket: TOS_BUCKET, region, expiredTime };
return credentialCache;
}
export interface TosUploadOptions {
userId?: string | number;
onFileProgress?: (fileIndex: number, percent: number) => void;
}
export interface TosUploadResult {
storageKey: string;
storageUrl: string;
originalFileName: string;
fileSize: number;
mimeType: string;
}
function encodeTosKeyPathForUrl(key: string): string {
// 仅对 URL 路径的每一段做编码,避免把 '/' 编码成 '%2F'。
// 否则会改变 TOS object key 的语义,导致后端下载/预签名失败。
return key
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
}
function getFileExt(fileName: string): string {
const idx = fileName.lastIndexOf(".");
if (idx < 0 || idx === fileName.length - 1) return "";
return fileName.slice(idx + 1).toLowerCase();
}
function genUploadId(): string {
// 浏览器端尽量使用 randomUUID,兼容性不足则回退到 Math.random
const anyCrypto = crypto as any;
return (
anyCrypto?.randomUUID?.()?.replace(/-/g, "") ??
Math.random().toString(36).slice(2)
);
}
export async function uploadFilesToTos(
files: File[],
options: TosUploadOptions = {},
): Promise<TosUploadResult[]> {
const { client, bucket, region } = await fetchTosCredential();
const results: TosUploadResult[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i]!;
const timestamp = Date.now();
const userId = options.userId ?? "u";
// objectKey 不直接包含原始文件名,避免后端/URL 处理时把“文件名”当成“key 结构的一部分”
// 仅保留扩展名用于类型识别(originalFileName 仍会单独传给后端)。
const ext = getFileExt(file.name || "");
const extension = ext ? `.${ext}` : "";
const key = `uploads/${userId}/${timestamp}_${genUploadId()}${extension}`;
await client.uploadFile({
key,
file,
bucket,
taskNum: 1,
dataTransferStatusChange: (status: any) => {
const consumed = status?.consumedBytes;
const total = status?.totalBytes;
if (consumed != null && total != null && total > 0) {
const percent = Math.floor((consumed / total) * 95);
options.onFileProgress?.(i, percent);
}
},
});
// storageUrl 会被后端用于拉取/转换;如果 key 中包含中文/空格等字符,
// 需要对 URL path 做编码,避免构造出的 URL 非法或被解析错误。
const storageUrl = `https://${bucket}.tos-${region}.volces.com/${encodeTosKeyPathForUrl(key)}`;
results.push({
storageKey: key,
storageUrl,
originalFileName: file.name,
fileSize: file.size,
mimeType: file.type || "application/octet-stream",
});
}
return results;
}
export async function getTosDownloadUrl(
storageKey: string,
fileName: string,
): Promise<string> {
const { client } = await fetchTosCredential();
const url = client.getPreSignedUrl({
bucket: TOS_BUCKET,
key: storageKey,
method: "GET",
expires: 3600,
response: {
contentDisposition: `attachment; filename="${encodeURIComponent(fileName)}"`,
},
});
return url;
}
function isPresignedTosUrl(url: string): boolean {
// TOS/S3 预签名 URL 常见参数
return /[?&](X-Tos-Algorithm|X-Tos-Credential|X-Tos-Signature|X-Amz-Algorithm|X-Amz-Credential|X-Amz-Signature)=/i.test(url);
}
function safeDecodeURIComponent(v: string): string {
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
function tryExtractStorageKeyFromTosUrl(rawUrl: string): string | undefined {
try {
const u = new URL(rawUrl);
const host = u.hostname;
const pathname = u.pathname || "";
// Path-style: https://tos-xx.volces.com/<bucket>/<key>
// e.g. https://tos-cn-beijing.volces.com/linkmed/uploads/1/a%20b.pdf
const parts = pathname.split("/").filter(Boolean);
if (parts.length >= 2) {
const bucket = parts[0];
if (bucket === TOS_BUCKET) {
const keyRaw = parts.slice(1).join("/");
return safeDecodeURIComponent(keyRaw);
}
}
// Virtual-hosted-style: https://<bucket>.tos-xx.volces.com/<key>
// e.g. https://linkmed.tos-cn-beijing.volces.com/uploads/1/a%20b.pdf
if (host.startsWith(`${TOS_BUCKET}.`)) {
const keyRaw = pathname.replace(/^\/+/, "");
if (keyRaw) return safeDecodeURIComponent(keyRaw);
}
return undefined;
} catch {
return undefined;
}
}
/**
* 从 OSS/TOS 获取文件内容为 Blob(用于预览,而不是触发下载)。
*
* 用法:
* 1) const blob = await fetchOssBlob({ storageKey, fileName })
* 2) const blob = await fetchOssBlob({ downloadUrl })
*/
export async function fetchOssBlob(params: {
storageKey?: string;
downloadUrl?: string;
fileName?: string;
}): Promise<Blob> {
const { storageKey, downloadUrl, fileName } = params;
let url = downloadUrl;
if (!url) {
if (!storageKey) {
throw new Error("fetchOssBlob requires either storageKey or downloadUrl");
}
// fileName 只用于 content-disposition;预览时可传可不传
url = await getTosDownloadUrl(storageKey, fileName || "file");
} else {
// downloadUrl 如果是原始 TOS 路径式 URL,浏览器匿名访问会 403,需要先转成预签名 URL
if (!isPresignedTosUrl(url)) {
const extractedKey = tryExtractStorageKeyFromTosUrl(url);
if (extractedKey) {
url = await getTosDownloadUrl(extractedKey, fileName || "file");
}
}
}
const resp = await fetch(url, {
method: "GET",
// 预签名 URL 通常不需要携带 cookie
credentials: "omit",
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`fetchOssBlob failed: ${resp.status} ${resp.statusText} ${text}`);
}
return await resp.blob();
}
/**
* 从 OSS/TOS 获取文件并转成 object URL(blob:...),用于 PDF/图片预览。
* 注意:用完后记得 URL.revokeObjectURL(url)。
*/
export async function fetchOssObjectUrl(params: {
storageKey?: string;
downloadUrl?: string;
fileName?: string;
}): Promise<string> {
const blob = await fetchOssBlob(params);
return URL.createObjectURL(blob);
}