clear-session-bindings.ts 30 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
  1. #!/usr/bin/env bun
  2. /**
  3. * 清除指定供应商的会话绑定
  4. *
  5. * 用法:
  6. * # 交互式模式(推荐)
  7. * bun run scripts/clear-session-bindings.ts
  8. *
  9. * # 按优先级筛选
  10. * bun run scripts/clear-session-bindings.ts --priority <number>
  11. *
  12. * # 指定供应商 ID
  13. * bun run scripts/clear-session-bindings.ts --id 1,2,3
  14. *
  15. * # 指定供应商名称(模糊匹配)
  16. * bun run scripts/clear-session-bindings.ts --name "cubence"
  17. *
  18. * 选项:
  19. * --priority, -p <number> 优先级阈值(清除 priority < 该值的供应商绑定)
  20. * --id <ids> 指定供应商 ID(逗号分隔)
  21. * --name <pattern> 按名称模糊匹配供应商
  22. * --type <type> 供应商类型筛选(claude, claude-auth, codex, 默认全部)
  23. * --yes, -y 跳过确认提示
  24. * --dry-run 仅显示将要清理的内容,不实际执行
  25. * --help, -h 显示帮助信息
  26. */
  27. import { createInterface, type Interface as ReadlineInterface } from "node:readline";
  28. import { and, asc, eq, ilike, inArray, isNull, lt, or } from "drizzle-orm";
  29. import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
  30. import Redis, { type RedisOptions } from "ioredis";
  31. import postgres from "postgres";
  32. import * as schema from "../src/drizzle/schema";
  33. // ============================================================================
  34. // 常量配置
  35. // ============================================================================
  36. const SCAN_BATCH_SIZE = 500;
  37. const PIPELINE_BATCH_SIZE = 200;
  38. // Session 相关的 Redis Key 后缀
  39. const SESSION_KEY_SUFFIXES = [
  40. "provider",
  41. "info",
  42. "usage",
  43. "key",
  44. "last_seen",
  45. "messages",
  46. "response",
  47. "concurrent_count",
  48. ];
  49. // ============================================================================
  50. // 类型定义
  51. // ============================================================================
  52. type Database = PostgresJsDatabase<typeof schema>;
  53. type PostgresClient = ReturnType<typeof postgres>;
  54. type ProviderType =
  55. | "claude"
  56. | "claude-auth"
  57. | "codex"
  58. | "gemini-cli"
  59. | "gemini"
  60. | "openai-compatible";
  61. const VALID_PROVIDER_TYPES: ReadonlyArray<ProviderType | "all"> = [
  62. "claude",
  63. "claude-auth",
  64. "codex",
  65. "gemini-cli",
  66. "gemini",
  67. "openai-compatible",
  68. "all",
  69. ];
  70. function isValidProviderType(value: string): value is ProviderType | "all" {
  71. return VALID_PROVIDER_TYPES.includes(value as ProviderType | "all");
  72. }
  73. interface ProviderRecord {
  74. id: number;
  75. name: string;
  76. priority: number;
  77. providerType: string;
  78. isEnabled: boolean;
  79. }
  80. interface SessionBinding {
  81. providerId: number;
  82. keyId?: number | null;
  83. }
  84. interface CliOptions {
  85. mode: "interactive" | "priority" | "id" | "name";
  86. priorityThreshold?: number;
  87. providerIds?: number[];
  88. namePattern?: string;
  89. providerType?: ProviderType | "all";
  90. assumeYes: boolean;
  91. dryRun: boolean;
  92. }
  93. interface CleanupResult {
  94. sessionCount: number;
  95. deletedKeys: number;
  96. zsetRemovals: number;
  97. missingKeyRefs: number;
  98. }
  99. // ============================================================================
  100. // CLI 参数解析
  101. // ============================================================================
  102. function printUsage(): void {
  103. console.log(`
  104. 用法: bun run scripts/clear-session-bindings.ts [选项]
  105. 清除指定供应商的会话绑定。支持交互式选择或通过参数指定。
  106. 模式选择(互斥,不指定则进入交互模式):
  107. --priority, -p <number> 按优先级筛选(清除 priority < 该值的供应商)
  108. --id <ids> 指定供应商 ID(逗号分隔,如 1,2,3)
  109. --name <pattern> 按名称模糊匹配供应商
  110. 筛选选项:
  111. --type <type> 供应商类型筛选(claude, claude-auth, codex, all)
  112. 默认: all
  113. 执行选项:
  114. --yes, -y 跳过确认提示,直接执行
  115. --dry-run 仅显示将要清理的内容,不实际执行删除操作
  116. --help, -h 显示此帮助信息
  117. 示例:
  118. # 交互式模式(推荐新手使用)
  119. bun run scripts/clear-session-bindings.ts
  120. # 清除优先级小于 10 的 Claude 供应商
  121. bun run scripts/clear-session-bindings.ts --priority 10 --type claude
  122. # 清除指定 ID 的供应商绑定
  123. bun run scripts/clear-session-bindings.ts --id 1,2,3
  124. # 按名称模糊匹配
  125. bun run scripts/clear-session-bindings.ts --name "cubence"
  126. # 预览模式
  127. bun run scripts/clear-session-bindings.ts --priority 5 --dry-run
  128. `);
  129. }
  130. function parseCliArgs(args: string[]): CliOptions {
  131. let priorityValue: number | null = null;
  132. let providerIds: number[] | null = null;
  133. let namePattern: string | null = null;
  134. let providerType: ProviderType | "all" | undefined;
  135. let assumeYes = false;
  136. let dryRun = false;
  137. for (let i = 0; i < args.length; i++) {
  138. const arg = args[i];
  139. if (arg === "--priority" || arg === "-p") {
  140. const nextValue = args[++i];
  141. if (!nextValue) throw new Error("--priority 需要一个数值参数");
  142. priorityValue = Number.parseInt(nextValue, 10);
  143. if (Number.isNaN(priorityValue)) throw new Error("--priority 必须是整数");
  144. } else if (arg.startsWith("--priority=")) {
  145. priorityValue = Number.parseInt(arg.split("=")[1], 10);
  146. if (Number.isNaN(priorityValue)) throw new Error("--priority 必须是整数");
  147. } else if (arg === "--id") {
  148. const nextValue = args[++i];
  149. if (!nextValue) throw new Error("--id 需要供应商 ID 列表");
  150. providerIds = nextValue.split(",").map((s) => {
  151. const id = Number.parseInt(s.trim(), 10);
  152. if (Number.isNaN(id)) throw new Error(`无效的供应商 ID: ${s}`);
  153. return id;
  154. });
  155. } else if (arg.startsWith("--id=")) {
  156. providerIds = arg
  157. .split("=")[1]
  158. .split(",")
  159. .map((s) => {
  160. const id = Number.parseInt(s.trim(), 10);
  161. if (Number.isNaN(id)) throw new Error(`无效的供应商 ID: ${s}`);
  162. return id;
  163. });
  164. } else if (arg === "--name") {
  165. namePattern = args[++i];
  166. if (!namePattern) throw new Error("--name 需要一个匹配模式");
  167. } else if (arg.startsWith("--name=")) {
  168. namePattern = arg.split("=")[1];
  169. } else if (arg === "--type") {
  170. const typeValue = args[++i];
  171. if (!typeValue || !isValidProviderType(typeValue)) {
  172. throw new Error(
  173. `无效的供应商类型: ${typeValue}。有效值: ${VALID_PROVIDER_TYPES.join(", ")}`
  174. );
  175. }
  176. providerType = typeValue;
  177. } else if (arg.startsWith("--type=")) {
  178. const typeValue = arg.split("=")[1];
  179. if (!isValidProviderType(typeValue)) {
  180. throw new Error(
  181. `无效的供应商类型: ${typeValue}。有效值: ${VALID_PROVIDER_TYPES.join(", ")}`
  182. );
  183. }
  184. providerType = typeValue;
  185. } else if (arg === "--yes" || arg === "-y") {
  186. assumeYes = true;
  187. } else if (arg === "--dry-run") {
  188. dryRun = true;
  189. } else if (arg === "--help" || arg === "-h") {
  190. printUsage();
  191. process.exit(0);
  192. } else {
  193. throw new Error(`未知参数: ${arg}`);
  194. }
  195. }
  196. // 确定模式
  197. const modeCount = [priorityValue !== null, providerIds !== null, namePattern !== null].filter(
  198. Boolean
  199. ).length;
  200. if (modeCount > 1) {
  201. throw new Error("--priority, --id, --name 不能同时使用,请选择一种筛选方式");
  202. }
  203. let mode: CliOptions["mode"] = "interactive";
  204. if (priorityValue !== null) mode = "priority";
  205. else if (providerIds !== null) mode = "id";
  206. else if (namePattern !== null) mode = "name";
  207. return {
  208. mode,
  209. priorityThreshold: priorityValue ?? undefined,
  210. providerIds: providerIds ?? undefined,
  211. namePattern: namePattern ?? undefined,
  212. providerType,
  213. assumeYes,
  214. dryRun,
  215. };
  216. }
  217. // ============================================================================
  218. // 数据库和 Redis 连接
  219. // ============================================================================
  220. function createDatabaseConnection(connectionString: string): {
  221. client: PostgresClient;
  222. db: Database;
  223. } {
  224. const client = postgres(connectionString);
  225. const db = drizzle(client, { schema });
  226. return { client, db };
  227. }
  228. async function createRedisClient(redisUrl: string): Promise<Redis> {
  229. const options: RedisOptions = {
  230. enableOfflineQueue: false,
  231. maxRetriesPerRequest: 3,
  232. retryStrategy(times) {
  233. if (times > 5) {
  234. console.error("[Redis] 重试次数已达上限,放弃连接");
  235. return null;
  236. }
  237. return Math.min(times * 200, 2000);
  238. },
  239. lazyConnect: true,
  240. };
  241. if (redisUrl.startsWith("rediss://")) {
  242. const rejectUnauthorized = process.env.REDIS_TLS_REJECT_UNAUTHORIZED !== "false";
  243. try {
  244. const url = new URL(redisUrl);
  245. options.tls = {
  246. host: url.hostname,
  247. servername: url.hostname, // SNI support for cloud Redis providers
  248. rejectUnauthorized,
  249. };
  250. } catch {
  251. options.tls = { rejectUnauthorized };
  252. }
  253. }
  254. const redis = new Redis(redisUrl, options);
  255. // 等待连接就绪
  256. await redis.connect();
  257. return redis;
  258. }
  259. async function safeCloseRedis(redis: Redis | null): Promise<void> {
  260. if (!redis) return;
  261. try {
  262. await redis.quit();
  263. } catch (error) {
  264. console.warn("关闭 Redis 连接时发生错误:", error);
  265. }
  266. }
  267. async function safeClosePostgres(client: PostgresClient | null): Promise<void> {
  268. if (!client) return;
  269. try {
  270. await client.end({ timeout: 5 });
  271. } catch (error) {
  272. console.warn("关闭数据库连接时发生错误:", error);
  273. }
  274. }
  275. // ============================================================================
  276. // 数据库查询
  277. // ============================================================================
  278. async function fetchAllProviders(db: Database): Promise<ProviderRecord[]> {
  279. const rows = await db
  280. .select({
  281. id: schema.providers.id,
  282. name: schema.providers.name,
  283. priority: schema.providers.priority,
  284. providerType: schema.providers.providerType,
  285. isEnabled: schema.providers.isEnabled,
  286. })
  287. .from(schema.providers)
  288. .where(isNull(schema.providers.deletedAt))
  289. .orderBy(asc(schema.providers.priority), asc(schema.providers.id));
  290. return rows.map((row) => ({
  291. id: row.id,
  292. name: row.name,
  293. priority: row.priority ?? 0,
  294. providerType: row.providerType,
  295. isEnabled: row.isEnabled,
  296. }));
  297. }
  298. async function fetchProvidersByPriority(
  299. db: Database,
  300. threshold: number,
  301. providerType?: ProviderType | "all"
  302. ): Promise<ProviderRecord[]> {
  303. const conditions = [isNull(schema.providers.deletedAt), lt(schema.providers.priority, threshold)];
  304. if (providerType && providerType !== "all") {
  305. if (providerType === "claude") {
  306. conditions.push(
  307. or(
  308. eq(schema.providers.providerType, "claude"),
  309. eq(schema.providers.providerType, "claude-auth")
  310. )!
  311. );
  312. } else {
  313. conditions.push(eq(schema.providers.providerType, providerType));
  314. }
  315. }
  316. const rows = await db
  317. .select({
  318. id: schema.providers.id,
  319. name: schema.providers.name,
  320. priority: schema.providers.priority,
  321. providerType: schema.providers.providerType,
  322. isEnabled: schema.providers.isEnabled,
  323. })
  324. .from(schema.providers)
  325. .where(and(...conditions))
  326. .orderBy(asc(schema.providers.priority), asc(schema.providers.id));
  327. return rows.map((row) => ({
  328. id: row.id,
  329. name: row.name,
  330. priority: row.priority ?? 0,
  331. providerType: row.providerType,
  332. isEnabled: row.isEnabled,
  333. }));
  334. }
  335. async function fetchProvidersByIds(db: Database, ids: number[]): Promise<ProviderRecord[]> {
  336. const rows = await db
  337. .select({
  338. id: schema.providers.id,
  339. name: schema.providers.name,
  340. priority: schema.providers.priority,
  341. providerType: schema.providers.providerType,
  342. isEnabled: schema.providers.isEnabled,
  343. })
  344. .from(schema.providers)
  345. .where(and(isNull(schema.providers.deletedAt), inArray(schema.providers.id, ids)))
  346. .orderBy(asc(schema.providers.priority), asc(schema.providers.id));
  347. return rows.map((row) => ({
  348. id: row.id,
  349. name: row.name,
  350. priority: row.priority ?? 0,
  351. providerType: row.providerType,
  352. isEnabled: row.isEnabled,
  353. }));
  354. }
  355. async function fetchProvidersByName(db: Database, pattern: string): Promise<ProviderRecord[]> {
  356. const rows = await db
  357. .select({
  358. id: schema.providers.id,
  359. name: schema.providers.name,
  360. priority: schema.providers.priority,
  361. providerType: schema.providers.providerType,
  362. isEnabled: schema.providers.isEnabled,
  363. })
  364. .from(schema.providers)
  365. .where(and(isNull(schema.providers.deletedAt), ilike(schema.providers.name, `%${pattern}%`)))
  366. .orderBy(asc(schema.providers.priority), asc(schema.providers.id));
  367. return rows.map((row) => ({
  368. id: row.id,
  369. name: row.name,
  370. priority: row.priority ?? 0,
  371. providerType: row.providerType,
  372. isEnabled: row.isEnabled,
  373. }));
  374. }
  375. // ============================================================================
  376. // Redis 操作
  377. // ============================================================================
  378. function extractSessionIdFromKey(redisKey: string): string | null {
  379. const match = /^session:(.+):provider$/.exec(redisKey);
  380. return match ? match[1] : null;
  381. }
  382. async function findSessionsBoundToProviders(
  383. redis: Redis,
  384. providerIds: Set<number>
  385. ): Promise<Map<string, SessionBinding>> {
  386. const sessionMap = new Map<string, SessionBinding>();
  387. if (providerIds.size === 0) return sessionMap;
  388. let cursor = "0";
  389. let scannedKeys = 0;
  390. do {
  391. const [nextCursor, keys] = await redis.scan(
  392. cursor,
  393. "MATCH",
  394. "session:*:provider",
  395. "COUNT",
  396. SCAN_BATCH_SIZE
  397. );
  398. cursor = nextCursor;
  399. if (!keys || keys.length === 0) continue;
  400. scannedKeys += keys.length;
  401. const pipeline = redis.pipeline();
  402. for (const key of keys) {
  403. pipeline.get(key);
  404. }
  405. const results = await pipeline.exec();
  406. keys.forEach((key, index) => {
  407. const result = results?.[index];
  408. if (!result) return;
  409. const [error, value] = result;
  410. if (error || typeof value !== "string") return;
  411. const providerId = Number.parseInt(value, 10);
  412. if (!Number.isFinite(providerId) || !providerIds.has(providerId)) return;
  413. const sessionId = extractSessionIdFromKey(key);
  414. if (!sessionId) return;
  415. sessionMap.set(sessionId, { providerId });
  416. });
  417. } while (cursor !== "0");
  418. console.log(` 扫描了 ${scannedKeys} 个 session:*:provider key`);
  419. return sessionMap;
  420. }
  421. async function populateSessionKeyBindings(
  422. redis: Redis,
  423. sessionMap: Map<string, SessionBinding>
  424. ): Promise<void> {
  425. if (sessionMap.size === 0) return;
  426. const sessionIds = Array.from(sessionMap.keys());
  427. // 第一轮:尝试从 session:${sessionId}:key 获取
  428. for (let i = 0; i < sessionIds.length; i += PIPELINE_BATCH_SIZE) {
  429. const chunk = sessionIds.slice(i, i + PIPELINE_BATCH_SIZE);
  430. const pipeline = redis.pipeline();
  431. for (const sessionId of chunk) {
  432. pipeline.get(`session:${sessionId}:key`);
  433. }
  434. const results = await pipeline.exec();
  435. chunk.forEach((sessionId, index) => {
  436. const result = results?.[index];
  437. if (!result) return;
  438. const [error, value] = result;
  439. if (error || typeof value !== "string") return;
  440. const keyId = Number.parseInt(value, 10);
  441. if (Number.isFinite(keyId)) {
  442. const binding = sessionMap.get(sessionId);
  443. if (binding) binding.keyId = keyId;
  444. }
  445. });
  446. }
  447. // 第二轮:从 info hash 补充缺失的 keyId
  448. const missingKeyIds = sessionIds.filter((id) => sessionMap.get(id)?.keyId == null);
  449. if (missingKeyIds.length === 0) return;
  450. console.log(` ${missingKeyIds.length} 个 session 缺少 key 绑定,尝试从 info hash 获取...`);
  451. for (let i = 0; i < missingKeyIds.length; i += PIPELINE_BATCH_SIZE) {
  452. const chunk = missingKeyIds.slice(i, i + PIPELINE_BATCH_SIZE);
  453. const pipeline = redis.pipeline();
  454. for (const sessionId of chunk) {
  455. pipeline.hget(`session:${sessionId}:info`, "keyId");
  456. }
  457. const results = await pipeline.exec();
  458. chunk.forEach((sessionId, index) => {
  459. const result = results?.[index];
  460. if (!result) return;
  461. const [error, value] = result;
  462. if (error || typeof value !== "string") return;
  463. const keyId = Number.parseInt(value, 10);
  464. if (Number.isFinite(keyId)) {
  465. const binding = sessionMap.get(sessionId);
  466. if (binding) binding.keyId = keyId;
  467. }
  468. });
  469. }
  470. }
  471. async function clearSessionBindings(
  472. redis: Redis,
  473. sessionMap: Map<string, SessionBinding>,
  474. dryRun: boolean
  475. ): Promise<CleanupResult> {
  476. const entries = Array.from(sessionMap.entries());
  477. let deletedKeys = 0;
  478. let zsetRemovals = 0;
  479. let missingKeyRefs = 0;
  480. if (dryRun) {
  481. for (const [, binding] of entries) {
  482. deletedKeys += SESSION_KEY_SUFFIXES.length;
  483. zsetRemovals += 2;
  484. if (binding.keyId != null) zsetRemovals += 1;
  485. else missingKeyRefs += 1;
  486. }
  487. return { sessionCount: entries.length, deletedKeys, zsetRemovals, missingKeyRefs };
  488. }
  489. for (let i = 0; i < entries.length; i += PIPELINE_BATCH_SIZE) {
  490. const chunk = entries.slice(i, i + PIPELINE_BATCH_SIZE);
  491. const pipeline = redis.pipeline();
  492. const commandTypes: Array<"del" | "zrem"> = [];
  493. for (const [sessionId, binding] of chunk) {
  494. const keysToDelete = SESSION_KEY_SUFFIXES.map((suffix) => `session:${sessionId}:${suffix}`);
  495. pipeline.del(...keysToDelete);
  496. commandTypes.push("del");
  497. pipeline.zrem("global:active_sessions", sessionId);
  498. commandTypes.push("zrem");
  499. pipeline.zrem(`provider:${binding.providerId}:active_sessions`, sessionId);
  500. commandTypes.push("zrem");
  501. if (binding.keyId != null) {
  502. pipeline.zrem(`key:${binding.keyId}:active_sessions`, sessionId);
  503. commandTypes.push("zrem");
  504. } else {
  505. missingKeyRefs += 1;
  506. }
  507. }
  508. const results = await pipeline.exec();
  509. results?.forEach(([error, value], index) => {
  510. if (error) return;
  511. const type = commandTypes[index];
  512. if (type === "del" && typeof value === "number") deletedKeys += value;
  513. else if (type === "zrem" && typeof value === "number") zsetRemovals += value;
  514. });
  515. const processed = Math.min(i + PIPELINE_BATCH_SIZE, entries.length);
  516. process.stdout.write(`\r 清理进度: ${processed}/${entries.length}`);
  517. }
  518. console.log();
  519. return { sessionCount: entries.length, deletedKeys, zsetRemovals, missingKeyRefs };
  520. }
  521. // ============================================================================
  522. // 交互式界面
  523. // ============================================================================
  524. class InteractiveMenu {
  525. private rl: ReadlineInterface;
  526. constructor() {
  527. this.rl = createInterface({
  528. input: process.stdin,
  529. output: process.stdout,
  530. });
  531. }
  532. async question(prompt: string): Promise<string> {
  533. return new Promise((resolve) => {
  534. this.rl.question(prompt, (answer) => resolve(answer.trim()));
  535. });
  536. }
  537. close(): void {
  538. this.rl.close();
  539. }
  540. displayProviderList(providers: ProviderRecord[]): void {
  541. console.log("\n可用供应商列表:\n");
  542. console.log(" 序号 ID 名称 优先级 类型 状态");
  543. console.log(` ${"-".repeat(75)}`);
  544. providers.forEach((p, index) => {
  545. const status = p.isEnabled ? "启用" : "禁用";
  546. const name = p.name.length > 28 ? `${p.name.substring(0, 25)}...` : p.name.padEnd(28);
  547. console.log(
  548. ` ${String(index + 1).padStart(4)} ${String(p.id).padStart(4)} ${name} ${String(p.priority).padStart(6)} ${p.providerType.padEnd(13)} ${status}`
  549. );
  550. });
  551. console.log();
  552. }
  553. async selectProviders(providers: ProviderRecord[]): Promise<ProviderRecord[]> {
  554. this.displayProviderList(providers);
  555. console.log("选择方式:");
  556. console.log(" - 输入序号(逗号分隔): 1,2,3");
  557. console.log(" - 输入范围: 1-5");
  558. console.log(" - 输入 'all' 选择全部");
  559. console.log(" - 输入 'q' 退出\n");
  560. const input = await this.question("请选择要清理的供应商: ");
  561. if (input.toLowerCase() === "q") {
  562. return [];
  563. }
  564. if (input.toLowerCase() === "all") {
  565. return providers;
  566. }
  567. const selectedIndices = new Set<number>();
  568. // 解析输入
  569. const parts = input.split(",").map((s) => s.trim());
  570. for (const part of parts) {
  571. if (part.includes("-")) {
  572. const [start, end] = part.split("-").map((s) => Number.parseInt(s.trim(), 10));
  573. if (!Number.isNaN(start) && !Number.isNaN(end)) {
  574. for (let i = start; i <= end; i++) {
  575. if (i >= 1 && i <= providers.length) {
  576. selectedIndices.add(i - 1);
  577. }
  578. }
  579. }
  580. } else {
  581. const index = Number.parseInt(part, 10);
  582. if (!Number.isNaN(index) && index >= 1 && index <= providers.length) {
  583. selectedIndices.add(index - 1);
  584. }
  585. }
  586. }
  587. return Array.from(selectedIndices)
  588. .sort((a, b) => a - b)
  589. .map((i) => providers[i]);
  590. }
  591. async confirm(message: string): Promise<boolean> {
  592. const answer = await this.question(`${message} [y/N]: `);
  593. return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
  594. }
  595. async selectMainAction(): Promise<"priority" | "select" | "type" | "name" | "quit"> {
  596. console.log("\n请选择操作:");
  597. console.log(" 1. 按优先级筛选");
  598. console.log(" 2. 手动选择供应商");
  599. console.log(" 3. 按类型筛选");
  600. console.log(" 4. 按名称搜索");
  601. console.log(" q. 退出\n");
  602. const input = await this.question("请输入选项: ");
  603. switch (input.toLowerCase()) {
  604. case "1":
  605. return "priority";
  606. case "2":
  607. return "select";
  608. case "3":
  609. return "type";
  610. case "4":
  611. return "name";
  612. case "q":
  613. case "quit":
  614. case "exit":
  615. return "quit";
  616. default:
  617. console.log("无效选项,请重新输入");
  618. return this.selectMainAction();
  619. }
  620. }
  621. async selectProviderType(providers: ProviderRecord[]): Promise<string | null> {
  622. // 统计每种类型的数量
  623. const typeCounts = new Map<string, number>();
  624. for (const p of providers) {
  625. typeCounts.set(p.providerType, (typeCounts.get(p.providerType) || 0) + 1);
  626. }
  627. const types = Array.from(typeCounts.keys()).sort();
  628. console.log("\n可用的供应商类型:\n");
  629. types.forEach((type, index) => {
  630. console.log(` ${index + 1}. ${type} (${typeCounts.get(type)} 个)`);
  631. });
  632. console.log(` a. 全部类型`);
  633. console.log(` q. 返回上级菜单\n`);
  634. const input = await this.question("请选择类型: ");
  635. if (input.toLowerCase() === "q") {
  636. return null;
  637. }
  638. if (input.toLowerCase() === "a" || input.toLowerCase() === "all") {
  639. return "all";
  640. }
  641. const index = Number.parseInt(input, 10);
  642. if (!Number.isNaN(index) && index >= 1 && index <= types.length) {
  643. return types[index - 1];
  644. }
  645. // 也支持直接输入类型名称
  646. if (types.includes(input)) {
  647. return input;
  648. }
  649. console.log("无效选项,请重新输入");
  650. return this.selectProviderType(providers);
  651. }
  652. async inputPriority(): Promise<number | null> {
  653. const input = await this.question("请输入优先级阈值(清除 priority < 该值的供应商): ");
  654. const value = Number.parseInt(input, 10);
  655. if (Number.isNaN(value)) {
  656. console.log("无效的数字,请重新输入");
  657. return this.inputPriority();
  658. }
  659. return value;
  660. }
  661. async inputNamePattern(): Promise<string | null> {
  662. const input = await this.question("请输入供应商名称(模糊匹配): ");
  663. return input || null;
  664. }
  665. }
  666. // ============================================================================
  667. // 主流程
  668. // ============================================================================
  669. function displaySelectedProviders(providers: ProviderRecord[]): void {
  670. console.log(`\n已选择 ${providers.length} 个供应商:\n`);
  671. console.table(
  672. providers.map((p) => ({
  673. ID: p.id,
  674. 名称: p.name,
  675. 优先级: p.priority,
  676. 类型: p.providerType,
  677. 状态: p.isEnabled ? "启用" : "禁用",
  678. }))
  679. );
  680. }
  681. function displayResult(result: CleanupResult, dryRun: boolean): void {
  682. console.log(`\n${"=".repeat(60)}`);
  683. console.log(dryRun ? " Dry-Run 结果摘要" : " 清理完成");
  684. console.log("=".repeat(60));
  685. console.log(` Session 数量: ${result.sessionCount}`);
  686. console.log(` 删除的 Key 数量: ${result.deletedKeys}`);
  687. console.log(` ZSET 移除数量: ${result.zsetRemovals}`);
  688. if (result.missingKeyRefs > 0) {
  689. console.log(` 缺少 key 绑定: ${result.missingKeyRefs}`);
  690. }
  691. if (dryRun) {
  692. console.log("\n[Dry-Run] 以上为预计操作,实际未执行任何删除。");
  693. }
  694. }
  695. async function runCleanup(
  696. redis: Redis,
  697. providers: ProviderRecord[],
  698. dryRun: boolean
  699. ): Promise<CleanupResult | null> {
  700. console.log("\n正在扫描 session 绑定...");
  701. const providerIds = new Set(providers.map((p) => p.id));
  702. const sessions = await findSessionsBoundToProviders(redis, providerIds);
  703. if (sessions.size === 0) {
  704. console.log("\n没有找到需要清理的 session 绑定。");
  705. return null;
  706. }
  707. console.log(` 匹配到 ${sessions.size} 个 session`);
  708. console.log("正在获取 session 的 key 绑定信息...");
  709. await populateSessionKeyBindings(redis, sessions);
  710. console.log(dryRun ? "\n[Dry-Run] 预计清理:" : "\n正在清理...");
  711. return clearSessionBindings(redis, sessions, dryRun);
  712. }
  713. async function main(): Promise<void> {
  714. console.log("=".repeat(60));
  715. console.log(" Claude Code Hub - Session 绑定清理工具");
  716. console.log("=".repeat(60));
  717. const options = parseCliArgs(process.argv.slice(2));
  718. if (options.dryRun) {
  719. console.log("\n[Dry-Run 模式] 仅显示将要执行的操作,不实际删除数据\n");
  720. }
  721. const dsn = process.env.DSN;
  722. if (!dsn) throw new Error("DSN 环境变量未设置");
  723. const redisUrl = process.env.REDIS_URL;
  724. if (!redisUrl) throw new Error("REDIS_URL 环境变量未设置");
  725. console.log("正在连接数据库...");
  726. const { client, db } = createDatabaseConnection(dsn);
  727. let redis: Redis | null = null;
  728. let menu: InteractiveMenu | null = null;
  729. try {
  730. let targetProviders: ProviderRecord[] = [];
  731. if (options.mode === "interactive") {
  732. // 交互式模式
  733. menu = new InteractiveMenu();
  734. const allProviders = await fetchAllProviders(db);
  735. if (allProviders.length === 0) {
  736. console.log("\n数据库中没有供应商。");
  737. return;
  738. }
  739. const action = await menu.selectMainAction();
  740. if (action === "quit") {
  741. console.log("\n已退出。");
  742. return;
  743. }
  744. if (action === "priority") {
  745. const threshold = await menu.inputPriority();
  746. if (threshold === null) return;
  747. targetProviders = allProviders.filter((p) => p.priority < threshold);
  748. } else if (action === "type") {
  749. const selectedType = await menu.selectProviderType(allProviders);
  750. if (selectedType === null) {
  751. // 返回主菜单
  752. const retryAction = await menu.selectMainAction();
  753. if (retryAction === "quit") {
  754. console.log("\n已退出。");
  755. return;
  756. }
  757. // 简单处理:重新开始
  758. console.log("\n请重新运行脚本。");
  759. return;
  760. }
  761. const filteredProviders =
  762. selectedType === "all"
  763. ? allProviders
  764. : allProviders.filter((p) => p.providerType === selectedType);
  765. if (filteredProviders.length === 0) {
  766. console.log(`\n没有找到类型为 "${selectedType}" 的供应商。`);
  767. return;
  768. }
  769. // 从筛选后的列表中选择
  770. targetProviders = await menu.selectProviders(filteredProviders);
  771. } else if (action === "name") {
  772. const pattern = await menu.inputNamePattern();
  773. if (!pattern) return;
  774. targetProviders = allProviders.filter((p) =>
  775. p.name.toLowerCase().includes(pattern.toLowerCase())
  776. );
  777. } else {
  778. targetProviders = await menu.selectProviders(allProviders);
  779. }
  780. if (targetProviders.length === 0) {
  781. console.log("\n未选择任何供应商。");
  782. return;
  783. }
  784. displaySelectedProviders(targetProviders);
  785. if (!options.dryRun) {
  786. const confirmed = await menu.confirm("\n确认清理这些供应商的 session 绑定?");
  787. if (!confirmed) {
  788. console.log("\n操作已取消。");
  789. return;
  790. }
  791. }
  792. } else {
  793. // 命令行模式
  794. if (options.mode === "priority") {
  795. targetProviders = await fetchProvidersByPriority(
  796. db,
  797. options.priorityThreshold!,
  798. options.providerType
  799. );
  800. } else if (options.mode === "id") {
  801. targetProviders = await fetchProvidersByIds(db, options.providerIds!);
  802. } else if (options.mode === "name") {
  803. targetProviders = await fetchProvidersByName(db, options.namePattern!);
  804. }
  805. if (targetProviders.length === 0) {
  806. console.log("\n未找到符合条件的供应商。");
  807. return;
  808. }
  809. displaySelectedProviders(targetProviders);
  810. if (!options.assumeYes && !options.dryRun) {
  811. menu = new InteractiveMenu();
  812. const confirmed = await menu.confirm("\n确认清理这些供应商的 session 绑定?");
  813. if (!confirmed) {
  814. console.log("\n操作已取消。");
  815. return;
  816. }
  817. }
  818. }
  819. // 连接 Redis 并执行清理
  820. console.log("\n正在连接 Redis...");
  821. redis = await createRedisClient(redisUrl);
  822. console.log("Redis 连接成功");
  823. const result = await runCleanup(redis, targetProviders, options.dryRun);
  824. if (result) {
  825. displayResult(result, options.dryRun);
  826. }
  827. console.log();
  828. } finally {
  829. menu?.close();
  830. await safeCloseRedis(redis);
  831. await safeClosePostgres(client);
  832. }
  833. }
  834. main()
  835. .catch((error) => {
  836. console.error("\n发生错误:", error);
  837. process.exitCode = 1;
  838. })
  839. .finally(() => {
  840. process.exit(process.exitCode ?? 0);
  841. });