111 lines
4.5 KiB
Plaintext
111 lines
4.5 KiB
Plaintext
/**
|
||||
|
|
* 家书 FamilyBook · 图片上传(**唯一的 multipart 实现**)
|
|||
|
|
*
|
|||
|
|
* 🔴 为什么单独一个文件(v77,2026-10-22):
|
|||
|
|
* 在这之前有两份几乎一样的 multipart 手写实现 ——
|
|||
|
|
* `FbPersonEditSheet.uploadAvatar`(换头像)与 `FbMovieRepo.uploadOne`
|
|||
|
|
* (海报 / 观影照片)。再给旅程题图抄第三份必然漂移:本项目为
|
|||
|
|
* "两份实现漂移"栽过好几次(改了一边、另一边留在旧语义上,
|
|||
|
|
* 而两边都"看着能用",只在边界条件上不同 —— 最难查的一类 bug)。
|
|||
|
|
* ⇒ 抽成一份,大家共同调用。
|
|||
|
|
*
|
|||
|
|
* ⚠️ HarmonyOS 的 `http` **没有** multipart 封装,boundary 与分片要手写;
|
|||
|
|
* 顺序必须是 head(字符串) → 文件(ArrayBuffer) → tail(字符串),
|
|||
|
|
* 服务端按这个顺序解析出一个分片。
|
|||
|
|
* ⚠️ `filename` 给固定值:服务端**不信客户端文件名**(`Upload::image`
|
|||
|
|
* 自己生成随机名 + `finfo` 嗅探真实 MIME),这里给什么都不影响结果,
|
|||
|
|
* 但必须给 —— 否则 multipart 头不合法。
|
|||
|
|
* ⚠️ 读超时给足 60 秒:手机原图可能十几 MB,服务端还要裁/缩/转码。
|
|||
|
|
* 用业务请求那个 15 秒会**假性失败**(图其实存下来了,用户却看到
|
|||
|
|
* "上传失败",再点一次就变成两张)。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { http } from '@kit.NetworkKit';
|
|||
|
|
import { fileIo } from '@kit.CoreFileKit';
|
|||
|
|
import { hilog } from '@kit.PerformanceAnalysisKit';
|
|||
|
|
import { BusinessError } from '@kit.BasicServicesKit';
|
|||
|
|
import { fbApiBase } from './FbHttp';
|
|||
|
|
|
|||
|
|
const DOMAIN: number = 0x0000;
|
|||
|
|
const TAG: string = 'FbUpload';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 上传一个文件(**手工拼 multipart**)。
|
|||
|
|
*
|
|||
|
|
* @param path 接口路径(如 `/api/v1/trips/12/cover`)
|
|||
|
|
* @param field 表单字段名(`cover` / `poster` / `photo` / `avatar`
|
|||
|
|
* —— **必须与服务端读的那个键一致**,否则服务端收到空文件、
|
|||
|
|
* 报一句"没收到图片",而端上完全看不出是字段名不对)
|
|||
|
|
* @param uri picker 给的文件 URI(`file://...`)
|
|||
|
|
* @param token 访问令牌
|
|||
|
|
* @returns 空串 = 成功;否则是**给人看**的错误说明(服务端 `error` 原文优先)
|
|||
|
|
*/
|
|||
|
|
export async function fbUploadOne(path: string, field: string, uri: string,
|
|||
|
|
token: string): Promise<string> {
|
|||
|
|
const base: string = fbApiBase();
|
|||
|
|
if (base === '') {
|
|||
|
|
return '还没有设置服务器地址';
|
|||
|
|
}
|
|||
|
|
if (token === '') {
|
|||
|
|
return '还没登录';
|
|||
|
|
}
|
|||
|
|
if (uri === '') {
|
|||
|
|
return '没选到文件';
|
|||
|
|
}
|
|||
|
|
let buf: ArrayBuffer;
|
|||
|
|
try {
|
|||
|
|
const file: fileIo.File = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
|
|||
|
|
const stat: fileIo.Stat = fileIo.statSync(file.fd);
|
|||
|
|
buf = new ArrayBuffer(stat.size);
|
|||
|
|
fileIo.readSync(file.fd, buf);
|
|||
|
|
fileIo.closeSync(file);
|
|||
|
|
} catch (err) {
|
|||
|
|
hilog.warn(DOMAIN, TAG, '读文件失败:%{public}s', JSON.stringify(err));
|
|||
|
|
return '读不出这张图';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const boundary: string = '----FbUpload' + Date.now().toString();
|
|||
|
|
const head: string = '--' + boundary + '\r\n'
|
|||
|
|
+ 'Content-Disposition: form-data; name="' + field + '"; filename="upload.jpg"\r\n'
|
|||
|
|
+ 'Content-Type: image/jpeg\r\n\r\n';
|
|||
|
|
const tail: string = '\r\n--' + boundary + '--\r\n';
|
|||
|
|
|
|||
|
|
const req: http.HttpRequest = http.createHttp();
|
|||
|
|
try {
|
|||
|
|
const resp: http.HttpResponse = await req.request(base + path, {
|
|||
|
|
method: http.RequestMethod.POST,
|
|||
|
|
header: {
|
|||
|
|
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
|||
|
|
'Authorization': 'Bearer ' + token,
|
|||
|
|
'Accept': 'application/json'
|
|||
|
|
},
|
|||
|
|
extraData: [head, buf, tail],
|
|||
|
|
connectTimeout: 20000,
|
|||
|
|
readTimeout: 60000,
|
|||
|
|
expectDataType: http.HttpDataType.STRING
|
|||
|
|
});
|
|||
|
|
if (resp.responseCode >= 200 && resp.responseCode < 300) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
const body: string = typeof resp.result === 'string' ? resp.result : '';
|
|||
|
|
// 服务端的错误说明是 JSON 信封里的 `error` —— 尽量把它抠出来给人话
|
|||
|
|
let msg: string = 'HTTP ' + resp.responseCode.toString();
|
|||
|
|
try {
|
|||
|
|
const o: Record<string, Object> = JSON.parse(body) as Record<string, Object>;
|
|||
|
|
const e: Object | undefined = o['error'];
|
|||
|
|
if (typeof e === 'string' && e !== '') {
|
|||
|
|
msg = e;
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
// 不是 JSON(如 Apache 的错误页)⇒ 保留 HTTP 码
|
|||
|
|
}
|
|||
|
|
return msg;
|
|||
|
|
} catch (err) {
|
|||
|
|
const e: BusinessError = err as BusinessError;
|
|||
|
|
hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e));
|
|||
|
|
return '网络异常';
|
|||
|
|
} finally {
|
|||
|
|
req.destroy();
|
|||
|
|
}
|
|||
|
|
}
|