From 9f77fb469cb546a7baf536d9b19f874bf3b62266 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 14:33:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20W41=20=E6=9C=AA=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=97=B6=E5=85=88=E6=8E=A2=E4=B8=80=E6=AC=A1=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=EF=BC=8C=E9=80=9A=E5=B0=B1=E5=9B=9E=E8=90=BD?= =?UTF-8?q?=20REST=20=E5=8F=91=E5=87=BA=E5=8E=BB=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E9=80=9A=E6=89=8D=E8=BF=9B=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人第二次定案:「未连接状态你就不能拿 httpAPI 尝试吗?」→ 明确为「可以走 health 端点 验证连通性」。W40 的「未连接就进队列」保留为最后一级,队列、气泡小标与那套柔和告知原样复用。 ## 为什么先探测再决定,而不是直接排队 网关只有一条:POST /api/v1/chat 与 /ws 是同一个端口上的两条车道。握手被挡(代理不转发 Upgrade、中间设备掐长连接)时 REST 往往是通的,直接排队等于把「WS 这条车道暂时不通」当成 「后端不可用」,让用户白等一次重连——而那一刻这条消息本来就能发出去。 ## 三级阶梯(判据是纯函数 sendRouteFor,见 ui/chat/SendFallback.kt) 1. WS 就绪 → 照旧走 WS。成功路径的字节与状态机一个字没变:sendThroughLadder 的第一件事 仍然是 sendChat,前面不加任何判断、不挂任何起点。 2. 未就绪 → 探一次 GET /api/v1/health(2.5 秒整通上限,只看 2xx;失败 / 超时 / 抛异常一律 按不通处理,fail-closed)。通 → 用既有的 POST /api/v1/chat 把这条发出去(短文本也走 REST);不通 → 进队列。 3. 回落 REST 失败:网络类与超时 → 进队列;服务端给过裁决的(明确拒绝、401)与配置类错误 (证书 / 明文被禁 / 地址格式 / 解析不出来)仍旧回滚加文案,不装成「还没连上」。 4. 补发走同一个函数(fromQueue=true):阶梯只有一份实现,两条路不会分叉成两种行为。 Offline 那一档直接进队列、连探测都不做:它的语义是握手 401 且续期也失败(会话已知是死的), 而健康端点不鉴权(Cyrene_v2/internal/services/gateway/routes.go:51)必然回 200 —— 探了只会 发出一次注定 401 的 REST,把 W40 那句「已排队不会丢」换成一次回滚加一句误导人的鉴权错误。 ## 为什么重发是安全的 回落与补发用的都是那条乐观气泡的 localId,也就是上行幂等键 clientMsgId,一个字节都不新铸。 服务端按 (session_id, client_msg_id) 唯一索引仲裁:重复到达只回 chat_duplicate / duplicate:true,不再落库、不再起回合(契约 v0.56 §3.13)。于是「超时了但服务端其实已经 收下」这种最难判的情况不需要本端猜。 ## 回复怎么回来 那一刻没有 WS 连接,内容帧没人收,靠重连时的正向增量补回来(gapCursor → catchUp → loadAfter → absorb)。本 VM 从没连上过时 gapCursor 是 null、重连那一步什么都不会拉,所以 回落发出去且服务端已受理之后补记一次游标(armGapCursorForFallback,只在它还是 null 时补)。 ## 其他 - 探测结果不缓存,每条消息最多探一次(只有 WS 没把帧交出去才问);WS 就绪那条路上一次探测 都不发。 - 补发从同步循环改成单飞协程:阶梯里要探测、要发 REST,会挂起,「两个触发点都在主线程上 所以不可能交错」这条旧论证不成立 ⇒ 改用 drainingQueue 单飞闸加串行出队,出队仍在有结论 之后。 - 离线时的上界:探测不通就直接进队列、一次 REST 都不会发,用户最多等探测那一段(约 2.5 秒); 回落那一次 REST 另有 10 秒上限(stream:true 的回执本该毫秒级回来)。 - 新增测试 SendFallbackTest(17 例):优先级、fail-closed、Offline 不探测、回落失败分档、 探测超时落在 2 到 3 秒、有图或有附件的消息压根不进这层阶梯。 - devtools/fake-backend 补上 GET /api/v1/health,否则本机跑假后端时这条阶梯会退化成直接排队。 --- .../top/yeij/cyrene/app/AppContainer.kt | 24 ++ .../yeij/cyrene/data/api/ApiClientFactory.kt | 46 +++ .../top/yeij/cyrene/data/api/HealthApi.kt | 22 ++ .../top/yeij/cyrene/data/api/dto/HealthDto.kt | 24 ++ .../data/repository/ConnectivityRepository.kt | 45 +++ .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 365 +++++++++++++++--- .../top/yeij/cyrene/ui/chat/SendFallback.kt | 171 ++++++++ .../top/yeij/cyrene/ui/chat/SendQueue.kt | 13 +- .../yeij/cyrene/ui/chat/SendFallbackTest.kt | 294 ++++++++++++++ devtools/fake-backend/README.md | 1 + devtools/fake-backend/fake_backend.py | 7 + 11 files changed, 954 insertions(+), 58 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 453e753..d3372a6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -10,12 +10,14 @@ import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi import top.yeij.cyrene.data.api.DocumentsApi +import top.yeij.cyrene.data.api.HealthApi import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository +import top.yeij.cyrene.data.repository.ConnectivityRepository import top.yeij.cyrene.data.repository.DocumentRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MetaRepository @@ -202,6 +204,28 @@ class AppContainer(context: Context) { val personaRepository: PersonaRepository get() = PersonaRepository(personaApi) + private var cachedHealthApi: HealthApi? = null + private var cachedHealthBaseUrl: String? = null + + /** + * 连通性探测客户端(`GET /api/v1/health`,W41)。与其余端点**同源**(同一个 baseUrl), + * 但它刻意**不注入令牌、不参与 401 续期**,并且整通调用有短上限 + * ([ApiClientFactory.createProbe],理由见那里的 KDoc)——探测要回答的是「后端在不在」, + * 带着过期令牌去问只会把「后端好好的」读成不通。 + */ + val healthApi: HealthApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedHealthApi + if (cached != null && cachedHealthBaseUrl == baseUrl) return cached + val created = ApiClientFactory.createProbe(baseUrl, HealthApi::class.java) + cachedHealthBaseUrl = baseUrl + cachedHealthApi = created + return created + } + + val connectivityRepository: ConnectivityRepository get() = ConnectivityRepository(healthApi) + /** * 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt index 3852f41..9605445 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt @@ -13,8 +13,27 @@ import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.time.Duration import java.util.concurrent.TimeUnit +/** + * 连通性探测的**整通上限**(毫秒):`GET /api/v1/health` 从发出到拿到回执,连接、写、读全算在内 + * (OkHttp 的 `callTimeout` 就是这么算的)。 + * + * 取值 2.5 秒。它直接**加在用户的发送动作上**——用户按了发送键,这条消息要么立刻上路,要么进 + * 补发队列,中间那一段探测是他等着的空白。健康端点是纯内存回一个 `{"status":"ok"}` + * (`Cyrene_v2/internal/services/gateway/rest.go:219`,且挂在**不鉴权**的公开路由里,见 + * `Cyrene_v2/internal/services/gateway/routes.go:51`),正常网络下毫秒级就该回来;2 秒量级对局域网 + * 与移动网都绰绰有余,3 秒是「人开始觉得卡」之前的上限。 + * + * 超过它一律按**不通**处理([top.yeij.cyrene.ui.chat.reachabilityOf] 的 fail-closed): + * **绝不许**把用户的发送挂在一个等不到头的探测上。 + * + * 它与共用客户端的 10s 连接 / 60s 读超时**刻意不同**:那两条是给「真请求」定的(长回合、上传), + * 探测只回答一个 yes/no,用不上那么长的耐心。 + */ +const val PROBE_TIMEOUT_MILLIS = 2_500L + object ApiClientFactory { val json: Json = Json { @@ -64,6 +83,33 @@ object ApiClientFactory { .create(service) } + /** + * 连通性探测专用客户端(W41):整通调用一个**短上限**([PROBE_TIMEOUT_MILLIS]), + * 不注入令牌、不参与 401 续期、不记日志。 + * + * 与上面那个通用 [create] 分开,理由三条: + * 1. **超时是这条路的语义本身**。共用客户端的 10s 连接 / 60s 读超时是给真请求定的,探测等不了 + * 那么久——判据见 [PROBE_TIMEOUT_MILLIS]。`callTimeout` 把「解析 DNS + 连接 + 写 + 读」 + * 整通一起框住,只会更严不会更松:**探测永远不会比这个上限更慢**。 + * 2. **不带令牌**。`GET /api/v1/health` 是公开路由(不鉴权),探测要回答的是「后端在不在」, + * 而不是「我这个令牌还有效吗」——带着一个过期令牌去问,只会把「后端好好的」读成不通。 + * 不挂 [AuthInterceptor] 同时也就没有「401 ⇒ 续期 ⇒ 再试」那串额外往返。 + * 3. **不共享连接池**:探测的成败不该被真请求的连接状况带着走(半死的 keep-alive 连接恰好是 + * 「要探测一下」的典型场景),自己一个池更干净。代价是一个只在探测时被用到的客户端。 + */ + fun createProbe(baseUrl: String, service: Class): T { + val client = OkHttpClient.Builder() + .callTimeout(Duration.ofMillis(PROBE_TIMEOUT_MILLIS)) + .build() + + return Retrofit.Builder() + .baseUrl(normalizeBaseUrl(baseUrl)) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(service) + } + /** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */ fun normalizeBaseUrl(raw: String): String { var url = raw.trim() diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt new file mode 100644 index 0000000..bec5e5c --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt @@ -0,0 +1,22 @@ +package top.yeij.cyrene.data.api + +import top.yeij.cyrene.data.api.dto.HealthDto +import retrofit2.Response +import retrofit2.http.GET + +/** + * 存活探针(`GET /api/v1/health`,api-client §1)。**不鉴权**、也没有任何重活: + * 后端就是回一个 `{"data":{"status":"ok"}}`(`Cyrene_v2/internal/services/gateway/rest.go:219`, + * 路由挂在公开那一组,见 `Cyrene_v2/internal/services/gateway/routes.go:51`)。 + * + * 本端只用它回答一个问题:「**这一刻这个 baseUrl 背后的后端在不在**」。答案与业务无关,所以这个 + * 接口**不注入 token、不参与 401 续期**(见 [ApiClientFactory.createProbe]),也刻意不与任何别的 + * 接口合并:它的调用代价与语义都和那些「真请求」不同(短超时、失败即不通)。 + * + * 按本工程惯例单独成接口。 + */ +interface HealthApi { + + @GET("api/v1/health") + suspend fun health(): Response> +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt new file mode 100644 index 0000000..95334b7 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt @@ -0,0 +1,24 @@ +package top.yeij.cyrene.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/v1/health` 的成功载荷(存活探针)。 + * + * 线上形状(`Cyrene_v2/internal/services/gateway/rest.go:219` 的 `handleHealth`): + * ``` + * 200 {"data":{"status":"ok"}} + * ``` + * + * 只有 `status` 一个键,且它**不参与本端判定**:探测要回答的是「这条路通不通」,`200` 本身就是 + * 答案(见 `ConnectivityRepository.probe`)。字段带默认值是为了「后端将来把 body 改胖 / 改瘦」 + * 不会让一次成功的探测变成解析失败——那是把「后端好好的」读成「不通」,方向恰好反了(会把一条 + * 本可以发出去的消息压进队列)。 + * + * 该端点**不鉴权**(`routes.go:51` 把它挂在 `registerPublicRoutes` 里),也没有任何重活: + * 它就是 `writeJSON(w, 200, {"status":"ok"})`。 + */ +@Serializable +data class HealthDto( + val status: String = "", +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt new file mode 100644 index 0000000..a645d27 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt @@ -0,0 +1,45 @@ +package top.yeij.cyrene.data.repository + +import top.yeij.cyrene.data.api.HealthApi +import top.yeij.cyrene.data.api.apiCall +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 连通性探测(W41):问一次后端「在不在」,只在**发送这条路上用得着**。 + * + * ## 它回答什么、不回答什么 + * 回答的是「**这一刻**这个 baseUrl 背后的后端在不在」——一个瞬时的、与业务无关的 yes/no。 + * 不回答「我能不能发这条消息」(那是编排器的事)、也不回答「我的令牌还有效吗」(这个端点不鉴权, + * 专门这样选:带着过期令牌去问会把「后端好好的」读成不通)。 + * + * ## 为什么用 `apiCall` 而不是自己判状态码 + * 探测只需要两样东西:**拿到 2xx 且回执能解析** = 通,其余 = 不通。[apiCall] 的口径正好是这两样 + * (成功但 `data` 为空判 Parse、非 2xx 按 [top.yeij.cyrene.data.api.ApiErrorMapper] 归类、网络异常 + * 归 [top.yeij.cyrene.core.error.CyreneError.Local]),与别的仓库**同一份**错误映射,不另造一套。 + * + * 返回 [Result] 而不是直接给个布尔:`false` 与「失败了 / 超时了」在这一层是两件事(一个说后端 + * 回了非 2xx,一个说连回话都没有),要不要都归成一档由判据决定 + * ([top.yeij.cyrene.ui.chat.reachabilityOf]:都归「不通」,fail-closed)。 + * + * ## 超时是客户端的属性,不是这里的 + * 上限由 [top.yeij.cyrene.data.api.ApiClientFactory.createProbe] 的 `callTimeout` 兜住 + * ([top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS]),所以这里**不需要**再包一层 `withTimeout`: + * 一道闸就够,两道只会让「超时到底是谁判的」变得含糊。 + * + * ## 不缓存 + * 没有缓存、也没有「上次探测时间」之类的记录:结论只对**探测那一刻**成立(这正是探测这个词的 + * 意思),缓存它就会拿一个几秒前的乐观结论去发一条这一刻发不出去的消息。代价上也不成立—— + * 每条消息**最多探一次**(只有 WS 未就绪那一刻才问),而健康端点就是一次内存读。 + */ +class ConnectivityRepository( + private val api: HealthApi, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + /** 这一刻后端在不在。**永不抛异常**:一切失败都折进 `Result.failure`。 */ + suspend fun probe(): Result = withContext(dispatcher) { + apiCall { api.health() }.map { true } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 64ab393..94928db 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.app.ShareIntake import top.yeij.cyrene.core.error.CyreneError @@ -723,6 +724,13 @@ data class ChatUiState( * 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页 * 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。 * + * **本该走 WS 的那条短文本**(W41 起)再走一道三级阶梯([sendThroughLadder],判据是纯函数 + * [sendRouteFor]):WS 就绪 ⇒ 照旧走 WS(成功路径零改动)→ 未就绪 ⇒ 短探一次后端连通性 + * (`GET /api/v1/health`,[probeReachability])⇒ 通就**回落 REST** 把这条发出去、不通才进队列。 + * 于是「未连接」不再直接等于「没法发」:网关只有一条,`/ws` 那半边的握手被挡时 REST 往往是通的。 + * 回落发出去的那条,回复靠重连时的正向增量([gapCursor] / [catchUp])补回来,见 + * [armGapCursorForFallback] 与 [sendViaRestFallback]。 + * * 上行的两个入口共用这一套实现:输入框的 [send],以及「分享到 App」的 [sendShared] * (`ACTION_SEND` 进来的内容由 `app/ShareIntake` 拿着,等本会话在登录态下组合出来再落地)。 * @@ -741,8 +749,10 @@ data class ChatUiState( * [RetryKeyMemory.SLOT_TTL_MILLIS](10 分钟,见那里「时间上界」)——多槽让记录跨很多次发送存活, * 太久以前那条失败过的内容再打一遍是**新**消息,不能被当成重试吞掉。**没有「失败之后自动重试」**: * 键的复用只发生在用户手动重发的那一刻。唯一会自动重发的是**一次都还没上过路**的那几条 - * (未连接 ⇒ 进 [ChatUiState.queuedForConnection],连接恢复后按发送顺序补发)——那不是重试, + * (WS 未就绪 ⇒ 进 [ChatUiState.queuedForConnection],连接恢复后按发送顺序补发)——那不是重试, * 是这条消息的**第一次**投递,用的还是同一个键,所以「补发」与「手动重发」在服务端是同一种判重。 + * 回落 REST 也失败(网络类除外的一切另说,见 [restFailureLanding])时同样进这个队列, + * 用的还是同一个键——**同一条消息无论在路上摔了几次,服务端都只认一条**。 * * ## 单条消息的编辑 / 删除(D3) * 编辑**只对 `user` 消息开放**([canEditMessage]),删除对 `user` 与 `assistant` 都开放 @@ -846,6 +856,15 @@ class ChatViewModel( */ private var queuedNoticeTold = false + /** + * 有补发协程在跑(**单飞闸**,见 [flushQueuedSends])。 + * + * W41 起补发那条路会挂起(探测与回落 REST),所以「两个触发点都在主线程上 ⇒ 不可能交错」这条 + * 旧论证不再成立,改用这一位:同一时刻最多一个补发循环,而它串行地一条一条走。只在 + * [flushQueuedSends] 里置真、只在那一个协程的 `finally` 里置假——没有第三个地方碰它。 + */ + private var drainingQueue = false + init { start() } @@ -1102,8 +1121,9 @@ class ChatViewModel( } /** - * 发送:先乐观上屏,再按路由决定走哪条上行通道;**WS 未就绪时留在待发态等连接** - * ([ChatUiState.queuedForConnection],这不算失败),其余失败回滚并把文本 / 图片 / 附件还给输入框。 + * 发送:先乐观上屏,再按路由决定走哪条上行通道;**本该走 WS 而 WS 未就绪时先探一次**—— + * 探测通就回落 REST 发出去,不通才留在待发态等连接([ChatUiState.queuedForConnection], + * 这不算失败,见 [sendThroughLadder]),其余失败回滚并把文本 / 图片 / 附件还给输入框。 * * 路由 [ChatSendRouting]:**短文本且无图无附件 → WS**(直达、绕过有损总线,今天就能用); * **长文本、任何图片、任何附件 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上 @@ -1398,24 +1418,10 @@ class ChatViewModel( val parallel = applySendPreference && parallelForSend(mode, busy) when (ChatSendRouting.channelFor(text, hasImage = image != null, hasDocuments = payload.isNotEmpty())) { - SendChannel.Ws -> { - // socket 只读一次:下面两处必须是**同一次**观察。`sendChat` 判的是 socket 自己的状态, - // 而界面那一格(`ChatUiState.connection`)是经 collect 镜像过来的,可能晚一帧。 - val ws = socket - val sent = ws?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false - // 处置按**纯函数** [wsSendOutcome] 分三档(成功 / 排队 / 真失败): - // 连接没就绪时这条消息一个字节都没上路(`ChatSocket.send` 第一行就挡掉、服务端 - // 一个请求都收不到)⇒ 主人 2026-10-05 的裁决是**进队列**:不摘气泡、不报横幅, - // 连接恢复后按发送顺序自动补发([flushQueuedSends])。只有「连接就绪却仍然发不出去」 - // 那一档走今天那条回滚 + 文案。socket 为 null 按 Idle 处理(「未启动」正是它的语义, - // 也确实是没连上)。 - when (wsSendOutcome(sent, ws?.state?.value ?: WsState.Idle)) { - // 成功路径**一个字都没变**:帧交给 socket 之后这里什么都不做。 - WsSendOutcome.Dispatched -> Unit - WsSendOutcome.Queued -> queueForConnection(localId, parallel) - WsSendOutcome.Failed -> rollback(localId, SEND_FAILED_MESSAGE) - } - } + // 三级阶梯的**唯一**落地处(W41):首次发送与队列补发走的是同一个函数 + // ([sendThroughLadder]),所以「WS 就绪 ⇒ 照旧走 WS」那条成功路径的字节与状态机 + // 一个都没变——它仍然是这个函数的第一件事,前面不加任何判断、不挂任何起点。 + SendChannel.Ws -> sendThroughLadder(localId, text, parallel, fromQueue = false) SendChannel.Rest -> if (image == null) { sendViaRest(localId, text, image = null, documents = payload, parallel = parallel) @@ -1425,9 +1431,200 @@ class ChatViewModel( } } + /** + * 三级阶梯(W41 主人第二次定案:**未连接时先探一次,能发就发,不通才排队**)。 + * 首次发送([deliver] 的 WS 分支)与队列补发([drainQueuedSends])共用这一个函数—— + * 共用是刻意的:阶梯只要有两份实现,两份迟早会分叉成两种行为。 + * + * ## 走法(判据全在纯函数 [sendRouteFor],这里只落地) + * 1. **WS 就绪 ⇒ 照旧走 WS**。这一段与 W40 之前逐字相同:`socket` 与连接状态各读一次 + * (必须是**同一次**观察:`sendChat` 判的是 socket 自己的状态,而界面那一格是经 collect + * 镜像过来的、可能晚一帧),帧交出去就什么都不做。**成功路径一个字都不许变**。 + * 2. **未就绪 ⇒ 探一次**([probeReachability]):通 ⇒ [sendViaRestFallback] 用既有的 + * `POST /api/v1/chat` 把这条发出去(短文本也走 REST);不通 / 超时 ⇒ 进队列。 + * 3. **回落 REST 也失败**(网络类)⇒ 进队列:重发仍用同一个 `clientMsgId`,服务端判重 + * (见 [restFailureLanding])。 + * + * ## 一次发送最多一次探测 + * [probeReachability] 在这里只被调用**一次**,且只在「WS 没把帧交出去」之后——WS 就绪那条 + * 成功路径上一次探测都不发(这是「成功路径零改动」的一部分)。补发那条路会再探一次,但那是 + * **另一次投递**:同一条消息(同一个 `localId`)不会因为一次投递而打出一串探测请求。 + * + * ## 已知边界:打断声明不随补发重放 + * [deliver] 里那个打断声明(`interrupt{scope:"current"}`)发生在通道分支**之前**,未连接时它 + * 同样发不出去(`sendInterrupt` 静默回 false),而这里重发的只有 `chat` 那一帧 / 那一次 + * REST ⇒ 这条消息最终以**默认档**(追加队列)被受理。W40 那条路的结果完全一样,不在这里补 + * 打断帧(理由见 [queueForConnection] 的说明)。 + * + * @param fromQueue true = 这次是队列补发。它只改**处置**、不改阶梯:不再次入队(它本来就在 + * 队里,重复入队会让同一条补发两次),也不再重复说一遍「排队等连接」的告知 + * (气泡上的小标一直挂着,刷第二遍只是噪声)。 + */ + private suspend fun sendThroughLadder( + localId: String, + text: String, + parallel: Boolean, + fromQueue: Boolean, + ) { + // socket 只读一次:下面三处必须是**同一次**观察(成功后不再读,探测与队列判定共用这一份)。 + val ws = socket + val sent = ws?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false + val state = ws?.state?.value ?: WsState.Idle + + when (wsSendOutcome(sent, state)) { + // 成功路径**一个字都没变**:帧交给 socket 之后这里什么都不做(补发那条顺手出队)。 + WsSendOutcome.Dispatched -> if (fromQueue) dropQueuedSend(localId) + + // 连接就绪却仍然发不出去:只剩「socket 对象已空 / 已被 cancel」或「toJson() / send() + // 抛异常」两种成因 ⇒ 那是事实上的失败,走今天那条回滚 + 文案。 + WsSendOutcome.Failed -> { + if (fromQueue) dropQueuedSend(localId) + rollback(localId, SEND_FAILED_MESSAGE) + } + + // 未就绪 ⇒ 先探一次再决定(W41)。探测失败 / 超时一律按不通处理 + // ([reachabilityOf] 的 fail-closed)⇒ 落回 W40 的队列与那套 UI,不新造。 + WsSendOutcome.Queued -> when ( + sendRouteFor(wsDispatched = false, connection = state, reachability = probeReachability()) + ) { + SendRoute.Rest -> sendViaRestFallback(localId, text, parallel, fromQueue) + + // Ws 在这一支不可达(刚刚才问过 `sendChat`,它没把帧交出去);真出现也只可能是 + // 判据被改坏 ⇒ 与 Queue 一样落回队列:不会凭一个不可能的值凭空多发一条。 + SendRoute.Ws, SendRoute.Queue -> if (!fromQueue) queueForConnection(localId, parallel) + } + } + } + + /** + * 探一次后端连通性(W41):`GET /api/v1/health`,短超时,**不缓存**。 + * + * 结论只对**探的那一刻**成立,所以每次走到这一步都现问一次;而每条消息最多走到这里一次 + * (只有 WS 没把帧交出去才问),代价是一次内存读 + 一个本地来回,值。 + * + * ## 这一层是**全定义**的,永不抛异常 + * 失败 / 超时 / 抛异常一律 [Reachability.Unreachable]。`try` 覆盖的还不只是那一次请求 + * ([ConnectivityRepository.probe] 自己已经把网络层的一切折进 `Result` 了),还包括**把那次 + * 调用本身**:客户端是按当前 baseUrl 现建的,baseUrl 还没配好 / 格式不对时,建 Retrofit + * 那一步就会抛——那是配置问题,此刻的结论就是「不通」,该做的是把这条消息留在队列里 + * (用户修好地址、连接一就绪就自动补发),**不是**让一次发送把异常抛到界面上去。 + * + * 取消照旧抛出去(它是取消,不是「探测失败」,吞掉会让协程无法被取消)。 + */ + private suspend fun probeReachability(): Reachability = try { + reachabilityOf(container.connectivityRepository.probe()) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + Reachability.Unreachable + } + + /** + * 回落 REST(W41):WS 未就绪、而探测说「这一刻这个后端在」,就用既有的 + * `POST /api/v1/chat`(`stream:true`)把这条发出去。 + * + * ## 为什么短文本也走 REST + * 走 WS 本来就是**优选**(直达、绕过有损总线),不是契约要求;`POST /api/v1/chat` 从第一天起 + * 收的就是同一种消息,两者在服务端汇进同一个编排器、同一个幂等键、同一份落库。所以「WS 那半 + * 边不通时改走 REST」只是换一条车道,不是换一种语义。 + * + * ## 回复为什么收不到、又怎么补回来 + * 内容帧只走**已连接的 WS** 广播,而此刻没有连接 ⇒ 那一轮回复不会实时到达本端。它靠重连时的 + * 两段既有链路补回来:重连那次**正向增量**([gapCursor] + [catchUp])与握手 `history` 回放 + * (见 [armGapCursorForFallback] 与 [absorb])。这条消息自己落库的那条则由 + * `message_persisted` / 补拉里的 `clientMsgId` 归一([reconcilePending])。 + * + * ## 两道有界 + * 整通调用有上限 [REST_FALLBACK_TIMEOUT_MILLIS](超时按网络类失败 ⇒ 进队列);「离线」时 + * 真正会等的只有探测那一段([top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS]),因为探测不通 + * 就进队列、一次 REST 都不会发。 + * + * @param fromQueue true = 这是补发:失败时不再次入队(它本来就在队里),只让它留在队里等下一次。 + */ + private suspend fun sendViaRestFallback( + localId: String, + text: String, + parallel: Boolean, + fromQueue: Boolean, + ) { + val ack = withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { + // 回落这条路上永远没有图片与附件:能走到这里的只有「内容是短文本、本该走 WS」的那几条 + // (有图 / 有附件一律被 [ChatSendRouting.channelFor] 判成 Rest,走的是上面另一支, + // 根本不进这层阶梯)。 + container.chatRepository.send( + chatRequest(localId, text, image = null, documents = emptyList(), parallel = parallel), + ) + } + + // 超时(`null`)与网络类失败同一档:**服务端没给过裁决** ⇒ 这条还没上路 ⇒ 进队列。 + // 重发用的是同一个 `clientMsgId`,服务端若其实已经收下,只会回 `duplicate`(不会落两条)。 + if (ack == null) { + onRestFallbackUnsent(localId, parallel, fromQueue) + return + } + + ack.onSuccess { received -> + // 只有「服务端已经收下这条」才该往下走这两步:被拒的那些走 [onAck] 的回滚,那条消息 + // 没上路,既没有回复要等,队列也由 [rollback] 顺手出掉。 + if (received.isAccepted()) { + armGapCursorForFallback() + // 补发这一条**必须出队**:服务端已经收下 ⇒ 这条了结了。不出队的话它会一直挂在 + // 「排队中 · 还没连上」的小标上(界面上那句话就成了假话),下一次重连还会再补发 + // 一遍——服务端虽然按同一个 `clientMsgId` 判重、不会落两条,但那是一次白发的请求。 + if (fromQueue) dropQueuedSend(localId) + } + onAck(localId, received) + }.onFailure { e -> + val error = (e as? CyreneException)?.error + when (restFailureLanding(error)) { + RestFailureLanding.Queue -> onRestFallbackUnsent(localId, parallel, fromQueue) + RestFailureLanding.Rollback -> rollback(localId, e.userMessage()) + } + } + } + + /** + * 回落 REST 也没把这条送出去时的落点。 + * + * 补发时(`fromQueue`)**什么都不做**:它本来就在队列里,位置也没变,等下一次 Connected + * (或下一次探测)再来。这里必须什么也不做——再入队一次会让同一条在队列里出现两份, + * 而补发是照队头走的,那等于补发两次。 + */ + private fun onRestFallbackUnsent(localId: String, parallel: Boolean, fromQueue: Boolean) { + if (fromQueue) return + queueForConnection(localId, parallel) + } + + /** + * 给「回落 REST 发出去的那条消息的回复」把重连补拉的游标准备好(W41)。 + * + * [gapCursor] 平时由「**离开** Connected 那一刻」记下([onConnectionChanged])。本 VM 从没连上 + * 过的那一段里它是 null(进会话时连接就是断的,或者一开始就进了次级会话),于是重连那一步 + * **什么都不会拉**——而回落 REST 发出去的那一轮回复恰好只能靠那一步补回来(那一刻没有连接, + * 内容帧没人收)。所以这里补记一次:用本端已知最新一条的位置当洞的起点。 + * + * **只在它还是 null 时补**:掉线那一刻已经记过的那个游标不许被这里改写——那是洞的真正起点, + * 往后挪会把中间那一段永久漏掉。 + * + * 本端一条消息都没有时(首屏也没拉回来)游标仍然是 null,那一段洞今天补不上:`after=0` 会把 + * 整段历史按**最旧**的一页页拉,而页数上限([MAX_CATCH_UP_ROUNDS])一到就停在那儿,正好错过 + * 要看的那条。主会话那一半由重连时 `history` 帧的最近 50 条兜住([WsEvent.History]), + * 次级会话 + 首屏也没拉到的组合则要用户自己刷新一次——这条已知边界如实记在这里,不假装补上了。 + */ + private fun armGapCursorForFallback() { + if (gapCursor == null) gapCursor = newestKnownAt + } + /** * 记下「这条没连上、留在本端等连接」,并在该说的时候说一句(W40 主人裁决:未连接就进队列)。 * + * ## 它在 W41 里的位置 + * 它是**阶梯的最后一级**([sendThroughLadder]):WS 没把帧交出去、探测又说后端不通(或这一档 + * 压根不该探测),才落到这里。调用点两处,都走 [onRestFallbackUnsent],且都只在 + * **首次发送**(`fromQueue = false`)时: + * - 未就绪 + 探测不通 ⇒ 直接进队列; + * - 未就绪 + 探测通 ⇒ 回落 REST,而那次 REST 也失败(网络类 / 超时)⇒ 进队列。 + * * 三件事,顺序也就是优先级: * 1. **入队**([ChatUiState.withQueuedSend])——气泡照旧留在屏幕上(它本来就已经是乐观气泡了), * 只是多一个「排队中 · 还没连上」的小标([ChatUiState.isAwaitingConnection])。**不回滚、 @@ -1441,8 +1638,8 @@ class ChatViewModel( * * ## 一条已知边界:打断声明不随补发重放 * `deliver` 里那个打断声明(`interrupt{scope:"current"}`,见 [interruptRunningTurn])发生在 - * **通道分支之前**:未连接时它同样发不出去(`sendInterrupt` 静默回 false),而补发只重发 - * `chat` 这一帧 ⇒ 这条消息最终以**默认档**(追加队列)被受理。今天(回滚)那条路也是同一个结果: + * **通道分支之前**:未连接时它同样发不出去(`sendInterrupt` 静默回 false),而补发重发的只有 + * `chat` 那一帧 ⇒ 这条消息最终以**默认档**(追加队列)被受理。今天(回滚)那条路也是同一个结果: * 用户手动重发时若连接已就绪,打断会重新算一次,但那取决于用户什么时候按——两条路都不保证 * 「发的时候的那次打断」一定落地。不在这里补打断帧,是因为补发时那个「在跑的回合」早就不是 * 用户按发送时看到的那一个了:隔了半分钟再去打断,可能毁掉他没见过的一轮。 @@ -1472,14 +1669,47 @@ class ChatViewModel( * - 连接就绪却仍发不出去([WsSendOutcome.Failed],只剩 socket 对象已空 / 序列化抛异常那两种成因) * 或这条补发不了([QueuedSendStep.Failed])⇒ 出队 + 走今天那条回滚 + 文案:**绝不静默吞掉**。 * + * ## W41:补发走的是**同一个阶梯** + * 每一条都交给 [sendThroughLadder](`fromQueue = true`)——也就是首次发送走的那一个函数, + * 所以「WS 优先 → 探测 → 回落 REST → 队列」这套判据在两条路上**只有一份**。今天队列里只可能 + * 有「内容是短文本、本该走 WS」的那几条(能入队的两处:WS 分支没交出去、以及回落 REST 失败, + * 两者都只发生在短文本上),而本函数**只在 WS 就绪时被触发**(两个触发点各自都先看过 + * `Connected`)⇒ 实际落到的永远是阶梯第一步;后面两步是给「触发与执行之间连接又变了」这种 + * 窗口留的,判据一样,不需要另写一套。 + * * ## 「同一条不会被补发两次」的保证点 - * **队头 + 出队**:每一步只处理队头,而队头只在这一步有了结论之后才被摘掉([dropQueuedSend]), - * 所以同一批消息被多次触发(连接抖动、重组、刚连上又断)时,重复的触发拿到的是**同一个键**、 - * 不会多出第二次出队。本函数是同步的(一个挂起点都没有),两个触发点在主线程上因此不可能交错, - * 不需要锁;若将来在这里加挂起点,必须重新想一遍这件事。第二道保证在服务端:补发用的还是 - * 原来那个 `clientMsgId`,真重复到达也只会被回 `chat_duplicate`(契约 v0.56 §3.13)。 + * **队头 + 出队**:每一步只处理队头,而队头只在这一步有了结论之后才被摘掉([dropQueuedSend], + * 回滚那一支由 [rollback] 顺手出队)。本函数现在**有挂起点**了(W41 起阶梯里要探测、要发 REST), + * 所以「两个触发点在主线程上不可能交错」这条旧论证不再成立,改用**单飞**:[flushQueuedSends] + * 用 [drainingQueue] 保证同一时刻最多一个补发协程在跑,而它从头到尾是**串行**的(一条有了结论 + * 才看下一条)。于是重复触发(连接抖动、重组、刚连上又断)只会撞上那道闸,不会多出第二次投递。 + * 第二道保证仍在服务端:补发用的还是原来那个 `clientMsgId`,真重复到达也只会被回 + * `chat_duplicate`(契约 v0.56 §3.13)。 */ private fun flushQueuedSends() { + // 单飞:已经在补发了就不再起一个(见上面「同一条不会被补发两次」)。不起第二个协程不会 + // 漏掉新入队的条目——那个循环每一轮都重读 `_state`,队尾新来的会在这一轮之后被走到。 + if (drainingQueue) return + drainingQueue = true + viewModelScope.launch { + try { + drainQueuedSends() + } finally { + // 收尾必须放这里:循环正常结束、被取消、抛异常三种情况都要把闸放开, + // 否则下一次 Connected 就再也补发不了了。 + drainingQueue = false + } + } + } + + /** + * 补发循环本体([flushQueuedSends] 起的那个协程里跑,串行、队头优先)。 + * + * 「这条还有没有留在队列里」就是**下一步该不该继续**的判据:发出去了、回滚了都会出队 + * ([dropQueuedSend] / [rollback]),而「还是没上路」(连接又断回去、回落 REST 也没成) + * 一定还是队头 ⇒ 停手等下一次 [WsState.Connected],绝不在这里空转重试。 + */ + private suspend fun drainQueuedSends() { while (true) { when (val step = _state.value.nextQueuedSend()) { QueuedSendStep.Idle -> return @@ -1490,18 +1720,11 @@ class ChatViewModel( } is QueuedSendStep.Send -> { - val ws = socket - val sent = ws?.sendChat(sessionId, step.text, DEVICE_ID, step.localId, step.parallel) ?: false - when (wsSendOutcome(sent, ws?.state?.value ?: WsState.Idle)) { - WsSendOutcome.Dispatched -> dropQueuedSend(step.localId) - // 又断回去了:这条仍是「还没上路」,留在队列里等下一次 Connected(顺序不动)。 - // 这一刻**不再说一遍**告知:气泡上的小标一直挂着,刷第二遍只是噪声。 - WsSendOutcome.Queued -> return - WsSendOutcome.Failed -> { - dropQueuedSend(step.localId) - rollback(step.localId, SEND_FAILED_MESSAGE) - } - } + sendThroughLadder(step.localId, step.text, step.parallel, fromQueue = true) + // 还挂在队列里 ⇒ 这一步没把它送出去(连接又断了 / 回落也没成)⇒ 停手, + // 顺序一动不动地等下一次 Connected。这一刻**不再说一遍告知**:气泡上的小标 + // 一直挂着,刷第二遍只是噪声。 + if (_state.value.isAwaitingConnection(step.localId)) return } } } @@ -1704,6 +1927,10 @@ class ChatViewModel( * * [parallel] 与 [documents] 同一条纪律:`false` **整键省略**(只有用户选了「并行」且会话正忙时 * 才是 `true`,判据见 [parallelForSend])——默认档的请求体因此逐字节不变。 + * + * 失败一律回滚(今天的样子,一个字没变):这条路的两种入口(长文本、带图 / 带附件的消息)本来 + * 就是 REST,它们不在 W41 那条「先探测再决定」的阶梯上——那条阶梯只管「本该走 WS 的短文本」 + * (见 [sendThroughLadder])。 */ private suspend fun sendViaRest( localId: String, @@ -1712,26 +1939,42 @@ class ChatViewModel( documents: List = emptyList(), parallel: Boolean = false, ) { - val request = ChatRequest( - sessionId = sessionId, - text = text, - image = image, - deviceId = DEVICE_ID, - // 必须 true:`stream:false` 是同步的,服务端**没有写超时**而客户端读超时 60s—— - // 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用)。 - stream = true, - clientMsgId = localId, - documents = documents, - // 直接传 `false` 会序列化成 `"parallel":false`(字段默认值是 `null`,而 - // `encodeDefaults = false` 只省略「等于默认值」的字段)——那不是「未声明」而是**显式否认**。 - parallel = parallelWireValue(parallel), - ) container.chatRepository - .send(request) + .send(chatRequest(localId, text, image, documents, parallel)) .onSuccess { ack -> onAck(localId, ack) } .onFailure { e -> rollback(localId, e.userMessage()) } } + /** + * 一次 REST 投递的请求体(**唯一**一处构造):[sendViaRest](长文本 / 图片 / 附件)与 + * [sendViaRestFallback](WS 未就绪时的回落)共用它,免得两条路的请求形状各写一遍、日后分叉。 + * + * 两个字段的理由写在原地,改它们之前先看这两条: + * - `stream = true` 必须:`stream:false` 是同步的,服务端**没有写超时**而客户端读超时 60s—— + * 慢回合会在客户端先超时、服务端还在跑,重发就是第二次对话(契约明确禁用); + * - `parallel` 直接传 `false` 会序列化成 `"parallel":false`(字段默认值是 `null`,而 + * `encodeDefaults = false` 只省略「等于默认值」的字段)——那不是「未声明」而是**显式否认**。 + * + * `clientMsgId` 就是那条乐观气泡的 `localId`:**回落那条路也必须传它**,那是「发过两次也只落 + * 一条」的全部依据(服务端按 `(session_id, client_msg_id)` 仲裁)。 + */ + private fun chatRequest( + localId: String, + text: String, + image: String?, + documents: List, + parallel: Boolean, + ) = ChatRequest( + sessionId = sessionId, + text = text, + image = image, + deviceId = DEVICE_ID, + stream = true, + clientMsgId = localId, + documents = documents, + parallel = parallelWireValue(parallel), + ) + /** * REST 受理回执的六个分支(契约 v0.56 的响应面;形状见 [ChatAck])。 * @@ -1774,6 +2017,18 @@ class ChatViewModel( else -> rejected.message.ifBlank { "发送失败,请重试" } } + /** + * 这份回执照的是「**服务端已经收下这条**」(受理起回合 / 忙时续接排队 / 早就收过这条幂等键)。 + * + * 只有这一族才需要 [armGapCursorForFallback]:被拒的那三种(队列满 / 接不住 / 明确拒绝)走 + * [onAck] 的回滚,那条消息没有上路,也就没有回复要等。用 `when` 穷举而不是 `!=` 某个值: + * 回执面将来多一档时,这里必须有人做决定。 + */ + private fun ChatAck.isAccepted(): Boolean = when (this) { + ChatAck.Accepted, is ChatAck.Queued, is ChatAck.Duplicate -> true + ChatAck.QueueFull, is ChatAck.Unavailable, is ChatAck.Rejected -> false + } + /** * 「这条早已受理过」的对齐:去掉那条乐观气泡;本地若**没有**这条消息,再补拉最新一页。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt new file mode 100644 index 0000000..0532ba3 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt @@ -0,0 +1,171 @@ +package top.yeij.cyrene.ui.chat + +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.ws.WsState + +/** + * 回落 REST 那一次投递的**整通上限**(毫秒)——`POST /api/v1/chat` 从发出到拿到回执。 + * + * 探测通过只说明「这一刻 http 这条路通」,之后那一次真请求仍可能卡住(弱网、半死的代理、服务端 + * 被别的请求堵住)。共用 REST 客户端给的是连接 10s / 读 60s——那是给**已经确定要发的**请求定的, + * 而 `stream:true` 的回执本该毫秒级回来。回落这条路再压一道 10 秒上限,是为了让「没网」时的等待 + * 有上界可算:用户最多等 探测([top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS])+ 回落(本值), + * 不会以为界面卡死。 + * + * 超时**不当作「服务端拒绝」**:那条消息可能已经被服务端收下(只是回执没回来)⇒ 按网络类失败 + * 处理 ⇒ 进队列,重发时仍用同一个 `clientMsgId`,服务端按 `(session_id, client_msg_id)` 判重后 + * 回 `chat_duplicate` / `{duplicate:true}`,不会落成第二条消息。 + */ +const val REST_FALLBACK_TIMEOUT_MILLIS = 10_000L + +/** + * 一次连通性探测的结论([reachabilityOf] 的取值)。 + * + * **只有两档**,没有第三档「不确定」:探测失败 / 超时 / 抛异常 / 后端自称不健康,都归 + * [Unreachable](见 [reachabilityOf])。理由是这个结论**要直接决定一条消息的去向**,而两档的 + * 代价不对称:误判成 [Reachable] 会发一次注定失败的 REST(用户等一个来回、最后还是回滚), + * 误判成 [Unreachable] 只是走今天的队列(内容一个字节都不会丢,连接一好就自动补发)。 + * 所以不确定一律往队列那一侧倒(fail-closed)。 + */ +enum class Reachability { + /** 后端在这一刻**真的回了话**(`GET /api/v1/health` 拿到 2xx 且回执能解析)。 */ + Reachable, + + /** 其余一切:超时、连不上、DNS 不解析、非 2xx、回执解析不出来、后端自称不健康。 */ + Unreachable, +} + +/** + * 一条消息这一刻该怎么走([sendRouteFor] 的取值)。 + * + * 与 [top.yeij.cyrene.domain.chat.SendChannel] **不是一回事**,两者正交、同时存在: + * - [top.yeij.cyrene.domain.chat.SendChannel] 判的是「**这条内容**该走哪条上行通道」——静态的、 + * 只看文本字节数 / 有没有图片 / 有没有附件,与网络状况无关([top.yeij.cyrene.domain.chat.ChatSendRouting]); + * - 本枚举判的是「**这一刻这个动作**该怎么做」——动态的、看连接状态与一次探测的结论。 + * + * 阶梯的**第一步**永远是 `SendChannel` 那次判定(有图 / 有附件的消息根本进不到下面这两档, + * 它们本来就是 REST);本枚举只在「内容是短文本、本该走 WS」那条路上生效。 + */ +enum class SendRoute { + /** 帧已经交给 socket(或连接就绪、就该走 WS):照今天的样子继续等回执。 */ + Ws, + + /** WS 未就绪、而探测说后端通 ⇒ **回落 REST** 把这条发出去(短文本也走 REST)。 */ + Rest, + + /** WS 未就绪、而探测说后端不通(或压根不该探测)⇒ 进补发队列等连接。 */ + Queue, +} + +/** + * 回落 REST 宣告失败之后的落点([restFailureLanding] 的取值)。 + */ +enum class RestFailureLanding { + /** 这一条**还没上过路**(服务端没给过任何裁决)⇒ 进队列,连接一好就补发。 */ + Queue, + + /** 服务端**给过裁决**(拒绝 / 未授权 / 协议对不上)⇒ 走今天那条回滚 + 文案。 */ + Rollback, +} + +/** + * 「这条 `chat` 帧交出去了没有 + 这一刻的连接状态 + 一次探测的结论」⇒ 该走哪条路 + * (**纯函数**,VM 与测试共用)。 + * + * ## 为什么是「先探测再决定」,而不是直接排队(主人 2026-10-05 的第二次定案) + * W40 的裁决是「未连接就进队列」,它解决的是**内容不被退回给用户**(不报错、不丢字)。但 WS 断着 + * 不等于后端不可达:网关只有一条,`POST /api/v1/chat` 与 `/ws` 是同一个 `:8080` 上的两条车道, + * 握手被挡(代理不转发 Upgrade、中间设备掐长连接、只断了 WS 那一半)时 REST 往往是通的。 + * 直接排队等于把「WS 这一条车道暂时不通」当成「后端不可用」,让用户白等一次重连——而那时候 + * **这条消息本来就能发出去**。所以顺序改成:能走 WS 就走 WS(成功路径一个字不动)→ 走不了就 + * 花一次短探测问一句「后端在不在」→ 在就回落 REST 发出去,不在才排队。 + * + * ## 判据与优先级 + * 1. `wsDispatched == true` ⇒ [SendRoute.Ws]。帧已经交给 socket,连接状态与探测结论都**不再参与** + * 判断(`sendChat` 判的就是自己的状态)。**成功路径零改动**:不打标记、不说告知、不写任何状态。 + * 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue],**连探测都不做**。这一档的语义是「握手 401 且 + * 续期也失败」(终态、不会自动重连),也就是说**会话已知是死的**。这时候去探测毫无意义:健康 + * 端点**不鉴权**(`Cyrene_v2/internal/services/gateway/routes.go:51` 把它挂在公开路由里),它必然 + * 回 200,于是判成 [Reachable]、发出一次注定 401 的 REST,最后把 W40 那句「已排队不会丢」换成 + * 一次回滚 + 一句会误导人的鉴权错误。这一档该做的是让用户重新登录 / 检查服务器地址 + * ([SEND_QUEUED_OFFLINE_NOTICE]),队列照旧留着。 + * 3. 其余三档未就绪([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])且探测是 + * [Reachability.Reachable] ⇒ [SendRoute.Rest]:回落 REST,把这条发出去。 + * 4. 其余一切(未就绪 + 探测不通 / 超时 / 抛异常 / 还没探过 = `null`)⇒ [SendRoute.Queue]。 + * `null`(没有探测结论)与「探测不通」走同一条路:**没有证据说能发,就不发**。 + * + * 判据只认 `Connected` **一个值**(不是「不等于 Offline」之类的近似)——连接状态的另外四档都 + * 意味着帧没有被交给任何服务端。 + */ +fun sendRouteFor( + wsDispatched: Boolean, + connection: WsState, + reachability: Reachability?, +): SendRoute = when { + wsDispatched -> SendRoute.Ws + connection == WsState.Offline -> SendRoute.Queue + reachability == Reachability.Reachable -> SendRoute.Rest + else -> SendRoute.Queue +} + +/** + * 一次探测的原始产物 ⇒ 结论(**纯函数**)。 + * + * 判据只有一条:`Result` 里是 `true` 才算通。其余全部 [Reachability.Unreachable]—— + * 失败(超时 `SocketTimeoutException` / 连不上 `ConnectException` / DNS / 非 2xx / 回执解析不出来) + * 与「后端自称不健康」是同一档。**fail-closed**:探测这一层永远不抛异常给调用方,也永远不因为 + * 「拿不准」而放行一次真发送。 + * + * 之所以要一个函数而不是一个 `if`:这条「不确定就往队列倒」的规矩要被单测钉住(探测抛异常那条 + * 用例),而它恰好是整条阶梯里唯一没有第二次机会的一步。 + */ +fun reachabilityOf(probed: Result): Reachability = + if (probed.getOrNull() == true) Reachability.Reachable else Reachability.Unreachable + +/** + * 回落 REST 失败之后该去哪(**纯函数**):进队列,还是走今天那条回滚 + 文案。 + * + * ## 为什么网络类失败进队列是安全的(主人 2026-10-05) + * 重发用的还是**同一个** `clientMsgId`(就是那条乐观气泡的 `localId`, + * [QueuedSend.localId]),而服务端按 `(session_id, client_msg_id)` 唯一索引仲裁:重复到达只回 + * `chat_duplicate`(WS)/ `{duplicate:true}`(REST),**不会再落库、不会再起一个回合**(契约 v0.56 + * §3.13)。所以「超时了但服务端其实收下了」这种最难判的情况不需要本端猜:进队列、以后重发一次, + * 服务端自己会把重复吃掉。这也是「网络类失败可以自动补发」的**全部**依据——没有幂等键,这里只能 + * 回滚。 + * + * ## 分档 + * - `null` ⇒ [RestFailureLanding.Queue]。`null` 的语义是「本端**没等到任何回执**」(自己设的 + * [REST_FALLBACK_TIMEOUT_MILLIS] 到点了,或抛了一个映射不出来的异常):服务端没给过裁决, + * 这条就是「还没上路」,与网络类失败同一档。 + * - [CyreneError.Local] 里的网络族([CyreneError.LocalKind.NoNetwork] / [CyreneError.LocalKind.Dns] / + * [CyreneError.LocalKind.Timeout] / [CyreneError.LocalKind.ConnectFailed])⇒ + * [RestFailureLanding.Queue]:请求根本没被服务端读到,等一下多半会好。 + * - 其余一切 ⇒ [RestFailureLanding.Rollback]: + * - [CyreneError.Server] / [CyreneError.Unauthorized]:服务端**回了话**,那就是它的裁决(拒绝 / + * 令牌失效)。盲目重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话; + * - [CyreneError.Local] 里的配置与协议族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` / + * `Unknown`):**等下去不会变好**,它们要的是用户改设置或换地址,回滚时那句 `userMessage` + * 正是他需要看到的东西。这几档在回落这条路上本来就极少出现——连不上的话探测先就拦下了。 + * + * 用 `when` 穷举:错误模型将来多一档时,这里必须有人做决定,编不出档位就不该静默继承前一档。 + */ +fun restFailureLanding(error: CyreneError?): RestFailureLanding = when (error) { + null -> RestFailureLanding.Queue + + is CyreneError.Local -> when (error.kind) { + CyreneError.LocalKind.NoNetwork, + CyreneError.LocalKind.Dns, + CyreneError.LocalKind.Timeout, + CyreneError.LocalKind.ConnectFailed, + -> RestFailureLanding.Queue + + CyreneError.LocalKind.Tls, + CyreneError.LocalKind.CleartextBlocked, + CyreneError.LocalKind.InvalidUrl, + CyreneError.LocalKind.Parse, + CyreneError.LocalKind.Unknown, + -> RestFailureLanding.Rollback + } + + is CyreneError.Server, is CyreneError.Unauthorized -> RestFailureLanding.Rollback +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt index 70271b3..ed7c21b 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt @@ -51,7 +51,11 @@ enum class WsSendOutcome { /** 帧已经交给 socket(`sendChat` 回 `true`):照今天的样子继续等回执,这里什么都不做。 */ Dispatched, - /** 未就绪 ⇒ 这条**不是失败**:留在待发态,等连接恢复后按发送顺序补发。 */ + /** + * 未就绪 ⇒ 这条**不是失败**。W41 起它不再直接等于「进队列」:调用方(`ChatViewModel.sendThroughLadder`) + * 会先花一次短探测问后端在不在,通就**回落 REST** 把这条发出去,不通才留在待发态、等连接恢复后 + * 按发送顺序补发。无论哪一种,**都不回滚、不报错误横幅**。 + */ Queued, /** 没发出去,而这一刻连接**就绪** ⇒ 真失败:走今天那条回滚 + 文案。 */ @@ -80,6 +84,8 @@ enum class WsSendOutcome { * 3. 其余四档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline]) * ⇒ [WsSendOutcome.Queued]。`ChatSocket.send` 的**第一行**就按连接状态挡掉,帧连序列化都没做、 * 服务端一个字节都收不到 ⇒ 这不是「发送失败」,是**还没上路**:既不回滚,也不进 `error` 横幅。 + * (W41 起这一档由 `sendThroughLadder` 接着往下判:能回落 REST 发就发,发不了才真进队列。 + * 本函数只回答「帧交出去了没有、连接是不是好的」,不掺探测那一层——那层要发请求,不能是纯函数。) * * ## 为什么 Offline 也进队列(而不像告知那样另分一档) * 主人的原话是「未连接就进队列」,四档一视同仁:[WsState.Offline] 只是**不会自己变好**, @@ -158,8 +164,9 @@ sealed interface QueuedSendStep { * (回滚把它退回了输入栏、落库归一把它换成了权威消息),这时候再补发就是凭空多出一条。 * - 气泡在、但它不是**纯文本**(带图 / 带附件)⇒ [QueuedSendStep.Failed]:WS 那条路只走纯文本 * (有图 / 有附件一律 REST,见 [ChatSendRouting.channelFor]),只发正文会把图与附件**静默丢掉**。 - * 今天不可能出现(能进队列的只有已经走到 WS 分支的那几条),这道闸是给将来路由变化留的: - * 真出现了宁可回滚、让用户自己看见并重发,也不要发出一条缺了图的消息。 + * 今天不可能出现(能进队列的仍然只有「本该走 WS 的短文本」那几条——W41 也没有放宽这一条: + * 回落 REST 失败时进队列的同样是短文本,有图 / 有附件的那几条走的是另一条分支、压根不进这层阶梯), + * 这道闸是给将来路由变化留的:真出现了宁可回滚、让用户自己看见并重发,也不要发出一条缺了图的消息。 */ fun ChatUiState.nextQueuedSend(): QueuedSendStep { val head = queuedForConnection.firstOrNull() ?: return QueuedSendStep.Idle diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt new file mode 100644 index 0000000..e29f4d6 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt @@ -0,0 +1,294 @@ +package top.yeij.cyrene.ui.chat + +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS +import top.yeij.cyrene.data.ws.WsState +import top.yeij.cyrene.domain.chat.ChatSendRouting +import top.yeij.cyrene.domain.chat.DraftDocument +import top.yeij.cyrene.domain.chat.SendChannel + +/** + * 「未连接时先探测、能发就发,不通才排队」这条三级阶梯的判据 + * ([sendRouteFor] / [reachabilityOf] / [restFailureLanding])。 + * + * ## 背景:主人 2026-10-05 的第二次定案 + * W40 定的是「未连接就进队列」([SendQueueTest] 那次),它解决的是**内容不被退回给用户**。 + * 这一轮的追问是「**未连接状态你就不能拿 httpAPI 尝试吗?**」,并明确为「可以走 health 端点验证 + * 连通性」。理由是网关只有一条:`/ws` 与 `POST /api/v1/chat` 是同一个 `:8080` 上的两条车道, + * 握手被挡(代理不转发 Upgrade、中间设备掐长连接)时 REST 往往是通的——直接排队等于把 + * 「WS 这条车道暂时不通」当成「后端不可用」,让用户白等一次重连。 + * + * ## 本测试钉住五件事 + * 1. **优先级**([sendRouteFor]):帧交出去了 ⇒ 走 WS(连接那一格不再参与判断);未就绪 + 探测通 + * ⇒ 走 REST 回落;未就绪 + 探测不通 / 超时 / 抛异常 / 没探过 ⇒ 进队列。 + * 2. **fail-closed**([reachabilityOf]):只有「探到 `true`」才算通,其余一切(含超时与异常) + * 都归「不通」——探测这一层永远不因为拿不准而放行一次真发送。 + * 3. **探测必须短**:[PROBE_TIMEOUT_MILLIS] 落在 2~3 秒里(它直接加在用户的发送动作上)。 + * 4. **Offline 不探测**:那一档是「会话已知是死的」,健康端点不鉴权必然回 200,探了只会把 + * W40 的「已排队不会丢」换成一次回滚 + 一句误导人的鉴权错误。 + * 5. **回落失败后的落点**([restFailureLanding]):网络类失败(含超时)⇒ 进队列;服务端给过 + * 裁决的(拒绝 / 401)与配置类错误 ⇒ 今天那条回滚 + 文案。 + */ +class SendFallbackTest { + + // —— 一、优先级:这一刻该走哪条路 —— + + @Test + fun `a frame handed to the socket walks the ws path whatever else says`() { + // `sendChat` 回 true 就是「帧交给 socket 了」:连接状态那一格与探测结论都不再参与判断 + // (`sendChat` 判的就是它自己的状态)。这一档是**成功路径**,W41 一个字都不许动它—— + // 所以这里连探测结论都不给它:`null` 也得是 Ws。 + WsState.entries.forEach { connection -> + listOf(null, Reachability.Reachable, Reachability.Unreachable).forEach { reachability -> + assertEquals( + "$connection 上帧已经交出去了,不该改走别的路", + SendRoute.Ws, + sendRouteFor(wsDispatched = true, connection = connection, reachability = reachability), + ) + } + } + } + + @Test + fun `not connected but reachable falls back to rest`() { + // WS 没把帧交出去,而探测说后端这一刻在 ⇒ 用既有的 POST /api/v1/chat 把这条发出去。 + // 三档「未就绪」一视同仁:Idle / Connecting / Reconnecting 都只是「还没连上」, + // 与「后端不可达」根本不是一件事。 + listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting).forEach { connection -> + assertEquals( + "$connection 上探测通了,该回落 REST", + SendRoute.Rest, + sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Reachable), + ) + } + } + + @Test + fun `not connected and unreachable queues`() { + listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting).forEach { connection -> + assertEquals( + "$connection 上探测不通,该进队列", + SendRoute.Queue, + sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Unreachable), + ) + } + } + + @Test + fun `no probe result at all means queue, never a hopeful rest send`() { + // `null` = 还没探过(或判据被改坏没喂结论)。没有证据说能发,就不发: + // 这一档必须与「探测不通」同归一处,否则「拿不准」会变成一次注定失败的真发送。 + listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting, WsState.Offline).forEach { connection -> + assertEquals( + "$connection 上还没有探测结论,只许进队列", + SendRoute.Queue, + sendRouteFor(wsDispatched = false, connection = connection, reachability = null), + ) + } + } + + // —— 二、探测的结论怎么算:fail-closed —— + + @Test + fun `only a confirmed answer counts as reachable`() { + assertEquals(Reachability.Reachable, reachabilityOf(Result.success(true))) + // 后端回了话但非 2xx / 回执解析不出来:仓库层折成 `false` 或 `failure`,都不是通。 + assertEquals(Reachability.Unreachable, reachabilityOf(Result.success(false))) + } + + @Test + fun `a timing out probe is not reachable`() { + // 硬要求:探测超时一律按「不通」处理(fail-closed 到队列)——**绝不许**把用户的发送 + // 卡在一个等不到头的探测上,也绝不许因为「说不定通」就先发一次 REST。 + assertEquals( + Reachability.Unreachable, + reachabilityOf(Result.failure(SocketTimeoutException("call timeout"))), + ) + } + + @Test + fun `a probe that throws is not reachable, whatever it threw`() { + // 连不上 / DNS / 别的任何异常:同一档。探测这一层**永远不抛异常给调用方**—— + // 它要回答的是一个 yes/no,抛出去只会让「这条消息去哪」这件事没有答案。 + val thrown = listOf( + ConnectException("connection refused"), + UnknownHostException("cyrene.invalid"), + IllegalStateException("boom"), + ) + thrown.forEach { e -> + assertEquals("$e 不该被当成通", Reachability.Unreachable, reachabilityOf(Result.failure(e))) + } + } + + @Test + fun `an unset result is not reachable either`() { + // `Result` 里没有值(既没成功也没给异常)也是一种「没拿到答案」⇒ 不通。 + assertEquals(Reachability.Unreachable, reachabilityOf(Result.failure(RuntimeException()))) + } + + @Test + fun `the probe timeout is short enough to sit inside a send`() { + // 硬要求:探测必须有 2~3 秒量级的短超时。它直接加在用户的发送动作上(气泡已经上屏、 + // 用户等着这条消息的结论),所以既不能长到像卡死,也不能短到正常网络下误判成不通。 + assertTrue( + "探测超时 $PROBE_TIMEOUT_MILLIS ms 落在 2~3 秒之外", + PROBE_TIMEOUT_MILLIS in 2_000L..3_000L, + ) + // 回落 REST 那一次投递另有一道上限,且必须比探测宽(它是真请求,不是探针): + // 两道合起来才是「没网时用户最多等多久」的上界。 + assertTrue( + "回落 REST 的上限该比探测宽裕", + REST_FALLBACK_TIMEOUT_MILLIS > PROBE_TIMEOUT_MILLIS, + ) + } + + // —— 三、Offline 那一档:不探测,直接排队 —— + + @Test + fun `offline queues without probing, because the session is already known dead`() { + // Offline 的语义是「握手 401 且续期也失败」(终态、不自动重连)⇒ 会话已知是死的。 + // 健康端点**不鉴权**(`Cyrene_v2/internal/services/gateway/routes.go:51` 挂在公开路由里), + // 探它必然回 200 ⇒ 判成 Reachable ⇒ 发一次注定 401 的 REST ⇒ 把 W40 那句 + // 「已排队不会丢」换成一次回滚 + 一句会误导人的鉴权错误。所以这一档**根本不探**。 + listOf(null, Reachability.Reachable, Reachability.Unreachable).forEach { reachability -> + assertEquals( + "Offline 上不该因为探测通就去发 REST", + SendRoute.Queue, + sendRouteFor(wsDispatched = false, connection = WsState.Offline, reachability = reachability), + ) + } + } + + @Test + fun `offline is the only state that skips the probe`() { + // 三档「还没连上」都该走到探测那一步(判据只认 Connected 一个值,也排除 Offline 一个值): + // 将来连接状态多一档时,这一条会逼着有人来做决定,而不是静默继承。 + WsState.entries + .filter { it != WsState.Connected && it != WsState.Offline } + .forEach { connection -> + assertEquals( + "$connection 上该允许回落 REST", + SendRoute.Rest, + sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Reachable), + ) + } + } + + // —— 四、回落 REST 失败之后去哪 —— + + @Test + fun `network failures on the fallback queue instead of rolling back`() { + // 这一族都是「请求根本没被服务端读到」:等一下多半会好,而内容一点都不该退给用户。 + // 重发用的是同一个 clientMsgId,服务端按 (session_id, client_msg_id) 仲裁:真重复到达 + // 只回 chat_duplicate / {duplicate:true},不会再落库、不会再起回合(契约 v0.56 §3.13)。 + val network = listOf( + CyreneError.LocalKind.NoNetwork, + CyreneError.LocalKind.Dns, + CyreneError.LocalKind.Timeout, + CyreneError.LocalKind.ConnectFailed, + ) + network.forEach { kind -> + assertEquals( + "$kind 属于网络类失败,该进队列", + RestFailureLanding.Queue, + restFailureLanding(CyreneError.Local(kind)), + ) + } + } + + @Test + fun `a fallback that got no verdict at all queues`() { + // `null` = 本端自己设的那道上限到点了(withTimeoutOrNull 回 null)或抛了个映射不出来的 + // 异常:服务端没给过裁决 ⇒ 这条「还没上路」,与网络类失败同一档。 + assertEquals(RestFailureLanding.Queue, restFailureLanding(null)) + } + + @Test + fun `a verdict from the server is never re-sent behind the user's back`() { + // 服务端**回了话**就是它的裁决:拒绝(4xx/5xx 的明确码)与未授权都不该被悄悄重发—— + // 重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话。 + assertEquals( + RestFailureLanding.Rollback, + restFailureLanding(CyreneError.Server("bad_request", "invalid text", 400)), + ) + assertEquals( + RestFailureLanding.Rollback, + restFailureLanding(CyreneError.Server("internal", "boom", 500)), + ) + assertEquals( + RestFailureLanding.Rollback, + restFailureLanding(CyreneError.Unauthorized("invalid_token", "")), + ) + } + + @Test + fun `configuration failures roll back because waiting will not fix them`() { + // 配置与协议这一族要的是用户改设置 / 换地址,等下去不会变好;回滚时那句 userMessage + // 正是他需要看到的东西(把「证书不对」说成「还没连上」会让他干等)。 + val config = listOf( + CyreneError.LocalKind.Tls, + CyreneError.LocalKind.CleartextBlocked, + CyreneError.LocalKind.InvalidUrl, + CyreneError.LocalKind.Parse, + CyreneError.LocalKind.Unknown, + ) + config.forEach { kind -> + assertEquals( + "$kind 等下去不会变好,该回滚并说清", + RestFailureLanding.Rollback, + restFailureLanding(CyreneError.Local(kind)), + ) + } + } + + @Test + fun `every local kind is decided, none is inherited by accident`() { + // 穷举 `LocalKind`:判据要么进队列、要么回滚,**没有第三种默认**。这条断言的价值在 + // 错误模型将来多一档时——那时 `restFailureLanding` 会编译不过(when 不穷举), + // 而不是静默把新档位当成「网络类」重发出去。 + CyreneError.LocalKind.entries.forEach { kind -> + val landing = restFailureLanding(CyreneError.Local(kind)) + assertTrue( + "$kind 必须被明确分到某一档", + landing == RestFailureLanding.Queue || landing == RestFailureLanding.Rollback, + ) + } + // 队列那一档恰好是「请求没被服务端读到」的网络族;其余一律回滚。 + assertEquals( + RestFailureLanding.Queue, + restFailureLanding(CyreneError.Local(CyreneError.LocalKind.NoNetwork)), + ) + assertNotEquals( + RestFailureLanding.Queue, + restFailureLanding(CyreneError.Local(CyreneError.LocalKind.Tls)), + ) + } + + // —— 五、这条回落到不了「REST 专属」的消息 —— + + @Test + fun `image and document messages never enter this ladder at all`() { + // 回落 REST 只服务「本该走 WS 的短文本」:有图 / 有附件的消息在**进入阶梯之前**就被 + // [ChatSendRouting.channelFor] 判成 Rest,走的是另一条分支(今天的 `sendViaRest` / + // `deliverImage`,行为一个字没变)。所以它们既不会因为「WS 未就绪」而多花一次探测, + // 也不会因为回落失败而进队列——**没有本地 URI 过一阵子还能不能读、图片要不要重编码 + // 这一类新问题**(队列不持久化,真跨了进程 URI 授权就失效)。 + // 路由规则本身由 `ChatSendPolicyTest` 逐个钉住,这里只钉「阶梯的入口因此变窄」这件事。 + val documents = listOf(DraftDocument("content://doc", "a.txt")) + + assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("你好", hasImage = true)) + assertEquals( + SendChannel.Rest, + ChatSendRouting.channelFor("你好", hasImage = false, hasDocuments = documents.isNotEmpty()), + ) + // 反过来:能进阶梯的只有「无图无附件 + 文本没超 WS 帧上限」那一种。 + assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("你好", hasImage = false)) + } +} diff --git a/devtools/fake-backend/README.md b/devtools/fake-backend/README.md index 73e4b6c..85d35b5 100644 --- a/devtools/fake-backend/README.md +++ b/devtools/fake-backend/README.md @@ -278,6 +278,7 @@ curl -s "$B/__control/requests?limit=20" | 端点 | 备注 | |---|---| | `POST /api/v1/auth/login` `/refresh` `/logout` | 回假 token(`role:"owner"`,`expiresAt` 是 **Unix 秒**) | +| `GET /api/v1/health` | 存活探针:回 `{"status":"ok"}`。**不鉴权、不做任何重活**(对齐真后端 `registerPublicRoutes`)。客户端 W41 起在「WS 未就绪」时先探它一次,通就回落 REST 发那条消息;缺了它,那条阶梯在本机就退化成「直接排队」 | | `GET /api/v1/sessions` | 排序照真契约:`pinned DESC, type='main' DESC, created_at ASC`;`unreadCount` 按已读游标算 | | `POST /api/v1/sessions` | 可绑 `personaId`(未知角色 → 400);支持**完全不带 body** | | `PATCH /api/v1/sessions/{id}` | 局部响应;`{}` → 400;`personaId` → 400;title trim 后空 → 400;>80 字 → 400 | diff --git a/devtools/fake-backend/fake_backend.py b/devtools/fake-backend/fake_backend.py index a70416e..e18dd58 100644 --- a/devtools/fake-backend/fake_backend.py +++ b/devtools/fake-backend/fake_backend.py @@ -1042,6 +1042,13 @@ class Handler(BaseHTTPRequestHandler): self._parse_json_body() self._ok({"revoked": True, "revokedCount": 1}) return + if path == "/api/v1/health" and method == "GET": + # 存活探针:**不鉴权**、不做任何重活(对齐真后端 + # `internal/services/gateway/rest.go` 的 handleHealth,路由挂在公开那一组)。 + # 客户端 W41 起在「WS 未就绪」时先探它一次,通就回落 REST 发那条消息—— + # 假后端没有这个端点的话,探测会吃到 404、一律判「不通」,W41 那条阶梯在本机就退化成 W40。 + self._ok({"status": "ok"}) + return if path == "/api/v1/meta/enums" and method == "GET": self._ok(ENUMS_PAYLOAD) return