tt-help-cli-ycl 1.4.150 → 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.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 商家任务国家放开领取测试(claimNextJob 分支顺序)
3
+ *
4
+ * 验证(临时 SQLite 库):
5
+ * 1. 传了国家子集:先领对应国家的 ttseller,领完再领任意国家 ttseller
6
+ * 2. 未传国家:领商家(任意国家均可)
7
+ * 3. 其他来源级顺序不变:seed 仍排商家前,非商家任务仍按国家分层
8
+ * 4. 未登录客户端不领商家任务(保持原逻辑)
9
+ *
10
+ * 运行: TMPDIR=$(mktemp -d) node scripts/test-seller-any-country.mjs
11
+ */
12
+ import { createStore } from "../src/watch/data-store.js";
13
+ import { getDb } from "../src/watch/db-schema.js";
14
+ import { join } from "node:path";
15
+ import { mkdtempSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+
18
+ const tmp = mkdtempSync(join(tmpdir(), "seller-any-"));
19
+ const store = createStore(join(tmp, "test.db"));
20
+ const db = getDb();
21
+ if (!db) {
22
+ console.error("❌ db 未初始化");
23
+ process.exit(1);
24
+ }
25
+
26
+ let passed = 0;
27
+ function check(name, fn) {
28
+ try {
29
+ fn();
30
+ passed++;
31
+ console.log(` ✅ ${name}`);
32
+ } catch (e) {
33
+ console.error(` ❌ ${name}\n ${e.message}`);
34
+ process.exitCode = 1;
35
+ }
36
+ }
37
+ function assert(cond, msg = "断言失败") {
38
+ if (!cond) throw new Error(msg);
39
+ }
40
+
41
+ const now = Date.now();
42
+ // 直接插 task_queue(refill 后形态)
43
+ const insert = (id, loc, seller, sources, opts = {}) =>
44
+ db
45
+ .prepare(
46
+ `INSERT INTO task_queue (unique_id, status, pinned, guessed_location, tt_seller, verified, sources, video_count, follower_count, created_at, updated_at)
47
+ VALUES (?, 'pending', 0, ?, ?, 0, ?, ?, ?, ?, ?)`,
48
+ )
49
+ .run(
50
+ id,
51
+ loc,
52
+ seller,
53
+ JSON.stringify(sources),
54
+ opts.videoCount ?? 3,
55
+ opts.followers ?? 100,
56
+ now,
57
+ now,
58
+ );
59
+
60
+ console.log("===== 商家任务国家放开领取测试 =====");
61
+
62
+ // ── 场景 1:传 US,队列里有 US 商家 + DE 商家 ──
63
+ console.log("\n[1] 传国家子集 US:US 商家优先于 DE 商家");
64
+ insert("seller_de", "DE", 1, ["following"]);
65
+ insert("seller_us", "US", 1, ["following"]);
66
+ {
67
+ const job = await store.claimNextJob("t1", 5 * 60 * 1000, ["US"], true);
68
+ check("先领到 US 商家", () =>
69
+ assert(job?.uniqueId === "seller_us", `实际: ${job?.uniqueId}`),
70
+ );
71
+ }
72
+
73
+ // ── 场景 2:US 商家已不在(被领走),传 US → 兜任意国家 DE 商家 ──
74
+ console.log("\n[2] 无 US 商家可领时:兜到任意国家商家(DE)");
75
+ {
76
+ // seller_us 已被 t1 领走(processing),队列只剩 DE 商家 pending
77
+ const job = await store.claimNextJob("t2", 5 * 60 * 1000, ["US"], true);
78
+ check("兜到 DE 商家", () =>
79
+ assert(job?.uniqueId === "seller_de", `实际: ${job?.uniqueId}`),
80
+ );
81
+ }
82
+
83
+ // ── 场景 3:传 US 但队列只有 FR 商家 → 领 FR ──
84
+ console.log("\n[3] 传 US 但无 US 商家 → 领 FR 商家(任意国家兜底)");
85
+ db.prepare(`DELETE FROM task_queue`).run();
86
+ insert("seller_fr", "FR", 1, ["following"]);
87
+ {
88
+ const job = await store.claimNextJob("t3", 5 * 60 * 1000, ["US"], true);
89
+ check("领到 FR 商家", () =>
90
+ assert(job?.uniqueId === "seller_fr", `实际: ${job?.uniqueId}`),
91
+ );
92
+ }
93
+
94
+ // ── 场景 4:空位置商家(guessed_location='')→ 任意国家分支领到 ──
95
+ console.log("\n[4] 空位置商家可被任意国家分支领到");
96
+ db.prepare(`DELETE FROM task_queue`).run();
97
+ insert("seller_noloc", "", 1, ["following"]);
98
+ {
99
+ const job = await store.claimNextJob("t4", 5 * 60 * 1000, ["US"], true);
100
+ check("领到空位置商家", () =>
101
+ assert(job?.uniqueId === "seller_noloc", `实际: ${job?.uniqueId}`),
102
+ );
103
+ }
104
+
105
+ // ── 场景 5:未传国家,队列只有配置外国商家 → 直接领到 ──
106
+ console.log("\n[5] 未传国家:配置外国商家(FR)也能领到");
107
+ db.prepare(`DELETE FROM task_queue`).run();
108
+ insert("seller_fr2", "FR", 1, ["following"]);
109
+ {
110
+ const job = await store.claimNextJob("t5", 5 * 60 * 1000, null, true);
111
+ check("领到 FR 商家(未传国家不再被挡)", () =>
112
+ assert(job?.uniqueId === "seller_fr2", `实际: ${job?.uniqueId}`),
113
+ );
114
+ }
115
+
116
+ // ── 场景 6:seed 仍优先于商家(顺序不变)──
117
+ console.log("\n[6] seed 优先级仍高于商家");
118
+ db.prepare(`DELETE FROM task_queue`).run();
119
+ insert("seed_pl", "PL", 0, ["seed"]);
120
+ insert("seller_xx", "XX", 1, ["following"]);
121
+ {
122
+ const job = await store.claimNextJob("t6", 5 * 60 * 1000, null, true);
123
+ check("先领 seed", () =>
124
+ assert(job?.uniqueId === "seed_pl", `实际: ${job?.uniqueId}`),
125
+ );
126
+ }
127
+
128
+ // ── 场景 7:非商家任务仍按国家分层(PL 配置内优先于 ZZ 配置外)──
129
+ console.log("\n[7] 商家清空后:非商家按国家顺序领取");
130
+ db.prepare(`DELETE FROM task_queue`).run();
131
+ insert("other_pl", "PL", 0, ["search"]);
132
+ insert("other_zz", "ZZ", 0, ["search"]);
133
+ {
134
+ const job = await store.claimNextJob("t7", 5 * 60 * 1000, null, true);
135
+ check("PL 非商家先于 ZZ(配置内优先)", () =>
136
+ assert(job?.uniqueId === "other_pl", `实际: ${job?.uniqueId}`),
137
+ );
138
+ }
139
+
140
+ // ── 场景 8:未登录客户端不领商家(原逻辑不变)──
141
+ console.log("\n[8] 未登录不领商家任务");
142
+ db.prepare(`DELETE FROM task_queue`).run();
143
+ insert("seller_only", "US", 1, ["following"]);
144
+ insert("other_task", "PL", 0, ["search"]);
145
+ {
146
+ const job = await store.claimNextJob("t8", 5 * 60 * 1000, null, false);
147
+ check("未登录跳过商家领普通任务", () =>
148
+ assert(job?.uniqueId === "other_task", `实际: ${job?.uniqueId}`),
149
+ );
150
+ }
151
+
152
+ // ── 场景 9:非商家任务不享受任意国家兜底(配置内先于配置外)──
153
+ console.log("\n[9] 非商家配置外国家不越级");
154
+ db.prepare(`DELETE FROM task_queue`).run();
155
+ insert("plain_de", "DE", 0, ["search"]); // DE 在默认配置内
156
+ insert("plain_zz", "ZZ", 0, ["search"]); // ZZ 不在配置
157
+ {
158
+ const job = await store.claimNextJob("t9", 5 * 60 * 1000, null, true);
159
+ check("DE(配置内)先于 ZZ(配置外)", () =>
160
+ assert(job?.uniqueId === "plain_de", `实际: ${job?.uniqueId}`),
161
+ );
162
+ }
163
+
164
+ store.stopBackup?.();
165
+ console.log(
166
+ `\n结果: ${passed} 通过${process.exitCode ? ", 有失败" : ", 0 失败"}`,
167
+ );
@@ -540,9 +540,21 @@ export async function handleProductFill(options) {
540
540
  batchSuccess++;
541
541
  totalSuccess++;
542
542
  failCount.delete(uniqueId);
543
+ const skus = detail.skus;
544
+ const skuInfo = skus
545
+ ? ` | SKU ${skus.length}个 总库存 ${skus.reduce((sum, s) => sum + (s.stock || 0), 0)}`
546
+ : "";
543
547
  log(
544
- ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"})`,
548
+ ` ✓ @${uniqueId} 商品已补全 (评分${detail.rating ?? "-"} | 销量${detail.soldCount ?? "-"} | 评论${detail.reviewCount ?? "-"}${skuInfo})`,
545
549
  );
550
+ if (skus) {
551
+ // SKU 明细日志:规格 | 库存 | 状态(先观察数据质量,暂不入库)
552
+ for (const s of skus) {
553
+ log(
554
+ ` · ${s.skuId} | ${s.specs || "(无规格名)"} | 库存${s.stock ?? "?"} | 状态${s.stockStatus ?? "?"}`,
555
+ );
556
+ }
557
+ }
546
558
  } else {
547
559
  batchFailed++;
548
560
  totalFailed++;
@@ -46,9 +46,17 @@ function extractProductInfo(item) {
46
46
 
47
47
  /**
48
48
  * 构建统一的视频对象
49
+ *
50
+ * 图文帖(photo post)与视频混在 item_list 响应里,用 imagePost 字段区分
51
+ * (实测 @zoecole.fr:图文 item 有 imagePost={cover,images,...},
52
+ * video.duration=0 且无 PlayAddrStruct/codecType;视频 duration>0)。
53
+ * - isPhoto 标记贯穿全链路:EC 筛选/入库照常保留图文,仅国家采样排除
54
+ * - href 按真实路径生成:图文 /photo/{id},视频 /video/{id}
49
55
  */
50
56
  function buildVideoObject(item, username) {
51
- const href = `https://www.tiktok.com/@${username}/video/${item.id}`;
57
+ // 双保险:优先官方 imagePost 字段,duration=0 兜底(防字段偶发缺失)
58
+ const isPhoto = item.imagePost !== undefined || item.video?.duration === 0;
59
+ const href = `https://www.tiktok.com/@${username}/${isPhoto ? "photo" : "video"}/${item.id}`;
52
60
  const productInfo = extractProductInfo(item);
53
61
  return {
54
62
  id: item.id,
@@ -57,6 +65,7 @@ function buildVideoObject(item, username) {
57
65
  playCount: item.stats?.playCount || 0,
58
66
  isECVideo: item.isECVideo ? 1 : 0,
59
67
  isAd: item.isAd ? 1 : 0,
68
+ isPhoto: isPhoto ? 1 : 0,
60
69
  productId: productInfo?.productId || null,
61
70
  productTitle: productInfo?.productTitle || null,
62
71
  seoUrl: productInfo?.seoUrl || null,
@@ -97,6 +97,81 @@ function extractJsonByKey(html, keyPattern) {
97
97
  return null;
98
98
  }
99
99
 
100
+ /**
101
+ * Extract a JSON array by its key name (counterpart of extractJsonByKey for `[`).
102
+ * 用于 PDP SSR HTML 中的 "skus":[...] 段。
103
+ */
104
+ function extractJsonArrayByKey(html, keyPattern) {
105
+ const match = html.match(keyPattern);
106
+ if (!match) return null;
107
+
108
+ const bracketIdx = html.indexOf("[", match.index);
109
+ if (bracketIdx === -1) return null;
110
+
111
+ let depth = 0;
112
+ let inString = false;
113
+ let escaped = false;
114
+
115
+ for (let i = bracketIdx; i < html.length; i++) {
116
+ const ch = html[i];
117
+ if (escaped) {
118
+ escaped = false;
119
+ continue;
120
+ }
121
+ if (ch === "\\") {
122
+ escaped = true;
123
+ continue;
124
+ }
125
+ if (ch === '"') {
126
+ inString = !inString;
127
+ continue;
128
+ }
129
+ if (inString) continue;
130
+ if (ch === "[") depth++;
131
+ else if (ch === "]") {
132
+ depth--;
133
+ if (depth === 0) {
134
+ try {
135
+ return JSON.parse(html.substring(bracketIdx, i + 1));
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * 从 PDP SSR HTML 的 "skus":[...] 提取多 SKU 概要(原有商品级字段不动)。
147
+ * sku_id 为 19 位数字,必须按字符串保留(同 product_id 的精度坑)。
148
+ * @returns {Array<{skuId, specs, stock, stockStatus, warehouseId}>|null}
149
+ * 无 skus 段或解析失败返回 null(与现有字段缺失口径一致)
150
+ */
151
+ function extractSkus(html) {
152
+ const skus = extractJsonArrayByKey(html, /"skus"\s*:/);
153
+ if (!Array.isArray(skus) || skus.length === 0) return null;
154
+ const out = [];
155
+ for (const s of skus) {
156
+ try {
157
+ out.push({
158
+ skuId: String(s.sku_id ?? ""),
159
+ specs: (s.property_pairs || [])
160
+ .map((p) => `${p.sku_property_name}: ${p.sku_property_value_name}`)
161
+ .join(" | "),
162
+ stock: s.sku_quantity?.available_quantity ?? null,
163
+ stockStatus: s.sku_stock_status ?? null,
164
+ warehouseId: s.sku_quantity?.warehouse_id
165
+ ? String(s.sku_quantity.warehouse_id)
166
+ : null,
167
+ });
168
+ } catch {
169
+ // 单条损坏跳过,不影响其余 SKU
170
+ }
171
+ }
172
+ return out.length > 0 ? out : null;
173
+ }
174
+
100
175
  /**
101
176
  * Open a headed browser for manual captcha solving, extract cookies
102
177
  * so undici can reuse them for a while.
@@ -278,6 +353,7 @@ export function parseProductHtml(html) {
278
353
  ratingBreakdown: null,
279
354
  productId: null,
280
355
  sellerId: null,
356
+ skus: null, // 多 SKU 概要(无 skus 段或解析失败为 null,不影响原有字段)
281
357
  };
282
358
 
283
359
  // sold_count from product_model (string value, not shop-level integer)
@@ -308,6 +384,9 @@ export function parseProductHtml(html) {
308
384
  result.ratingBreakdown = ratings.rating_result ?? null;
309
385
  }
310
386
 
387
+ // skus(多规格概要:规格名/库存/仓库,sku_id 字符串保精度)
388
+ result.skus = extractSkus(html);
389
+
311
390
  // 壳页识别:有 product_id 骨架但 seller_id 为空且无任何销售/名称数据
312
391
  // → 商品已下架/店铺异常(200 空壳页),抛 delisted 供调用方标记放弃
313
392
  const hasSaleData =
@@ -181,18 +181,24 @@ async function processExplore(page, username, options, log) {
181
181
  return result;
182
182
  }
183
183
 
184
- // 国家采样判断
184
+ // 国家采样判断(只用真视频:图文帖 /photo/ 链接查不到 locationCreated,
185
+ // 会浪费采样位并污染众数统计;图文 EC 帖仍在上方 EC 筛选中正常保留)
185
186
  // explore 模式:采样 5 个,优先命中目标国家,不匹配则回退众数 → 写 locationCreated
186
187
  // refresh 模式:采样 7 个,纯众数逻辑 → 写 confirmedLocation(二次确认)
187
188
  const SAMPLE_SIZE = locationMode === "refresh" ? 7 : 5;
188
189
  let locationCreated = null;
189
190
  let confirmedLocation = null;
190
191
  let locationDecision = null;
191
- const sampleVideos = videoArray.slice(0, SAMPLE_SIZE);
192
+ const sampleVideos = videoArray
193
+ .filter((v) => !v.isPhoto)
194
+ .slice(0, SAMPLE_SIZE);
192
195
  if (sampleVideos.length > 0) {
193
196
  const sampleUrls = sampleVideos.map((v) =>
194
197
  v.href.startsWith("http") ? v.href : `https://www.tiktok.com${v.href}`,
195
198
  );
199
+ log(
200
+ ` 国家采样(${sampleUrls.length}个, 已排除图文${videoArray.length - videoArray.filter((v) => !v.isPhoto).length}条): 开始查询`,
201
+ );
196
202
  let locations;
197
203
  if (webserverUrl) {
198
204
  // 走集中式 webserver,不再启动本地 TikTokScraper
@@ -683,7 +683,13 @@ function getPreciseMerchantsFromDb(targetLocations = [], options = {}) {
683
683
  product_review_count,
684
684
  product_sold_count,
685
685
  product_name,
686
+ product_skus,
686
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,
687
693
  (SELECT COUNT(*) FROM json_each(recent_ec_videos)
688
694
  WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000) AS ecOver1k,
689
695
  (SELECT MAX(CAST(json_extract(value, '$.playCount') AS INTEGER))
@@ -1388,7 +1394,11 @@ export function createStore(filePath, options = {}) {
1388
1394
  filter: `instr(COALESCE(sources, ''), '"seed"') > 0`,
1389
1395
  loggedInOnly: false,
1390
1396
  },
1391
- { 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
+ },
1392
1402
  {
1393
1403
  filter: `(instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0 OR instr(COALESCE(sources, ''), '"comment"') > 0)`,
1394
1404
  loggedInOnly: false,
@@ -1407,8 +1417,28 @@ export function createStore(filePath, options = {}) {
1407
1417
  );
1408
1418
  if (normalizedLocations.length === 0)
1409
1419
  locationBranches.push(locNotIn(orderedCountries));
1420
+ // 商家级专用(sellerAnyCountry):国家对商家放开——末尾追加"任意国家"分支。
1421
+ // 传了国家子集时对应国家的商家仍优先,领完再兜任意国家(含空位置商家);
1422
+ // 未传国家时同理(配置内各国 → 配置外 → 任意)。其他来源级分支不受影响。
1423
+ if (p.sellerAnyCountry) locationBranches.push(null); // null → 任意国家分支
1410
1424
  let subPrio = 0;
1411
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
+ }
1412
1442
  if (!loc) continue;
1413
1443
  // hasVideo
1414
1444
  addBranch(
@@ -2452,28 +2482,40 @@ export function createStore(filePath, options = {}) {
2452
2482
  const placeholders = targetLocations.map(() => "?").join(",");
2453
2483
 
2454
2484
  // SELECT 候选用户
2455
- const selected = getDb()
2456
- .prepare(
2457
- `
2458
- SELECT unique_id, nickname, video_count, COALESCE(refresh_time, ?) AS refresh_time
2459
- 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 = `
2460
2494
  WHERE tt_seller = 1
2461
2495
  AND verified = 0
2462
2496
  AND (pending_refresh IS NULL OR pending_refresh = 0)
2463
2497
  AND location_created IN (${placeholders})
2464
- AND COALESCE(refresh_time, ?) < ?
2465
- ORDER BY COALESCE(refresh_time, ?) ASC
2466
- LIMIT ?
2467
- `,
2468
- )
2469
- .all(
2470
- defaultTime,
2471
- ...targetLocations,
2472
- defaultTime,
2473
- threshold,
2474
- defaultTime,
2475
- limit,
2476
- );
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
+ }
2477
2519
 
2478
2520
  // 立即更新 refresh_time 防重复领取(非事务,小概率并发重复可接受)
2479
2521
  if (selected.length > 0) {
@@ -2799,8 +2841,9 @@ export function createStore(filePath, options = {}) {
2799
2841
  /**
2800
2842
  * 回写商品详情到 jobs 表(product_* 列)
2801
2843
  * @param {string} uniqueId
2802
- * @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string}} productDetail
2844
+ * @param {{rating?:number, reviewCount?:number, soldCount?:number, productName?:string, skus?:Array}} productDetail
2803
2845
  * rating = -1 为哨兵值:商品不可售/已下架,写入后任务移出候选(正常评分 0-5)
2846
+ * skus = 多 SKU 概要数组(skuId/specs/stock/stockStatus/warehouseId),存 product_skus JSON
2804
2847
  */
2805
2848
  function updateProductInfo(uniqueId, productDetail) {
2806
2849
  if (getDb()) {
@@ -2814,6 +2857,10 @@ export function createStore(filePath, options = {}) {
2814
2857
  productReviewCount: productDetail.reviewCount ?? null,
2815
2858
  productSoldCount: productDetail.soldCount ?? null,
2816
2859
  productName: productDetail.productName || null,
2860
+ // 多 SKU 概要:数组经 normalizeJobValue 自动序列化为 JSON(无 skus 存 null)
2861
+ productSkus: Array.isArray(productDetail.skus)
2862
+ ? productDetail.skus
2863
+ : null,
2817
2864
  },
2818
2865
  false, // 商品补全与用户刷新计数无关,不增加 user_update_count
2819
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
  });
@@ -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() {