gaoluyang
昨天 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
import type { Conversation, Message } from '../types'
 
import type { AxiosProgressEvent } from '#/api/infra/file'
 
import { isOpenableUrl } from '#/packages/utils/src'
 
import { message } from 'ant-design-vue'
 
import { uploadFile } from '#/api/infra/file'
import { getCurrentUserId } from '#/views/im/utils/auth'
 
import {
  MESSAGE_FILE_MAX_MB,
  MESSAGE_IMAGE_MAX_MB,
  MESSAGE_VIDEO_MAX_MB,
  MESSAGE_VOICE_MAX_MB
} from '../../utils/config'
import { ImContentType, ImMessageStatus } from '../../utils/constants'
import { getConversationKey } from '../../utils/conversation'
import {
  type AudioMessage,
  BLOB_URL_PREFIX,
  type FileMessage,
  generateClientMessageId,
  type ImageMessage,
  parseMessage,
  type QuoteMessage,
  serializeMessage,
  type VideoMessage,
  withQuotePayload
} from '../../utils/message'
import { useConversationStore } from '../store/conversationStore'
import { useMessageStore } from '../store/messageStore'
import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay'
 
type UploadProgressEvent = Parameters<NonNullable<AxiosProgressEvent>>[0]
 
/** 单条媒体 payload 联合(覆盖 IMAGE / FILE / VOICE / VIDEO 四种) */
export type MediaPayload = AudioMessage | FileMessage | ImageMessage | VideoMessage
 
/**
 * 媒体特定的元数据上下文:首发 / 重传共用入参;不同 type 关心不同字段
 *
 * - voiceDuration:语音时长(秒),首发由 VoiceRecorder 给,重传从旧 AudioMessage.duration 取
 * - videoProbe:视频元信息(首发由 probeVideoFile 解出,重传从旧 VideoMessage 直接拷字段)
 * - videoCoverUrl:视频封面真实 URL;占位阶段不设(避免传 blob 当 poster 在部分浏览器退化),commit 阶段由 cover 上传结果填入;重传时从旧 VideoMessage.coverUrl 复用,旧值若是 blob 会被跳过
 */
export interface MediaTypeContext {
  voiceDuration?: number
  videoProbe?: { duration?: number; height?: number; width?: number; }
  videoCoverUrl?: string
}
 
export interface MediaTypeHandler {
  /** 中文名,仅日志用(替代之前散落 9 处的 kind 字符串) */
  kind: string
  /** 由 file + url + context 生成 payload;占位时 url 是 blob URL,commit 时是真实 url */
  build: (file: File, url: string, context: MediaTypeContext) => MediaPayload
  /** 重传场景:从旧 content 提取 context(让重传不需要重做 probe / 重录语音) */
  extractResendContext: (oldContent: string) => MediaTypeContext
}
 
/** 媒体类型注册表:image / file / voice / video 各自的 kind + 首发 / 重传共用的 build / extract */
export const mediaTypeHandlers: Partial<Record<number, MediaTypeHandler>> = {
  [ImContentType.IMAGE]: {
    kind: '图片',
    build: (_file, url) => ({ url }) as ImageMessage,
    extractResendContext: () => ({})
  },
  [ImContentType.FILE]: {
    kind: '文件',
    build: (file, url) => ({ url, name: file.name, size: file.size }) as FileMessage,
    extractResendContext: () => ({})
  },
  [ImContentType.VOICE]: {
    kind: '语音',
    build: (_file, url, context) => ({ url, duration: context.voiceDuration ?? 0 }) as AudioMessage,
    extractResendContext: (oldContent) => {
      const old = parseMessage<AudioMessage>(oldContent)
      return { voiceDuration: old?.duration ?? 0 }
    }
  },
  [ImContentType.VIDEO]: {
    kind: '视频',
    build: (file, url, context) =>
      ({
        url,
        coverUrl: context.videoCoverUrl,
        duration: context.videoProbe?.duration,
        width: context.videoProbe?.width,
        height: context.videoProbe?.height,
        size: file.size
      }) as VideoMessage,
    extractResendContext: (oldContent) => {
      const old = parseMessage<VideoMessage>(oldContent)
      // 旧 coverUrl 是 blob 说明上传期失败(cover 没传成功),不复用;真实 URL 直接复用,省一次封面上传
      const reuseCover =
        old?.coverUrl && !old.coverUrl.startsWith(BLOB_URL_PREFIX) ? old.coverUrl : undefined
      return {
        videoProbe: { duration: old?.duration, width: old?.width, height: old?.height },
        videoCoverUrl: reuseCover
      }
    }
  }
}
 
