minio.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import type { InferResponseType } from 'hono/client';
  2. import Taro from '@tarojs/taro';
  3. import { fileClient } from "../api";
  4. export interface MinioProgressEvent {
  5. stage: 'uploading' | 'complete' | 'error';
  6. message: string;
  7. progress: number;
  8. details?: {
  9. loaded: number;
  10. total: number;
  11. };
  12. timestamp: number;
  13. }
  14. export interface MinioProgressCallbacks {
  15. onProgress?: (event: MinioProgressEvent) => void;
  16. onComplete?: () => void;
  17. onError?: (error: Error) => void;
  18. signal?: { aborted: boolean };
  19. }
  20. export interface UploadResult {
  21. fileUrl: string;
  22. fileKey: string;
  23. bucketName: string;
  24. }
  25. interface UploadPart {
  26. ETag: string;
  27. PartNumber: number;
  28. }
  29. interface UploadProgressDetails {
  30. partNumber: number;
  31. totalParts: number;
  32. partSize: number;
  33. totalSize: number;
  34. partProgress?: number;
  35. }
  36. type MinioMultipartUploadPolicy = InferResponseType<typeof fileClient["multipart-policy"]['$post'], 200>
  37. type MinioUploadPolicy = InferResponseType<typeof fileClient["upload-policy"]['$post'], 200>
  38. const PART_SIZE = 5 * 1024 * 1024; // 每部分5MB
  39. export class TaroMinIOMultipartUploader {
  40. /**
  41. * 使用 Taro.uploadFile 分段上传文件到 MinIO
  42. */
  43. static async upload(
  44. policy: MinioMultipartUploadPolicy,
  45. filePath: string,
  46. key: string,
  47. callbacks?: MinioProgressCallbacks
  48. ): Promise<UploadResult> {
  49. const partSize = PART_SIZE;
  50. // 获取文件信息
  51. const fileInfo = await Taro.getFileSystemManager().getFileInfo({
  52. filePath
  53. });
  54. const totalSize = fileInfo.size;
  55. const totalParts = Math.ceil(totalSize / partSize);
  56. const uploadedParts: UploadPart[] = [];
  57. callbacks?.onProgress?.({
  58. stage: 'uploading',
  59. message: '准备上传文件...',
  60. progress: 0,
  61. details: {
  62. loaded: 0,
  63. total: totalSize
  64. },
  65. timestamp: Date.now()
  66. });
  67. // 分段上传
  68. for (let i = 0; i < totalParts; i++) {
  69. if (callbacks?.signal?.aborted) {
  70. throw new Error('上传已取消');
  71. }
  72. const start = i * partSize;
  73. const end = Math.min(start + partSize, totalSize);
  74. const partNumber = i + 1;
  75. try {
  76. // 读取文件片段
  77. const partData = await this.readFileSlice(filePath, start, end);
  78. const etag = await this.uploadPart(
  79. policy.partUrls[i],
  80. partData,
  81. callbacks,
  82. {
  83. partNumber,
  84. totalParts,
  85. partSize: end - start,
  86. totalSize
  87. }
  88. );
  89. uploadedParts.push({
  90. ETag: etag,
  91. PartNumber: partNumber
  92. });
  93. // 更新进度
  94. const progress = Math.round((end / totalSize) * 100);
  95. callbacks?.onProgress?.({
  96. stage: 'uploading',
  97. message: `上传文件片段 ${partNumber}/${totalParts}`,
  98. progress,
  99. details: {
  100. loaded: end,
  101. total: totalSize,
  102. },
  103. timestamp: Date.now()
  104. });
  105. } catch (error) {
  106. callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
  107. throw error;
  108. }
  109. }
  110. // 完成上传
  111. try {
  112. await this.completeMultipartUpload(policy, key, uploadedParts);
  113. callbacks?.onProgress?.({
  114. stage: 'complete',
  115. message: '文件上传完成',
  116. progress: 100,
  117. timestamp: Date.now()
  118. });
  119. callbacks?.onComplete?.();
  120. return {
  121. fileUrl: `${policy.host}/${key}`,
  122. fileKey: key,
  123. bucketName: policy.bucket
  124. };
  125. } catch (error) {
  126. callbacks?.onError?.(error instanceof Error ? error : new Error(String(error)));
  127. throw error;
  128. }
  129. }
  130. // 读取文件片段
  131. private static readFileSlice(filePath: string, start: number, end: number): Promise<ArrayBuffer> {
  132. return new Promise((resolve, reject) => {
  133. const fs = Taro.getFileSystemManager();
  134. try {
  135. const fileData = fs.readFileSync(filePath, undefined, {
  136. position: start,
  137. length: end - start
  138. });
  139. resolve(fileData);
  140. } catch (error) {
  141. reject(error);
  142. }
  143. });
  144. }
  145. // 上传单个片段
  146. private static async uploadPart(
  147. uploadUrl: string,
  148. partData: ArrayBuffer,
  149. callbacks?: MinioProgressCallbacks,
  150. progressDetails?: UploadProgressDetails
  151. ): Promise<string> {
  152. return new Promise((resolve, reject) => {
  153. const uploadTask = Taro.uploadFile({
  154. url: uploadUrl,
  155. filePath: '',
  156. name: 'file',
  157. formData: {},
  158. header: {
  159. 'Content-Type': 'application/octet-stream'
  160. },
  161. success: (res) => {
  162. if (res.statusCode >= 200 && res.statusCode < 300) {
  163. // 从响应头获取ETag
  164. const etag = res.header['ETag']?.replace(/"/g, '') || '';
  165. resolve(etag);
  166. } else {
  167. reject(new Error(`上传片段失败: ${res.statusCode} ${res.errMsg}`));
  168. }
  169. },
  170. fail: (error) => {
  171. reject(new Error(`上传片段失败: ${error.errMsg}`));
  172. }
  173. });
  174. // 由于小程序 uploadFile 不直接支持 ArrayBuffer,我们需要使用 putFile
  175. // 改用 fetch API 上传二进制数据
  176. this.uploadBinaryData(uploadUrl, partData)
  177. .then(resolve)
  178. .catch(reject);
  179. });
  180. }
  181. // 上传二进制数据
  182. private static async uploadBinaryData(uploadUrl: string, data: ArrayBuffer): Promise<string> {
  183. return new Promise((resolve, reject) => {
  184. Taro.request({
  185. url: uploadUrl,
  186. method: 'PUT',
  187. data: data,
  188. header: {
  189. 'Content-Type': 'application/octet-stream'
  190. },
  191. success: (res) => {
  192. if (res.statusCode >= 200 && res.statusCode < 300) {
  193. const etag = res.header['ETag']?.replace(/"/g, '') || '';
  194. resolve(etag);
  195. } else {
  196. reject(new Error(`上传片段失败: ${res.statusCode}`));
  197. }
  198. },
  199. fail: (error) => {
  200. reject(new Error(`上传片段失败: ${error.errMsg}`));
  201. }
  202. });
  203. });
  204. }
  205. // 完成分段上传
  206. private static async completeMultipartUpload(
  207. policy: MinioMultipartUploadPolicy,
  208. key: string,
  209. uploadedParts: UploadPart[]
  210. ): Promise<void> {
  211. const response = await fileClient["multipart-complete"].$post({
  212. json: {
  213. bucket: policy.bucket,
  214. key,
  215. uploadId: policy.uploadId,
  216. parts: uploadedParts.map(part => ({ partNumber: part.PartNumber, etag: part.ETag }))
  217. }
  218. });
  219. if (!response.ok) {
  220. throw new Error(`完成分段上传失败: ${response.status} ${response.statusText}`);
  221. }
  222. }
  223. }
  224. export class TaroMinIOUploader {
  225. /**
  226. * 使用 Taro.uploadFile 上传文件到 MinIO
  227. */
  228. static async upload(
  229. policy: MinioUploadPolicy,
  230. filePath: string,
  231. key: string,
  232. callbacks?: MinioProgressCallbacks
  233. ): Promise<UploadResult> {
  234. // 获取文件信息
  235. const fileInfo = await Taro.getFileSystemManager().getFileInfo({
  236. filePath
  237. });
  238. const totalSize = fileInfo.size;
  239. callbacks?.onProgress?.({
  240. stage: 'uploading',
  241. message: '准备上传文件...',
  242. progress: 0,
  243. details: {
  244. loaded: 0,
  245. total: totalSize
  246. },
  247. timestamp: Date.now()
  248. });
  249. // 准备表单数据
  250. const formData: Record<string, any> = {};
  251. // 添加 MinIO 需要的字段
  252. Object.entries(policy.uploadPolicy).forEach(([k, value]) => {
  253. if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') {
  254. formData[k] = value;
  255. }
  256. });
  257. // 添加自定义 key 字段
  258. formData['key'] = key;
  259. return new Promise((resolve, reject) => {
  260. // 使用 Taro 的文件系统读取文件
  261. Taro.getFileSystemManager().readFile({
  262. filePath,
  263. success: (fileData) => {
  264. // 构建 FormData
  265. const formDataObj = new FormData();
  266. Object.entries(formData).forEach(([key, value]) => {
  267. formDataObj.append(key, value);
  268. });
  269. formDataObj.append('file', new Blob([fileData.data]));
  270. // 使用 Taro.request 上传
  271. Taro.request({
  272. url: policy.uploadPolicy.host,
  273. method: 'POST',
  274. data: formDataObj,
  275. header: {
  276. 'Content-Type': 'multipart/form-data'
  277. },
  278. success: (res) => {
  279. if (res.statusCode >= 200 && res.statusCode < 300) {
  280. callbacks?.onProgress?.({
  281. stage: 'complete',
  282. message: '文件上传完成',
  283. progress: 100,
  284. timestamp: Date.now()
  285. });
  286. callbacks?.onComplete?.();
  287. resolve({
  288. fileUrl: `${policy.uploadPolicy.host}/${key}`,
  289. fileKey: key,
  290. bucketName: policy.uploadPolicy.bucket
  291. });
  292. } else {
  293. const error = new Error(`上传失败: ${res.statusCode}`);
  294. callbacks?.onError?.(error);
  295. reject(error);
  296. }
  297. },
  298. fail: (error) => {
  299. const err = new Error(`上传失败: ${error.errMsg}`);
  300. callbacks?.onError?.(err);
  301. reject(err);
  302. }
  303. });
  304. },
  305. fail: (error) => {
  306. const err = new Error(`读取文件失败: ${error.errMsg}`);
  307. callbacks?.onError?.(err);
  308. reject(err);
  309. }
  310. });
  311. // 由于小程序限制,我们使用更简单的 uploadFile 方式
  312. // 但先保存临时文件
  313. this.uploadWithTempFile(policy, filePath, key, callbacks)
  314. .then(resolve)
  315. .catch(reject);
  316. });
  317. }
  318. private static async uploadWithTempFile(
  319. policy: MinioUploadPolicy,
  320. filePath: string,
  321. key: string,
  322. callbacks?: MinioProgressCallbacks
  323. ): Promise<UploadResult> {
  324. return new Promise((resolve, reject) => {
  325. // 由于小程序的 uploadFile 只支持文件路径,我们需要构建完整的 FormData
  326. // 这里使用 request 方式上传
  327. Taro.getFileSystemManager().readFile({
  328. filePath,
  329. success: (fileData) => {
  330. const formData = new FormData();
  331. // 添加所有必需的字段
  332. Object.entries(policy.uploadPolicy).forEach(([k, value]) => {
  333. if (k !== 'key' && k !== 'host' && k !== 'prefix' && k !== 'ossType' && typeof value === 'string') {
  334. formData.append(k, value);
  335. }
  336. });
  337. formData.append('key', key);
  338. formData.append('file', new Blob([fileData.data]));
  339. Taro.request({
  340. url: policy.uploadPolicy.host,
  341. method: 'POST',
  342. data: formData,
  343. header: {
  344. 'Content-Type': 'multipart/form-data'
  345. },
  346. success: (res) => {
  347. if (res.statusCode >= 200 && res.statusCode < 300) {
  348. callbacks?.onProgress?.({
  349. stage: 'complete',
  350. message: '文件上传完成',
  351. progress: 100,
  352. timestamp: Date.now()
  353. });
  354. callbacks?.onComplete?.();
  355. resolve({
  356. fileUrl: `${policy.uploadPolicy.host}/${key}`,
  357. fileKey: key,
  358. bucketName: policy.uploadPolicy.bucket
  359. });
  360. } else {
  361. reject(new Error(`上传失败: ${res.statusCode}`));
  362. }
  363. },
  364. fail: (error) => {
  365. reject(new Error(`上传失败: ${error.errMsg}`));
  366. }
  367. });
  368. },
  369. fail: (error) => {
  370. reject(new Error(`读取文件失败: ${error.errMsg}`));
  371. }
  372. });
  373. });
  374. }
  375. }
  376. export async function getUploadPolicy(key: string, fileName: string, fileType?: string, fileSize?: number): Promise<MinioUploadPolicy> {
  377. const policyResponse = await fileClient["upload-policy"].$post({
  378. json: {
  379. path: key,
  380. name: fileName,
  381. type: fileType,
  382. size: fileSize
  383. }
  384. });
  385. if (!policyResponse.ok) {
  386. throw new Error('获取上传策略失败');
  387. }
  388. return policyResponse.json();
  389. }
  390. export async function getMultipartUploadPolicy(totalSize: number, fileKey: string, fileType?: string, fileName: string = 'unnamed-file') {
  391. const policyResponse = await fileClient["multipart-policy"].$post({
  392. json: {
  393. totalSize,
  394. partSize: PART_SIZE,
  395. fileKey,
  396. type: fileType,
  397. name: fileName
  398. }
  399. });
  400. if (!policyResponse.ok) {
  401. throw new Error('获取分段上传策略失败');
  402. }
  403. return await policyResponse.json();
  404. }
  405. export async function uploadMinIOWithPolicy(
  406. uploadPath: string,
  407. filePath: string,
  408. fileKey: string,
  409. callbacks?: MinioProgressCallbacks
  410. ): Promise<UploadResult> {
  411. if(uploadPath === '/') uploadPath = '';
  412. else{
  413. if(!uploadPath.endsWith('/')) uploadPath = `${uploadPath}/`
  414. // 去掉开头的 /
  415. if(uploadPath.startsWith('/')) uploadPath = uploadPath.replace(/^\//, '');
  416. }
  417. // 获取文件信息
  418. const fileInfo = await Taro.getFileSystemManager().getFileInfo({
  419. filePath
  420. });
  421. const fileSize = fileInfo.size;
  422. if(fileSize > PART_SIZE) {
  423. const policy = await getMultipartUploadPolicy(
  424. fileSize,
  425. `${uploadPath}${fileKey}`,
  426. undefined,
  427. fileKey
  428. );
  429. return TaroMinIOMultipartUploader.upload(
  430. policy,
  431. filePath,
  432. policy.key,
  433. callbacks
  434. );
  435. } else {
  436. const policy = await getUploadPolicy(`${uploadPath}${fileKey}`, fileKey, undefined, fileSize);
  437. return TaroMinIOUploader.upload(policy, filePath, policy.uploadPolicy.key, callbacks);
  438. }
  439. }
  440. // 新增:小程序专用的上传函数
  441. export async function uploadMinIOWithTaroFile(
  442. uploadPath: string,
  443. tempFilePath: string,
  444. fileName: string,
  445. callbacks?: MinioProgressCallbacks
  446. ): Promise<UploadResult> {
  447. const fileKey = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${fileName}`;
  448. return uploadMinIOWithPolicy(uploadPath, tempFilePath, fileKey, callbacks);
  449. }
  450. // 新增:从 chooseImage 或 chooseVideo 获取文件
  451. export async function uploadFromChoose(
  452. sourceType: ('album' | 'camera')[] = ['album', 'camera'],
  453. uploadPath: string = '',
  454. callbacks?: MinioProgressCallbacks
  455. ): Promise<UploadResult> {
  456. return new Promise((resolve, reject) => {
  457. Taro.chooseImage({
  458. count: 1,
  459. sourceType,
  460. success: async (res) => {
  461. const tempFilePath = res.tempFilePaths[0];
  462. const fileName = res.tempFiles[0]?.name || tempFilePath.split('/').pop() || 'unnamed-file';
  463. try {
  464. const result = await uploadMinIOWithPolicy(uploadPath, tempFilePath, fileName, callbacks);
  465. resolve(result);
  466. } catch (error) {
  467. reject(error);
  468. }
  469. },
  470. fail: reject
  471. });
  472. });
  473. }