From 5989e0d4ebacecea554b5d65f6b7e17c0ef23734 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 21:59:39 +0800 Subject: [PATCH] =?UTF-8?q?W74=20=E6=92=A4=E6=8E=89=E3=80=8C=E5=AE=B9?= =?UTF-8?q?=E5=BF=8D=20+=20=E4=B8=8B=E6=AC=A1=E6=8B=89=E5=8F=96=E3=80=8D?= =?UTF-8?q?=E5=85=9C=E5=BA=95=20=E2=80=94=E2=80=94=20voice=20=E9=94=AE?= =?UTF-8?q?=E5=AD=98=E5=9C=A8=E5=8D=B3=E8=AF=AD=E9=9F=B3=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=85=A8=E9=83=A8=E8=AF=BB=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端回执三十八 §二:M44(合并号 fb55769,契约 v0.97)把落库顺序改成 INSERT → 写 voice 列 → notify,于是语音消息的**第一条** message_persisted / channel_persisted 帧就带 voice,握手 history 回放也带(与 REST 读面同一收口点 attachVoiceTo → historyVoiceOf)。据此撤掉 W73 时期的「容忍 + 下次拉取」兜底。 ## 撤掉的到底是什么(调查结论,不是运行时改动) 全仓 grep 过「容忍 / tolerat / 下次拉取 / refetch / 自动升级 / 四个 REST / 覆盖率」, **没有任何运行时的兜底分支**:`domain.chat.isVoiceMessage` 从 W73 起就一直只是 `message.voice != null`,没有重试、轮询、延迟判定或「先按文本显示」的标记位。 兜底的全部实体是三处**口径**(两处 KDoc + 一条测试注释),本次逐条改正: 1. MessageDto.kt 类头:「[voice] 是『一处覆盖三条路』的例外,只在四个 REST 读取面填充, 实时帧与握手回放缺席」→ v0.97 起**不再是例外**,四条读面都填、第一条帧就带。 2. MessageDto.kt 的 voice 字段 KDoc:删掉「覆盖率(v0.79):四个 REST 读取面才有 / 实时帧没有 / 不要在实时帧上等 voice / 等下一次拉取自动升级成语音气泡 / 这是设计不是缺陷」, 换成 v0.97 口径(全部读面都带、第一条帧就带),并把回执点名的两条实现前提写成护栏: ① voice 列写入必须先于 notify;② 帧内 voice 是发出那一刻的即时列读。 任一被改回「先 notify 再写列」,本端判据立刻退回 v0.79 行为——而本端刻意不再留兼容分支。 「列被外部改坏 ⇒ 字段缺席,与『不是语音』同形」那段仍然成立,原样保留。 3. MessageMergeTest.kt 用例 `a later copy carrying voice wins over the earlier copy` (原 `a later page carrying voice upgrades …`):断言一字未改,只重写注释理由—— 按 id 合并「新到的那份胜出」照旧必需(同一条消息会从落库帧 / 握手回放 / 分页 / 编辑帧 多路到达),但它不再是「语音升级的唯一通路」。 ## 新增的钉子 WsEventPersistedVoiceTest(5 个用例)钉住 v0.97 的正面事实:message_persisted 载荷里 voice 四键齐全时读得回来且 isVoiceMessage 为真;channel_persisted 同样;平台自带转写 (四键全缺席)仍是语音但 voiceAudio 为 null(不画播放控件);voice 键缺席 ⇒ null ⇒ 不是语音(判据只有一个方向);message_edited 帧不许把语音气泡打回纯文本。 这条测试的意义:PersistedData 只解 message,生产 Json 是 ignoreUnknownKeys = true ⇒ 未来谁把 voice 从落库帧上摘掉不会报错,只会静默退回纯文本。 ## 验证 ./gradlew.bat assembleDebug → BUILD SUCCESSFUL ./gradlew.bat testDebugUnitTest → 134 suites / 1675 tests,0 failures / 0 errors / 0 skipped (基线 133 / 1670;+1 suite +5 tests 全部来自新增的 WsEventPersistedVoiceTest) --- .../yeij/cyrene/data/api/dto/MessageDto.kt | 28 +++-- .../data/repository/MessageMergeTest.kt | 13 +- .../data/ws/WsEventPersistedVoiceTest.kt | 115 ++++++++++++++++++ 3 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt index 08b495b..21b480c 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt @@ -9,8 +9,9 @@ import kotlinx.serialization.Serializable * 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型 * (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。 * - * ⚠️ [voice] 是这条「一处覆盖三条路」的**例外**:它只在四个 REST 读取面填充,实时帧与握手回放 - * 上缺席(理由写在 [voice] 的 KDoc 里)。 + * [voice] 从契约 v0.97(后端 M44 `fb55769`)起也**不再是例外**:REST 四路、握手 `history` 回放、 + * `message_persisted` / `channel_persisted` 落库帧**都填它**,第一条帧就带 + * (前提与理由写在 [voice] 的 KDoc 里)。 */ @Serializable data class HistoryMessageDto( @@ -64,18 +65,23 @@ data class HistoryMessageDto( val turnId: String? = null, /** * 这条消息的语音元数据(契约 v0.67 登记、**v0.79 已实现**,形状见 [VoiceDto]); - * **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事,判据只有一个:这个键在不在。 + * **非语音消息整键缺席** —— 所以缺席与 `null` 是同一件事,判据只有一个:这个键在不在 + * (落在一处:`domain.chat.isVoiceMessage`)。 * - * ## ⚠️ 覆盖率(契约 v0.79 如实):四个 REST 读取面才有,实时帧没有 - * **有** `voice` 的:`GET /sessions/{id}/messages`、`GET /admin/channels/messages`、 + * ## 覆盖率(契约 v0.97):**全部读面都带**,第一条帧就带 + * `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、 * `GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的响应与 - * `message_edited` 广播帧。 + * `message_edited` 广播帧,**外加** WS 握手 `history` 回放与 `message_persisted` / + * `channel_persisted` 落库帧——后三处由后端 M44(`fb55769`)修好:落库顺序改为 + * **INSERT → 写 `voice` 列 → notify**,于是语音消息的**第一条**落库帧就带 `voice` + * (v0.79 时期「落库那一刻还拿不到、等下一次拉取自动升级」的说法**已作废**)。 * - * **没有** `voice` 的:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放 - * ——入站语音的 `voice` 列是**落库之后**回填的,而落库帧在**落库那一刻**发出,天生拿不到 - * 这个值。所以**不要在实时帧上等 `voice`**(本端也刻意不为它加任何重试 / 轮询):实时到达的 - * 语音消息先按普通文本显示(转写文本本来就在 [content] 里),**等下一次拉取自动升级**成语音气泡。 - * 这是设计,不是缺陷。 + * 所以本端**没有任何**「实时帧先按普通文本显示、稍后升级成语音气泡」的兜底: + * `voice` 在就判成语音消息,不在就不是。**不要为它加重试 / 轮询 / 延迟判定**。 + * + * ⚠️ 两条实现前提(服务端口径,未来改动**别破坏它**):① `voice` 列写入必须先于 notify; + * ② 帧内 `voice` 是**发出那一刻的即时列读**(每帧多一次查询,亚毫秒)。任一被改回 + * 「先 notify 再写列」,本端判据立刻退回 v0.79 的行为——而本端**刻意不再为它留兼容分支**。 * * ⚠️ 列被外部改坏(非法 JSON / 越出闭集)时服务端**让字段缺席**(记一条不带正文的 WARN), * 于是这种情况在本端与「不是语音」同形——契约的 `voice` 没有「读不出」这一态,缺席是唯一 diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt index c9b5d3e..7793ea1 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/MessageMergeTest.kt @@ -88,12 +88,13 @@ class MessageMergeTest { } @Test - fun `a later page carrying voice upgrades the earlier copy of the same message`() { - // 契约 v0.79(api-client §4.4 覆盖率):`voice` **只在四个 REST 读取面**填充, - // `message_persisted` / 握手 `history` 回放**不带**它(入站语音的 voice 列是落库之后回填的)。 - // ⇒ 设计上「实时到达的语音先按普通文本显示,等下一次拉取自动升级成语音气泡」, - // 而这条设计**完全依赖**下面这条既有规则:按 id 合并时**新到的那一份胜出**。 - // 这条用例存在的意义是:谁哪天把「incoming 胜出」改成「保留旧的」,语音气泡就永远不会出现。 + fun `a later copy carrying voice wins over the earlier copy of the same message`() { + // 同一条消息会从**多路**到本端:落库帧、握手 `history` 回放、REST 分页、编辑帧。 + // 按 id 合并时「新到的那一份胜出」是这几路唯一的收口规则——不然后到的那份白到。 + // 契约 v0.97 之后 `voice` **每一条路都带**(后端 M44 `fb55769`:落库顺序 INSERT → + // 写 voice 列 → notify),所以这条规则**不再是**「语音升级的唯一通路」; + // 但它照旧必需:谁哪天把「incoming 胜出」改成「保留旧的」,编辑过的正文与任何后补字段 + // 都会永远不生效。 val withoutVoice = msg("m1", 100, content = "明天几点出发") val withVoice = withoutVoice.copy( voice = VoiceDto( diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt new file mode 100644 index 0000000..756e30b --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventPersistedVoiceTest.kt @@ -0,0 +1,115 @@ +package top.yeij.cyrene.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.domain.chat.isVoiceMessage +import top.yeij.cyrene.domain.chat.voiceAudio + +/** + * 落库帧上的 `voice`(契约 **v0.97**,后端 M44 `fb55769`)。 + * + * ## 这条钉子钉的是「撤掉兜底」之后唯一剩下的判据 + * W73 时期的旧口径是「`voice` 只在四个 REST 读取面填充,实时帧与握手回放缺席 ⇒ 实时到达的语音 + * 先按普通文本显示,等下一次拉取自动升级」。后端把落库顺序改成 **INSERT → 写 `voice` 列 → + * notify** 之后,语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`, + * 于是那条兜底**整条撤掉**,判据只剩一条:`voice` 键存在 = 语音消息(`isVoiceMessage`)。 + * + * 钉在这里的三件事,任何一件被将来的改动破坏都会让语音消息静默退回纯文本: + * 1. `message_persisted` 载荷里的 `voice` **真的被解出来**(`PersistedData` 只解 `message`, + * 而生产 `Json` 是 `ignoreUnknownKeys = true` ⇒ 漏解不报错,只是判据永远为假); + * 2. `channel_persisted` 同样——两条帧共用一个载荷类型; + * 3. `voice` 键**缺席**仍是「不是语音」(判据只有一个,不为「实时帧可能不带」留任何分支)。 + * + * 帧形状照服务端实现:`data:{message:{…}}`(`channel_persisted` 同形;与 `message_edited` + * 那条「`data` 就是消息本身」**不同**)。 + */ +class WsEventPersistedVoiceTest { + + private fun frame(type: String, data: String) = """{"type":"$type","sessionId":"web","data":$data}""" + + private val copyName = "a1b2c3d4e5f60718293a4b5c6d7e8f90.ogg" + + /** 一条真实的入站语音行:转写文本在 `content` 里,`voice` 四键齐全(有音频副本)。 */ + private val voiceRow = """ + {"id":"1371","sessionId":"web","role":"user","content":"明天几点出发", + "voice":{"source":"asr","status":"transcribed","name":"$copyName","mime":"audio/ogg", + "size":12345,"durationMs":3200,"url":"/api/v1/media/$copyName"}, + "createdAt":1791088571785} + """.trimIndent().replace("\n", " ") + + /** 微信那条真实的无副本形状:平台自带转写 ⇒ 只有 🎤 标记,四个音频键全缺席。 */ + private val noCopyRow = """ + {"id":"1372","sessionId":"web","role":"user","content":"晚上回来吃饭吗", + "voice":{"source":"platform","status":"transcribed"}, + "createdAt":1791088571786} + """.trimIndent().replace("\n", " ") + + private val plainRow = """ + {"id":"1373","sessionId":"web","role":"user","content":"在的","createdAt":1791088571787} + """.trimIndent().replace("\n", " ") + + @Test + fun `a persisted frame carries voice back to the decoder`() { + val event = WsEvent.parse(frame("message_persisted", """{"message":$voiceRow}""")) as WsEvent.MessagePersisted + + val message = event.message!! + val voice = message.voice!! + assertEquals("asr", voice.source) + assertEquals("transcribed", voice.status) + assertEquals(copyName, voice.name) + assertEquals("audio/ogg", voice.mime) + assertEquals(12_345L, voice.size) + assertEquals(3_200, voice.durationMs) + assertEquals("/api/v1/media/$copyName", voice.url) + // 判据:键存在即语音——这条帧到达的那一刻就该是语音气泡,不需要任何后续拉取。 + assertTrue("落库帧上的语音必须在第一帧就判成语音", isVoiceMessage(message)) + assertEquals(copyName, voiceAudio(voice)?.name) + } + + @Test + fun `a channel persisted frame carries voice too`() { + // 隔离会话那条帧与 message_persisted 共用 `PersistedData`:给载荷加字段不许只覆盖其中一条。 + val event = WsEvent.parse(frame("channel_persisted", """{"message":$voiceRow}""")) as WsEvent.ChannelPersisted + + val message = event.message!! + assertTrue(isVoiceMessage(message)) + assertEquals(3_200, message.voice?.durationMs) + } + + @Test + fun `a transcribed voice without an audio copy is still a voice message`() { + // 「是语音」与「能不能播」是两件事:判据只看键在不在,播放控件另看四键是否齐全。 + val event = WsEvent.parse(frame("message_persisted", """{"message":$noCopyRow}""")) as WsEvent.MessagePersisted + + val message = event.message!! + assertTrue(isVoiceMessage(message)) + assertEquals("platform", message.voice?.source) + assertNull("没有副本就不许画播放控件", voiceAudio(message.voice!!)) + } + + @Test + fun `an absent voice key still means not a voice message`() { + // 判据只有一个方向上的推论:缺席 = 不是语音。这里刻意不留「实时帧可能不带,先按文本显示」 + // 那条分支——M44 之后缺席就只意味着「这条本来就不是语音」。 + val event = WsEvent.parse(frame("message_persisted", """{"message":$plainRow}""")) as WsEvent.MessagePersisted + + assertNull(event.message?.voice) + assertFalse(isVoiceMessage(event.message!!)) + } + + @Test + fun `an edited frame keeps the voice of the message it replaces`() { + // 编辑帧的 `data` **就是**消息本身(没有 `message` 包一层),但它同样是 `voice` 的读面之一: + // 编辑正文不许把已渲染的语音气泡打回纯文本。 + val event = WsEvent.parse( + """{"type":"message_edited","sessionId":"web","data":$voiceRow}""", + ) as WsEvent.MessageEdited + + val message = event.message!! + assertTrue(isVoiceMessage(message)) + assertEquals("明天几点出发", message.content) + } +}