feat(media-upload): 增加自愈机制,自动切换 STS 直传以应对后端策略变化
This commit is contained in:
@@ -116,8 +116,17 @@ export async function uploadImage(filePath, opts = {}) {
|
||||
|
||||
if (finalUseSts) {
|
||||
return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config, detectedSize)
|
||||
} else {
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +168,18 @@ export async function uploadMedia(filePath, kind, opts = {}) {
|
||||
|
||||
// 商家未开启云存储 (strategy=backend): 与图片保持一致走 PHP 中转,
|
||||
// 避免没配 STS 时"视频/音频"按钮一选就报 "STS 凭据获取失败" 完全不可用
|
||||
// PR-UPLOAD-ADAPTIVE-FALLBACK (2026-09-24): 若后端已切 oss_sts 而本地 strategy 过期,
|
||||
// PHP 中转被拒 (OSS_STS_REQUIRED) → 自动切 STS 直传重试
|
||||
if (strategy === 'backend') {
|
||||
return await doPhpUpload()
|
||||
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 {
|
||||
@@ -168,8 +187,15 @@ export async function uploadMedia(filePath, kind, opts = {}) {
|
||||
} 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)
|
||||
return await doPhpUpload(e)
|
||||
try {
|
||||
return await doPhpUpload(e)
|
||||
} catch (e2) {
|
||||
if (e2 && e2.code === 'OSS_STS_REQUIRED') throw e
|
||||
throw e2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +239,11 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
oss_sts_required: data.data.oss_sts_required || false,
|
||||
})
|
||||
} else {
|
||||
reject(new Error(data.msg || data.message || '上传失败'))
|
||||
// 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))
|
||||
@@ -256,6 +286,12 @@ async function uploadViaPhp(filePath, kind, addon, agentId, knownSize = 0) {
|
||||
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
|
||||
}
|
||||
@@ -311,7 +347,153 @@ function extractExt(filePath, mimeHint = '', nameHint = '') {
|
||||
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'
|
||||
@@ -321,7 +503,7 @@ async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize =
|
||||
|
||||
// 1. 拿 STS 凭证 (addon 模式 URL)
|
||||
const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts'
|
||||
const stsResp = await uni.request({
|
||||
const stsResp = await uniRequest({
|
||||
url: Config.baseUrl + stsUrl,
|
||||
method: 'POST',
|
||||
header: {
|
||||
|
||||
Reference in New Issue
Block a user