gaoluyang
2026-06-24 c0cb161bb52ce0fbdce5c66ec391d107c75e2452
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
import { getDb } from './db'
 
/**
 * IM 状态事件补偿(增量拉取)通用编排
 *
 * 各业务模块(好友、群、申请、读位置)共用一套 update_time + id 正向游标:
 * 从持久化游标出发循环拉取变更并合并进本地 store,直到某页不满(没有更多),
 * 用于 WebSocket 漏推后的兜底补偿(进入 IM、断线重连时触发)
 */
 
/** 增量拉取游标:上次拉到的位置 */
export interface PullCursor {
  lastUpdateTime?: number
  lastId?: number
}
 
/** 可作为游标的拉取记录:服务端按 update_time + id 返回,客户端取最后一条推进游标 */
interface PullRecord {
  id: number
  updateTime?: number
}
 
/** 单次拉取条数(与后端 limit 上限对齐) */
const PULL_PAGE_SIZE = 100
/** 单轮最多翻页数,兜底防御异常游标导致的死循环 */
const PULL_MAX_PAGES = 100
/** 状态事件拉取回扫窗口,覆盖同秒内旧行更新和客户端 / 服务端时钟精度差 */
const PULL_OVERLAP_MS = 5000
/** 消息类 minId 拉取的单轮翻页上限,兜底防御异常游标死翻;消息量可能远大于状态事件,放宽到 1000 */
const MIN_ID_PULL_MAX_PAGES = 1000
 
/** 读取某模块的拉取游标;无则返回空游标(首次拉全量) */
export async function getPullCursor(key: string): Promise<PullCursor> {
  return (await getDb().getSetting<PullCursor>(key)) ?? {}
}
 
/**
 * 通用增量拉取:从持久化游标出发,循环拉取并应用变更,直到某页不满
 *
 * @param cursorKey 游标在 settings 里的 key(每个模块一份)
 * @param fetchPage 按游标拉一页(调对应 pull 接口,返回 VO 列表)
 * @param apply 把一页变更合并进本地 store;返回 false 表示本页未完全落地(如账号已切、依赖资源补拉失败),
 *              此时不推进游标并终止本轮,避免游标越过未落地的记录、导致后续增量永久漏拉。可返回 Promise
 */
export async function runIncrementalPull<T extends PullRecord>(
  cursorKey: string,
  fetchPage: (params: { lastId?: number; lastUpdateTime?: number; limit: number }) => Promise<T[]>,
  apply: (records: T[]) => boolean | Promise<boolean>,
  isActive?: () => boolean
): Promise<void> {
  const storedCursor = await getPullCursor(cursorKey)
  const highWater = { ...storedCursor }
  let cursor =
    storedCursor.lastUpdateTime == null
      ? {}
      : { lastUpdateTime: Math.max(0, storedCursor.lastUpdateTime - PULL_OVERLAP_MS), lastId: 0 }
  for (let page = 0; page < PULL_MAX_PAGES; page++) {
    if (isActive && !isActive()) {
      return
    }
    const list = await fetchPage({
      lastUpdateTime: cursor.lastUpdateTime,
      lastId: cursor.lastId,
      limit: PULL_PAGE_SIZE
    })
    if (isActive && !isActive()) {
      return
    }
    if (list.length > 0) {
      // apply 未完全落地(返回 false)时直接终止:游标只能跟着已落地的数据走,否则会跳过本页记录
      if ((await apply(list)) === false) {
        return
      }
      if (isActive && !isActive()) {
        return
      }
      // 推进游标到本页最后一条并持久化:下次从这里接着拉
      const last = list[list.length - 1]
      if (!last) {
        return
      }
      if (last.updateTime == null) {
        return
      }
      cursor = { lastUpdateTime: last.updateTime, lastId: last.id }
      if (
        highWater.lastUpdateTime == null ||
        cursor.lastUpdateTime > highWater.lastUpdateTime ||
        (cursor.lastUpdateTime === highWater.lastUpdateTime &&
          cursor.lastId > (highWater.lastId ?? 0))
      ) {
        highWater.lastUpdateTime = cursor.lastUpdateTime
        highWater.lastId = cursor.lastId
        await getDb().setSetting(cursorKey, highWater)
      }
    }
    // 不满一页 = 没有更多变更
    if (list.length < PULL_PAGE_SIZE) {
      return
    }
  }
  console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`)
}
 
/**
 * 消息类增量拉取通用编排:按单调 minId 游标循环翻页,直到空页或游标不再前进
 *
 * 与 runIncrementalPull 的区别:消息游标是单调消息 id(非 update_time + id 复合游标),且 messageMaxId 的持久化
 * 跟随消息入库在 applyPage 内完成(messageStore),故本函数只负责「循环翻页机制」,不碰 settings,也不掺消息业务。
 *
 * @param initialMinId 起始游标:上次入库的最大消息 id
 * @param pageSize     单页条数
 * @param fetchPage    按 minId 拉一页
 * @param applyPage    处理本页(建会话 / 分发 / 入库 + 推进 messageMaxId);返回 false 表示本页未落地,停止且不推进游标
 * @param isActive     每次 await 前后自检:取消 / 切账号时返回 false,丢弃本批不入库、不再翻页
 * @param maxPages     单轮翻页上限
 */
export async function runMinIdPull<T extends { id?: number }>(options: {
  applyPage: (records: T[], nextMinId?: number) => Promise<boolean> | Promise<void>
  fetchPage: (params: { minId: number; size: number }) => Promise<T[]>
  initialMinId: number
  isActive?: () => boolean
  maxPages?: number
  pageSize: number
}): Promise<void> {
  const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options
  const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES
  let minId = initialMinId || 0
  for (let page = 0; page < maxPages; page++) {
    if (isActive && !isActive()) {
      return
    }
    const list = await fetchPage({ minId, size: pageSize })
    // 拉取期间取消 / 切账号:丢弃本批不入库,也不再翻页
    if (isActive && !isActive()) {
      return
    }
    if (!list || list.length === 0) {
      return
    }
    // 本批最大消息 id 作为下次游标;无有效 id 则本批 apply 后停(无法继续翻页)
    const validIds = list.map((record) => record.id).filter((id): id is number => id != null)
    const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined
    // applyPage 返回 false:本页未落地(如入库失败),不推进游标并终止,避免漏消息
    if ((await applyPage(list, nextMinId)) === false) {
      return
    }
    // 无有效 id,或游标没前进(后端契约是 id > minId,理论不会出现):停,防御死翻
    if (nextMinId == null || nextMinId <= minId) {
      return
    }
    minId = nextMinId
  }
  console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮')
}