fix(messages): 聊天记录大对话超时——DESC首屏+keyset游标+群聊直达索引

用户报:聊天记录多时「聊天记录加载失败:请求失败 (ECONNABORTED)」(2026-09-07)。
服务器 EXPLAIN ANALYZE 实测两级根因:
- 单聊 1 万条:ASC 末页 OFFSET 10486 逐条回表 2515ms(DESC LIMIT 15ms,167 倍);
- 群聊 16,290 条:查询刻意不带 owner(chat_group 去重后 owner 错位),现有
  (channel,owner,room,timestamp) 索引被 owner 挡住,退化到 room 单列索引+
  filter+filesort——count 4.7s/首页 5.1s/末页 10.7s;真凶群 53132846289@chatroom
  40,863 条,三段串行 ~40s 必撞前端 axios 15s 超时。

修法(不动共享 MySQL 配置,沿用既往修复模式):
1. 迁移 e6f7a8b9c0d1:message 补 (channel, room_id, timestamp) 索引,群查询
   seek+排序直达+覆盖 count;模型侧双声明防 autogenerate 反 DROP。
2. /api/messages 加 order=asc|desc(默认 asc 不变)与 before_ts/before_id
   keyset 游标(成对,单传 422)——排序列 (timestamp,msg_id) 全序,平手按
   msg_id 决胜,游标页恒 O(page_size)。
3. 前端 ChatMain/ChatTimeline 首屏改「order=desc 取最新 50 反转」单请求
   (原 page1 拿 total+深 OFFSET 末页两请求),「加载更多」改游标上翻;
   hasMoreOlder=已加载<总数。SessionPane 搜索等 order=asc 默认路径不受影响。

