Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbUpload.ets
T
laoyang 86de59616c 端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)
#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
2026-11-05 12:41:04 +08:00

235 lines
9.1 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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 r: FbUploadRaw = await fbUploadRaw(path, field, uri, token);
return r.ok ? '' : r.error;
}
/**
* 暂存上传:把文件传到暂存区,返回**令牌**。
*
* 🔴 2026-11-05。用户:「先上传,拿到地址,然后提交的时候,连带着跟其他参数一起提交,
* 后台接口拿到数据,先插入行程,拿到 id,再把图片插入进去。」
*
* 这是「新增时也能挂照片」的关键:新增行程时节点还不存在,
* 只能先把文件暂存起来拿 stoken,等保存表单时把 stoken 一起提交,
* 服务端建完节点再 `claim` 上去。
*
* @param uri picker 给的文件 URI
* @param token 访问令牌
* @returns stoken 为空串 = 失败(看 error)
*/
export interface FbStageUpload {
/** 暂存令牌(空 = 失败) */
stoken: string;
/** 原始文件名 */
name: string;
/** 是否图片(服务端按 MIME 嗅探) */
isImage: boolean;
/** 预览地址(相对路径) */
url: string;
/** 错误说明(成功时为空) */
error: string;
}
export async function fbUploadStage(uri: string, token: string): Promise<FbStageUpload> {
const out: FbStageUpload = {
stoken: '', name: '', isImage: false, url: '', error: ''
};
const r: FbUploadRaw = await fbUploadRaw('/api/v1/uploads/stage', 'file', uri, token);
if (!r.ok) {
out.error = r.error !== '' ? r.error : '上传失败';
return out;
}
/* 服务端信封:{ok:true, data:{stoken,...}}(`fbApi` 层已拆过一层,
但 multipart 这条路是自己解析的,要按原始信封读) */
try {
const env: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
let dataObj: Record<string, Object> = env;
const nested: Object | undefined = env['data'];
if (nested !== undefined) {
dataObj = nested as Record<string, Object>;
}
const st: Object | undefined = dataObj['stoken'];
if (typeof st === 'string' && st !== '') {
out.stoken = st as string;
}
const nm: Object | undefined = dataObj['name'];
if (typeof nm === 'string') {
out.name = nm as string;
}
out.isImage = dataObj['is_image'] === true;
const u: Object | undefined = dataObj['url'];
if (typeof u === 'string') {
out.url = u as string;
}
if (out.stoken === '') {
out.error = '服务端没返回令牌';
}
} catch (err) {
out.error = '响应解析失败';
}
return out;
}
/**
* 上传图片并**拿回响应载荷**(工单 #23 用)。
*
* 笔记插图要拿到服务端刚存的那张图的附件 key(存进 `image` 块),
* 而 `fbUploadOne` 只返回"成没成"、把 key 丢了。这里复用同一个
* multipart 核心(`fbUploadRaw`),成功时把信封里的 `key` 吐出来。
*
* @returns key = 附件相对键;空串且 error 非空 = 失败
*/
export interface FbUploadKey {
/** 附件 key(成功时有值) */
key: string;
/** 错误说明(空 = 成功) */
error: string;
}
export async function fbUploadKey(path: string, field: string, uri: string,
token: string): Promise<FbUploadKey> {
const r: FbUploadRaw = await fbUploadRaw(path, field, uri, token);
if (!r.ok) {
return { key: '', error: r.error };
}
try {
const o: Record<string, Object> = JSON.parse(r.body) as Record<string, Object>;
const k: Object | undefined = o['key'];
if (typeof k === 'string' && k !== '') {
return { key: k, error: '' };
}
// 没拿到 key:再按信封的 `data` 兜一把(不同端点习惯不同)
const d: Object | undefined = o['data'];
if (d !== undefined && typeof d === 'object' && d !== null) {
const dk: Object | undefined = (d as Record<string, Object>)['key'];
if (typeof dk === 'string' && dk !== '') {
return { key: dk, error: '' };
}
}
return { key: '', error: '服务端没回图片地址' };
} catch (err) {
return { key: '', error: '响应不是合法 JSON' };
}
}
/** `fbUploadOne` 与 `fbUploadKey` 共用的 multipart 核心(勿复制,见文件头说明) */
interface FbUploadRaw {
ok: boolean;
/** 响应正文(成功时是非空 JSON) */
body: string;
/** 给人看的错误说明 */
error: string;
}
async function fbUploadRaw(path: string, field: string, uri: string,
token: string): Promise<FbUploadRaw> {
const base: string = fbApiBase();
if (base === '') {
return { ok: false, body: '', error: '还没有设置服务器地址' };
}
if (token === '') {
return { ok: false, body: '', error: '还没登录' };
}
if (uri === '') {
return { ok: false, body: '', error: '没选到文件' };
}
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 { ok: false, body: '', error: '读不出这张图' };
}
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 { ok: true, body: typeof resp.result === 'string' ? resp.result : '', error: '' };
}
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 { ok: false, body: body, error: msg };
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, '上传失败 %{public}s:%{public}s', path, JSON.stringify(e));
return { ok: false, body: '', error: '网络异常' };
} finally {
req.destroy();
}
}