768 lines
34 KiB
JavaScript
768 lines
34 KiB
JavaScript
// common/js/media-upload.js
|
|
// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件
|
|
//
|
|
// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了
|
|
// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛
|
|
// "Cannot find module '@/common/js/media-upload.js'". 此次按
|
|
// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏).
|
|
//
|
|
// 调用方:
|
|
// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile()
|
|
// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场)
|
|
//
|
|
// 上传策略:
|
|
// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传
|
|
// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB)
|
|
//
|
|
// 端点:
|
|
// PHP 中转: POST /<addon>/api/upload/<image|file> multipart/form-data
|
|
// STS 凭证: POST /<addon>/api/upload/ossSts application/json
|
|
// STS 直传: PUT <provider>/<key> 云存储协议 (4-field STS 或 single-URL)
|
|
|
|
import aiService from './ai-service.js'
|
|
import Config from './config.js'
|
|
import store from '@/store/index.js'
|
|
|
|
const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB
|
|
const SIZE_LIMIT_H5_COMPRESS = 3 * 1024 * 1024 // 3MB (从 10MB 下调,移动端 H5 体验更流畅)
|
|
// PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): WB Agent J7 inline base64 上限 5MB
|
|
// 与 backend WbAgentOsClient::INLINE_MEDIA_LIMIT = 5242880 对齐
|
|
// 超过这个 size 后 buildMediaBlocks 会改走 URL fetch 路径, 上限失效
|
|
const SIZE_LIMIT_INLINE_BASE64 = 5 * 1024 * 1024 // 5MB (与后端 INLINE_MEDIA_LIMIT 对齐)
|
|
|
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 现代格式 mime 同步后端 guessMimeFromPath
|
|
// STS 直传绕过后端 mime 推断, 前端必须自带, 否则附件 mime 降级 application/octet-stream
|
|
const EXT_MIME_MAP = {
|
|
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp',
|
|
heic: 'image/heic', heif: 'image/heif', avif: 'image/avif',
|
|
bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff',
|
|
mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac',
|
|
m4a: 'audio/mp4', flac: 'audio/flac', ogg: 'audio/ogg',
|
|
oga: 'audio/ogg', opus: 'audio/opus', amr: 'audio/amr', wma: 'audio/x-ms-wma',
|
|
aiff: 'audio/aiff', aif: 'audio/aiff', ape: 'audio/x-ape', mid: 'audio/midi',
|
|
// PR-AV-FULL-SUPPORT (2026-09-24): 全量音视频格式 (与后端 MediaUploadPolicy::guessMimeByExt 对齐)
|
|
midi: 'audio/midi', caf: 'audio/x-caf', mka: 'audio/x-matroska', ac3: 'audio/ac3',
|
|
mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
|
mkv: 'video/x-matroska', webm: 'video/webm', flv: 'video/x-flv',
|
|
m4v: 'video/x-m4v', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp',
|
|
mpeg: 'video/mpeg', mpg: 'video/mpeg', ts: 'video/mp2t',
|
|
'3g2': 'video/3gpp2', f4v: 'video/x-flv', mpe: 'video/mpeg', m2v: 'video/mpeg',
|
|
m2ts: 'video/mp2t', mts: 'video/mp2t', ogv: 'video/ogg', vob: 'video/mpeg',
|
|
asf: 'video/x-ms-asf', rm: 'application/vnd.rn-realmedia',
|
|
rmvb: 'application/vnd.rn-realmedia-vbr', divx: 'video/x-divx',
|
|
doc: 'application/msword',
|
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
ppt: 'application/vnd.ms-powerpoint',
|
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
zip: 'application/zip', rar: 'application/x-rar-compressed',
|
|
'7z': 'application/x-7z-compressed',
|
|
json: 'application/json', xml: 'application/xml', md: 'text/markdown',
|
|
pdf: 'application/pdf', txt: 'text/plain',
|
|
xls: 'application/vnd.ms-excel',
|
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
}
|
|
|
|
/**
|
|
* 上传图片 (type=image)
|
|
* @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0]
|
|
* @returns {Promise<{url, mime, size, name, kind, used_strategy}>}
|
|
*/
|
|
export async function uploadImage(filePath, opts = {}) {
|
|
const addon = aiService.getAddonName() // 'aikefu' | 'aiagent'
|
|
const config = await aiService.getServiceConfig()
|
|
const strategy = config?.upload_strategy || 'backend'
|
|
|
|
// 压缩: H5/MP-WEIXIN 双端都做, iPhone HEIC 大图走不通 PHP 中转也走不通 STS, 必须本地压
|
|
let compressedPath = filePath
|
|
let detectedSize = 0
|
|
try {
|
|
detectedSize = await getFileSize(filePath)
|
|
} catch (e) {
|
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测失败不静默降级到 0 (会让 auto 模式永远走 PHP 中转)
|
|
// 改为抛错, 上层选择兜底走 STS (size=0 → 大于任何阈值 → 走 STS)
|
|
console.warn('[media-upload] getFileSize failed:', e)
|
|
}
|
|
// H5 + MP-WEIXIN 都压缩 (微信 chooseImage 默认 sizeType:['compressed'] 但 HEIC 不在压缩白名单)
|
|
// #ifdef H5 || MP-WEIXIN
|
|
if (detectedSize > SIZE_LIMIT_H5_COMPRESS || opts.forceCompress) {
|
|
try {
|
|
const compressed = await uni.compressImage({
|
|
src: filePath,
|
|
quality: 80,
|
|
compressedWidth: 2048,
|
|
compressedHeight: 2048,
|
|
})
|
|
compressedPath = compressed.tempFilePath || filePath
|
|
console.log('[media-upload] compressImage OK', detectedSize, '→', compressedPath)
|
|
} catch (e) {
|
|
console.warn('[media-upload] compressImage failed, use original:', e)
|
|
}
|
|
}
|
|
// #endif
|
|
|
|
// 检查是否需要走 STS (>2MB 或商家选 oss_sts)
|
|
// 决策: STS 触发条件 = oss_sts(总是) || auto + 大于 2MB
|
|
// fileSizeFinal = 探测失败的兜底:
|
|
// - 第一次探测失败(原始大小未知) → 保守走 STS
|
|
// - 第一次成功, 第二次探测压缩后失败 → 用原始 detectedSize
|
|
let fileSizeFinal = detectedSize
|
|
if (compressedPath !== filePath) {
|
|
try { fileSizeFinal = await getFileSize(compressedPath) } catch (e) {
|
|
// 压缩后探测失败, 退回到原始大小 (可能没压成功)
|
|
fileSizeFinal = detectedSize
|
|
}
|
|
}
|
|
const useSts = strategy === 'oss_sts'
|
|
|| (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH)
|
|
// ★ PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 探测完全失败(原始+压缩后都 0) → 保守走 STS
|
|
// 原代码 fileSizeFinal === 0 会让"auto 模式永远走 PHP 中转", 大文件 OOM
|
|
// 但 fileSizeFinal = detectedSize (有值) 时按值判断; 只有两边都失败才兜底 STS
|
|
const totallyUnknown = detectedSize === 0 && fileSizeFinal === 0
|
|
const finalUseSts = useSts || (strategy === 'auto' && totallyUnknown)
|
|
|
|
if (finalUseSts) {
|
|
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
|
}
|
|
try {
|
|
return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId(), detectedSize)
|
|
} catch (e) {
|
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端已切 oss_sts 而 /info 缓存里的 strategy 过期
|
|
// → PHP 中转被拒 (OSS_STS_REQUIRED), 自动切 STS 直传重试 (自愈, 无需用户重试)
|
|
if (e && e.code === 'OSS_STS_REQUIRED') {
|
|
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
|
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 上传 audio / video / file
|
|
* 策略: 商家配了云存储 (oss_sts/auto) → STS 直传, 失败回退 PHP 中转;
|
|
* 未配云存储 (backend) → 直接 PHP 中转
|
|
* @param {string} filePath
|
|
* @param {'audio'|'video'|'file'} kind
|
|
* @param {{name?:string, mime?:string, size?:number}} [opts]
|
|
* PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 传进来的是 blob URL (无扩展名),
|
|
* 必须由调用方把原始 File 的 name/mime/size 带进来, 否则 STS key 会以 .bin 结尾,
|
|
* Content-Type 退化成 application/octet-stream, 音频/视频播放器取不到正确类型。
|
|
*/
|
|
export async function uploadMedia(filePath, kind, opts = {}) {
|
|
if (!['audio', 'video', 'file'].includes(kind)) {
|
|
throw new Error(`invalid kind: ${kind}`)
|
|
}
|
|
const addon = aiService.getAddonName()
|
|
const config = await aiService.getServiceConfig()
|
|
const agentId = aiService.getAgentId()
|
|
const hints = { name: opts.name || '', mime: opts.mime || '' }
|
|
const size = opts.size || 0
|
|
const strategy = (config && config.upload_strategy) || 'backend'
|
|
|
|
// PHP 中转 (kind 以调用方传入的音视频分类为准, 后端只区分 image|file)
|
|
const doPhpUpload = async (fallbackErr) => {
|
|
let phpRes = null
|
|
try {
|
|
phpRes = await uploadViaPhp(filePath, kind, addon, agentId, size)
|
|
} catch (err) {
|
|
throw new Error((err && err.message) || (fallbackErr && fallbackErr.message) || '上传失败')
|
|
}
|
|
if (!phpRes || !phpRes.url) {
|
|
throw new Error((fallbackErr && fallbackErr.message) || '上传失败: 当前平台不支持该上传方式')
|
|
}
|
|
return Object.assign({}, phpRes, { kind })
|
|
}
|
|
|
|
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
|
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
|
|
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
|
|
if (strategy === 'backend') {
|
|
try {
|
|
return await doPhpUpload()
|
|
} catch (e) {
|
|
if (e && e.code === 'OSS_STS_REQUIRED') {
|
|
console.warn('[media-upload] PHP 中转被拒 (后端策略已是 oss_sts), 自动切换 STS 直传')
|
|
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await uploadViaSts(filePath, kind, addon, agentId, config, size, hints)
|
|
} catch (e) {
|
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): STS 不可用 (未配置云存储 / 凭证接口失败)
|
|
// 时回退 PHP 中转, 保证音频/视频文件仍能上传
|
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 回退的 PHP 中转若也被 oss_sts 策略拒收,
|
|
// 保留原始 STS 错误抛出 (回退注定失败, 报真实根因比报 "操作失败" 有用)
|
|
console.warn('[media-upload] STS 上传失败, 回退 PHP 中转:', e && e.message)
|
|
try {
|
|
return await doPhpUpload(e)
|
|
} catch (e2) {
|
|
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
|
|
throw e2
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PHP 中转 (multipart/form-data)
|
|
* 端点必须用 addon 模式 URL: /<addon>/api/upload/<image|file>
|
|
* 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下,
|
|
* /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中
|
|
* (那是普通接口命名空间, 上传走的是 addon 专属路由)
|
|
*/
|
|
async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
|
const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/')
|
|
+ (kind === 'image' ? 'image' : 'file')
|
|
|
|
// #ifdef MP-WEIXIN
|
|
return new Promise((resolve, reject) => {
|
|
uni.uploadFile({
|
|
url: Config.baseUrl + url,
|
|
filePath,
|
|
name: 'file',
|
|
formData: {
|
|
kind,
|
|
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
|
agent_id: agentId || undefined,
|
|
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
uniacid: aiService.getUniacid(),
|
|
user_id: aiService.getUserId?.() || '',
|
|
},
|
|
success: (res) => {
|
|
try {
|
|
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
|
if (data.code === 0 || data.success) {
|
|
resolve({
|
|
url: data.data.url,
|
|
mime: data.data.mime,
|
|
size: data.data.size || knownSize || 0,
|
|
name: data.data.name,
|
|
kind: data.data.kind || kind,
|
|
used_strategy: data.data.used_strategy || 'backend',
|
|
oss_sts_required: data.data.oss_sts_required || false,
|
|
})
|
|
} else {
|
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端策略已切 oss_sts 时拒收 PHP 中转,
|
|
// 顶层 oss_sts_required=true → 标记错误码, 调用方捕获后自动切 STS 直传 (自愈)
|
|
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
|
if (data.oss_sts_required === true) err.code = 'OSS_STS_REQUIRED'
|
|
reject(err)
|
|
}
|
|
} catch (e) {
|
|
reject(new Error('解析响应失败: ' + e.message))
|
|
}
|
|
},
|
|
fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))),
|
|
})
|
|
})
|
|
// #endif
|
|
|
|
// #ifdef H5
|
|
const form = new FormData()
|
|
const file = await pathToFile(filePath, kind)
|
|
form.append('file', file)
|
|
form.append('kind', kind)
|
|
if (addon === 'aiagent') form.append('agent_id', agentId)
|
|
else {
|
|
// aikefu 后端必填 config_uuid; 同时兼容 config_uid 命名并附 agent_id 兜底
|
|
const uuid = aiService.getCurrentConfigUuid?.() || ''
|
|
form.append('config_uuid', uuid)
|
|
form.append('config_uid', uuid)
|
|
form.append('agent_id', agentId)
|
|
}
|
|
form.append('uniacid', aiService.getUniacid())
|
|
form.append('user_id', aiService.getUserId?.() || '')
|
|
const res = await fetch(Config.baseUrl + url, {
|
|
method: 'POST',
|
|
headers: { 'token': store.state.token || '' },
|
|
body: form,
|
|
})
|
|
const data = await res.json()
|
|
if (data.code === 0 || data.success) {
|
|
return {
|
|
url: data.data.url,
|
|
mime: data.data.mime,
|
|
size: data.data.size || knownSize || 0,
|
|
name: data.data.name,
|
|
kind: data.data.kind || kind,
|
|
used_strategy: data.data.used_strategy || 'backend',
|
|
oss_sts_required: data.data.oss_sts_required || false,
|
|
}
|
|
}
|
|
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 后端拒收且标记 oss_sts_required → 自动切 STS 直传
|
|
if (data.oss_sts_required === true) {
|
|
const err = new Error(data.data || data.msg || data.message || '上传失败')
|
|
err.code = 'OSS_STS_REQUIRED'
|
|
throw err
|
|
}
|
|
throw new Error(data.msg || data.message || '上传失败')
|
|
// #endif
|
|
}
|
|
|
|
/**
|
|
* STS 直传 (拿临时凭证 → 直传云存储)
|
|
*
|
|
* PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类:
|
|
* A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
|
* → 前端拼 PUT URL, Authorization header = STS <ak>:<sk>
|
|
* B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio)
|
|
* → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='<PROVIDER>-SIGNED'
|
|
* 标记, 不带 Authorization
|
|
*
|
|
* 后端 stsResp.data.data 可能字段:
|
|
* { provider, bucket, region, accessKeyId, accessKeySecret, securityToken,
|
|
* key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) }
|
|
*/
|
|
/**
|
|
* PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 三段兜底扩展名提取
|
|
* 1) filePath 最后一段正则匹配 .xxx
|
|
* 2) filePath.name 兜底 (H5 File 对象 / APP tempFile.name)
|
|
* 3) mimeHint 反查 EXT_MIME_MAP (H5 blob URL 没扩展名但 blob.type 通常是 image/png)
|
|
* 4) 最终 'bin' (防 URL 片段被误当扩展名)
|
|
*
|
|
* PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): 提到模块顶层,
|
|
* 之前只在 uploadViaSts 里, pathToFile 引用不到 → "extractExt is not defined"
|
|
*/
|
|
function extractExt(filePath, mimeHint = '', nameHint = '') {
|
|
let ext = 'bin'
|
|
if (typeof filePath === 'string') {
|
|
const lastSeg = filePath.split('?')[0].split('/').pop() || ''
|
|
const m = lastSeg.match(/\.([a-z0-9]+)$/i)
|
|
if (m) ext = m[1].toLowerCase()
|
|
}
|
|
if (ext === 'bin' && filePath && filePath.name) {
|
|
const m2 = String(filePath.name).match(/\.([a-z0-9]+)$/i)
|
|
if (m2) ext = m2[1].toLowerCase()
|
|
}
|
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 blob URL + 调用方带下来的原始文件名
|
|
if (ext === 'bin' && nameHint) {
|
|
const m3 = String(nameHint).match(/\.([a-z0-9]+)$/i)
|
|
if (m3) ext = m3[1].toLowerCase()
|
|
}
|
|
// 3) mime→ext 反查 (PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX): blob URL 无扩展名但
|
|
// blob.type 是 image/png 这种, 反查 EXT_MIME_MAP → png
|
|
if (ext === 'bin' && mimeHint) {
|
|
for (const [e, m] of Object.entries(EXT_MIME_MAP)) {
|
|
if (m === mimeHint) { ext = e; break }
|
|
}
|
|
}
|
|
if (!/^[a-z0-9]{2,5}$/.test(ext)) ext = 'bin'
|
|
return ext
|
|
}
|
|
|
|
/**
|
|
* PR-H5-PRESIGN-DIRECT (2026-09-24): alioss 直传改走服务端预签名 URL (ossPresign)
|
|
*
|
|
* 背景: 旧 uploadViaSts 对 alioss 走 4 字段 STS 凭证 + 前端裸 PUT
|
|
* (Authorization: STS ak:sk + x-oss-security-token)。该 Authorization 头
|
|
* 不是合法 OSS V1 签名 (合法格式为 `OSS AccessKeyId:HMAC-SHA1(StringToSign)`,
|
|
* 签名必须由持有 secret 的一方计算) → OSS 恒 403 AccessDenied。
|
|
* 后端「对话测试」已切 ossPresign (服务端签 PUT URL) 实测可用 (tests/repro_h5_sts_put.js:
|
|
* 裸 PUT 403 / presign PUT 200), 前端同款切换。
|
|
* 额外收益: object key 由服务端 KeyPrefixBuilder 统一拼, 消除前端模板替换的
|
|
* 双重前缀/占位符错配; content_type 由服务端签名后回传, PUT 带同值即可。
|
|
*
|
|
* 端点: POST /<addon>/api/upload/ossPresign
|
|
* 入参: filename / content_type / uniacid + (aikefu: config_uuid | aiagent: agent_id)
|
|
* 出参: {upload_url, content_type, public_url, object_key, provider, ...}
|
|
* PUT 时不带任何 Authorization 头 (签名在 query 里), Content-Type 必须与签名一致。
|
|
*/
|
|
/**
|
|
* PR-UNI-REQUEST-CB (2026-09-24): uni.request 的 Promise 兼容封装
|
|
* 实测 (tests/probe_uni_request.js): 本 uni-app H5 版本 `await uni.request(opts)`
|
|
* resolve 的是 **undefined** (不带 success 回调时 Promise 返回值不可用),
|
|
* 导致 stsResp.statusCode/preResp.data 全部 TypeError。
|
|
* 回调式调用是唯一跨端可靠用法, 统一从这里走。
|
|
*/
|
|
function uniRequest(opts) {
|
|
return new Promise((resolve, reject) => {
|
|
uni.request(Object.assign({}, opts, {
|
|
success: resolve,
|
|
fail: (err) => reject(new Error((err && err.errMsg) || 'request failed')),
|
|
}))
|
|
})
|
|
}
|
|
|
|
async function uploadViaPresign(filePath, kind, addon, agentId, knownSize = 0, hints = {}) {
|
|
// PR-H5-PRESIGN-EXT (2026-09-24): H5 图片是 blob URL (无扩展名), extractExt 会得 'bin'
|
|
// → 先经 pathToFile 还原 File, 用 file.type / file.name 反推扩展名与 Content-Type
|
|
let mimeHint = hints.mime || ''
|
|
let nameHint = hints.name || ''
|
|
let probeFile = null
|
|
// #ifdef H5
|
|
try {
|
|
probeFile = await pathToFile(filePath, kind)
|
|
if (!mimeHint && probeFile && probeFile.type) mimeHint = probeFile.type
|
|
if (!nameHint && probeFile && probeFile.name) nameHint = probeFile.name
|
|
} catch (e) {
|
|
// blob URL 还原失败不致命: ext 兜底 jpg, PUT 时再走 pathToFile
|
|
console.warn('[media-upload] presign probe failed:', e && e.message)
|
|
}
|
|
// #endif
|
|
const ext = extractExt(filePath, mimeHint, nameHint)
|
|
// 兜底: 彻底拿不到类型时, 图片按 jpg 传 (后端后缀白名单拒收 .bin; jpg 嗅探渲染无碍)
|
|
const finalExt = (ext === 'bin' && kind === 'image') ? 'jpg' : ext
|
|
const contentType = mimeHint || guessMime(finalExt)
|
|
const filename = (nameHint && /^[^\/\\]+\.[a-zA-Z0-9]{2,5}$/.test(nameHint))
|
|
? nameHint
|
|
: `media_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${finalExt}`
|
|
|
|
const preUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossPresign'
|
|
const preResp = await uniRequest({
|
|
url: Config.baseUrl + preUrl,
|
|
method: 'POST',
|
|
header: {
|
|
'Content-Type': 'application/json',
|
|
'token': store.state.token || ''
|
|
},
|
|
data: {
|
|
filename,
|
|
content_type: contentType,
|
|
uniacid: aiService.getUniacid(),
|
|
agent_id: addon === 'aiagent' ? (agentId || undefined) : undefined,
|
|
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
},
|
|
})
|
|
if (preResp.statusCode === 404) {
|
|
const err = new Error('后端无 ossPresign 端点 (版本过旧)')
|
|
err.code = 'PRESIGN_UNSUPPORTED'
|
|
throw err
|
|
}
|
|
if (preResp.statusCode !== 200 || (preResp.data.code !== 0 && !preResp.data.success)) {
|
|
throw new Error(preResp.data?.msg || preResp.data?.message || '预签名获取失败')
|
|
}
|
|
const pre = preResp.data.data
|
|
const uploadUrl = pre.upload_url
|
|
const finalContentType = pre.content_type || contentType
|
|
|
|
let uploadedSize = knownSize || 0
|
|
// #ifdef H5
|
|
if (probeFile && probeFile.size) uploadedSize = probeFile.size
|
|
// #endif
|
|
|
|
// 直传: PUT 已签名 URL, 无 Authorization 头 (签名在 query 里)
|
|
// #ifdef H5
|
|
const file = probeFile || await pathToFile(filePath, kind)
|
|
const ossRes = await fetch(uploadUrl, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': finalContentType },
|
|
body: file,
|
|
})
|
|
console.log('[media-upload] presign PUT', ossRes.status, (pre.public_url || '').slice(0, 90))
|
|
if (!ossRes.ok) throw new Error(`${pre.provider || 'alioss'} PUT ${ossRes.status}`)
|
|
// #endif
|
|
// #ifdef MP-WEIXIN
|
|
// uni.uploadFile 只会发 multipart POST, 不能 PUT → 读成 ArrayBuffer 用 uni.request 发原始 PUT
|
|
await new Promise((resolve, reject) => {
|
|
uni.getFileSystemManager().readFile({
|
|
filePath,
|
|
success: (res) => {
|
|
uni.request({
|
|
url: uploadUrl,
|
|
method: 'PUT',
|
|
header: { 'Content-Type': finalContentType },
|
|
data: res.data,
|
|
success: (r) => (r.statusCode >= 200 && r.statusCode < 300)
|
|
? resolve()
|
|
: reject(new Error(`${pre.provider || 'alioss'} PUT ${r.statusCode}`)),
|
|
fail: reject,
|
|
})
|
|
},
|
|
fail: (e) => reject(new Error('readFile failed: ' + (e.errMsg || ''))),
|
|
})
|
|
})
|
|
// #endif
|
|
|
|
return {
|
|
url: (pre.public_url || uploadUrl.split('?')[0]),
|
|
mime: finalContentType,
|
|
size: uploadedSize,
|
|
name: (pre.object_key || filename).split('/').pop(),
|
|
kind,
|
|
used_strategy: 'oss_sts',
|
|
provider: pre.provider || 'alioss',
|
|
}
|
|
}
|
|
|
|
async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = 0, hints = {}) {
|
|
// PR-H5-PRESIGN-DIRECT (2026-09-24): alioss (及 provider 未知时) 先走服务端预签名;
|
|
// 旧 4 字段裸 PUT 的 Authorization 头不是合法 OSS 签名, 恒 403 (tests/repro_h5_sts_put.js 实测)
|
|
// 仅当后端明确配置了非 alioss provider, 或后端过旧无 presign 端点时才落回旧流程
|
|
const providerHint = (config && config.oss_provider) || ''
|
|
if (!providerHint || providerHint === 'alioss') {
|
|
try {
|
|
return await uploadViaPresign(filePath, kind, addon, agentId, knownSize, hints)
|
|
} catch (e) {
|
|
if (!(e && e.code === 'PRESIGN_UNSUPPORTED')) throw e
|
|
console.warn('[media-upload] ossPresign 不可用, 回退旧 STS 流程:', e.message)
|
|
}
|
|
}
|
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 用更稳的扩展名提取
|
|
// H5 blob URL (blob:https://xxx/uuid) 无扩展名 → split('.').pop() 会拿整条 URL
|
|
// 优先 file.name (H5 file / APP tempFile.name), 都没有就 blob URL → 'bin'
|
|
// PR-FRONTEND-MEDIA-ACCEPT (2026-09-17): H5 走 blob URL 时 file.name 拿不到,
|
|
// 由 uploadMedia 的 opts.name/mime 兜底 (音频 .mp3 / 视频 .mp4 才不会被写成 .bin)
|
|
const ext = extractExt(filePath, hints.mime || '', hints.name || '')
|
|
|
|
// 1. 拿 STS 凭证 (addon 模式 URL)
|
|
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
|
const stsResp = await uniRequest({
|
|
url: Config.baseUrl + stsUrl,
|
|
method: 'POST',
|
|
header: {
|
|
'Content-Type': 'application/json',
|
|
'token': store.state.token || ''
|
|
},
|
|
data: {
|
|
agent_id: agentId || undefined,
|
|
config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
config_uid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined,
|
|
uniacid: aiService.getUniacid(),
|
|
user_id: aiService.getUserId?.() || '',
|
|
},
|
|
})
|
|
if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) {
|
|
throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败')
|
|
}
|
|
const sts = stsResp.data.data
|
|
const provider = sts.provider || 'alioss' // 老后端默认 alioss
|
|
|
|
// 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符)
|
|
const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
const r = Math.random() * 16 | 0
|
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
|
|
})
|
|
const now = new Date()
|
|
const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}')
|
|
.replace('{site_id}', sts.site_id || '0')
|
|
.replace('{agent_id}', agentId || '0')
|
|
.replace('{yyyy}', now.getFullYear().toString())
|
|
.replace('{mm}', String(now.getMonth() + 1).padStart(2, '0'))
|
|
.replace('{dd}', String(now.getDate()).padStart(2, '0'))
|
|
.replace('{rand}', uuid)
|
|
+ '.' + ext
|
|
|
|
// ★ PR-OSS-REGISTRY (2026-09-15): provider 分流
|
|
// single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED)
|
|
const isSingleUrlMode = typeof sts.securityToken === 'string'
|
|
&& /-(SIGNED)$/.test(sts.securityToken)
|
|
|
|
let ossUrl, headers
|
|
if (isSingleUrlMode) {
|
|
// B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header
|
|
ossUrl = sts.accessKeyId
|
|
headers = { 'Content-Type': guessMime(ext) }
|
|
} else if (provider === 'qiniu') {
|
|
// A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host
|
|
ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key
|
|
headers = {
|
|
'Authorization': 'UpToken ' + sts.accessKeyId,
|
|
'Content-Type': guessMime(ext),
|
|
}
|
|
} else {
|
|
// A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss)
|
|
// endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼
|
|
// ★ FIX-ALIOSS-ENDPOINT-OSS-PREFIX (2026-09-23): alioss 区域名缺前缀 bug
|
|
// sts.region 是用户输入的简写形式 (cn-hangzhou), 但真实 OSS endpoint 必须用完整形式 (oss-cn-hangzhou)
|
|
// 之前 `https://${bucket}.${region}.aliyuncs.com` 拼成 `suzhou-oss-data-xyz.cn-hangzhou.aliyuncs.com` (DNS 不存在)
|
|
// 正确: `https://${bucket}.oss-${region}.aliyuncs.com` → `suzhou-oss-data-xyz.oss-cn-hangzhou.aliyuncs.com`
|
|
// 后端 sts.oss_url 已拼好正确 URL (用 OssProviderRegistry::buildDirectEndpoint), 优先用它
|
|
const aliossTpl = sts.oss_url
|
|
? sts.oss_url.replace(/\/[^\/?#]+$/, '') // 去尾 key, 留 base endpoint
|
|
: `https://${sts.bucket}.oss-${sts.region}.aliyuncs.com`
|
|
const tpl = (provider === 'alioss') ? aliossTpl
|
|
: (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com`
|
|
: (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com`
|
|
: (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com`
|
|
: (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}`
|
|
: (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`)
|
|
ossUrl = `${tpl}/${key}`
|
|
headers = {
|
|
'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`,
|
|
'x-oss-security-token': sts.securityToken,
|
|
'Content-Type': guessMime(ext),
|
|
}
|
|
}
|
|
|
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): MP-WEIXIN uni.uploadFile 拿不到 file.size
|
|
// H5 pathToFile 拿到的 file 有 size, MP-WEIXIN 没有; STS 完成后用已知 size 或后端返回
|
|
let uploadedSize = knownSize || 0
|
|
// #ifdef H5
|
|
try {
|
|
const probe = await pathToFile(filePath, kind)
|
|
if (probe && probe.size) uploadedSize = probe.size
|
|
} catch (e) {}
|
|
// #endif
|
|
|
|
// 3. 直传云存储
|
|
// #ifdef MP-WEIXIN
|
|
await new Promise((resolve, reject) => {
|
|
uni.uploadFile({
|
|
url: ossUrl,
|
|
filePath,
|
|
name: 'file',
|
|
formData: {},
|
|
header: headers,
|
|
success: (res) => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) resolve()
|
|
else reject(new Error(`${provider} PUT ${res.statusCode}`))
|
|
},
|
|
fail: reject,
|
|
})
|
|
})
|
|
// #endif
|
|
// #ifdef H5
|
|
const file = await pathToFile(filePath, kind)
|
|
const ossRes = await fetch(ossUrl, {
|
|
method: 'PUT',
|
|
headers,
|
|
body: file,
|
|
})
|
|
if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`)
|
|
// #endif
|
|
|
|
// 4. 返回公网 URL
|
|
return {
|
|
url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL
|
|
mime: guessMime(ext),
|
|
size: uploadedSize,
|
|
name: key.split('/').pop(),
|
|
kind,
|
|
used_strategy: 'oss_sts',
|
|
provider,
|
|
}
|
|
}
|
|
|
|
async function getFileSize(filePath) {
|
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX (2026-09-16): 失败抛错而不是 resolve(0)
|
|
// 0 会让 auto 模式永远走 PHP 中转 (0 > 2MB = false), 失去 STS 直传能力
|
|
// 上层 uploadImage 用 try/catch 兜底, 探测失败视为 "大小未知, 默认走 STS"
|
|
// #ifdef MP-WEIXIN
|
|
return new Promise((resolve, reject) => {
|
|
uni.getFileInfo({
|
|
filePath,
|
|
success: (res) => resolve(res.size || 0),
|
|
fail: (err) => reject(new Error('getFileInfo failed: ' + (err.errMsg || ''))),
|
|
})
|
|
})
|
|
// #endif
|
|
// #ifdef H5
|
|
try {
|
|
const res = await fetch(filePath)
|
|
const blob = await res.blob()
|
|
return blob.size
|
|
} catch (e) {
|
|
throw new Error('getFileSize fetch failed: ' + e.message)
|
|
}
|
|
// #endif
|
|
}
|
|
|
|
async function pathToFile(filePath, kind) {
|
|
// #ifdef H5
|
|
const res = await fetch(filePath)
|
|
const blob = await res.blob()
|
|
// PR-FRONTEND-MEDIA-UPLOAD-FIX-HOTFIX (2026-09-16): H5 blob URL 路径修复扩展名
|
|
// 原代码 `filePath.split('/').pop()` 对 blob:http://xxx/uuid 取出 'uuid' (无扩展名)
|
|
// → 后端 multipart filename 缺失扩展名 → ext_rule 拒绝 "上传文件后缀不允许"
|
|
// extractExt 三段兜底: 1) lastSegment 正则 2) file.name 3) 'bin' 最终兜底
|
|
// 若 ext='bin', 走 guessMime(EXT_MIME_MAP) 看能不能从 mime 反推 jpg/png (mime→ext)
|
|
const rawName = filePath.split('/').pop() || `${kind}.bin`
|
|
const ext = extractExt(filePath, blob.type)
|
|
const finalName = ext !== 'bin' ? `${rawName}.${ext}` : rawName
|
|
// blob.type 通常是 image/png 或空; 空时用 guessMime 兜底
|
|
const mime = blob.type || guessMime(ext)
|
|
return new File([blob], finalName, { type: mime })
|
|
// #endif
|
|
}
|
|
|
|
/**
|
|
* PR-FRONTEND-MEDIA-INLINE-BASE64 (2026-09-16): 把附件原文件读成 base64,
|
|
* 用于 ai-chat-message.vue sendMessage 给 attachment 加 `data` 字段,
|
|
* 让 backend 走 WbAgentOsClient::buildMediaBlocks 路径 ① (inline base64)
|
|
* 而不是路径 ②/③ (URL fetch / resource_link).
|
|
*
|
|
* 为什么必要: 之前 PR-FRONTEND-MEDIA-UPLOAD-FIX 已让上传链路 (uni.compressImage
|
|
* → backend Upload.php) 返回了公网 URL, 但此 URL 是 backend Upload.php::buildPublicUrl
|
|
* 用 request()->domain() 拼出来的 (例如 http://192.168.x.x:8050/upload/...).
|
|
* 买家端 AI chat 把这个 URL 透给 backend, backend MediaGuard::urlAllowed
|
|
* 把 192.168.x.x 当内网 IP (FILTER_FLAG_NO_PRIV_RANGE) 拒绝, 附件被静默丢弃.
|
|
*
|
|
* 修复: 同步给 attachment 加 data: <bare-base64> 字段.
|
|
* - backend MessageMedia::normalizeInput 直接透传 data (line 207-208)
|
|
* - backend MediaGuard::check 看到 data 非空就无条件放行, 不参与 URL 校验 (line 137-140)
|
|
* - backend WbAgentOsClient::buildMediaBlocks 路径 ① 优先 data + image/audio → 直接 base64 内联
|
|
* 全链路 bypass URL 公网可达性 (无论 LAN 还是公网, 都不依赖 WB Cloud 拉 URL).
|
|
*
|
|
* 返回: { data: '<bare-base64>', size, mime } | null
|
|
* - null 时表示文件 > maxBytes (默认 5MB, J7 上限) 或读取失败 → 调用方跳过 data 字段, 走 URL 路径
|
|
* - bare-base64 (不带 data:image/png;base64, 前缀) — 后端 buildMediaBlocks 需要纯 base64
|
|
* - mime 从 blob.type (H5) / 调用方传 mimeHint (uni-app 平台不知道 blob.type)
|
|
*
|
|
* @param {string} filePath 选图/选文件时的原始路径 (blob: URL 或 _doc/tmp_xxx 路径)
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.maxBytes=5MB] 超限返回 null (frontend 主动限流, 减轻带宽/内存)
|
|
* @param {string} [opts.mimeHint] 非 H5 平台拿不到 blob.type, 用 pending.uploaded.mime 兜底
|
|
*/
|
|
export async function fileToBase64(filePath, opts = {}) {
|
|
const maxBytes = opts.maxBytes || SIZE_LIMIT_INLINE_BASE64
|
|
if (!filePath) return null
|
|
|
|
// #ifdef H5
|
|
try {
|
|
const res = await fetch(filePath)
|
|
if (!res.ok) throw new Error('fetch failed: ' + res.status)
|
|
const blob = await res.blob()
|
|
if (blob.size > maxBytes) {
|
|
console.log('[media-upload] fileToBase64: size', blob.size, '> maxBytes', maxBytes, '→ 跳过内联')
|
|
return null
|
|
}
|
|
const dataUri = await new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(reader.result)
|
|
reader.onerror = () => reject(new Error('FileReader failed'))
|
|
reader.readAsDataURL(blob)
|
|
})
|
|
const m = /^data:([^;,]+);base64,(.*)$/.exec(dataUri)
|
|
if (!m) throw new Error('unexpected data URI shape: ' + String(dataUri).slice(0, 40))
|
|
return { data: m[2], size: blob.size, mime: m[1] }
|
|
} catch (e) {
|
|
console.warn('[media-upload] fileToBase64 H5 失败:', e)
|
|
return null
|
|
}
|
|
// #endif
|
|
|
|
// #ifdef MP-WEIXIN || APP-PLUS
|
|
try {
|
|
const fsm = uni.getFileSystemManager()
|
|
// 1. 探测大小, 超限直接返回 null
|
|
let size = 0
|
|
try {
|
|
const stat = fsm.statSync(filePath)
|
|
// statSync 在部分平台返回 {size}, 部分返回 {fileSize}, 部分直接是数字
|
|
size = (stat && (stat.size || stat.fileSize)) || 0
|
|
} catch (e) {
|
|
// statSync 失败 (权限/路径) → 直接尝试 readFile, 后端会再校验 size
|
|
console.warn('[media-upload] fileToBase64 statSync failed, fallback to readFile:', e)
|
|
}
|
|
if (size > maxBytes) {
|
|
console.log('[media-upload] fileToBase64: size', size, '> maxBytes', maxBytes, '→ 跳过内联')
|
|
return null
|
|
}
|
|
// 2. readFile 拿 base64 — 同步 API 简单可靠, base64 在 5MB 文件 ≈ 6.7MB 字符串, 内存可控
|
|
const data = fsm.readFileSync(filePath, 'base64')
|
|
return { data, size: size || 0, mime: opts.mimeHint || '' }
|
|
} catch (e) {
|
|
console.warn('[media-upload] fileToBase64 uni-app 失败:', e)
|
|
return null
|
|
}
|
|
// #endif
|
|
}
|
|
|
|
function guessMime(ext) {
|
|
return EXT_MIME_MAP[ext] || 'application/octet-stream'
|
|
}
|
|
|
|
export default { uploadImage, uploadMedia } |