本地实测(37,532 条群):desc 首页 0.39s、游标上翻 0.24s,浏览器真机
渲染+上翻 150 条无错。测试 8 增(desc/游标/平手边界/422/窗口叠加/
前端流程不变式),全量 601 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 16:59:04 +08:00
parent 085a9b4fd3
commit 9d3cc8e76e
7 changed files with 348 additions and 77 deletions
+37 -9
View File
@@ -12,10 +12,20 @@ Read-only query over the mirrored ``message`` table. Filters:
see ``_MSG_TYPE_FILTER``). Unknown values are rejected with 422.
* ``q`` — substring match on ``text`` (``%``/``_`` escaped
as literals).
* ``order`` — ``asc`` (default, oldest-first timeline) /
``desc`` (newest-first; 聊天窗首屏「取最新一页」用它,反向索引扫描恒
O(page_size),避开 ASC 深 OFFSET 逐行回表——2026-09-07 实测 1 万条对话
末页 2.5s、4 万条群 10.7s 撞前端 15s 超时)。
* ``before_ts``/``before_id`` — keyset 游标(成对,可选):只取严格早于
``(before_ts, before_id)`` 的消息(排序列 (timestamp, msg_id) 全序,
平手按 msg_id 决胜)。「加载更多」上翻历史用它,每页恒 O(page_size);
两参必须成对,单传任一 422。
Ordered by ``timestamp asc`` so the timeline renders oldest-first. The index
``ix_message_channel_owner_talker_time`` covers the hot path
``(channel, owner_account_id, talker_id, timestamp)``.
Ordered by ``(timestamp, msg_id)`` in the requested direction. The index
``ix_message_channel_owner_talker_time`` covers the 1:1 hot path
``(channel, owner_account_id, talker_id, timestamp)``; group queries filter
``channel + room_id`` without owner(群聊 owner 错位,见 ChatMain queryFor),
served by ``ix_message_channel_room_time``.
"""
from __future__ import annotations
@@ -23,7 +33,7 @@ from __future__ import annotations
from typing import Annotated, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
@@ -70,8 +80,16 @@ async def list_messages(
end: Annotated[int | None, Query(ge=0)] = None,
msg_type: Annotated[MsgTypeParam | None, Query()] = None,
q: Annotated[str | None, Query()] = None,
order: Annotated[Literal["asc", "desc"], Query()] = "asc",
before_ts: Annotated[int | None, Query(ge=0)] = None,
before_id: Annotated[str | None, Query()] = None,
) -> Page[MessageOut]:
"""List messages for a timeline (ordered by ``timestamp asc``)."""
"""List messages for a timeline (ordered by ``(timestamp, msg_id)``)."""
if (before_ts is None) != (before_id is None):
raise HTTPException(
status_code=422, detail="before_ts 与 before_id 必须成对使用"
)
conditions = []
if channel is not None:
conditions.append(Message.channel == channel)
@@ -91,20 +109,30 @@ async def list_messages(
# LIKE 通配符转义: %/_ 按字面匹配
escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conditions.append(Message.text.like(f"%{escaped}%", escape="\\"))
if before_ts is not None:
# keyset 游标:严格早于 (before_ts, before_id) 的全序前缀,平手按
# msg_id 决胜——与 order_by 同一全序,翻页无缝
conditions.append(
sa.or_(
Message.timestamp < before_ts,
sa.and_(Message.timestamp == before_ts, Message.msg_id < before_id),
)
)
total_stmt = sa.select(sa.func.count()).select_from(Message)
if conditions:
total_stmt = total_stmt.where(*conditions)
total = (await session.execute(total_stmt)).scalar_one()
ts_order = (
Message.timestamp.desc() if order == "desc" else Message.timestamp.asc()
)
id_order = Message.msg_id.desc() if order == "desc" else Message.msg_id.asc()
list_stmt = sa.select(Message)
if conditions:
list_stmt = list_stmt.where(*conditions)
list_stmt = (
list_stmt.order_by(
sa.nullslast(Message.timestamp.asc()),
Message.msg_id.asc(),
)
list_stmt.order_by(sa.nullslast(ts_order), id_order)
.limit(page_size)
.offset((page - 1) * page_size)
)
+13
View File
@@ -434,6 +434,19 @@ class Message(Base):
"room_id",
"timestamp",
),
# 群聊消息窗实际查询形如 ``channel=? AND room_id=? ORDER BY timestamp``
# (不带 owner——chat_group 按 room 去重后 owner 与消息归属错位,见
# ChatMain queryFor)。上面的索引被 owner 挡住前缀用不上,优化器退化到
# room 单列索引 + filter + filesort:2026-09-07 服务器实测 4 万条群
# count 4.7s / 首页 5.1s / 末页 10.7s,串行撞前端 15s 超时
# (ECONNABORTED)。此索引让群查询与单聊同享 seek+排序直达。
# 「模型+迁移双声明」防 alembic autogenerate 反 DROP。
Index(
"ix_message_channel_room_time",
"channel",
"room_id",
"timestamp",
),
# 今日收发聚合热路径:channel= + owner IN + timestamp>= 定位当天消息
# (customer 列表行内 send_today/receive_today)。上面两个索引把
# timestamp 排在 talker/room 之后,时间范围无法直接 seek;MySQL 预编译
@@ -0,0 +1,66 @@
"""message 补 (channel, room_id, timestamp) 索引(群聊消息窗超时修复)
Revision ID: e6f7a8b9c0d1
Revises: d9e0f1a2b3c4
Create Date: 2026-09-07
内容
----
群聊消息窗(/api/messages?channel=&room_id=)**刻意不带 owner**——chat_group
按 room_id 去重后表的 owner 被最后写入的员工覆盖,与消息实际归属错位,带上
会查空(2026-08-22 实测)。于是 ix_message_channel_owner_room_time 的
owner 前缀用不上,优化器退化到 room 单列索引(ix_message_room)取全行再
filter channel + filesort:
- 16,290 条群: count 4.7s / 首页 5.1s / ASC 末页 10.7s(EXPLAIN ANALYZE
实测,Sort row IDs 全量 16k 行、128MB buffer pool 下逐行回表磁盘随机读);
- 真凶对话 53132846289@chatroom 40,863 条(count+首页+末页串行 ~40s),
前端 axios 15s 超时 → 聊天记录加载失败 ECONNABORTED(2026-09-07 用户报)。
本迁移补 (channel, room_id, timestamp) 三列索引:群查询与单聊同享
seek + 排序直达 + 覆盖 count。模型侧 models.py 同步双声明,防 alembic
autogenerate 反 DROP。窄列 BTREE,~94 万行在线建索引预计几十秒
(参照 c53f90b1e6a4 实测 86 万行 16s)。
配套:同日 /api/messages 加 order=desc + before_ts/before_id 游标参数,
聊天窗首屏「DESC 取最新一页 + 反转」、上翻游标,避开 ASC 深 OFFSET
逐行回表(单聊 1 万条末页实测 2.5s → DESC LIMIT 15ms)。
幂等:``sa.inspect`` 先查同名索引,存在即跳过。SQLite/MySQL 通用。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e6f7a8b9c0d1'
down_revision: str | Sequence[str] | None = 'd9e0f1a2b3c4'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = 'ix_message_channel_room_time'
def _has_index(table: str, index: str) -> bool:
insp = sa.inspect(op.get_bind())
return index in {i['name'] for i in insp.get_indexes(table)}
def upgrade() -> None:
"""Upgrade schema."""
if _has_index('message', _INDEX_NAME):
return # 已建过(或先手工建过),幂等跳过
op.create_index(
_INDEX_NAME,
'message',
['channel', 'room_id', 'timestamp'],
)
def downgrade() -> None:
"""Downgrade schema."""
if not _has_index('message', _INDEX_NAME):
return
op.drop_index(_INDEX_NAME, table_name='message')
+159
View File
@@ -476,3 +476,162 @@ async def test_msg_type_and_q_combine(make_client):
assert r.status_code == 200
body = r.json()
assert [it["msg_id"] for it in body["items"]] == ["ty_transfer"]
# ---------------------------------------------------------------------------
# order=desc + before_ts/before_id 游标(2026-09-07 深分页超时修复:
# 大对话 ASC 末页 OFFSET 数万行逐条回表 2.5s+,群聊 filesoort 10s+ 撞前端
# 15s 超时 ECONNABORTED。DESC 首页+游标上翻每页恒 O(page_size))
# ---------------------------------------------------------------------------
async def _seed_desc(session: AsyncSession) -> None:
"""t_a 会话 5 条,含两组同 timestamp 平手——验证 (timestamp, msg_id) 全序。
ASC 全序 = a1,a2,a3,a4,a5;DESC 全序 = a5,a4,a3,a2,a1。
"""
rows = [("a1", 1000), ("a2", 1000), ("a3", 2000), ("a4", 3000), ("a5", 3000)]
session.add_all(
[
Message(
msg_id=mid,
channel="wechat",
owner_account_id="wx_owner_1",
talker_id="t_a",
type="text",
type_raw=1,
is_send=True,
timestamp=ts,
)
for mid, ts in rows
]
)
await session.commit()
async def test_order_desc_returns_newest_first(make_client):
"""order=desc → 按 (timestamp, msg_id) 降序,平手时 msg_id 大者在前。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages", params={"talker_id": "t_a", "order": "desc"}
)
assert r.status_code == 200
body = r.json()
assert body["total"] == 5
assert [it["msg_id"] for it in body["items"]] == ["a5", "a4", "a3", "a2", "a1"]
async def test_order_desc_pagination_from_newest(make_client):
"""desc 的 page 从最新端数:page1=最新 slice(前端首屏即此页)。"""
client = await make_client(_seed_desc)
r1 = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "page": 1, "page_size": 2},
)
assert [it["msg_id"] for it in r1.json()["items"]] == ["a5", "a4"]
r2 = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "page": 2, "page_size": 2},
)
assert [it["msg_id"] for it in r2.json()["items"]] == ["a3", "a2"]
async def test_order_invalid_rejected_422(make_client):
client = await make_client(_seed_desc)
r = await client.get("/api/messages", params={"order": "bogus"})
assert r.status_code == 422
async def test_before_cursor_returns_preceding_slice(make_client):
"""before=(3000,a4) → 严格早于该游标的消息按 desc 取,平手边界不含游标本行。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages",
params={
"talker_id": "t_a",
"order": "desc",
"page_size": 2,
"before_ts": 3000,
"before_id": "a4",
},
)
assert r.status_code == 200
body = r.json()
# 游标后剩余 a1/a2/a3;desc 前 2 条
assert [it["msg_id"] for it in body["items"]] == ["a3", "a2"]
assert body["total"] == 3
async def test_before_cursor_tie_boundary_exclusive(make_client):
"""before=(3000,a5):同 ts=3000 的 a4 因 msg_id<a5 仍在界内。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "before_ts": 3000, "before_id": "a5"},
)
body = r.json()
assert [it["msg_id"] for it in body["items"]] == ["a4", "a3", "a2", "a1"]
assert body["total"] == 4
async def test_before_requires_both_params_422(make_client):
"""游标两参必须成对:单传任一 422,防止静默忽略半个游标查错页。"""
client = await make_client(_seed_desc)
r1 = await client.get("/api/messages", params={"before_ts": 3000})
assert r1.status_code == 422
r2 = await client.get("/api/messages", params={"before_id": "a5"})
assert r2.status_code == 422
async def test_before_cursor_with_window_filter(make_client):
"""游标与 start/end 过滤叠加(loadOlder 带日期窗场景)。"""
client = await make_client(_seed_desc)
r = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "start": 1500},
)
assert [it["msg_id"] for it in r.json()["items"]] == ["a5", "a4", "a3"]
r2 = await client.get(
"/api/messages",
params={
"talker_id": "t_a",
"order": "desc",
"start": 1500,
"before_ts": 2000,
"before_id": "a3",
},
)
body = r2.json()
assert body["items"] == []
assert body["total"] == 0
async def test_load_older_flow_reproduces_full_asc_order(make_client):
"""前端新流程端到端不变式:desc 首页反转 + 逐游标上翻反转,拼接 = 全量 ASC。
这正是 ChatMain/ChatTimeline 的首屏+「加载更多」序列——顺序若与旧
ASC 分页不一致,时间线就会错乱。
"""
client = await make_client(_seed_desc)
page_size = 2
async def fetch(**extra):
r = await client.get(
"/api/messages",
params={"talker_id": "t_a", "order": "desc", "page_size": page_size, **extra},
)
assert r.status_code == 200
return r.json()
displayed: list[str] = []
body = await fetch()
# 会话总数以首屏(无游标)响应为准;游标响应的 total 是「游标后剩余数」
conv_total = body["total"]
displayed = [it["msg_id"] for it in body["items"][::-1]]
while len(displayed) < conv_total:
oldest = body["items"][-1]
body = await fetch(before_ts=oldest["timestamp"], before_id=oldest["msg_id"])
assert body["items"], "游标还未走完 total 就断了"
displayed = [it["msg_id"] for it in body["items"][::-1]] + displayed
assert displayed == ["a1", "a2", "a3", "a4", "a5"]
+11
View File
@@ -94,6 +94,14 @@ export interface MessageQuery {
end?: number
page?: number
page_size?: number
/** 排序方向:asc(默认,最旧在前)/ desc(最新在前)。聊天窗首屏用 desc
* 取最新一页(O(page_size)),避开 ASC 深 OFFSET 逐行回表(大对话 2.5s+,
* 2026-09-07 超时修复)。 */
order?: 'asc' | 'desc'
/** keyset 游标(与 before_id 成对):只取严格早于 (before_ts, before_id) 的
* 消息。「加载更多」上翻用,每页恒 O(page_size),与 order 独立组合。 */
before_ts?: number
before_id?: string
}
export function getMessages(query: MessageQuery): Promise<Page<Message>> {
@@ -110,6 +118,9 @@ export function getMessages(query: MessageQuery): Promise<Page<Message>> {
end: query.end,
page: query.page ?? 1,
page_size: query.page_size ?? 50,
order: query.order,
before_ts: query.before_ts,
before_id: query.before_id,
}),
)
}
+31 -34
View File
@@ -32,17 +32,17 @@ const loading = ref(false)
const loadingOlder = ref(false)
const errorMsg = ref<string | null>(null)
// Asc-ordered pagination: page 1 = oldest 50, last page = most recent 50.
// We land on the last page (most recent activity) and walk backwards to load
// older history. `currentPage` is the smallest page number currently loaded.
const currentPage = ref(1)
const totalPages = ref(1)
// Desc-keyset 流式加载(2026-09-07 深分页超时修复):首屏 order=desc 取最新
// PAGE_SIZE 条反转成 ASC 展示,「加载更多」带 (before_ts, before_id) 游标向更旧
// 翻——每页恒 O(PAGE_SIZE)。旧方案「先拉 page1 拿 total 再拉 ASC 末页」在大对话
// 上 OFFSET 数万行逐行回表(1 万条 2.5s),群聊再叠加 filesort(4 万条 count 4.7s
// /首页 5.1s/末页 10.7s),串行撞 axios 15s 超时 → ECONNABORTED。
const total = ref(0)
/** 首次加载是否完成(msgid 定位只在已加载数据上尝试,避免误报「不在当前页」)。 */
const initialLoaded = ref(false)
// 请求序号防竞态:快速切换会话时,旧会话的请求仍在途、响应后到,若不拦截会把
// 新会话已渲染的 messages/total/currentPage/标题 覆盖回旧会话数据(自愈但错乱)。
// 新会话已渲染的 messages/total/标题 覆盖回旧会话数据(自愈但错乱)。
// 每次 loadInitial/loadTalkerName 自增取号,响应回来时号不对即整体丢弃。
let loadSeq = 0
let nameSeq = 0
@@ -51,7 +51,8 @@ const isGroup = computed(() => props.kind === 'group')
/** 是否已选定具体会话(决定是否拉消息 / 显示空态)。账号级入口(talkerId='')→ 空态。 */
const hasActive = computed(() => !!props.talkerId)
const hasMoreOlder = computed(() => currentPage.value > 1)
/** 还有更旧历史未加载:已加载数 < 会话总数(总数以首屏响应为准,游标响应的 total 是剩余数)。 */
const hasMoreOlder = computed(() => messages.value.length < total.value)
/**
* 关键词过滤:仅作用于"当前已加载"的消息(纯前端过滤,不触发重载——输入过程中
@@ -164,8 +165,8 @@ function scrollToBottom(): void {
})
}
/** Shared query params for both DM and group timelines. */
function queryFor(pageNum: number) {
/** Shared query params for both DM and group timelines (without pagination bits). */
function queryFor() {
return {
channel: CHANNEL,
// 群聊不带 owner:chat_group 表按 room_id 去重、owner 被最后写入的员工
@@ -176,15 +177,14 @@ function queryFor(pageNum: number) {
: { owner_account_id: props.ownerId, talker_id: props.talkerId as string }),
...(winStart.value != null && winEnd.value != null ? { start: winStart.value, end: winEnd.value } : {}),
...(msgType.value ? { msg_type: msgType.value } : {}),
page: pageNum,
page_size: PAGE_SIZE,
}
}
/**
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
* purely to learn `total` so we can compute `lastPage` — the messages API
* returns asc, so the last page holds the newest slice.
* Fetch the newest slice on entry: `order=desc` page 1 returns the most recent
* PAGE_SIZE messages; we reverse it to ASC for display. One request replaces
* the old two-step dance (page 1 for `total` + deep-offset last page).
*/
async function loadInitial(): Promise<void> {
const seq = ++loadSeq
@@ -193,8 +193,6 @@ async function loadInitial(): Promise<void> {
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
pendingMsgid.value = null
initialLoaded.value = true
// 本分支已取最新序号,在途旧请求的 finally 会被拦下,须自行复位加载态
@@ -204,19 +202,11 @@ async function loadInitial(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const first = await getMessages(queryFor(1))
const res = await getMessages({ ...queryFor(), order: 'desc', page: 1 })
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
total.value = first.total
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
if (totalPages.value <= 1) {
messages.value = first.items
} else {
const last = await getMessages(queryFor(totalPages.value))
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
messages.value = last.items
}
currentPage.value = totalPages.value
total.value = res.total
// 后端 desc(新→旧);反转成时间线正序(旧→新)。
messages.value = [...res.items].reverse()
scrollToBottom()
} catch (e) {
if (seq !== loadSeq) return // 竞态:旧请求的报错不展示(会话已切换)
@@ -233,18 +223,25 @@ async function loadInitial(): Promise<void> {
nextTick(() => tryLocate())
}
/** Prepend the next older page (currentPage - 1). No-op on page 1. */
/** Prepend the next older slice via keyset cursor. No-op when nothing older. */
async function loadOlder(): Promise<void> {
if (loadingOlder.value || !hasMoreOlder.value) return
const oldest = messages.value[0]
// 游标取最早一条已加载消息的 (timestamp, msg_id);timestamp 缺失无法定位
// (生产数据 0 行 NULL,防御性早退)。
if (!oldest || oldest.timestamp == null) return
loadingOlder.value = true
const prevHeight = streamRef.value?.scrollHeight ?? 0
try {
const prevPage = currentPage.value - 1
const res = await getMessages(queryFor(prevPage))
// Backend returns asc, so res.items are older than everything currently
// shown — prepend them.
messages.value = [...res.items, ...messages.value]
currentPage.value = prevPage
const res = await getMessages({
...queryFor(),
order: 'desc',
page: 1,
before_ts: oldest.timestamp,
before_id: oldest.msg_id,
})
// 后端 desc(新→旧);反转后正好衔接在现有列表之前。
messages.value = [...[...res.items].reverse(), ...messages.value]
// Keep the user's scroll anchored near where they were.
nextTick(() => {
const el = streamRef.value
+31 -34
View File
@@ -35,15 +35,15 @@ const loading = ref(false)
const loadingOlder = ref(false)
const errorMsg = ref<string | null>(null)
// Asc-ordered pagination: page 1 = oldest 50, last page = most recent 50.
// We land on the last page (most recent activity) and walk backwards to load
// older history. `currentPage` is the smallest page number currently loaded.
const currentPage = ref(1)
const totalPages = ref(1)
// Desc-keyset 流式加载(2026-09-07 深分页超时修复,与 staffchat/ChatMain 同款):
// 首屏 order=desc 取最新 PAGE_SIZE 条反转成 ASC 展示,「加载更多」带
// (before_ts, before_id) 游标向更旧翻——每页恒 O(PAGE_SIZE)。旧方案
// 「先拉 page1 拿 total 再拉 ASC 末页」在大对话上 OFFSET 数万行逐行回表,
// 群聊再叠加 filesort,串行撞 axios 15s 超时 → ECONNABORTED。
const total = ref(0)
// 请求序号防竞态:快速切换会话时,旧会话的请求仍在途、响应后到,若不拦截会把
// 新会话已渲染的 messages/total/currentPage/标题 覆盖回旧会话数据(自愈但错乱)。
// 新会话已渲染的 messages/total/标题 覆盖回旧会话数据(自愈但错乱)。
// 每次 loadInitial/loadTalkerName 自增取号,响应回来时号不对即整体丢弃。
let loadSeq = 0
let nameSeq = 0
@@ -62,7 +62,8 @@ const activeId = computed<string | null>(() => props.talkerId ?? props.roomId ??
/** 是否已选定具体会话(决定是否拉消息 / 显示空态)。 */
const hasActive = computed(() => !!activeId.value)
const hasMoreOlder = computed(() => currentPage.value > 1)
/** 还有更旧历史未加载:已加载数 < 会话总数(总数以首屏响应为准,游标响应的 total 是剩余数)。 */
const hasMoreOlder = computed(() => messages.value.length < total.value)
/**
* 关键词过滤:仅作用于"当前已加载"的消息(/api/messages 无 q 参数,非全量历史)。
@@ -175,8 +176,8 @@ function scrollToBottom(): void {
})
}
/** Shared query params for both DM and group timelines. */
function queryFor(pageNum: number) {
/** Shared query params for both DM and group timelines (without pagination bits). */
function queryFor() {
return {
channel: channelTyped.value,
// 群聊不带 owner:chat_group 表按 room_id 去重、owner 被最后写入的员工
@@ -186,15 +187,14 @@ function queryFor(pageNum: number) {
? { room_id: props.roomId as string }
: { owner_account_id: props.ownerId, talker_id: props.talkerId as string }),
...(winStart.value != null && winEnd.value != null ? { start: winStart.value, end: winEnd.value } : {}),
page: pageNum,
page_size: PAGE_SIZE,
}
}
/**
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
* purely to learn `total` so we can compute `lastPage` — the messages API
* returns asc, so the last page holds the newest slice.
* Fetch the newest slice on entry: `order=desc` page 1 returns the most recent
* PAGE_SIZE messages; we reverse it to ASC for display. One request replaces
* the old two-step dance (page 1 for `total` + deep-offset last page).
*/
async function loadInitial(): Promise<void> {
const seq = ++loadSeq
@@ -202,8 +202,6 @@ async function loadInitial(): Promise<void> {
if (!hasActive.value) {
messages.value = []
total.value = 0
totalPages.value = 1
currentPage.value = 1
// 本分支已取最新序号,在途旧请求的 finally 会被拦下,须自行复位加载态
loading.value = false
return
@@ -211,19 +209,11 @@ async function loadInitial(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const first = await getMessages(queryFor(1))
const res = await getMessages({ ...queryFor(), order: 'desc', page: 1 })
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
total.value = first.total
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
if (totalPages.value <= 1) {
messages.value = first.items
} else {
const last = await getMessages(queryFor(totalPages.value))
if (seq !== loadSeq) return // 竞态:已有更新的加载,丢弃旧会话响应
messages.value = last.items
}
currentPage.value = totalPages.value
total.value = res.total
// 后端 desc(新→旧);反转成时间线正序(旧→新)。
messages.value = [...res.items].reverse()
scrollToBottom()
} catch (e) {
if (seq !== loadSeq) return // 竞态:旧请求的报错不展示(会话已切换)
@@ -235,18 +225,25 @@ async function loadInitial(): Promise<void> {
}
}
/** Prepend the next older page (currentPage - 1). No-op on page 1. */
/** Prepend the next older slice via keyset cursor. No-op when nothing older. */
async function loadOlder(): Promise<void> {
if (loadingOlder.value || !hasMoreOlder.value) return
const oldest = messages.value[0]
// 游标取最早一条已加载消息的 (timestamp, msg_id);timestamp 缺失无法定位
// (生产数据 0 行 NULL,防御性早退)。
if (!oldest || oldest.timestamp == null) return
loadingOlder.value = true
const prevHeight = streamRef.value?.scrollHeight ?? 0
try {
const prevPage = currentPage.value - 1
const res = await getMessages(queryFor(prevPage))
// Backend returns asc, so res.items are older than everything currently
// shown — prepend them.
messages.value = [...res.items, ...messages.value]
currentPage.value = prevPage
const res = await getMessages({
...queryFor(),
order: 'desc',
page: 1,
before_ts: oldest.timestamp,
before_id: oldest.msg_id,
})
// 后端 desc(新→旧);反转后正好衔接在现有列表之前。
messages.value = [...[...res.items].reverse(), ...messages.value]
// Keep the user's scroll anchored near where they were.
nextTick(() => {
const el = streamRef.value