feat(chat): 接上 A5 正向增量 after,并用 404 session_not_found 把「会话已删」如实说出来

改了什么:
- SessionsApi.listMessages 加可选 after(契约 §1 行 6)。before / q / after 三者互斥是服务端的
  硬约束,本端三个调用点各只给一个;after 为 null 时 Retrofit 整个省掉这个参数 ⇒ 既有三条路径
  的线上请求逐字节不变。
- MessageRepository 加 loadAfter:同一个端点,服务端按【升序】返回(与 before 的倒序相反),
  仍统一转成升序的 PagedMessages。顺手去掉 toPagedMessages 参数名里那个已经不对的 newestFirst。
- ChatViewModel:
  · 新增 newestKnownAt(从状态现算的 after 游标)与 absorb(只并消息与乐观气泡,不碰
    loading / loadingOlder / hasMore / error —— 一次静默补拉不该把上拉位置和错误横幅掀掉)。
  · 掉线那一刻记下游标、重新连上时用它做一次正向增量。记录时机必须在「掉线」而不是「重连后」:
    重连的 history 帧会把最近 50 条灌进来、游标随即被推到「现在」,那时再取就取不到那个洞了。
    「再拉一页最新」永远补不回中间断掉的那一段,这正是 after 存在的理由(02/A5 的欠账)。
  · ensureMessageKnown 先用增量(只要新来的那几条),还认不出来才退回整页重拉。
  · 循环推进抽成纯函数 loadForwardPages:游标必须严格前进(同毫秒并列且超过一页时推不动,
    契约 §A5 明写的已知边界)+ 轮数上限 20,两道防死循环的闸都由单测钉住。
- 404 session_not_found(契约 v0.63):首屏 / 上拉 / 增量三条读路径都判,非主会话就置
  ChatUiState.sessionGone;ChatScreen 据此显示一句实话并回调 ChatHomeScreen 重拉会话列表
  (refresh 内部会把「指向已删会话」的 activeSessionId 收敛回主会话)。
  主会话不置位:那说明部署本身出了问题,该显示的是普通错误横幅,而不是一句假的「已切回主会话」。

为什么:
主人那次「图片凭空消失、别的端也看不到」的真相,就是会话在别处被删掉之后,客户端把它渲染成了
一条空会话 —— 让用户对着空白猜。现在有了机器可读的 404,就能如实说一句并把人带回主会话。
另一半是重连补洞:一直是「再拉一页最新」在顶着,断线期间超过 50 条必丢中间那一段。

验证:assembleDebug + testDebugUnitTest 全绿,846 测试 / 0 失败 / 0 错误。
较基线 831 新增 15 条(MessageAfterRepositoryTest 5 条走真 OkHttp 断言线上 URI;
ChatRecoveryTest 10 条钉住 404 判据与增量循环的两道闸)。
设备侧验证(点开看到那句实话、断线补洞)另行做,不在本次提交的断言范围内。
This commit is contained in:
AskaEth committed 2026-10-04 22:00:55 +08:00
1 parent a84d473e2f
commit 28c314262e
7 files changed
+549 -24

No files matched your search