/** 单次媒体上传的入参(image / file / voice 走 uploadAndSendMedia;video 走低层 helper 自行组装) */
export interface UploadAndSendMediaOptions {
  file: File
  /** 对齐 ImContentType;mediaTypeHandlers 必须有对应项 */
  type: number
  /** 媒体特定的元数据(如语音时长 / 视频元信息);不传按空对象处理 */
  context?: MediaTypeContext
  /** 引用消息(若有),写进 payload.quote */
  quote?: QuoteMessage
  /** 锁定起始会话,上传期间会话切走则放弃发送 */
  conversation: Conversation
  /** 重试已有占位消息时复用的客户端消息编号 */
  existingClientMessageId?: string
}
 
/**
 * 媒体上传 + 发送 composable(image / file / voice / video 共用底层 helper)
 *
 * 与 useMessageSender.sendRaw 的「先发请求再 ack」不同,媒体链路必须「先占位再上传」:
 * 1. 立即 insertMessage 写入占位消息(status=SENDING、content 用 blob URL、_localFile 内存留 File)
 * 2. uploadFile 上传,onUploadProgress 回调 patchMessage 更新 uploadProgress;UI 实时显示进度条
 * 3. 上传成功后用真实 url 重生 content,patchMessage 替换;旧 blob URL 由 store 自动 revoke
 * 4. 走 sendRaw(existingClientMessageId) 复用占位发送请求,避免重复插入两条
 *
 * 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_localFile 还在内存就行)
 */
/** 按内容类型映射体积上限(MB);未识别类型返回 0 表示不限 */
function resolveMediaMaxMb(type: number): number {
  switch (type) {
    case ImContentType.FILE: {
      return MESSAGE_FILE_MAX_MB
    }
    case ImContentType.IMAGE: {
      return MESSAGE_IMAGE_MAX_MB
    }
    case ImContentType.VIDEO: {
      return MESSAGE_VIDEO_MAX_MB
    }
    case ImContentType.VOICE: {
      return MESSAGE_VOICE_MAX_MB
    }
    default: {
      return 0
    }
  }
}
 
/** 校验媒体文件大小是否超过内容类型上限;超限触发 warn 并返回 false,调用方不应进入占位 / 上传链路 */
export function ensureMediaSizeWithinLimit(
  file: File,
  type: number,
  warn: (text: string) => void
): boolean {
  const maxMb = resolveMediaMaxMb(type)
  if (maxMb && file.size > maxMb * 1024 * 1024) {
    warn(`文件大小超过上限 ${maxMb}MB,请压缩后再发`)
    return false
  }
  return true
}
 
