dsh-account-pool 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * 账号选号器:决定「这次请求用哪个账号」。
3
+ *
4
+ * 策略复刻自已验证的 Go 网关(workbuddy2api),三因子加权 + Top-5 抽签:
5
+ *
6
+ * weight = 积分比例 × 10 + 闲置补偿 + 成功率 × 3
7
+ *
8
+ * - 积分比例:该号积分 / 候选集最高积分。让积分多的多分担,但要和闲置、
9
+ * 成功率一起看,不是谁分高就一直用谁。
10
+ * - 闲置补偿:闲置越久加权越高,防止某号长期轮不到。
11
+ * - 成功率:成功次数 / 总次数,避免一直打故障号。
12
+ *
13
+ * 取权重最高的 5 个作为候选池,再在池内按权重加权随机抽签。这一步是为了
14
+ * 「打散热点」——只按权重降序取第一名的话,积分最高的号会被一直打。
15
+ *
16
+ * 失败处置:
17
+ * - 429 限流 → 软冷却,600s 起指数退避
18
+ * - 402 欠费 → 硬冷却到次日 04:00
19
+ * - 连续失败 → 熔断,退避 30 分钟起
20
+ * - 全部不可用 → 不报错,从冷却号里挑最早到期的顶上(兜底)
21
+ */
22
+
23
+ /** 一天 04:00 这个恢复点:欠费账号的硬冷却目标时刻。 */
24
+ function nextFourAM(now) {
25
+ const at = new Date(now)
26
+ at.setHours(4, 0, 0, 0)
27
+ if (at.getTime() <= now) at.setDate(at.getDate() + 1)
28
+ return at.getTime()
29
+ }
30
+
31
+ /** 数值夹取。 */
32
+ function clamp(value, min, max) {
33
+ return Math.min(Math.max(value, min), max)
34
+ }
35
+
36
+ /**
37
+ * 单个账号的运行期状态。持久化部分(成功/失败计数)由调用方存取,
38
+ * 冷却与熔断是进程内状态,重启即重置——与网关行为一致。
39
+ */
40
+ class AccountState {
41
+ constructor(accountId) {
42
+ this.accountId = accountId
43
+ /** 冷却截止时间戳;0 表示不冷却。 */
44
+ this.cooldownUntilMs = 0
45
+ /** 熔断截止时间戳;0 表示未熔断。 */
46
+ this.breakerUntilMs = 0
47
+ /** 连续失败次数,成功即清零。 */
48
+ this.consecutiveFails = 0
49
+ /** 软冷却连续触发次数,决定退避倍数。 */
50
+ this.softStreak = 0
51
+ /** 上次被选中的时刻,用于闲置补偿。 */
52
+ this.lastUsedAtMs = 0
53
+ /** 累计成功 / 失败次数,算成功率用。 */
54
+ this.successCount = 0
55
+ this.failCount = 0
56
+ /** 已知积分(用于加权);未知时按中性值参与。 */
57
+ this.credits = 0
58
+ /**
59
+ * 最近一次被停用的原因:'breaker'(熔断)| 'hard_credit' | 'soft_rate'
60
+ * | 'session_dead' | null。前端据此显示准确的状态文字,不用靠
61
+ * 「连续失败次数」猜——阈值是可配置的,猜会猜错。
62
+ */
63
+ this.stopReason = null
64
+ }
65
+
66
+ /** 是否因冷却或熔断暂时不可用。 */
67
+ isCooling(now) {
68
+ return this.cooldownUntilMs > now || this.breakerUntilMs > now
69
+ }
70
+
71
+ /** 冷却截止时刻(取两者较晚者)。 */
72
+ cooldownEnd(now) {
73
+ return Math.max(this.cooldownUntilMs, this.breakerUntilMs)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 选号器。
79
+ *
80
+ * 用法:
81
+ * const picker = new AccountPicker({ softCooldownMs, breakerThreshold, ... })
82
+ * const id = picker.pick(候选账号 id 列表, 会话键)
83
+ * picker.reportSuccess(id) / picker.reportFailure(id, kind)
84
+ */
85
+ export class AccountPicker {
86
+ /**
87
+ * @param {object} options
88
+ * @param {number} options.softCooldownMs 软冷却基数(默认 600s)
89
+ * @param {number} options.softCooldownMaxMs 软冷却上限(默认 2h)
90
+ * @param {number} options.breakerThreshold 连续失败多少次熔断(默认 3)
91
+ * @param {number} options.breakerCooldownMs 熔断基数(默认 30m)
92
+ * @param {number} options.breakerCooldownMaxMs 熔断上限(默认 6h)
93
+ * @param {number} options.stickyTtlMs 会话粘性存活时间(默认 30m)
94
+ * @param {number} options.idleWeightPerHour 每小时闲置补偿(默认 0.5)
95
+ * @param {number} options.idleWeightMax 闲置补偿上限(默认 5)
96
+ * @param {() => number} options.now 取当前时间,便于测试注入
97
+ */
98
+ constructor(options = {}) {
99
+ this.softCooldownMs = options.softCooldownMs ?? 600_000
100
+ this.softCooldownMaxMs = options.softCooldownMaxMs ?? 2 * 3600_000
101
+ this.breakerThreshold = options.breakerThreshold ?? 3
102
+ this.breakerCooldownMs = options.breakerCooldownMs ?? 30 * 60_000
103
+ this.breakerCooldownMaxMs = options.breakerCooldownMaxMs ?? 6 * 3600_000
104
+ this.stickyTtlMs = options.stickyTtlMs ?? 30 * 60_000
105
+ this.idleWeightPerHour = options.idleWeightPerHour ?? 0.5
106
+ this.idleWeightMax = options.idleWeightMax ?? 5
107
+ this.now = options.now ?? (() => Date.now())
108
+
109
+ /** @type {Map<string, AccountState>} */
110
+ this.states = new Map()
111
+ /** 会话粘性:会话键 → { accountId, expiresAtMs } */
112
+ this.sticky = new Map()
113
+ }
114
+
115
+ /** 取(必要时创建)一个账号的状态。 */
116
+ stateOf(accountId) {
117
+ let state = this.states.get(accountId)
118
+ if (state === undefined) {
119
+ state = new AccountState(accountId)
120
+ this.states.set(accountId, state)
121
+ }
122
+ return state
123
+ }
124
+
125
+ /** 更新账号积分(拉余额后调用),只影响加权,不影响可用性。 */
126
+ setCredits(accountId, credits) {
127
+ this.stateOf(accountId).credits = Number(credits) || 0
128
+ }
129
+
130
+ /**
131
+ * 算某个账号的权重。三因子加权,理由见文件头注释。
132
+ * @param {AccountState} state
133
+ * @param {number} maxCredits 候选集里的最高积分,作为归一化基准
134
+ * @param {number} now
135
+ */
136
+ weightOf(state, maxCredits, now) {
137
+ const creditRatio = maxCredits > 0 ? state.credits / maxCredits : 1
138
+ const idleHours = state.lastUsedAtMs === 0
139
+ ? 24 // 从未用过 → 给满分闲置补偿,让它有机会上场
140
+ : (now - state.lastUsedAtMs) / 3600_000
141
+ const idleWeight = clamp(idleHours * this.idleWeightPerHour, 0, this.idleWeightMax)
142
+ const total = state.successCount + state.failCount
143
+ // 无记录给中性值 1.5(略高于 1,鼓励新号参与试错)
144
+ const successRate = total === 0 ? 1.5 : state.successCount / total
145
+ return creditRatio * 10 + idleWeight + successRate * 3
146
+ }
147
+
148
+ /**
149
+ * 选一个账号。
150
+ *
151
+ * @param {string[]} candidateIds 本次可用的账号 id 列表
152
+ * @param {string} [sessionKey] 会话键;给了就优先复用该会话已绑定的账号
153
+ * @returns {string|undefined} 选中的账号 id;一个都没有返回 undefined
154
+ */
155
+ pick(candidateIds, sessionKey) {
156
+ if (candidateIds.length === 0) return undefined
157
+ const now = this.now()
158
+
159
+ // 会话粘性:同一会话尽量复用同一账号,多轮对话不跳号。
160
+ if (sessionKey !== undefined && sessionKey !== '') {
161
+ const bound = this.sticky.get(sessionKey)
162
+ if (bound !== undefined) {
163
+ if (bound.expiresAtMs > now && candidateIds.includes(bound.accountId)) {
164
+ const state = this.stateOf(bound.accountId)
165
+ if (!state.isCooling(now)) {
166
+ // 续期:滚动延长,活跃会话不会中途被解绑。
167
+ bound.expiresAtMs = now + this.stickyTtlMs
168
+ state.lastUsedAtMs = now
169
+ return bound.accountId
170
+ }
171
+ }
172
+ if (bound.expiresAtMs <= now) this.sticky.delete(sessionKey)
173
+ }
174
+ }
175
+
176
+ // 过滤掉冷却中 / 熔断中的账号。
177
+ const usable = candidateIds.filter(id => !this.stateOf(id).isCooling(now))
178
+
179
+ if (usable.length === 0) {
180
+ // 兜底:全都在冷却时,挑最早到期的顶上,避免直接失败。
181
+ // 这是有意的降级——宁可让用户拿到可能限流的响应,也不要空手而归。
182
+ let earliest
183
+ let earliestEnd = Infinity
184
+ for (const id of candidateIds) {
185
+ const end = this.stateOf(id).cooldownEnd(now)
186
+ if (end < earliestEnd) {
187
+ earliestEnd = end
188
+ earliest = id
189
+ }
190
+ }
191
+ if (earliest !== undefined) this.bind(sessionKey, earliest, now)
192
+ return earliest
193
+ }
194
+
195
+ // 取 Top-5 候选(按权重降序),再在池内加权随机抽签。
196
+ const maxCredits = Math.max(...usable.map(id => this.stateOf(id).credits))
197
+ const weighted = usable
198
+ .map(id => ({ id, weight: this.weightOf(this.stateOf(id), maxCredits, now) }))
199
+ .sort((a, b) => b.weight - a.weight || a.id.localeCompare(b.id))
200
+ .slice(0, 5)
201
+
202
+ const totalWeight = weighted.reduce((sum, entry) => sum + entry.weight, 0)
203
+ let chosen = weighted[0].id
204
+ if (totalWeight > 0) {
205
+ let roll = Math.random() * totalWeight
206
+ for (const entry of weighted) {
207
+ roll -= entry.weight
208
+ if (roll <= 0) {
209
+ chosen = entry.id
210
+ break
211
+ }
212
+ }
213
+ }
214
+
215
+ this.stateOf(chosen).lastUsedAtMs = now
216
+ this.bind(sessionKey, chosen, now)
217
+ return chosen
218
+ }
219
+
220
+ /** 记一次会话粘性绑定。 */
221
+ bind(sessionKey, accountId, now) {
222
+ if (sessionKey === undefined || sessionKey === '') return
223
+ this.sticky.set(sessionKey, { accountId, expiresAtMs: now + this.stickyTtlMs })
224
+ }
225
+
226
+ /** 请求成功:清失败计数、清冷却、清软冷却退避。 */
227
+ reportSuccess(accountId) {
228
+ const state = this.stateOf(accountId)
229
+ state.successCount += 1
230
+ state.consecutiveFails = 0
231
+ state.cooldownUntilMs = 0
232
+ state.breakerUntilMs = 0
233
+ state.softStreak = 0
234
+ state.stopReason = null
235
+ }
236
+
237
+ /**
238
+ * 请求失败:按失败类型处置账号。
239
+ * @param {string} accountId
240
+ * @param {string} kind classifyUpstreamError 的返回值
241
+ * @returns {string} 人类可读的处置说明,用于日志
242
+ */
243
+ reportFailure(accountId, kind) {
244
+ const state = this.stateOf(accountId)
245
+ const now = this.now()
246
+ state.failCount += 1
247
+ state.consecutiveFails += 1
248
+
249
+ // 熔断判定优先于具体冷却:连续失败够多就先停一阵子。
250
+ if (state.consecutiveFails >= this.breakerThreshold) {
251
+ const backoff = clamp(
252
+ this.breakerCooldownMs * 2 ** (state.consecutiveFails - this.breakerThreshold),
253
+ this.breakerCooldownMs,
254
+ this.breakerCooldownMaxMs,
255
+ )
256
+ state.breakerUntilMs = now + backoff
257
+ state.stopReason = 'breaker'
258
+ return `连续失败 ${state.consecutiveFails} 次,熔断 ${Math.round(backoff / 60000)} 分钟`
259
+ }
260
+
261
+ if (kind === 'hard_credit') {
262
+ state.cooldownUntilMs = nextFourAM(now)
263
+ state.stopReason = 'hard_credit'
264
+ return '积分耗尽,冷却至次日 04:00'
265
+ }
266
+
267
+ if (kind === 'soft_rate') {
268
+ // 软冷却时长本身也指数退避:连续被限流的号越退越久。
269
+ const backoff = clamp(
270
+ this.softCooldownMs * 2 ** state.softStreak,
271
+ this.softCooldownMs,
272
+ this.softCooldownMaxMs,
273
+ )
274
+ state.softStreak += 1
275
+ state.cooldownUntilMs = now + backoff
276
+ state.stopReason = 'soft_rate'
277
+ return `限流,冷却 ${Math.round(backoff / 1000)} 秒`
278
+ }
279
+
280
+ if (kind === 'session_dead') {
281
+ // 掉线的号先短暂冷却;连续掉线会走到上面的熔断分支。
282
+ state.cooldownUntilMs = now + 60_000
283
+ state.stopReason = 'session_dead'
284
+ return '会话失效,冷却 60 秒'
285
+ }
286
+
287
+ return `失败(${kind}),换号重试`
288
+ }
289
+
290
+ /** 手动解冻一个账号(清全部冷却与失败计数)。 */
291
+ /**
292
+ * 手动解冻一个账号:清掉它的冷却、熔断与失败计数。
293
+ *
294
+ * 只解绑**该账号自己**的会话粘性,不动其他账号的绑定——
295
+ * 之前直接 `sticky.clear()` 会把所有会话的绑定一起清掉,
296
+ * 导致正在进行的多轮对话被踢到别的账号上。
297
+ *
298
+ * 注意不清 successCount / failCount:那是账号的历史统计,
299
+ * 解冻只该影响「当前是否可用」,不该抹掉历史。
300
+ */
301
+ revive(accountId) {
302
+ const state = this.stateOf(accountId)
303
+ state.cooldownUntilMs = 0
304
+ state.breakerUntilMs = 0
305
+ state.consecutiveFails = 0
306
+ state.softStreak = 0
307
+ state.stopReason = null
308
+
309
+ // 只解绑指向该账号的会话
310
+ for (const [sessionKey, bound] of this.sticky) {
311
+ if (bound.accountId === accountId) this.sticky.delete(sessionKey)
312
+ }
313
+ }
314
+
315
+ /** 导出各账号可观测状态,供设置页展示。 */
316
+ snapshot() {
317
+ const now = this.now()
318
+ const out = {}
319
+ for (const [id, state] of this.states) {
320
+ const cooling = state.isCooling(now)
321
+ out[id] = {
322
+ cooling,
323
+ cooldownUntilMs: cooling ? state.cooldownEnd(now) : 0,
324
+ // 停用原因:冷却中才给,供界面显示准确文字
325
+ stopReason: cooling ? state.stopReason : null,
326
+ consecutiveFails: state.consecutiveFails,
327
+ successCount: state.successCount,
328
+ failCount: state.failCount,
329
+ credits: state.credits,
330
+ lastUsedAtMs: state.lastUsedAtMs,
331
+ }
332
+ }
333
+ return out
334
+ }
335
+
336
+ /** 清理过期的粘性绑定,防止 Map 无限增长。 */
337
+ sweepSticky() {
338
+ const now = this.now()
339
+ for (const [key, bound] of this.sticky) {
340
+ if (bound.expiresAtMs <= now) this.sticky.delete(key)
341
+ }
342
+ }
343
+ }