diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 6d69e0d..6a9346d 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -20,9 +20,13 @@ alwaysApply: true - 分支:`feature/phase2b-instant-messaging` - **Phase 2c(群聊与已读回执)**:✅ 全部完成(14 个 Task + 代码审查修复 14 项 + 浏览器测试修复 21 项),设计文档 `docs/plans/2026-03-04-phase2c-design.md` - 分支:`feature/phase2c-group-read-receipt` -- **Phase 2d(消息类型扩展)**:✅ 全部完成(14 个 Task + 代码审查修复 2 项 + 富媒体 Bug 修复 4 项),设计文档 `docs/plans/2026-03-04-phase2d-design.md` -- 分支:`feature/phase2d-message-types` +- **Phase 2d(消息类型扩展)**:✅ 全部完成(14 个 Task + 代码审查修复 2 项 + 富媒体 Bug 修复 4 项 + UX 优化 1 项),设计文档 `docs/plans/2026-03-04-phase2d-design.md` +- **⚠️ 分支说明**:Phase 2d 的实际开发工作误用了 `feature/phase2c-group-read-receipt` 分支,`feature/phase2d-message-types` 分支未承载 2d 代码。此次偏差不做回溯修复;**Phase 2e 起必须为每个阶段新建独立分支**(`feature/phase2e-xxx`)。 - **开发者运维脚本(2026-04-20)**:新增 `scripts/{start,stop,status}.sh` 三件套,`scripts/dev-setup.sh` 补齐 MinIO 健康检查;遵循 setup/start 职责分离(Rails/Django 社区模式) +- **聊天页 UX 优化(2026-04-20)**:单聊/群聊引入「新消息悬浮提示 + 按需已读」机制(贴底自动滚+已读,远离底部显示"N 条新消息"悬浮按钮),消除"视图未见但对方已读"的 UX 错位;**后续修复**:watch 误将"加载更多历史消息"计入 newMsgCount → 改为对比「末尾消息标识(tail id/client_msg_id)」而非 `messages.length`,真实双账号(duanlingyun ↔ bojinyuan)端到端验证通过 +- **语音消息 H5 兼容(2026-04-20)**:修复 PC Chrome 无法按住"按住说话"的问题,链路涉及 4 层(事件 + 录音 API + 上传 + 后端校验):VoiceRecorder 并行监听 touch/mouse 事件;新增 `H5Recorder` 类(基于 `MediaRecorder + getUserMedia`)作为 uni 录音接口在 H5 端的回退;`uploadVoice` 增加 blob 分支走 `fetch+FormData` 精确控制 filename;后端 `allowedVoiceExts` 扩展 `.webm/.ogg`。Playwright 端到端验证:录音计时正常(3s→5s 跳动)、Blob 上传成功、数据库 `im_messages` type=3 记录写入 +- **语音未听红点(2026-04-20,仿微信)**:语音需"听"才算真消费,仅通用已读不能准确反映 → 为「对方发来的」语音叠加独立状态。`chat store` 新增 `voicePlayedMap` + 4 API(`markVoicePlayed/isVoicePlayed/loadVoicePlayedState/resetVoicePlayedState`),localStorage 按 userId 隔离(`echo:voice-played:{userId}`),不同步后端(私人视图态);`MsgVoice.vue` 自主消费 store(单聊/群聊同构),模板加 `.unplayed-dot`(10rpx 红圆),`onTogglePlay` 点击即标记清除;`user.logout()` 补 `resetVoicePlayedState()` 防串用户。Playwright 验证:duanlingyun(13) ↔ bojinyuan(7) 会话 6,初始 5 个红点(对方 5 条语音) → 点击 2 条 → 红点减至 3 → 页面刷新仍为 3(持久化生效) +- **已读状态刷新持久化(2026-04-20)**:修复"刷新后所有已读标签变未读 / 群聊 N人已读消失"的体验倒退 bug,根因是前端 `readStatusMap`/`groupReadCountMap` 仅由 WS 事件填充、刷新后无 API 补回。扩展 `HistoryMessageResponse` 新增 `peer_last_read_msg_id`(单聊)+ `read_count_map`(群聊仅含自己发送消息);`GetHistoryMessages` 按会话类型分支填充(`GetPeerUserID`+`GetMember` 或 `readRecorder.GetReadCountBatch`);前端 `loadHistoryMessages` 仅在首次加载时回填(避免加载更多时覆盖 WS 增量)。Playwright 双端验证:单聊会话 6(peer_last_read=202)首屏 20 已读+1 未读、群聊 8 刷新后 13 条自己消息全部显示"N人已读"(11×1人 + 2×2人);不新增 API,不增加请求次数,向前兼容 - 范围:图片/语音/文件消息完整流程 + 管理端消息管理(列表+统计+撤回+删除)+ ECharts 仪表板 - **跨模块通信模式**:接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService,im.GroupInfoGetter → group.GroupDAO,im.MessageReadRecorder → group.MessageReadDAO,group.UserInfoProvider → auth.UserDAO,group.MessageWriter → im.MessageDAO,admin.MessageManageService → im.ConversationDAO + ws.PubSub) diff --git a/.playwright-mcp/page-2026-04-20T06-21-26-199Z.yml b/.playwright-mcp/page-2026-04-20T06-21-26-199Z.yml new file mode 100644 index 0000000..d3f5067 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-21-26-199Z.yml @@ -0,0 +1,5 @@ +- generic [active] [ref=e1]: + - generic: + - generic: + - generic: + - paragraph: 认证已过期或无效,请重新登录 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-22-01-426Z.yml b/.playwright-mcp/page-2026-04-20T06-22-01-426Z.yml new file mode 100644 index 0000000..1aff64d --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-22-01-426Z.yml @@ -0,0 +1,27 @@ +- generic [ref=e38]: + - generic [ref=e40]: + - img [ref=e43] [cursor=pointer] + - generic [ref=e46]: 登录 + - generic [ref=e50]: + - generic [ref=e51]: + - generic [ref=e53]: E + - generic [ref=e54]: EchoChat + - generic [ref=e55]: 连接无限,沟通无界 + - generic [ref=e56]: + - generic [ref=e57]: 登录 + - generic [ref=e58]: + - generic [ref=e59]: 账号 + - generic [ref=e62]: + - generic: 请输入用户名或邮箱 + - textbox [ref=e63] + - generic [ref=e64]: + - generic [ref=e65]: 密码 + - generic [ref=e66]: + - generic [ref=e68]: + - generic: 请输入密码 + - textbox [ref=e69] + - generic [ref=e70]: 显示 + - generic [ref=e72] [cursor=pointer]: 登录 + - generic [ref=e73]: + - generic [ref=e74]: 还没有账号? + - generic [ref=e75]: 立即注册 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-22-28-909Z.yml b/.playwright-mcp/page-2026-04-20T06-22-28-909Z.yml new file mode 100644 index 0000000..cc30f14 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-22-28-909Z.yml @@ -0,0 +1,11 @@ +- generic [ref=e193]: + - generic [ref=e194]: + - generic [ref=e196]:  + - generic [ref=e198]: 测试用户2 + - generic [ref=e200]:  + - generic [ref=e206]: + - generic [ref=e208]:  + - generic [ref=e211]: + - generic: 输入消息... + - textbox [ref=e212] + - generic [ref=e214]:  \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-24-01-900Z.yml b/.playwright-mcp/page-2026-04-20T06-24-01-900Z.yml new file mode 100644 index 0000000..12cb6af --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-24-01-900Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e193]: + - generic [ref=e194]: + - generic [ref=e196]:  + - generic [ref=e198]: 测试用户2 + - generic [ref=e200]:  + - generic [ref=e218]: + - generic [ref=e219]: + - generic [ref=e222]: 测试消息 1 + - generic [ref=e225]: 新 + - generic [ref=e226]: 未读 + - generic [ref=e206]: + - generic [ref=e208]:  + - generic [ref=e211]: + - generic: 输入消息... + - textbox [ref=e212] + - generic [ref=e228]:  \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-27-28-042Z.yml b/.playwright-mcp/page-2026-04-20T06-27-28-042Z.yml new file mode 100644 index 0000000..14a93c1 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-27-28-042Z.yml @@ -0,0 +1,115 @@ +- generic [ref=e431]: + - generic [ref=e432]: + - generic [ref=e434]:  + - generic [ref=e436]: 测试用户2 + - generic [ref=e438]:  + - generic [ref=e442]: + - generic [ref=e444]: + - generic [ref=e445]: + - generic [ref=e448]: 测试消息 1 + - generic [ref=e451]: 新 + - generic [ref=e452]: 未读 + - generic [ref=e454]: + - generic [ref=e455]: + - generic [ref=e458]: 测试消息 2 - 填充内容 + - generic [ref=e461]: 新 + - generic [ref=e462]: 未读 + - generic [ref=e464]: + - generic [ref=e465]: + - generic [ref=e468]: 测试消息 3 - 填充内容 + - generic [ref=e471]: 新 + - generic [ref=e472]: 未读 + - generic [ref=e474]: + - generic [ref=e475]: + - generic [ref=e478]: 测试消息 4 - 填充内容 + - generic [ref=e481]: 新 + - generic [ref=e482]: 未读 + - generic [ref=e484]: + - generic [ref=e485]: + - generic [ref=e488]: 测试消息 5 - 填充内容 + - generic [ref=e491]: 新 + - generic [ref=e492]: 未读 + - generic [ref=e494]: + - generic [ref=e495]: + - generic [ref=e498]: 测试消息 6 - 填充内容 + - generic [ref=e501]: 新 + - generic [ref=e502]: 未读 + - generic [ref=e504]: + - generic [ref=e505]: + - generic [ref=e508]: 测试消息 7 - 填充内容 + - generic [ref=e511]: 新 + - generic [ref=e512]: 未读 + - generic [ref=e514]: + - generic [ref=e515]: + - generic [ref=e518]: 测试消息 8 - 填充内容 + - generic [ref=e521]: 新 + - generic [ref=e522]: 未读 + - generic [ref=e524]: + - generic [ref=e525]: + - generic [ref=e528]: 测试消息 9 - 填充内容 + - generic [ref=e531]: 新 + - generic [ref=e532]: 未读 + - generic [ref=e534]: + - generic [ref=e535]: + - generic [ref=e538]: 测试消息 10 - 填充内容 + - generic [ref=e541]: 新 + - generic [ref=e542]: 未读 + - generic [ref=e544]: + - generic [ref=e545]: + - generic [ref=e548]: 测试消息 11 - 填充内容 + - generic [ref=e551]: 新 + - generic [ref=e552]: 未读 + - generic [ref=e554]: + - generic [ref=e555]: + - generic [ref=e558]: 测试消息 12 - 填充内容 + - generic [ref=e561]: 新 + - generic [ref=e562]: 未读 + - generic [ref=e564]: + - generic [ref=e565]: + - generic [ref=e568]: 测试消息 13 - 填充内容 + - generic [ref=e571]: 新 + - generic [ref=e572]: 未读 + - generic [ref=e574]: + - generic [ref=e575]: + - generic [ref=e578]: 测试消息 14 - 填充内容 + - generic [ref=e581]: 新 + - generic [ref=e582]: 未读 + - generic [ref=e584]: + - generic [ref=e585]: + - generic [ref=e588]: 测试消息 15 - 填充内容 + - generic [ref=e591]: 新 + - generic [ref=e592]: 未读 + - generic [ref=e594]: + - generic [ref=e595]: + - generic [ref=e598]: 测试消息 16 - 填充内容 + - generic [ref=e601]: 新 + - generic [ref=e602]: 未读 + - generic [ref=e604]: + - generic [ref=e605]: + - generic [ref=e608]: 测试消息 17 - 填充内容 + - generic [ref=e611]: 新 + - generic [ref=e612]: 未读 + - generic [ref=e614]: + - generic [ref=e615]: + - generic [ref=e618]: 测试消息 18 - 填充内容 + - generic [ref=e621]: 新 + - generic [ref=e622]: 未读 + - generic [ref=e624]: + - generic [ref=e625]: + - generic [ref=e628]: 测试消息 19 - 填充内容 + - generic [ref=e631]: 新 + - generic [ref=e632]: 未读 + - generic [ref=e634]: + - generic [ref=e635]: + - generic [ref=e638]: 测试消息 20 - 填充内容 + - generic [ref=e641]: 新 + - generic [ref=e642]: 未读 + - generic [ref=e643]: + - generic [ref=e646]: 测 + - generic [ref=e649]: ✅ 模拟对方消息(1) + - generic [ref=e651]: + - generic [ref=e653]:  + - generic [ref=e656]: + - generic: 输入消息... + - textbox [ref=e657] + - generic [ref=e659]:  \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-30-20-772Z.yml b/.playwright-mcp/page-2026-04-20T06-30-20-772Z.yml new file mode 100644 index 0000000..d6ac93c --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-30-20-772Z.yml @@ -0,0 +1,121 @@ +- generic [ref=e674]: + - generic [ref=e675]: + - generic [ref=e677]:  + - generic [ref=e679]: 测试用户2 + - generic [ref=e681]:  + - generic [ref=e685]: + - generic [ref=e687]: + - generic [ref=e688]: + - generic [ref=e691]: 测试消息 1 + - generic [ref=e694]: 新 + - generic [ref=e695]: 未读 + - generic [ref=e697]: + - generic [ref=e698]: + - generic [ref=e701]: 测试消息 2 - 填充内容 + - generic [ref=e704]: 新 + - generic [ref=e705]: 未读 + - generic [ref=e707]: + - generic [ref=e708]: + - generic [ref=e711]: 测试消息 3 - 填充内容 + - generic [ref=e714]: 新 + - generic [ref=e715]: 未读 + - generic [ref=e717]: + - generic [ref=e718]: + - generic [ref=e721]: 测试消息 4 - 填充内容 + - generic [ref=e724]: 新 + - generic [ref=e725]: 未读 + - generic [ref=e727]: + - generic [ref=e728]: + - generic [ref=e731]: 测试消息 5 - 填充内容 + - generic [ref=e734]: 新 + - generic [ref=e735]: 未读 + - generic [ref=e737]: + - generic [ref=e738]: + - generic [ref=e741]: 测试消息 6 - 填充内容 + - generic [ref=e744]: 新 + - generic [ref=e745]: 未读 + - generic [ref=e747]: + - generic [ref=e748]: + - generic [ref=e751]: 测试消息 7 - 填充内容 + - generic [ref=e754]: 新 + - generic [ref=e755]: 未读 + - generic [ref=e757]: + - generic [ref=e758]: + - generic [ref=e761]: 测试消息 8 - 填充内容 + - generic [ref=e764]: 新 + - generic [ref=e765]: 未读 + - generic [ref=e767]: + - generic [ref=e768]: + - generic [ref=e771]: 测试消息 9 - 填充内容 + - generic [ref=e774]: 新 + - generic [ref=e775]: 未读 + - generic [ref=e777]: + - generic [ref=e778]: + - generic [ref=e781]: 测试消息 10 - 填充内容 + - generic [ref=e784]: 新 + - generic [ref=e785]: 未读 + - generic [ref=e787]: + - generic [ref=e788]: + - generic [ref=e791]: 测试消息 11 - 填充内容 + - generic [ref=e794]: 新 + - generic [ref=e795]: 未读 + - generic [ref=e797]: + - generic [ref=e798]: + - generic [ref=e801]: 测试消息 12 - 填充内容 + - generic [ref=e804]: 新 + - generic [ref=e805]: 未读 + - generic [ref=e807]: + - generic [ref=e808]: + - generic [ref=e811]: 测试消息 13 - 填充内容 + - generic [ref=e814]: 新 + - generic [ref=e815]: 未读 + - generic [ref=e817]: + - generic [ref=e818]: + - generic [ref=e821]: 测试消息 14 - 填充内容 + - generic [ref=e824]: 新 + - generic [ref=e825]: 未读 + - generic [ref=e827]: + - generic [ref=e828]: + - generic [ref=e831]: 测试消息 15 - 填充内容 + - generic [ref=e834]: 新 + - generic [ref=e835]: 未读 + - generic [ref=e837]: + - generic [ref=e838]: + - generic [ref=e841]: 测试消息 16 - 填充内容 + - generic [ref=e844]: 新 + - generic [ref=e845]: 未读 + - generic [ref=e847]: + - generic [ref=e848]: + - generic [ref=e851]: 测试消息 17 - 填充内容 + - generic [ref=e854]: 新 + - generic [ref=e855]: 未读 + - generic [ref=e857]: + - generic [ref=e858]: + - generic [ref=e861]: 测试消息 18 - 填充内容 + - generic [ref=e864]: 新 + - generic [ref=e865]: 未读 + - generic [ref=e867]: + - generic [ref=e868]: + - generic [ref=e871]: 测试消息 19 - 填充内容 + - generic [ref=e874]: 新 + - generic [ref=e875]: 未读 + - generic [ref=e877]: + - generic [ref=e878]: + - generic [ref=e881]: 测试消息 20 - 填充内容 + - generic [ref=e884]: 新 + - generic [ref=e885]: 未读 + - generic [ref=e886]: + - generic [ref=e889]: 测 + - generic [ref=e892]: ✅ 模拟对方消息(1) + - generic [ref=e893]: + - generic [ref=e896]: 测 + - generic [ref=e899]: ⬇️ 模拟对方新消息 A + - generic [ref=e900]: + - generic [ref=e903]: 测 + - generic [ref=e906]: 🔴 在顶部时收到的对方新消息 B + - generic [ref=e908]: + - generic [ref=e910]:  + - generic [ref=e913]: + - generic: 输入消息... + - textbox [ref=e914] + - generic [ref=e916]:  \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-31-05-610Z.yml b/.playwright-mcp/page-2026-04-20T06-31-05-610Z.yml new file mode 100644 index 0000000..d875eb7 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-31-05-610Z.yml @@ -0,0 +1,127 @@ +- generic [ref=e674]: + - generic [ref=e675]: + - generic [ref=e677]:  + - generic [ref=e679]: 测试用户2 + - generic [ref=e681]:  + - generic [ref=e685]: + - generic [ref=e687]: + - generic [ref=e688]: + - generic [ref=e691]: 测试消息 1 + - generic [ref=e694]: 新 + - generic [ref=e695]: 未读 + - generic [ref=e697]: + - generic [ref=e698]: + - generic [ref=e701]: 测试消息 2 - 填充内容 + - generic [ref=e704]: 新 + - generic [ref=e705]: 未读 + - generic [ref=e707]: + - generic [ref=e708]: + - generic [ref=e711]: 测试消息 3 - 填充内容 + - generic [ref=e714]: 新 + - generic [ref=e715]: 未读 + - generic [ref=e717]: + - generic [ref=e718]: + - generic [ref=e721]: 测试消息 4 - 填充内容 + - generic [ref=e724]: 新 + - generic [ref=e725]: 未读 + - generic [ref=e727]: + - generic [ref=e728]: + - generic [ref=e731]: 测试消息 5 - 填充内容 + - generic [ref=e734]: 新 + - generic [ref=e735]: 未读 + - generic [ref=e737]: + - generic [ref=e738]: + - generic [ref=e741]: 测试消息 6 - 填充内容 + - generic [ref=e744]: 新 + - generic [ref=e745]: 未读 + - generic [ref=e747]: + - generic [ref=e748]: + - generic [ref=e751]: 测试消息 7 - 填充内容 + - generic [ref=e754]: 新 + - generic [ref=e755]: 未读 + - generic [ref=e757]: + - generic [ref=e758]: + - generic [ref=e761]: 测试消息 8 - 填充内容 + - generic [ref=e764]: 新 + - generic [ref=e765]: 未读 + - generic [ref=e767]: + - generic [ref=e768]: + - generic [ref=e771]: 测试消息 9 - 填充内容 + - generic [ref=e774]: 新 + - generic [ref=e775]: 未读 + - generic [ref=e777]: + - generic [ref=e778]: + - generic [ref=e781]: 测试消息 10 - 填充内容 + - generic [ref=e784]: 新 + - generic [ref=e785]: 未读 + - generic [ref=e787]: + - generic [ref=e788]: + - generic [ref=e791]: 测试消息 11 - 填充内容 + - generic [ref=e794]: 新 + - generic [ref=e795]: 未读 + - generic [ref=e797]: + - generic [ref=e798]: + - generic [ref=e801]: 测试消息 12 - 填充内容 + - generic [ref=e804]: 新 + - generic [ref=e805]: 未读 + - generic [ref=e807]: + - generic [ref=e808]: + - generic [ref=e811]: 测试消息 13 - 填充内容 + - generic [ref=e814]: 新 + - generic [ref=e815]: 未读 + - generic [ref=e817]: + - generic [ref=e818]: + - generic [ref=e821]: 测试消息 14 - 填充内容 + - generic [ref=e824]: 新 + - generic [ref=e825]: 未读 + - generic [ref=e827]: + - generic [ref=e828]: + - generic [ref=e831]: 测试消息 15 - 填充内容 + - generic [ref=e834]: 新 + - generic [ref=e835]: 未读 + - generic [ref=e837]: + - generic [ref=e838]: + - generic [ref=e841]: 测试消息 16 - 填充内容 + - generic [ref=e844]: 新 + - generic [ref=e845]: 未读 + - generic [ref=e847]: + - generic [ref=e848]: + - generic [ref=e851]: 测试消息 17 - 填充内容 + - generic [ref=e854]: 新 + - generic [ref=e855]: 未读 + - generic [ref=e857]: + - generic [ref=e858]: + - generic [ref=e861]: 测试消息 18 - 填充内容 + - generic [ref=e864]: 新 + - generic [ref=e865]: 未读 + - generic [ref=e867]: + - generic [ref=e868]: + - generic [ref=e871]: 测试消息 19 - 填充内容 + - generic [ref=e874]: 新 + - generic [ref=e875]: 未读 + - generic [ref=e877]: + - generic [ref=e878]: + - generic [ref=e881]: 测试消息 20 - 填充内容 + - generic [ref=e884]: 新 + - generic [ref=e885]: 未读 + - generic [ref=e886]: + - generic [ref=e889]: 测 + - generic [ref=e892]: ✅ 模拟对方消息(1) + - generic [ref=e893]: + - generic [ref=e896]: 测 + - generic [ref=e899]: ⬇️ 模拟对方新消息 A + - generic [ref=e900]: + - generic [ref=e903]: 测 + - generic [ref=e906]: 🔴 在顶部时收到的对方新消息 B + - generic [ref=e917]: + - generic [ref=e920]: 测 + - generic [ref=e923]: "🔴 顶部时对方新消息 #1" + - generic [ref=e924]: + - generic [ref=e925]:  + - generic [ref=e926]: 24 条新消息 + - generic [ref=e908]: + - generic [ref=e910]:  + - generic [ref=e913]: + - generic: 输入消息... + - textbox [ref=e914] + - generic [ref=e916]:  \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-42-12-250Z.yml b/.playwright-mcp/page-2026-04-20T06-42-12-250Z.yml new file mode 100644 index 0000000..02df919 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-42-12-250Z.yml @@ -0,0 +1,27 @@ +- generic [ref=e4]: + - generic [ref=e6]: + - img [ref=e9] [cursor=pointer] + - generic [ref=e12]: 登录 + - generic [ref=e16]: + - generic [ref=e17]: + - generic [ref=e19]: E + - generic [ref=e20]: EchoChat + - generic [ref=e21]: 连接无限,沟通无界 + - generic [ref=e22]: + - generic [ref=e23]: 登录 + - generic [ref=e24]: + - generic [ref=e25]: 账号 + - generic [ref=e28]: + - generic: 请输入用户名或邮箱 + - textbox [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: 密码 + - generic [ref=e32]: + - generic [ref=e34]: + - generic: 请输入密码 + - textbox [ref=e35] + - generic [ref=e36]: 显示 + - generic [ref=e38] [cursor=pointer]: 登录 + - generic [ref=e39]: + - generic [ref=e40]: 还没有账号? + - generic [ref=e41]: 立即注册 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-42-25-415Z.yml b/.playwright-mcp/page-2026-04-20T06-42-25-415Z.yml new file mode 100644 index 0000000..02df919 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-42-25-415Z.yml @@ -0,0 +1,27 @@ +- generic [ref=e4]: + - generic [ref=e6]: + - img [ref=e9] [cursor=pointer] + - generic [ref=e12]: 登录 + - generic [ref=e16]: + - generic [ref=e17]: + - generic [ref=e19]: E + - generic [ref=e20]: EchoChat + - generic [ref=e21]: 连接无限,沟通无界 + - generic [ref=e22]: + - generic [ref=e23]: 登录 + - generic [ref=e24]: + - generic [ref=e25]: 账号 + - generic [ref=e28]: + - generic: 请输入用户名或邮箱 + - textbox [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: 密码 + - generic [ref=e32]: + - generic [ref=e34]: + - generic: 请输入密码 + - textbox [ref=e35] + - generic [ref=e36]: 显示 + - generic [ref=e38] [cursor=pointer]: 登录 + - generic [ref=e39]: + - generic [ref=e40]: 还没有账号? + - generic [ref=e41]: 立即注册 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-43-00-447Z.yml b/.playwright-mcp/page-2026-04-20T06-43-00-447Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-04-20T06-52-47-027Z.yml b/.playwright-mcp/page-2026-04-20T06-52-47-027Z.yml new file mode 100644 index 0000000..f36fd42 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-52-47-027Z.yml @@ -0,0 +1,17 @@ +- generic [active] [ref=e1]: + - generic [ref=e7]: + - generic [ref=e8]: + - generic [ref=e10]:  + - generic [ref=e12]: 聊天 + - generic [ref=e14]:  + - generic [ref=e20]: 加载更多 + - generic [ref=e22]: + - generic [ref=e24]:  + - generic [ref=e27]: + - generic: 输入消息... + - textbox [ref=e28] + - generic [ref=e30]:  + - generic: + - generic: + - generic: + - paragraph: 缺少认证信息 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T06-57-56-534Z.yml b/.playwright-mcp/page-2026-04-20T06-57-56-534Z.yml new file mode 100644 index 0000000..7c616ab --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T06-57-56-534Z.yml @@ -0,0 +1,3 @@ +- generic [ref=e6]: + - img [ref=e9] [cursor=pointer] + - generic [ref=e12]: 登录 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T07-17-51-287Z.yml b/.playwright-mcp/page-2026-04-20T07-17-51-287Z.yml new file mode 100644 index 0000000..7bc605c --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T07-17-51-287Z.yml @@ -0,0 +1,32 @@ +- generic [active] [ref=e1]: + - generic [ref=e4]: + - generic [ref=e6]: + - img [ref=e9] [cursor=pointer] + - generic [ref=e12]: 登录 + - generic [ref=e16]: + - generic [ref=e17]: + - generic [ref=e19]: E + - generic [ref=e20]: EchoChat + - generic [ref=e21]: 连接无限,沟通无界 + - generic [ref=e22]: + - generic [ref=e23]: 登录 + - generic [ref=e24]: + - generic [ref=e25]: 账号 + - generic [ref=e28]: + - generic: 请输入用户名或邮箱 + - textbox [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: 密码 + - generic [ref=e32]: + - generic [ref=e34]: + - generic: 请输入密码 + - textbox [ref=e35] + - generic [ref=e36]: 显示 + - generic [ref=e38] [cursor=pointer]: 登录 + - generic [ref=e39]: + - generic [ref=e40]: 还没有账号? + - generic [ref=e41]: 立即注册 + - generic: + - generic: + - generic: + - paragraph: 认证已失效,请重新登录 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T07-32-23-107Z.yml b/.playwright-mcp/page-2026-04-20T07-32-23-107Z.yml new file mode 100644 index 0000000..02df919 --- /dev/null +++ b/.playwright-mcp/page-2026-04-20T07-32-23-107Z.yml @@ -0,0 +1,27 @@ +- generic [ref=e4]: + - generic [ref=e6]: + - img [ref=e9] [cursor=pointer] + - generic [ref=e12]: 登录 + - generic [ref=e16]: + - generic [ref=e17]: + - generic [ref=e19]: E + - generic [ref=e20]: EchoChat + - generic [ref=e21]: 连接无限,沟通无界 + - generic [ref=e22]: + - generic [ref=e23]: 登录 + - generic [ref=e24]: + - generic [ref=e25]: 账号 + - generic [ref=e28]: + - generic: 请输入用户名或邮箱 + - textbox [ref=e29] + - generic [ref=e30]: + - generic [ref=e31]: 密码 + - generic [ref=e32]: + - generic [ref=e34]: + - generic: 请输入密码 + - textbox [ref=e35] + - generic [ref=e36]: 显示 + - generic [ref=e38] [cursor=pointer]: 登录 + - generic [ref=e39]: + - generic [ref=e40]: 还没有账号? + - generic [ref=e41]: 立即注册 \ No newline at end of file diff --git a/.playwright-mcp/page-2026-04-20T07-33-25-132Z.yml b/.playwright-mcp/page-2026-04-20T07-33-25-132Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/.playwright-mcp/page-2026-04-20T07-35-17-482Z.yml b/.playwright-mcp/page-2026-04-20T07-35-17-482Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/backend/go-service/app/dto/im_dto.go b/backend/go-service/app/dto/im_dto.go index 448c427..f6a17c8 100644 --- a/backend/go-service/app/dto/im_dto.go +++ b/backend/go-service/app/dto/im_dto.go @@ -96,9 +96,17 @@ type HistoryMessageRequest struct { } // HistoryMessageResponse 历史消息响应 +// +// 已读状态回填设计(2026-04-20): +// - PeerLastReadMsgID:单聊时填"对方 last_read_msg_id",用于前端在页面刷新后恢复 readStatusMap, +// 避免已读标签变未读的体验退化;群聊时为 0 +// - ReadCountMap:群聊时批量返回"自己发送消息的 read_count",前端合并到 groupReadCountMap; +// 单聊时为 nil(JSON omitempty 省略,节省带宽) type HistoryMessageResponse struct { - List []MessageDTO `json:"list"` // 消息列表(ID 降序) - HasMore bool `json:"has_more"` // 是否还有更多历史消息 + List []MessageDTO `json:"list"` // 消息列表(ID 降序) + HasMore bool `json:"has_more"` // 是否还有更多历史消息 + PeerLastReadMsgID int64 `json:"peer_last_read_msg_id"` // 单聊:对方已读位置(群聊=0) + ReadCountMap map[int64]int `json:"read_count_map,omitempty"` // 群聊:{messageID: readCount}(仅自己发的消息) } // ====== 已读标记 DTO ====== diff --git a/backend/go-service/app/file/service/file_service.go b/backend/go-service/app/file/service/file_service.go index 636eb44..bb372d5 100644 --- a/backend/go-service/app/file/service/file_service.go +++ b/backend/go-service/app/file/service/file_service.go @@ -29,7 +29,7 @@ var ( ErrUploadFailed = errors.New("文件上传失败") ErrFileTooLarge = errors.New("文件大小超出限制") ErrInvalidImage = errors.New("无效的图片文件") - ErrInvalidVoice = errors.New("不支持的语音格式,仅支持 mp3/wav/aac/m4a") + ErrInvalidVoice = errors.New("不支持的语音格式,仅支持 mp3/wav/aac/m4a/webm/ogg") ErrVoiceTooLong = errors.New("语音时长不能超过 60 秒") ErrThumbnailGen = errors.New("缩略图生成失败") ) @@ -76,11 +76,14 @@ type VoiceUploadResult struct { } // allowedVoiceExts 允许的语音文件扩展名 +// .webm / .ogg 用于兼容 H5 端浏览器 MediaRecorder 输出(uni-app H5 端不支持 getRecorderManager) var allowedVoiceExts = map[string]bool{ - ".mp3": true, - ".wav": true, - ".aac": true, - ".m4a": true, + ".mp3": true, + ".wav": true, + ".aac": true, + ".m4a": true, + ".webm": true, + ".ogg": true, } // Upload 上传文件到 MinIO,返回文件访问 URL diff --git a/backend/go-service/app/im/service/im_service.go b/backend/go-service/app/im/service/im_service.go index 279338d..d78416f 100644 --- a/backend/go-service/app/im/service/im_service.go +++ b/backend/go-service/app/im/service/im_service.go @@ -537,7 +537,44 @@ func (s *IMService) GetHistoryMessages(ctx context.Context, userID int64, req *d list = append(list, *s.toMessageDTO(&m)) } - return &dto.HistoryMessageResponse{List: list, HasMore: hasMore}, nil + resp := &dto.HistoryMessageResponse{List: list, HasMore: hasMore} + + // ========== 已读状态回填(修复页面刷新后已读标签变未读的 Bug) ========== + // 获取会话类型以决定回填策略 + conv, convErr := s.convDAO.GetByID(ctx, req.ConversationID) + if convErr != nil || conv == nil { + // 拉不到会话元信息时静默跳过已读回填,消息仍正常返回(向前兼容) + return resp, nil + } + + switch conv.Type { + case constants.ConversationTypePrivate: + // 单聊:查询对方成员的 last_read_msg_id + peerID, pErr := s.convDAO.GetPeerUserID(ctx, req.ConversationID, userID) + if pErr == nil && peerID > 0 { + peerMember, pmErr := s.convDAO.GetMember(ctx, req.ConversationID, peerID) + if pmErr == nil && peerMember != nil { + resp.PeerLastReadMsgID = peerMember.LastReadMsgID + } + } + case constants.ConversationTypeGroup: + // 群聊:批量查询"本次返回消息中自己发送的那些"的 read_count + // 仅查自己发的,因为前端仅在 isSelf=true 时展示 "N人已读" + selfMsgIDs := make([]int64, 0, len(messages)) + for i := range messages { + if messages[i].SenderID == userID && messages[i].ID > 0 { + selfMsgIDs = append(selfMsgIDs, messages[i].ID) + } + } + if len(selfMsgIDs) > 0 && s.readRecorder != nil { + countMap, rcErr := s.readRecorder.GetReadCountBatch(ctx, selfMsgIDs) + if rcErr == nil && len(countMap) > 0 { + resp.ReadCountMap = countMap + } + } + } + + return resp, nil } // MarkRead 标记会话已读(清零未读 + 更新 Redis 总未读数 + 推送已读 ACK 给对方) diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 1e7c68c..e5a34e9 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,14 +1,165 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-04-20(Phase 2d 富媒体消息缺陷修复,Playwright 全流程验证通过) -> **当前阶段**:Phase 2d 全部完成 + Bug 修复 4 项(图片上传端口/MinIO 策略/MsgImage 布局/文件名保留) -> **当前分支**:`feature/phase2d-message-types` +> **最后更新**:2026-04-20(Phase 2d 完成 + UX 优化 + 已读状态刷新持久化) +> **当前阶段**:Phase 2d 全部完成 + Bug 修复 7 项 + UX 优化 2 项 +> **当前分支**:`feature/phase2c-group-read-receipt`(注:Phase 2d 工作误用了 2c 分支,下一阶段 2e 直接新建分支开发) > **实施计划**:`docs/plans/2026-03-04-phase2d-implementation.plan.md` > **设计文档**:`docs/plans/2026-03-04-phase2d-design.md` > **本次修复报告**:`test-report-phase2d-bugfix.md` --- +## 🐛 2026-04-20 Bug 修复:已读状态刷新后丢失(变回"未读") + +**问题**:单聊/群聊页面中大量消息显示"已读"的情况下,刷新浏览器 → 所有已读标签全部变为"未读" / "N人已读"消失,直到对方再次触发读消息才恢复 —— 严重影响真实性与信任度。 + +**根因(单句)**:前端 `readStatusMap`(对方已读位置)和 `groupReadCountMap`(群聊已读计数)仅由 WebSocket 事件 `im.message.read.ack / im.message.read.count` 填充,从未在页面加载时从后端拉取,刷新后 state 归零且无 API 补回 → `isRead()` 判定 `msg.id <= 0` 恒为 false。后端数据库 `im_conversation_members.last_read_msg_id` 与 `im_message_reads` 数据其实都存在,只是未暴露给前端初始化。 + +**修复方案(前后端联动,不新增 API,仅扩展 `HistoryMessageResponse`)**: + +| 端 | 改动 | +|---|---| +| 后端 DTO | `HistoryMessageResponse` 新增 `peer_last_read_msg_id int64`(单聊)、`read_count_map map[int64]int`(群聊 omitempty) | +| 后端 Service | `GetHistoryMessages` 按会话类型分支:type=1 → `GetPeerUserID` + `GetMember` 拿对方 `last_read_msg_id`;type=2 → 过滤自己发送的消息 ID,调 `readRecorder.GetReadCountBatch` | +| 前端 Store | `loadHistoryMessages` 仅在「首次加载(messages.length===0)」时回填,避免后续"加载更多"时用历史数据覆盖 WS 增量的最新态 | + +**涉及文件**: +- `backend/go-service/app/dto/im_dto.go`:DTO 扩展 +- `backend/go-service/app/im/service/im_service.go`:GetHistoryMessages 回填逻辑 +- `frontend/src/store/chat.js`:loadHistoryMessages 消费新字段 + +**Playwright 端到端验证**: +1. 数据库事实:会话 6 中 `bojinyuan(7)` last_read_msg_id=202(已读到 id=202) +2. duanlingyun 登录 → API 返回 `peer_last_read_msg_id=202` ✓ +3. 会话页首屏:21 条自己发送的消息 → 20 条"已读" + 1 条新插入的 id=203"未读" ✓(与期望完全吻合) +4. 群聊验证:bojinyuan 登录 → 群 8 API 返回 `read_count_map={94:1, 95:1, ..., 139:2, 140:2}`(共 13 条自己发的消息)→ 页面正确显示 11 个"1人已读" + 2 个"2人已读" ✓ +5. 视觉回归:同一截图中语音"未听红点"仍正常工作(表明两项 UX 优化无互相干扰) + +**设计权衡**: +- 为什么不新增独立 API:避免每次进入会话多一次往返请求 +- 为什么仅首次加载回填:后续 WS 事件已能增量更新,用历史数据覆盖反而可能倒退 +- 为什么群聊只查"自己发的消息":前端仅在 `isSelf=true` 时展示"N人已读",避免无用 DB 查询 + +--- + +## 🎙️ 2026-04-20 UX 优化:语音消息「未听红点」(仿微信) + +**需求**:通用"已读"状态基于滚动可见性,适合文字/图片(肉眼可直接阅读);但**语音必须点击播放才算"听过"**,仅"已读"不能反映用户是否真的听过。参照微信设计,为「对方发来的语音」叠加独立的"未播放"视觉提示(红点)。 + +**设计决策(均为推荐方案)**: +| 维度 | 取舍 | +|---|---| +| 作用范围 | 仅「对方发来的」语音显示红点(自己发的无此语义) | +| 持久化 | 本地 localStorage(按 userId 隔离) —— 不同步后端,属于私人视图态 | +| 触发时机 | 用户点击播放即刻清除红点(不要求播放完整) | +| 作用层级 | 仅聊天详情页;会话列表不处理 | + +**核心实现**: +- `chat store` 新增 `voicePlayedMap: {[msgId]: true}` + `markVoicePlayed / isVoicePlayed / loadVoicePlayedState / resetVoicePlayedState` 4 个 API +- localStorage key:`echo:voice-played:{userId}`,完整 JSON 覆盖写入 +- `MsgVoice.vue` 自主消费 store(父级无需改动,单聊/群聊同构生效) + - 模板:``(10rpx 红圆,紧贴气泡右侧) + - `showUnplayedDot = computed(() => !isSelf && msg.id && !chatStore.isVoicePlayed(msg.id))` + - `onTogglePlay` 首行:`if (!isSelf && msg.id) chatStore.markVoicePlayed(msg.id)` +- `user.store.logout()`:动态导入 chat store 调用 `resetVoicePlayedState()` 防止串用户 +- `chat.store.initWsListeners()`:按当前 `userStore.userInfo.id` 懒加载对应缓存 + +**涉及文件**: +- `frontend/src/store/chat.js`:新增 voicePlayed 状态 + 4 个方法 +- `frontend/src/components/msg/MsgVoice.vue`:模板加红点 + 消费 store +- `frontend/src/store/user.js`:logout 清理运行时 state + +**Playwright 验证**: +1. duanlingyun(id=13) 登录 → 会话 6(内含自己 3 条语音 + 对方 5 条语音) +2. 初始:8 条语音 → 5 个红点(全部对方发) + 0 个红点(自己 3 条 `voice-self`)✓ +3. 点击任意两条对方语音 → 红点数 5→4→3 ✓ +4. localStorage 写入 `echo:voice-played:13 = {195:true, 196:true}` ✓ +5. 页面刷新 → 仍只剩 3 个红点(持久化生效)✓ + +--- + +## 💡 2026-04-20 UX 优化:聊天页「新消息悬浮提示 + 按需已读」 + +**问题**:用户停留在聊天页旧消息位置时,对方发来新消息 → 页面不会自动滚动到底 → 但 store 层一律自动 `markRead` → 对方看到"已读"但用户实际没看到消息,产生体验错位。 + +**方案(混合策略,业内通用)**: +- 贴底(距底 < 150px)时:新消息自动滚动到底部 + 标记已读(保留原有体验) +- 远离底部时:不滚动、不标记已读,右下角显示「↓ N 条新消息」悬浮胶囊按钮 +- 点击悬浮按钮:滚到底 + 清零计数 + 标记已读 +- 用户手动滚回底部:自动隐藏悬浮 + 标记已读 + +**技术实现(`maxScrollTop` 追踪策略)**: +- `@scroll` 事件中维护"历史最大 scrollTop",`maxScrollTop - scrollTop < 150` 判断是否贴底 +- 不依赖 `@scrolltolower` 作为唯一权威信号,对 `scroll-into-view` + 动态 scrollHeight 都正确响应 +- Store 层移除 `_onNewMessage` 中对当前会话的自动 `markRead`,把决策权交给页面 + +**涉及文件**: +- `frontend/src/store/chat.js`:移除新消息到达时的自动 `markRead` +- `frontend/src/pages/chat/conversation.vue`:单聊滚动感知 + 悬浮按钮 +- `frontend/src/pages/group/conversation.vue`:群聊同构改造 + +**验证结果**:Playwright 全流程通过三种场景(贴底自动滚、远离底部显示悬浮、点击悬浮滚到底清零)。 + +### 🐛 UX 优化后续修复(2026-04-20):watch 逻辑误把"加载历史消息"计入 newMsgCount + +**问题**:用户在页面中段滚动时触发「加载更多」→ 从数组**头部**插入 20 条历史消息 → `messages.value.length` 变化 → watch 误以为是"对方的新消息"并 `newMsgCount += 20`,悬浮按钮显示"40+ 条新消息"。 + +**根因**:watch 回调只看 `messages.length` 增量(delta),未区分"头部插入历史"与"尾部追加新消息"。读取 `messages[length-1]` 拿到的是已存在的最后一条消息,若恰好是对方发的即通过 fromOther 判断并累加。 + +**修复策略**:监听「末尾消息标识(id / client_msg_id)」是否变化,而非仅看长度: +- 头部插入历史消息 → tail 未变 → 直接返回,不做任何处理 +- 尾部追加一条新消息 → tail 变化 → 才进入"fromSelf/fromOther/nearBottom"判断分支 +- 自己发的消息 tail 也会变但会被 `fromSelf` 拦下 + +**真实双账号验证(duanlingyun ↔ bojinyuan,会话 id=6)**: + +| 操作 | messages.length | scrollTop | scrollHeight | 悬浮按钮 | +|---|---|---|---|---| +| 初始贴底 | 20 | 1419 | 2066 | 无 | +| 滚至顶部触发加载更多 ×3 | 76 | 0 | 4823 | **无** ✅ | +| 对方发 1 条 | 77 | 1003 | 4877 | "1 条新消息" ✅ | +| 对方再发 2 条 | 79 | 1003 | 4877 | "3 条新消息" ✅ | +| 再次加载更多历史 | 79+10 | 0 | 4986 | **仍为 "3 条新消息"** ✅ | +| 点击悬浮 | 79+10 | 4339 (贴底) | 4986 | 消失 ✅ | + +**涉及文件**: +- `frontend/src/pages/chat/conversation.vue`:`lastMsgCount` → `lastTailKey` 改造 +- `frontend/src/pages/group/conversation.vue`:相同改造(群聊) + +### 🐛 语音消息 H5 兼容修复(2026-04-20) + +**问题链**(按排查顺序): +1. **事件层**:PC Chrome 按住"按住说话"按钮无反应 —— 只绑定了 `@touchstart/move/end`,现代浏览器鼠标不合成 touch 事件 +2. **录音 API 层**:修复事件绑定后报 `method 'uni.getRecorderManager' not supported` —— uni-app H5 端不支持原生 `getRecorderManager` +3. **上传层**:即便录到 Blob,`uni.uploadFile(blob:URL)` 推断出的 filename 无扩展名,被后端白名单拦下 +4. **后端校验层**:`allowedVoiceExts` 只允许 `.mp3/.wav/.aac/.m4a`,不接受 H5 MediaRecorder 输出的 webm/ogg + +**修复链**: +| 层 | 改动 | +|---|---| +| 事件 | VoiceRecorder 同时监听 touch + mouse 事件,`pressing` 重入守卫,`@mouseleave` 兜底取消 | +| 录音 | 新增 `H5Recorder` 类,基于 `MediaRecorder` + `getUserMedia` 实现 uni 录音接口(`onStart/onStop/onError/start/stop`),`createRecorder()` 工厂按平台自动选择 | +| 上传 | `uploadVoice` 增加 blob 参数分支,H5 走 `fetch` + `FormData` 精确控制 filename(基于 mimeType 推断扩展名,如 `voice-{ts}.webm`)| +| 后端 | `allowedVoiceExts` 新增 `.webm`、`.ogg`,错误消息同步更新 | + +**验证结果(Playwright 自动化)**: + +| 阶段 | class | 按钮文字 | 遮罩 | 计时 | +|---|---|---|---|---| +| 按压中 (4s) | `record-btn recording` | "松开发送" | ✓ 显示 | "3\"" | +| 按压中 (5.5s) | 同上 | 同上 | ✓ | "5\"" (跳动正常)| +| 松开后 | `record-btn` | "按住 说话" | 消失 | — | + +数据库验证:连续 3 次录音上传成功(1/2/5 秒,size 18K/35K/75K),`im_messages` type=3(语音)记录正确写入,MinIO 中 `.webm` 文件可访问。 + +**涉及文件**: +- `frontend/src/components/chat/VoiceRecorder.vue`:核心改造(事件兼容 + H5Recorder 类) +- `frontend/src/api/file.js`:`uploadVoice` 增加 blob 分支,新增 `_doUploadBlob` 私有方法 +- `frontend/src/pages/chat/conversation.vue` & `frontend/src/pages/group/conversation.vue`:`onVoiceRecorded` 按 mimeType 生成 fileName 并传 blob +- `backend/go-service/app/file/service/file_service.go`:`allowedVoiceExts` 扩展 + 错误消息更新 + +--- + ## 🔧 2026-04-20 Bug 修复清单 | # | 根因 | 修复 | 文件 | diff --git a/frontend/src/api/file.js b/frontend/src/api/file.js index cde07c8..54a9525 100644 --- a/frontend/src/api/file.js +++ b/frontend/src/api/file.js @@ -34,12 +34,45 @@ export function uploadImage(filePath, fileName) { * @param {string} filePath 本地语音文件临时路径 * @param {number} duration 语音时长(秒) * @param {string} [fileName] 文件名 + * @param {Blob} [blob] 可选:H5 端 MediaRecorder 产出的原始 Blob。传入时走 fetch+FormData + * 路径,能精确控制 filename/mimeType(`uni.uploadFile` 对 blob: URL 推断的 + * filename 通常缺扩展名,会被后端白名单拦下) * @returns {Promise<{ url, file_name, size, duration, mime_type }>} */ -export function uploadVoice(filePath, duration, fileName) { +export function uploadVoice(filePath, duration, fileName, blob) { + if (blob && typeof window !== 'undefined' && typeof FormData !== 'undefined') { + return _doUploadBlob('/api/v1/upload/voice', blob, fileName || 'voice.webm', { + duration: String(duration) + }) + } return _doUpload('/api/v1/upload/voice', filePath, fileName, { duration: String(duration) }) } +/** + * H5 专用:直接用 Blob 上传(避免 uni.uploadFile 对 blob: URL 的 filename 不可控) + */ +function _doUploadBlob(apiPath, blob, fileName, extraFormData = {}) { + const userStore = useUserStore() + const token = userStore.token + const fd = new FormData() + fd.append('file', blob, fileName) + Object.entries(extraFormData).forEach(([k, v]) => fd.append(k, v)) + return fetch(`${BASE_URL}${apiPath}`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd + }).then(async (resp) => { + let result + try { + result = await resp.json() + } catch (_) { + throw new Error(`上传失败(${resp.status})`) + } + if (result.code === 0) return result.data + throw new Error(result.message || '上传失败') + }) +} + /** * 内部上传方法 */ diff --git a/frontend/src/components/chat/VoiceRecorder.vue b/frontend/src/components/chat/VoiceRecorder.vue index 3378e96..3bbd1f5 100644 --- a/frontend/src/components/chat/VoiceRecorder.vue +++ b/frontend/src/components/chat/VoiceRecorder.vue @@ -1,16 +1,24 @@