export const useMediaUploader = () => {
  const conversationStore = useConversationStore()
  const messageStore = useMessageStore()
  const muteOverlay = useMuteOverlay()
  const { sendRaw } = useMessageSender()
 
  /**
   * 立即写入媒体占位消息(低层 helper;image/file/voice 走 uploadAndSendMedia 包装,video 直接用本函数)
   *
   * 用 createObjectURL(file) 生成临时 blob URL 喂给 buildContent;占位 status=SENDING + uploadProgress=0;
   * file 挂在 _localFile 上供失败重试时重走上传
   */
  const insertMediaPlaceholder = (opts: {
    buildContent: (blobUrl: string) => string
    conversation: Conversation
    existingClientMessageId?: string
    file: File
    type: number
  }): { blobUrl: string; clientMessageId: string; } => {
    const { conversation } = opts
    const blobUrl = URL.createObjectURL(opts.file)
    const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
    if (opts.existingClientMessageId) {
      messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
        content: opts.buildContent(blobUrl),
        status: ImMessageStatus.SENDING,
        uploadProgress: 0,
        _localFile: opts.file
      })
      return { clientMessageId, blobUrl }
    }
    const placeholder: Message = {
      clientMessageId,
      type: opts.type,
      content: opts.buildContent(blobUrl),
      status: ImMessageStatus.SENDING,
      sendTime: Date.now(),
      senderId: getCurrentUserId(),
      targetId: conversation.targetId,
      selfSend: true,
      uploadProgress: 0,
      _localFile: opts.file
    }
    void messageStore.insertMessage(
      {
        type: conversation.type,
        targetId: conversation.targetId,
        name: conversation.name || String(conversation.targetId),
        avatar: conversation.avatar || ''
      },
      placeholder
    ).catch(() => undefined)
    return { clientMessageId, blobUrl }
  }
 
  /**
   * 把占位消息置为 FAILED(上传失败 / 会话切走 / 禁言期到点 等场景统一收尾)
   *
   * 同时清掉 uploadProgress —— 失败后 MessageItem 的 isUploading 不再命中,进度遮罩 / 文件点击禁用 / 语音 loading 抑制 都同步解除;
   * _localFile 保留,让用户点重试可以走 uploadAndSendMedia 重传
   */
  const markMediaFailed = (
    conversationType: number,
    targetId: number,
    clientMessageId: string
  ): void => {
    messageStore.patchMessage(conversationType, targetId, clientMessageId, {
      status: ImMessageStatus.FAILED,
      uploadProgress: undefined
    })
  }
 
  /**
   * 生成 axios `onUploadProgress` 回调:用 closure 缓存上次百分比,未变化直接 return 不进 store
   *
   * XHR onProgress 大文件下每秒触发 10-50 次,但 Math.round 后百分比有大量重复(一秒内可能十几次同一个数字);
   * 在源头去重,能省掉 store 的 find + Object.assign + Vue reactivity 触发链
   */
  const createUploadProgressHandler = (conversation: Conversation, clientMessageId: string) => {
    let lastPercent = -1
    return (event: UploadProgressEvent): void => {
      if (!event.total) {
        return
      }
      const percent = Math.round((event.loaded / event.total) * 100)
      if (percent === lastPercent) {
        return
      }
      lastPercent = percent
      messageStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
        uploadProgress: percent
      })
    }
  }
 
  /** 取媒体类型中文名(仅日志用);未注册 type 退化为通用「媒体」 */
  const getMediaKind = (type: number): string => mediaTypeHandlers[type]?.kind ?? '媒体'
 
  /**
   * 按 type 取 handler,缺则抛错(程序错误集中在这一处)
   *
   * 调用方拿到的是 `MediaTypeHandler`(非 undefined),不再需要 `!` 断言。
   * 仅给「确定 type 在表里」的调用方用 —— image/file/voice/video 四类入口;通用 dispatcher 仍可用 `mediaTypeHandlers[type]?.` optional chain
   */
  const requireMediaHandler = (type: number): MediaTypeHandler => {
    const handler = mediaTypeHandlers[type]
    if (!handler) {
      throw new Error(`[IM] 未注册的媒体类型 ${type}`)
    }
    return handler
  }
 
  /**
   * 上传完成后的收口校验:会话仍是占位时锁定的那个 + 当前未被禁言;任一不满足 markMediaFailed + 返回 false
   *
   * image / file / voice / video 链路都要在「拿到真实 url 后、调 sendRaw 之前」过一遍这两道
   */
  const verifyMediaUploadStillAllowed = (
    conversation: Conversation,
    startKey: string,
    type: number,
    clientMessageId: string
  ): boolean => {
    const activeConversation = conversationStore.activeConversation
    if (!activeConversation || getConversationKey(activeConversation) !== startKey) {
      console.warn(`[IM] ${getMediaKind(type)}上传期间切换了会话,放弃发送`, { startKey })
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return false
    }
    if (muteOverlay.value) {
      console.warn(`[IM] ${getMediaKind(type)}上传期间被禁言,放弃发送`, { startKey })
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return false
    }
    return true
  }
 
  /**
   * 占位完成后用真实 url 替换 content,再走 sendRaw 完成发送
   *
   * 上传成功 → patch content → sendRaw 复用 existingClientMessageId;store 内部 revoke 旧 blob URL
   */
  const commitMediaPlaceholder = async (opts: {
    clientMessageId: string
    conversation: Conversation
    realContent: string
    type: number
  }): Promise<void> => {
    messageStore.patchMessage(
      opts.conversation.type,
      opts.conversation.targetId,
      opts.clientMessageId,
      { content: opts.realContent }
    )
    // 显式传 conversation 而非依赖 sendRaw 内部取 active:
    // verifyMediaUploadStillAllowed 与 sendRaw 之间存在微秒窗口,期间用户切会话也能保证发到原会话
    await sendRaw(opts.type, opts.realContent, {
      existingClientMessageId: opts.clientMessageId,
      targetId: opts.conversation.targetId,
      conversation: opts.conversation
    })
  }
 
  /**
   * 上传媒体文件并发送消息(高层入口;image / file / voice 用,video 走低层 helper 自行组装)
   *
   * @returns 占位消息的 clientMessageId(调用方按需用于后续 patch / 移除;上传失败时占位仍保留为 FAILED 态)
   */
  const uploadAndSendMedia = async (opts: UploadAndSendMediaOptions): Promise<string> => {
    const { conversation } = opts
    const handler = mediaTypeHandlers[opts.type]
    if (!handler) {
      console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type })
      return ''
    }
    // 体积上限拦截:大文件浏览器内截帧 / 解码可致 OOM;超限直接 warning,不进入占位 / 上传链路
    if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) {
      return ''
    }
    const startKey = getConversationKey(conversation)
    const context = opts.context ?? {}
    const buildContent = (url: string): string =>
      serializeMessage(withQuotePayload(handler.build(opts.file, url, context), opts.quote))
 
    // 1. 立即占位
    const { clientMessageId } = insertMediaPlaceholder({
      file: opts.file,
      type: opts.type,
      conversation,
      buildContent,
      existingClientMessageId: opts.existingClientMessageId
    })
 
    // 2. 上传:进度回调 patch uploadProgress;失败保留 _localFile 供重试
    let url: string | undefined
    try {
      url = await uploadFile(
        { file: opts.file },
        createUploadProgressHandler(conversation, clientMessageId)
      )
    } catch (error) {
      console.error(`[IM] ${handler.kind}上传失败`, error)
    }
    if (!url) {
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return clientMessageId
    }
    if (!isOpenableUrl(url)) {
      console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url })
      message.warning('上传返回的文件地址不支持打开')
      markMediaFailed(conversation.type, conversation.targetId, clientMessageId)
      return clientMessageId
    }
 
    // 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED
    if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) {
      return clientMessageId
    }
 
    // 4. patch content + sendRaw 收尾
    await commitMediaPlaceholder({
      type: opts.type,
      conversation,
      clientMessageId,
      realContent: buildContent(url)
    })
    return clientMessageId
  }
 
  return {
    uploadAndSendMedia,
    insertMediaPlaceholder,
    markMediaFailed,
    commitMediaPlaceholder,
    createUploadProgressHandler,
    verifyMediaUploadStillAllowed,
    getMediaKind,
    requireMediaHandler
  }
}