tt-help-cli-ycl 1.4.149 → 1.4.151

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.
@@ -20,7 +20,23 @@ import fs from "fs";
20
20
  import path from "path";
21
21
  import Database from "better-sqlite3";
22
22
  import { isLocationInList } from "../lib/target-locations.js";
23
- import { getTargetLocations } from "../lib/app-config.js";
23
+ import { getTargetLocations, locationRank } from "../lib/app-config.js";
24
+
25
+ /**
26
+ * 按目标国家配置顺序解析领取顺序(优先级 = 配置顺序,越靠前越先领)。
27
+ * 传入子集时:配置内国家按配置顺序在前,配置外国家按传入顺序附在后面。
28
+ */
29
+ function resolveOrderedLocations(normalizedLocations) {
30
+ const configured = getTargetLocations();
31
+ if (!normalizedLocations || normalizedLocations.length === 0)
32
+ return [...configured];
33
+ const configSet = new Set(configured);
34
+ const requestedSet = new Set(normalizedLocations);
35
+ return [
36
+ ...configured.filter((loc) => requestedSet.has(loc)),
37
+ ...normalizedLocations.filter((loc) => !configSet.has(loc)),
38
+ ];
39
+ }
24
40
 
25
41
  // Schema 与连接管理
26
42
  import {
@@ -667,7 +683,13 @@ function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
667
683
  product_review_count,
668
684
  product_sold_count,
669
685
  product_name,
686
+ product_skus,
670
687
  recent_ec_videos,
688
+ (CASE WHEN json_valid(product_skus) THEN
689
+ (SELECT COUNT(*) FROM json_each(product_skus)) ELSE 0 END) AS skuCount,
690
+ (CASE WHEN json_valid(product_skus) THEN
691
+ (SELECT COALESCE(SUM(CAST(json_extract(value, '$.stock') AS INTEGER)), 0)
692
+ FROM json_each(product_skus)) ELSE 0 END) AS skuTotalStock,
671
693
  (SELECT COUNT(*) FROM json_each(recent_ec_videos)
672
694
  WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000) AS ecOver1k,
673
695
  (SELECT MAX(CAST(json_extract(value, '$.playCount') AS INTEGER))
@@ -925,13 +947,9 @@ export function createStore(filePath, options = {}) {
925
947
  let statusGroups = null;
926
948
  let groupsDirty = true;
927
949
 
928
- const tier1LocSet = new Set(["PL", "NL", "BE"]);
929
- const tier2LocSet = new Set(["DE", "FR", "IT", "IE", "ES"]);
950
+ // 优先级 = 目标国家配置顺序(越靠前越优先),空/配置外国家排最后
930
951
  function locationTier(u) {
931
- const loc = (u.guessedLocation || "").toUpperCase();
932
- if (tier1LocSet.has(loc)) return 0;
933
- if (tier2LocSet.has(loc)) return 1;
934
- return 2;
952
+ return locationRank(u.guessedLocation);
935
953
  }
936
954
 
937
955
  function sortGroup(key, arr) {
@@ -1278,13 +1296,13 @@ export function createStore(filePath, options = {}) {
1278
1296
  };
1279
1297
  }
1280
1298
 
1281
- const tier1 = new Set(["PL", "NL", "BE"]);
1282
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1283
1299
  const normalizedLocations = Array.isArray(locations)
1284
1300
  ? locations
1285
1301
  .map((loc) => String(loc).trim().toUpperCase())
1286
1302
  .filter(Boolean)
1287
1303
  : [];
1304
+ // 优先级 = 目标国家配置顺序;传子集时按配置顺序取交集(配置外国家按传入顺序排后)
1305
+ const orderedCountries = resolveOrderedLocations(normalizedLocations);
1288
1306
 
1289
1307
  // ---- 过期 processing 回收 ----
1290
1308
  const expiredRow = getDb()
@@ -1304,31 +1322,19 @@ export function createStore(filePath, options = {}) {
1304
1322
  expiredCandidate = expiredRow;
1305
1323
  }
1306
1324
 
1307
- // ---- 构建统一优先级查询(合并原 20+ 次独立 SELECT 为 1 次 UNION) ----
1325
+ // ---- 逐级短路查询:按优先级顺序逐个分支执行,命中即领取 ----
1308
1326
  const statusCodeWhere = loggedIn
1309
1327
  ? "(status_code IS NULL OR status_code = 0 OR status_code = 209002)"
1310
1328
  : "(status_code IS NULL OR status_code = 0)";
1311
1329
  const notSellerWhere = loggedIn ? "" : "AND COALESCE(tt_seller, 0) != 1";
1312
1330
 
1313
- const tier1List = normalizedLocations.length
1314
- ? normalizedLocations.filter((loc) => tier1.has(loc))
1315
- : [...tier1];
1316
- const tier2List = normalizedLocations.length
1317
- ? normalizedLocations.filter((loc) => tier2.has(loc))
1318
- : [...tier2];
1319
- const otherFiltered = normalizedLocations.length
1320
- ? normalizedLocations.filter(
1321
- (loc) => !tier1.has(loc) && !tier2.has(loc),
1322
- )
1323
- : null;
1324
-
1325
- const locIn = (list) => {
1331
+ const locIn = (list, { includeEmpty = true } = {}) => {
1326
1332
  if (!list || list.length === 0) return null;
1327
1333
  const ph = list.map(() => "?").join(", ");
1328
- return {
1329
- sql: `(UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`,
1330
- params: [...list],
1331
- };
1334
+ const sql = includeEmpty
1335
+ ? `(UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`
1336
+ : `UPPER(COALESCE(guessed_location, '')) IN (${ph})`;
1337
+ return { sql, params: [...list] };
1332
1338
  };
1333
1339
  const locNotIn = (list) => {
1334
1340
  if (!list || list.length === 0) return null;
@@ -1338,13 +1344,6 @@ export function createStore(filePath, options = {}) {
1338
1344
  params: [...list],
1339
1345
  };
1340
1346
  };
1341
- const locOther = () => {
1342
- if (otherFiltered && otherFiltered.length > 0)
1343
- return locIn(otherFiltered);
1344
- if (normalizedLocations.length === 0)
1345
- return locNotIn([...tier1, ...tier2]);
1346
- return null;
1347
- };
1348
1347
 
1349
1348
  // 全局 location 过滤(pinned 查询用)
1350
1349
  let pinnedLocSql = "";
@@ -1355,45 +1354,38 @@ export function createStore(filePath, options = {}) {
1355
1354
  pinnedLocParams.push(...normalizedLocations);
1356
1355
  }
1357
1356
 
1358
- const sqlParts = [];
1359
- const allParams = [];
1357
+ // 逐级短路:单分支 LIMIT 1 走索引仅几十 ms;原 26 分支 UNION ALL 需 MERGE
1358
+ // 收集全部分支结果再排序,task_queue 增长后成为 CPU/延迟瓶颈。
1359
+ // 语义与原「UNION ALL + ORDER BY _prio,_sub_prio,... LIMIT 1」完全等价:
1360
+ // 分支添加顺序即 (_prio,_sub_prio),首个非空分支的最优行即全局最优行。
1361
+ const branchOrder =
1362
+ " ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1";
1363
+ const branches = [];
1360
1364
 
1361
- const addPart = (prio, subPrio, conditions, params) => {
1362
- sqlParts.push(
1363
- `SELECT *, ${prio} as _prio, ${subPrio} as _sub_prio FROM task_queue WHERE ${conditions}`,
1364
- );
1365
- allParams.push(...params);
1365
+ const addBranch = (conditions, params) => {
1366
+ branches.push({ conditions, params });
1366
1367
  };
1367
1368
 
1368
1369
  // prio 0: pinned + hasVideo
1369
- addPart(
1370
- 0,
1371
- 0,
1370
+ addBranch(
1372
1371
  `status = 'pending' AND pinned = 1 AND video_count > 0 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1373
1372
  pinnedLocParams,
1374
1373
  );
1375
1374
  // prio 1: pinned (no video req)
1376
- addPart(
1377
- 1,
1378
- 0,
1375
+ addBranch(
1379
1376
  `status = 'pending' AND pinned = 1 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1380
1377
  pinnedLocParams,
1381
1378
  );
1382
1379
 
1383
1380
  // prio 2-3: expired candidate(回收的超时任务优先)
1384
1381
  if (expiredCandidate) {
1385
- addPart(
1386
- 2,
1387
- 0,
1382
+ addBranch(
1388
1383
  `unique_id = ? AND video_count > 0 AND ${statusCodeWhere} ${notSellerWhere}`,
1389
1384
  [expiredCandidate.unique_id],
1390
1385
  );
1391
- addPart(
1392
- 3,
1393
- 0,
1394
- `unique_id = ? AND ${statusCodeWhere} ${notSellerWhere}`,
1395
- [expiredCandidate.unique_id],
1396
- );
1386
+ addBranch(`unique_id = ? AND ${statusCodeWhere} ${notSellerWhere}`, [
1387
+ expiredCandidate.unique_id,
1388
+ ]);
1397
1389
  }
1398
1390
 
1399
1391
  // 非 pinned 优先级:seed → seller → follow → other,每级 × 3 个 location tier × (hasVideo / noVideoReq)
@@ -1402,7 +1394,11 @@ export function createStore(filePath, options = {}) {
1402
1394
  filter: `instr(COALESCE(sources, ''), '"seed"') > 0`,
1403
1395
  loggedInOnly: false,
1404
1396
  },
1405
- { filter: `tt_seller = 1 AND verified = 0`, loggedInOnly: true },
1397
+ {
1398
+ filter: `tt_seller = 1 AND verified = 0`,
1399
+ loggedInOnly: true,
1400
+ sellerAnyCountry: true,
1401
+ },
1406
1402
  {
1407
1403
  filter: `(instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0 OR instr(COALESCE(sources, ''), '"comment"') > 0)`,
1408
1404
  loggedInOnly: false,
@@ -1413,27 +1409,45 @@ export function createStore(filePath, options = {}) {
1413
1409
  let basePrio = 4;
1414
1410
  for (const p of priorities) {
1415
1411
  if (p.loggedInOnly && !loggedIn) continue;
1416
- const tierLocations = [
1417
- { list: tier1List, fn: locIn },
1418
- { list: tier2List, fn: locIn },
1419
- { list: null, fn: locOther },
1420
- ];
1412
+ // 逐国顺序分支:配置内国家按优先级顺序;未传子集时末尾补“配置外国家”兑底。
1413
+ // 空位置(loc='')只在首个国家的分支参与:首国的 noVideoReq 分支 miss 即意味着
1414
+ // 空位置池已耗尽,后续分支无需再 OR ''(避免反复扫描空位置大池)
1415
+ const locationBranches = orderedCountries.map((loc, idx) =>
1416
+ locIn([loc], { includeEmpty: idx === 0 }),
1417
+ );
1418
+ if (normalizedLocations.length === 0)
1419
+ locationBranches.push(locNotIn(orderedCountries));
1420
+ // 商家级专用(sellerAnyCountry):国家对商家放开——末尾追加"任意国家"分支。
1421
+ // 传了国家子集时对应国家的商家仍优先,领完再兜任意国家(含空位置商家);
1422
+ // 未传国家时同理(配置内各国 → 配置外 → 任意)。其他来源级分支不受影响。
1423
+ if (p.sellerAnyCountry) locationBranches.push(null); // null → 任意国家分支
1421
1424
  let subPrio = 0;
1422
- for (const tl of tierLocations) {
1423
- const loc = tl.fn(tl.list);
1425
+ for (const loc of locationBranches) {
1426
+ // 商家任意国家分支:不限 guessed_location(含空位置的商家)
1427
+ if (loc === null) {
1428
+ // hasVideo
1429
+ addBranch(
1430
+ `status = 'pending' AND pinned = 0 AND ${p.filter} AND video_count > 0 AND ${statusCodeWhere} ${notSellerWhere}`,
1431
+ [],
1432
+ );
1433
+ subPrio++;
1434
+ // noVideoReq
1435
+ addBranch(
1436
+ `status = 'pending' AND pinned = 0 AND ${p.filter} AND ${statusCodeWhere} ${notSellerWhere}`,
1437
+ [],
1438
+ );
1439
+ subPrio++;
1440
+ continue;
1441
+ }
1424
1442
  if (!loc) continue;
1425
1443
  // hasVideo
1426
- addPart(
1427
- basePrio,
1428
- subPrio,
1444
+ addBranch(
1429
1445
  `status = 'pending' AND pinned = 0 AND ${p.filter} AND video_count > 0 AND ${loc.sql} AND ${statusCodeWhere} ${notSellerWhere}`,
1430
1446
  loc.params,
1431
1447
  );
1432
1448
  subPrio++;
1433
1449
  // noVideoReq
1434
- addPart(
1435
- basePrio + 1,
1436
- subPrio,
1450
+ addBranch(
1437
1451
  `status = 'pending' AND pinned = 0 AND ${p.filter} AND ${loc.sql} AND ${statusCodeWhere} ${notSellerWhere}`,
1438
1452
  loc.params,
1439
1453
  );
@@ -1442,14 +1456,16 @@ export function createStore(filePath, options = {}) {
1442
1456
  basePrio += 2;
1443
1457
  }
1444
1458
 
1459
+ // 逐级执行:首个命中分支的最优行即领取结果(未命中继续下一优先级分支)
1445
1460
  let selectedRow = null;
1446
- if (sqlParts.length > 0) {
1447
- const unionSql =
1448
- sqlParts.join(" UNION ALL ") +
1449
- " ORDER BY _prio, _sub_prio, follower_count DESC, created_at ASC, unique_id ASC LIMIT 1";
1450
- selectedRow = getDb()
1451
- .prepare(unionSql)
1452
- .get(...allParams);
1461
+ for (const b of branches) {
1462
+ selectedRow =
1463
+ getDb()
1464
+ .prepare(
1465
+ `SELECT * FROM task_queue WHERE ${b.conditions}${branchOrder}`,
1466
+ )
1467
+ .get(...b.params) || null;
1468
+ if (selectedRow) break;
1453
1469
  }
1454
1470
 
1455
1471
  if (selectedRow) {
@@ -1493,15 +1509,8 @@ export function createStore(filePath, options = {}) {
1493
1509
  };
1494
1510
  }
1495
1511
 
1496
- // 按猜测国家梯队排序
1497
- const tier1 = new Set(["PL", "NL", "BE"]);
1498
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1499
- function locationTier(u) {
1500
- const loc = (u.guessedLocation || "").toUpperCase();
1501
- if (tier1.has(loc)) return 0;
1502
- if (tier2.has(loc)) return 1;
1503
- return 2;
1504
- }
1512
+ // 按目标国家配置顺序排序(越靠前越优先)
1513
+ const locationTier = (u) => locationRank(u.guessedLocation);
1505
1514
 
1506
1515
  // 国家过滤:如果指定了 locations,只保留 guessedLocation 在列表中的用户
1507
1516
  function locationFilter(u) {
@@ -1641,8 +1650,6 @@ export function createStore(filePath, options = {}) {
1641
1650
  }
1642
1651
  : null;
1643
1652
 
1644
- const tier1 = new Set(["PL", "NL", "BE"]);
1645
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
1646
1653
  const normalizedLocations = Array.isArray(locations)
1647
1654
  ? locations
1648
1655
  .map((loc) => String(loc).trim().toUpperCase())
@@ -1650,29 +1657,11 @@ export function createStore(filePath, options = {}) {
1650
1657
  : [];
1651
1658
 
1652
1659
  function getLocationGroups() {
1653
- const selected = normalizedLocations.length
1654
- ? normalizedLocations
1655
- : null;
1656
- const tier1List = selected
1657
- ? selected.filter((loc) => tier1.has(loc))
1658
- : [...tier1];
1659
- const tier2List = selected
1660
- ? selected.filter((loc) => tier2.has(loc))
1661
- : [...tier2];
1662
- const otherList = selected
1663
- ? selected.filter((loc) => !tier1.has(loc) && !tier2.has(loc))
1664
- : null;
1665
- const groups = [];
1666
- if (tier1List.length > 0)
1667
- groups.push({ type: "include", values: tier1List });
1668
- if (tier2List.length > 0)
1669
- groups.push({ type: "include", values: tier2List });
1670
- if (selected) {
1671
- if (otherList.length > 0)
1672
- groups.push({ type: "include", values: otherList });
1673
- } else {
1674
- groups.push({ type: "exclude", values: [...tier1, ...tier2] });
1675
- }
1660
+ const ordered = resolveOrderedLocations(normalizedLocations);
1661
+ if (ordered.length === 0) return [];
1662
+ const groups = [{ type: "include", values: ordered }];
1663
+ if (normalizedLocations.length === 0)
1664
+ groups.push({ type: "exclude", values: [...ordered] });
1676
1665
  return groups;
1677
1666
  }
1678
1667
 
@@ -2493,28 +2482,40 @@ export function createStore(filePath, options = {}) {
2493
2482
  const placeholders = targetLocations.map(() => "?").join(",");
2494
2483
 
2495
2484
  // SELECT 候选用户
2496
- const selected = getDb()
2497
- .prepare(
2498
- `
2499
- SELECT unique_id, nickname, video_count, COALESCE(refresh_time, ?) AS refresh_time
2500
- FROM jobs
2485
+ // 性能三件套(76 万行实测 1~3s → ~80ms):
2486
+ // 1. WHERE/ORDER BY 不用 COALESCE(refresh_time, ?)(表达式挡索引);
2487
+ // ASC 排序 NULL 天然最前,与 COALESCE 2016 默认值语义等价
2488
+ // 2. INDEXED BY 强制走 idx_jobs_precheck 部分索引(优化器无统计时会
2489
+ // 误选 idx_jobs_seller_verified_loc 走 TEMP B-TREE 全排序)
2490
+ // 3. ORDER BY jobs.refresh_time 限定表名——裸列名会被 SELECT 输出别名
2491
+ // refresh_time(COALESCE 表达式)遮蔽,又退回表达式排序
2492
+ // 索引不存在的环境(老库未重启)抛 "no such index",自动回退普通查询。
2493
+ const baseWhere = `
2501
2494
  WHERE tt_seller = 1
2502
2495
  AND verified = 0
2503
2496
  AND (pending_refresh IS NULL OR pending_refresh = 0)
2504
2497
  AND location_created IN (${placeholders})
2505
- AND COALESCE(refresh_time, ?) < ?
2506
- ORDER BY COALESCE(refresh_time, ?) ASC
2507
- LIMIT ?
2508
- `,
2509
- )
2510
- .all(
2511
- defaultTime,
2512
- ...targetLocations,
2513
- defaultTime,
2514
- threshold,
2515
- defaultTime,
2516
- limit,
2517
- );
2498
+ AND (refresh_time IS NULL OR refresh_time < ?)`;
2499
+ const orderLimit = `
2500
+ ORDER BY jobs.refresh_time ASC
2501
+ LIMIT ?`;
2502
+ const selectSql = `SELECT unique_id, nickname, video_count, COALESCE(refresh_time, ?) AS refresh_time
2503
+ FROM jobs`;
2504
+ const args = [defaultTime, ...targetLocations, threshold, limit];
2505
+
2506
+ let selected;
2507
+ try {
2508
+ selected = getDb()
2509
+ .prepare(
2510
+ `${selectSql} INDEXED BY idx_jobs_precheck${baseWhere}${orderLimit}`,
2511
+ )
2512
+ .all(...args);
2513
+ } catch (e) {
2514
+ if (!/no such index/i.test(e.message)) throw e;
2515
+ selected = getDb()
2516
+ .prepare(`${selectSql}${baseWhere}${orderLimit}`)
2517
+ .all(...args);
2518
+ }
2518
2519
 
2519
2520
  // 立即更新 refresh_time 防重复领取(非事务,小概率并发重复可接受)
2520
2521
  if (selected.length > 0) {
@@ -2840,8 +2841,9 @@ export function createStore(filePath, options = {}) {
2840
2841
  /**
2841
2842
  * 回写商品详情到 jobs 表(product_* 列)
2842
2843
  * @param {string} uniqueId
2843
- * @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
2844
+ * @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string, skus?:Array}} productDetail
2844
2845
  * rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
2846
+ * skus = 多 SKU 概要数组(skuId/specs/stock/stockStatus/warehouseId),存 product_skus JSON
2845
2847
  */
2846
2848
  function updateProductInfo(uniqueId, productDetail) {
2847
2849
  if (getDb()) {
@@ -2855,6 +2857,10 @@ export function createStore(filePath, options = {}) {
2855
2857
  productReviewCount: productDetail.reviewCount ?? null,
2856
2858
  productSoldCount: productDetail.soldCount ?? null,
2857
2859
  productName: productDetail.productName || null,
2860
+ // 多 SKU 概要:数组经 normalizeJobValue 自动序列化为 JSON(无 skus 存 null)
2861
+ productSkus: Array.isArray(productDetail.skus)
2862
+ ? productDetail.skus
2863
+ : null,
2858
2864
  },
2859
2865
  false, // 商品补全与用户刷新计数无关,不增加 user_update_count
2860
2866
  "jobs",
@@ -99,6 +99,7 @@ export const WRITABLE_JOB_COLUMNS = new Set([
99
99
  "product_review_count",
100
100
  "product_sold_count",
101
101
  "product_name",
102
+ "product_skus",
102
103
  "user_create_time",
103
104
  "recent_ec_videos",
104
105
  "pending_refresh",
@@ -173,6 +173,7 @@ export function initDb(filePath) {
173
173
  product_review_count: "INTEGER",
174
174
  product_sold_count: "INTEGER",
175
175
  product_name: "TEXT",
176
+ product_skus: "TEXT",
176
177
  user_create_time: "INTEGER",
177
178
  recent_ec_videos: "TEXT",
178
179
  });
@@ -209,6 +210,7 @@ export function initDb(filePath) {
209
210
  product_review_count: "INTEGER",
210
211
  product_sold_count: "INTEGER",
211
212
  product_name: "TEXT",
213
+ product_skus: "TEXT",
212
214
  user_create_time: "INTEGER",
213
215
  recent_ec_videos: "TEXT",
214
216
  });
@@ -246,6 +248,7 @@ export function initDb(filePath) {
246
248
  product_review_count: "INTEGER",
247
249
  product_sold_count: "INTEGER",
248
250
  product_name: "TEXT",
251
+ product_skus: "TEXT",
249
252
  user_create_time: "INTEGER",
250
253
  recent_ec_videos: "TEXT",
251
254
  });
@@ -446,19 +449,28 @@ export function initDb(filePath) {
446
449
  db.exec(`CREATE INDEX IF NOT EXISTS idx_videos_old_id ON videos_old(id)`);
447
450
  }
448
451
 
449
- // ===== task_queue 索引(对应 claimNextJob UNION 查询的 4 个部分索引) =====
452
+ // ===== task_queue 索引(claimNextJob 逐级短路查询 + 监控列表) =====
453
+ // idx_tq_claim_fast:claim 分支查询主索引(2026-09-17 实战验证:分支 7s→1ms)。
454
+ // 普通复合索引,不依赖部分索引的 WHERE 表达式文本匹配(旧 4 个部分索引因
455
+ // pinned=0 与 COALESCE(pinned,0)=0 文本不一致、首国分支 OR 空串,从未被命中,已废弃清理)。
450
456
  const tqIndexes = [
451
457
  `CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)`,
452
458
  `CREATE INDEX IF NOT EXISTS idx_tq_claimed_by_status ON task_queue(claimed_by, status, claimed_at)`,
453
459
  `CREATE INDEX IF NOT EXISTS idx_tq_status_claimed_at ON task_queue(status, claimed_at)`,
454
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_pinned ON task_queue(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
455
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_seller ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
456
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_follow ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
457
- `CREATE INDEX IF NOT EXISTS idx_tq_claim_pending_other ON task_queue(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
460
+ `CREATE INDEX IF NOT EXISTS idx_tq_claim_fast ON task_queue(status, UPPER(COALESCE(guessed_location,'')), follower_count DESC, created_at ASC, unique_id ASC)`,
458
461
  // 监控页面用户列表查询加速
459
462
  `CREATE INDEX IF NOT EXISTS idx_tq_list_pending ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'pending'`,
460
463
  `CREATE INDEX IF NOT EXISTS idx_tq_list_processing ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'processing'`,
461
464
  ];
465
+ // 清理从未被命中的旧 claim 部分索引(省写入开销与空间,幂等)
466
+ for (const oldIdx of [
467
+ "idx_tq_claim_pending_pinned",
468
+ "idx_tq_claim_pending_seller",
469
+ "idx_tq_claim_pending_follow",
470
+ "idx_tq_claim_pending_other",
471
+ ]) {
472
+ db.exec(`DROP INDEX IF EXISTS ${oldIdx}`);
473
+ }
462
474
  for (const sql of tqIndexes) {
463
475
  db.exec(sql);
464
476
  }
@@ -375,15 +375,25 @@ export function getAllTags(
375
375
  }
376
376
 
377
377
  export function rawQuery(sql, params = []) {
378
- // 通用查询器:默认查主库(stats/db-query 复用),勿切到 tag 库
378
+ // 通用查询器:默认查主库(stats/db-query 复用),勿切到 tag 库。
379
+ // 注意:原实现用 prepare().all() 跑一切语句,DDL(CREATE INDEX 等)会抛
380
+ // "This statement does not return data" 且被 catch 吞成 {error} 返回——
381
+ // server.js 启动清单 ensureIdx 曾因此静默失效(生产库从未建过启动索引)。
382
+ // 现按语句类型分流:DDL/写语句走 exec,查询走 all;失败时抛错让调用方感知。
379
383
  const db = getMainDb();
380
- if (!db) return { error: "db not ready" };
381
- try {
384
+ if (!db) throw new Error("db not ready");
385
+ const trimmed = sql.trim().toUpperCase();
386
+ const isQuery =
387
+ trimmed.startsWith("SELECT") ||
388
+ trimmed.startsWith("PRAGMA") ||
389
+ trimmed.startsWith("EXPLAIN") ||
390
+ trimmed.startsWith("WITH");
391
+ if (isQuery) {
382
392
  const rows = db.prepare(sql).all(...params);
383
393
  return { rows };
384
- } catch (e) {
385
- return { error: e.message };
386
394
  }
395
+ db.exec(sql);
396
+ return { rows: [] };
387
397
  }
388
398
 
389
399
  export function normalizeTags() {