@@ -81,7 +81,14 @@ interface SessionsApi {
* 支持搜索之前**逐字节相同**(Retrofit 会整个省掉 null 的查询参数)。非空时服务端按
* 大小写不敏感的 ASCII LIKE 匹配 `content`(`%` / `_` / `\` 已转义为字面量),
* 仍按时间倒序、`before` / `limit` 同分页语义,搜索**严格限定在 `{id}` 会话内**。
* **`q` 与 `after` 互斥**(同传 `400`)——本方法本来就没有 `after` 参数,天然不冲突。
* **`q` 与 `after` 互斥**(同传 `400`)。
* @param after **正向增量**(A5,契约 §1 行 6):返回 `created_at > after` 的消息,**按时间
* **升序**(与 `before` 的倒序**相反**)——用来补「WS 重连只回放最近 50 条」漏掉的那一段。
* 游标是**本端已经看到的最后一条 `createdAt`**;服务端配 `limit` 从最旧的一条开始给,所以
* 用「本页最后一条的 `createdAt`」反复推进就能一直追到当前。
* **与 `before` / `q` 互斥**(同传 `400`)——三个调用点各自只给一个
* (`loadPage` 只给 `before`、`search` 只给 `q`、`loadAfter` 只给 `after`),天然不冲突。
* 同毫秒并列超过 `limit` 时纯 `createdAt` 游标会跨页重读,调用方按 `id` 去重兜底。
*/
@GET("api/v1/sessions/{id}/messages")
suspend fun listMessages(
@@ -90,6 +97,7 @@ interface SessionsApi {
@Query("before") before: Long? = null,
@Query("platform") platform: String? = null,
@Query("q") query: String? = null,
@Query("after") after: Long? = null,
): Response<ApiEnvelope<List<HistoryMessageDto>>>
/**
@@ -11,8 +11,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 会话消息层:分页 / 搜索**读取**,以及单条消息的**编辑 / 删除**(D3)。
* 服务端按倒序返回分页结果,这里统一转成升序的 [PagedMessages]。去重 / 合并请用 [mergeMessages]。
* 会话消息层:分页 / 搜索 / **正向增量**读取,以及单条消息的**编辑 / 删除**(D3)。
*
* 三条读取出口共用同一个端点,只是给的查询参数不同:
* - [loadPage] / [loadLatest]:`before` 往回翻(服务端**倒序**返回);
* - [search]:`q` 会话内搜索(也是倒序);
* - [loadAfter]:`after` 往前追(服务端**升序**返回,契约 §1 行 6)。
*
* 三者都统一转成**升序**的 [PagedMessages] 再交出去。去重 / 合并请用 [mergeMessages]。
*/
class MessageRepository(
private val api: SessionsApi,
@@ -69,6 +75,31 @@ class MessageRepository(
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* **正向增量**(A5,契约 §1 行 6):`GET /messages?after=<ms>`,返回 `created_at > after` 的
* 消息,**升序**(与 [loadPage] 的倒序相反)。
*
* 它存在的唯一理由是补断线的洞:WS 重连只回放主会话**最近 50 条**,断线期间超过 50 条的
* 消息用「再拉一页最新」是补不回来的——最新那一页会直接跳过中间那段。[after] 就是那个游标:
* 传本端已看到的最后一条 `createdAt`,服务端从**最旧的一条开始**给。
*
* 分页推进的规矩(与 [loadPage] 不同,别照抄):下一页的游标是本页**最新**一条的
* `createdAt`([PagedMessages.newestCreatedAt]),不是最旧的。
*
* 同毫秒并列且条数超过 `limit` 时,纯 `createdAt` 游标推不动(契约 §A5 明说的已知边界)——
* 调用方按 `id` 去重、并在游标没前进时停下来,不在这里兜。
*/
suspend fun loadAfter(
sessionId: String,
after: Long,
limit: Int = DEFAULT_PAGE_SIZE,
platform: String? = null,
): Result<PagedMessages> = withContext(dispatcher) {
val pageSize = limit.coerceIn(1, MAX_PAGE_SIZE)
apiCall { api.listMessages(sessionId, pageSize, before = null, platform = platform, after = after) }
.map { raw -> toPagedMessages(raw, pageSize) }
}
/**
* 编辑单条消息(D3,契约 §1 行 6a):`PATCH /api/v1/messages/{id}`,成功返回**服务端更新后的
* 完整消息 DTO**。
@@ -140,14 +171,21 @@ class MessageRepository(
*/
fun normalizeSearchQuery(raw: String): String? = raw.trim().takeIf { it.isNotEmpty() }
/**
* 原始响应 → [PagedMessages]:**无论服务端给的是倒序(`before` / `q`)还是升序(`after`)**,
* 一律按 [MessageOrder] 排成升序。
*
* [hasMore] 两向同义:拿到满页 ⇒ 该方向**可能还有**(`before` 方向是更旧的、`after` 方向是更新的)——
* 它是保守判断,具体能不能继续由调用方的游标决定。
*/
private fun toPagedMessages(
rawNewestFirst: List<HistoryMessageDto>,
raw: List<HistoryMessageDto>,
pageSize: Int,
): PagedMessages {
val ascending = rawNewestFirst.sortedWith(MessageOrder)
val ascending = raw.sortedWith(MessageOrder)
return PagedMessages(
messages = ascending,
hasMore = rawNewestFirst.size >= pageSize,
hasMore = raw.size >= pageSize,
oldestCreatedAt = ascending.firstOrNull()?.createdAt,
newestCreatedAt = ascending.lastOrNull()?.createdAt,
)