From 297b545e550ee2969f66bc55ab690b6dddc3753d Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 24 Sep 2026 10:29:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(media-upload):=20alioss=20=E7=9B=B4?= =?UTF-8?q?=E4=BC=A0=20endpoint=20=E7=BC=BA=20oss-=20=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sts.region 是简写 (cn-hangzhou), 真实 OSS endpoint 须完整形式 (oss-cn-hangzhou) - 优先使用后端 sts.oss_url (OssProviderRegistry::buildDirectEndpoint 已拼好), 兜底 oss-${region} 拼接 --- common/js/media-upload.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/common/js/media-upload.js b/common/js/media-upload.js index d1b9a34..fb71381 100644 --- a/common/js/media-upload.js +++ b/common/js/media-upload.js @@ -377,7 +377,15 @@ async function uploadViaSts(filePath, kind, addon, agentId, config, knownSize = } else { // A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) // endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼 - const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com` + // ★ 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` From 86b87eeb9d3c176824dcc4a30789d2e659721286 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 24 Sep 2026 10:30:28 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(ai-chat):=20=E8=81=8A=E5=A4=A9=20UI=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20V3=20=E2=80=94=20=E5=90=8D=E7=A7=B0/?= =?UTF-8?q?=E5=A4=B4=E5=83=8F/=E6=97=B6=E9=97=B4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=A1=B9=E6=B6=88=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHAT_UI_DEFAULTS 同步后端 4 新键 (show_name/show_avatar/avatar/time_format/time_humanize) - 标题与 AI 头像按开关显隐, 自定义头像 (素材库 pic_path / URL) 优先于内置 - formatTime 支持 auto(智能分段)/full(完整) 与 humanizeTime 相对时间优先 (刚刚/N分钟前/N小时前/昨天/前天/M月D日/Y年M月D日) - i18n 中英各+5 键 --- common/js/ai-service.js | 11 +++- lang/en-us/ai-chat/index.js | 6 ++ lang/zh-cn/ai-chat/index.js | 6 ++ pages_tool/ai-chat/ai-chat-message.vue | 79 ++++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/common/js/ai-service.js b/common/js/ai-service.js index 372c131..d0d21d7 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -451,7 +451,16 @@ const CHAT_UI_DEFAULTS = { ui_enable_attachment_types: ['image', 'file', 'audio', 'video'], ui_input_max_length: 500, ui_available_models: ['Auto'], - ui_enable_feedback: true + ui_enable_feedback: true, + // PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_assistant_name: '', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_show_assistant_name: true, + ui_show_assistant_avatar: true, + ui_assistant_avatar: '', + ui_show_message_time: true, + ui_time_format: 'auto', + ui_time_humanize: false }; // chat_ui_config 自带短 TTL 缓存(60s, 与 service_config 同步); diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index e880b56..d24ad50 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -12,6 +12,12 @@ export const lang = { timeFormatToday: 'Today', timeFormatYesterday: 'Yesterday', timeFormatBeforeYesterday: 'Day before yesterday', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): Humanized relative time (ui_time_humanize) + timeJustNow: 'Just now', + timeMinutesAgo: '{n} min ago', + timeHoursAgo: '{n} h ago', + timeMonthDay: '{m}/{d}', + timeYearMonthDay: '{y}/{m}/{d}', // ====== ai-chat-message ====== aiNickname: 'AI Assistant', diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index f5bda06..3668e11 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -12,6 +12,12 @@ export const lang = { timeFormatToday: '今天', timeFormatYesterday: '昨天', timeFormatBeforeYesterday: '前天', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 人性化相对时间 (ui_time_humanize) + timeJustNow: '刚刚', + timeMinutesAgo: '{n}分钟前', + timeHoursAgo: '{n}小时前', + timeMonthDay: '{m}月{d}日', + timeYearMonthDay: '{y}年{m}月{d}日', // ====== ai-chat-message ====== aiNickname: '智能助手', diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index 9c8add5..cc87ba1 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -13,7 +13,9 @@ - {{ $lang('ai-chat.index.aiNickname') }} + + {{ chatUiConfig.ui_assistant_name || $lang('ai-chat.index.aiNickname') }} @@ -137,7 +139,8 @@ - + + - + {{ formatTime(message.timestamp) }} @@ -737,7 +740,16 @@ export default { ui_enable_attachment_types: ['image', 'file', 'audio', 'video'], ui_input_max_length: 500, ui_available_models: ['Auto'], - ui_enable_feedback: true + ui_enable_feedback: true, + // PR-CHAT-UI-CONFIG-V2 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_assistant_name: '', + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 与后端 Config::DEFAULT_UI_CONFIG 同步 + ui_show_assistant_name: true, + ui_show_assistant_avatar: true, + ui_assistant_avatar: '', + ui_show_message_time: true, + ui_time_format: 'auto', + ui_time_humanize: false }, chatUiConfigLoaded: false, @@ -768,13 +780,18 @@ export default { } }, computed: { + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 后台配置了自定义头像则优先 ($util.img 兼容 pic_path 与完整 URL) aiAvatar() { + const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar; + if (custom) return this.$util.img(custom); return this.$util.img(IMAGE_CONFIG.aiAvatar); }, defaultAvatar() { return this.$util.img(IMAGE_CONFIG.userAvatar); }, aiAvatarUrl() { + const custom = this.chatUiConfig && this.chatUiConfig.ui_assistant_avatar; + if (custom) return this.$util.img(custom); return this.$util.img(IMAGE_CONFIG.aiAvatar); }, chatMessagesStyle() { @@ -2316,7 +2333,10 @@ _handleStreamError(msg) { return html }, - // 格式化时间(人性化显示) + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 时间格式化支持后端配置 + // ui_time_format = 'auto' 智能分段(默认, 与历史行为一致): 今天 HH:mm / 昨天 / 前天 / 本年 M/D / 跨年 Y/M/D + // ui_time_format = 'full' 始终完整 YYYY-MM-DD HH:mm + // ui_time_humanize = true 微信式相对时间(优先于 format): 刚刚 / N分钟前 / N小时前 / 昨天 / 前天 / M月D日 / Y年M月D日 formatTime(timestamp) { if (!timestamp) return ''; @@ -2352,6 +2372,19 @@ _handleStreamError(msg) { const minutes = date.getMinutes().toString().padStart(2, '0'); const timeStr = `${hours}:${minutes}`; + // 人性化相对时间 (开启后优先于 ui_time_format) + if (this.chatUiConfig && this.chatUiConfig.ui_time_humanize) { + return this.humanizeTime(date, msgDate, today, timeStr); + } + + // 完整时间格式: 始终 YYYY-MM-DD HH:mm + if (this.chatUiConfig && this.chatUiConfig.ui_time_format === 'full') { + const mm = (date.getMonth() + 1).toString().padStart(2, '0'); + const dd = date.getDate().toString().padStart(2, '0'); + return `${date.getFullYear()}-${mm}-${dd} ${timeStr}`; + } + + // auto: 智能分段 (原有逻辑) if (msgDate.getTime() === today.getTime()) { return timeStr; } @@ -2373,6 +2406,42 @@ _handleStreamError(msg) { const yearStr = date.getFullYear().toString(); return `${yearStr}/${monthStr}/${dayStr} ${timeStr}`; }, + + // PR-CHAT-UI-CONFIG-V3 (2026-09-24): 微信式人性化相对时间 + // 刚刚 / N分钟前 / 今天内N小时前 / 昨天 HH:mm / 前天 HH:mm / 本年 M月D日 HH:mm / 跨年 Y年M月D日 HH:mm + humanizeTime(date, msgDate, today, timeStr) { + const diff = Date.now() - date.getTime(); + const oneMin = 60 * 1000; + const oneHour = 60 * oneMin; + + if (diff >= 0 && diff < oneMin) { + return this.$lang('ai-chat.index.timeJustNow'); + } + if (diff >= 0 && diff < oneHour) { + return this.$lang('ai-chat.index.timeMinutesAgo', { n: Math.floor(diff / oneMin) }); + } + // 今天内且已超过 1 小时 + if (msgDate.getTime() === today.getTime()) { + if (diff < 24 * oneHour) { + return this.$lang('ai-chat.index.timeHoursAgo', { n: Math.floor(diff / oneHour) }); + } + return timeStr; // 兜底(理论不可达: 同一天 diff 不可能 >= 24h) + } + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + if (msgDate.getTime() === yesterday.getTime()) { + return `${this.$lang('ai-chat.index.timeFormatYesterday')} ${timeStr}`; + } + const beforeYesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000); + if (msgDate.getTime() === beforeYesterday.getTime()) { + return `${this.$lang('ai-chat.index.timeFormatBeforeYesterday')} ${timeStr}`; + } + const m = date.getMonth() + 1; + const d = date.getDate(); + if (date.getFullYear() === new Date().getFullYear()) { + return `${this.$lang('ai-chat.index.timeMonthDay', { m, d })} ${timeStr}`; + } + return `${this.$lang('ai-chat.index.timeYearMonthDay', { y: date.getFullYear(), m, d })} ${timeStr}`; + }, // 格式化文件大小 formatFileSize(bytes) { From 70f8fb867d294b93437ac47414527ad62064e3c6 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 24 Sep 2026 11:10:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(chat-ui):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=20UI=20=E9=85=8D=E7=BD=AE=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=8C=81=E4=B9=85=E5=8C=96=E5=92=8C=E9=A2=84=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E9=A6=96?= =?UTF-8?q?=E5=B8=A7=E6=B8=B2=E6=9F=93=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/js/ai-service.js | 20 ++++++++++++++ pages_tool/ai-chat/ai-chat-message.vue | 22 ++++++++++++++++ pages_tool/ai-chat/index.vue | 36 ++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/common/js/ai-service.js b/common/js/ai-service.js index d0d21d7..50a97ea 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -1004,6 +1004,9 @@ export default { const merged = mergeChatUiConfig(remote); _chatUiConfigCache = merged; _chatUiConfigCacheAt = Date.now(); + // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 持久化到本地, 供下次进入页面时 + // getChatUiConfigSync() 同步取用, 实现首帧即用真实配置 (消除默认值闪烁) + try { uni.setStorageSync('AI_CHAT_UI_CONFIG', merged); } catch (e) { /* 存储失败不影响主流程 */ } return merged; } catch (e) { console.warn('[AI Service] getChatUiConfig 失败, 使用前端默认值:', (e && e.message) || e); @@ -1012,6 +1015,23 @@ export default { } }, + /** + * PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 同步获取聊天 UI 配置 (本地持久缓存) + * 场景: 页面 onLoad 首帧渲染前调用, 上次会话成功拉取过的配置直接可用, + * 避免"先按默认值渲染 → 异步配置到达后跳变"的闪烁。 + * 返回: 配置对象 (缓存命中, 已 merge 兜底) 或 null (从未成功拉取过) + * 注意: 只读缓存不发起网络请求; 最新值仍由 getChatUiConfig() 异步刷新 + */ + getChatUiConfigSync() { + try { + const cached = uni.getStorageSync('AI_CHAT_UI_CONFIG'); + if (cached && typeof cached === 'object' && Object.keys(cached).length) { + return mergeChatUiConfig(cached); + } + } catch (e) { /* 存储读取失败按无缓存处理 */ } + return null; + }, + /** * 清除会话历史(调用后端接口) * PR-7: URL 按 agent_id 路由 diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index cc87ba1..a5ce9dd 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -615,6 +615,13 @@ export default { type: Array, default: () => [] }, + // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面渲染前预取的聊天 UI 配置 + // 传入后 created() 立即用作首帧值, 消除"默认值渲染 → 异步配置到达跳变"的闪烁; + // 之后 _loadChatUiConfig() 仍会拉最新值覆盖 (与父页面共享 60s TTL 缓存) + preloadedUiConfig: { + type: Object, + default: null + }, // 初始用户头像(外部传入,可选) initUserAvatar: { type: String, @@ -880,6 +887,21 @@ export default { this.currentConversationId = aiService.getConversationId(); console.log('[AI Chat] created, currentConversationId:', this.currentConversationId, 'messages.length:', this.messages.length); + // PR-CHAT-UI-CONFIG-PRELOAD (2026-09-24): 父页面预取配置优先作为首帧值 + // 父页面已保证渲染本组件时配置就绪 (缓存或预取), 此处同步落位; + // _loadChatUiConfig() 随后仍拉最新值 (TTL 缓存命中则同值, 无视觉跳变) + if (this.preloadedUiConfig && typeof this.preloadedUiConfig === 'object') { + this.chatUiConfig = Object.assign({}, this.chatUiConfig, this.preloadedUiConfig) + if (Array.isArray(this.preloadedUiConfig.ui_available_models) && this.preloadedUiConfig.ui_available_models.length) { + this.availableModels = this.preloadedUiConfig.ui_available_models.slice() + if (!this.availableModels.includes(this.selectedModel)) { + this.selectedModel = this.availableModels[0] || 'Auto' + } + } + this.chatUiConfigLoaded = true + console.log('[AI Chat] chatUiConfig 使用父页面预取值 (首帧即真实配置)') + } + // 异步获取服务配置(含欢迎语) this._loadWelcomeMessage() // PR-CHAT-UI-CONFIG (2026-09-20): 异步加载 UI 开关 (与欢迎语并行, 不阻塞 created) diff --git a/pages_tool/ai-chat/index.vue b/pages_tool/ai-chat/index.vue index aa2ac60..7d56618 100644 --- a/pages_tool/ai-chat/index.vue +++ b/pages_tool/ai-chat/index.vue @@ -16,7 +16,9 @@ setTimeout(() => resolve(null), 6000)) + ]); + if (cfg) this.preloadedUiCfg = cfg; + else console.warn('[AI Chat] chatUiConfig 预取超时(6s), 先渲染后由组件异步刷新'); + } catch (e) { /* 失败兜底默认值 */ } + this.uiCfgReady = true; + console.log('[AI Chat] chatUiConfig 预取完成, 渲染聊天组件:', !!this.preloadedUiCfg); + } + // 最新值刷新: 组件 created 的 _loadChatUiConfig() 会异步拉取, + // 与此处 force 拉取共享 60s TTL 内存缓存, 不会产生二次 /info 请求 + // ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ====== // 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。 // 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值, From e079afc0a23f36144e71594c3192560b0ae99d0d Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Thu, 24 Sep 2026 16:27:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(media-upload):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=87=AA=E6=84=88=E6=9C=BA=E5=88=B6=EF=BC=8C=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=87=E6=8D=A2=20STS=20=E7=9B=B4=E4=BC=A0=E4=BB=A5=E5=BA=94?= =?UTF-8?q?=E5=AF=B9=E5=90=8E=E7=AB=AF=E7=AD=96=E7=95=A5=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/js/media-upload.js | 192 +++++++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 5 deletions(-) diff --git a/common/js/media-upload.js b/common/js/media-upload.js index fb71381..d5cb469 100644 --- a/common/js/media-upload.js +++ b/common/js/media-upload.js @@ -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 //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: {