tt-help-cli-ycl 1.4.142 → 1.4.145

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.
@@ -112,6 +112,12 @@ body {
112
112
  color: #f472b6;
113
113
  }
114
114
 
115
+ /* 精准商家卡片文字改为红色 */
116
+ #statPreciseCard .label,
117
+ #statPreciseCard .value {
118
+ color: #ef4444;
119
+ }
120
+
115
121
  .stat-card .value-sub {
116
122
  font-size: 12px;
117
123
  font-weight: 400;
@@ -878,6 +884,14 @@ td.user-id:hover {
878
884
  display: block;
879
885
  }
880
886
 
887
+ #precisePage {
888
+ display: none;
889
+ }
890
+
891
+ #precisePage.active {
892
+ display: block;
893
+ }
894
+
881
895
  #backToTop {
882
896
  position: fixed;
883
897
  bottom: 32px;
@@ -1834,6 +1848,22 @@ td.user-id:hover {
1834
1848
  background: #e61944;
1835
1849
  }
1836
1850
 
1851
+ .settings-form .btn-cancel {
1852
+ padding: 10px 20px;
1853
+ border: 1px solid #333;
1854
+ border-radius: 8px;
1855
+ background: #2a2a3a;
1856
+ color: #ccc;
1857
+ font-size: 14px;
1858
+ font-weight: 600;
1859
+ cursor: pointer;
1860
+ transition: background 0.2s;
1861
+ }
1862
+
1863
+ .settings-form .btn-cancel:hover {
1864
+ background: #333;
1865
+ }
1866
+
1837
1867
  /* ── 看门狗页面 ── */
1838
1868
 
1839
1869
  #watchdogPage {
@@ -1876,3 +1906,191 @@ td.user-id:hover {
1876
1906
  color: #888;
1877
1907
  font-weight: 600;
1878
1908
  }
1909
+
1910
+ /* ===== 精准商家:视频卡片视图 ===== */
1911
+ .precise-list-header {
1912
+ display: flex;
1913
+ align-items: center;
1914
+ justify-content: space-between;
1915
+ gap: 10px;
1916
+ margin-bottom: 8px;
1917
+ }
1918
+ .precise-list-header h3 {
1919
+ margin: 0;
1920
+ }
1921
+ .view-toggle {
1922
+ display: inline-flex;
1923
+ align-items: center;
1924
+ gap: 2px;
1925
+ border-bottom: 2px solid #2f2f3f;
1926
+ }
1927
+ .view-toggle button {
1928
+ padding: 7px 18px;
1929
+ border: none;
1930
+ border-bottom: 2px solid transparent;
1931
+ border-radius: 6px 6px 0 0;
1932
+ background: transparent;
1933
+ color: #9aa;
1934
+ font-size: 13px;
1935
+ cursor: pointer;
1936
+ transition: all 0.2s;
1937
+ }
1938
+ .view-toggle button:hover {
1939
+ color: #ccc;
1940
+ }
1941
+ .view-toggle button.active {
1942
+ color: #fff;
1943
+ border-bottom-color: #7c3aed;
1944
+ background: rgba(124, 58, 237, 0.14);
1945
+ font-weight: 600;
1946
+ }
1947
+
1948
+ .precise-video-wrap {
1949
+ display: flex;
1950
+ flex-direction: column;
1951
+ gap: 14px;
1952
+ }
1953
+ .precise-merchant-card {
1954
+ background: #1a1a24;
1955
+ border: 1px solid #2a2a3a;
1956
+ border-radius: 10px;
1957
+ padding: 12px 14px;
1958
+ }
1959
+ .precise-merchant-head {
1960
+ display: flex;
1961
+ flex-wrap: wrap;
1962
+ align-items: baseline;
1963
+ gap: 10px;
1964
+ margin-bottom: 10px;
1965
+ }
1966
+ .precise-merchant-head .pm-user {
1967
+ font-weight: 700;
1968
+ color: #60a5fa;
1969
+ text-decoration: none;
1970
+ font-size: 15px;
1971
+ }
1972
+ .precise-merchant-head .pm-user:hover {
1973
+ text-decoration: underline;
1974
+ }
1975
+ .precise-merchant-head .pm-nick {
1976
+ color: #ccc;
1977
+ font-size: 13px;
1978
+ }
1979
+ .precise-merchant-head .pm-country {
1980
+ font-size: 11px;
1981
+ background: #2a2a3a;
1982
+ color: #a78bfa;
1983
+ padding: 1px 7px;
1984
+ border-radius: 10px;
1985
+ }
1986
+ .precise-merchant-head .pm-stats {
1987
+ color: #888;
1988
+ font-size: 12px;
1989
+ margin-left: auto;
1990
+ }
1991
+
1992
+ .precise-video-grid {
1993
+ display: grid;
1994
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1995
+ gap: 10px;
1996
+ }
1997
+ .precise-video-item {
1998
+ background: #20202e;
1999
+ border: 1px solid #2c2c3c;
2000
+ border-radius: 8px;
2001
+ overflow: hidden;
2002
+ position: relative;
2003
+ display: flex;
2004
+ flex-direction: column;
2005
+ }
2006
+ .precise-video-tile {
2007
+ display: block;
2008
+ position: relative;
2009
+ aspect-ratio: 9 / 14;
2010
+ background: linear-gradient(160deg, #2a2a3a 0%, #1c1c28 100%);
2011
+ color: #e0e0e0;
2012
+ text-decoration: none;
2013
+ overflow: hidden;
2014
+ }
2015
+ .precise-video-tile:hover {
2016
+ border-color: #7c3aed;
2017
+ }
2018
+ .precise-video-tile .pv-play {
2019
+ position: absolute;
2020
+ left: 50%;
2021
+ top: 50%;
2022
+ transform: translate(-50%, -50%);
2023
+ width: 42px;
2024
+ height: 42px;
2025
+ border-radius: 50%;
2026
+ background: rgba(0, 0, 0, 0.55);
2027
+ display: flex;
2028
+ align-items: center;
2029
+ justify-content: center;
2030
+ font-size: 20px;
2031
+ color: #fff;
2032
+ }
2033
+ .precise-video-tile .pv-count {
2034
+ position: absolute;
2035
+ left: 6px;
2036
+ top: 6px;
2037
+ font-size: 12px;
2038
+ color: #fff;
2039
+ text-shadow: 0 1px 2px #000;
2040
+ }
2041
+ /* 播放量最大的视频:播放量文字特殊高亮 */
2042
+ .precise-video-tile .pv-count-max {
2043
+ color: #1a1a24;
2044
+ font-weight: 800;
2045
+ background: #fbbf24;
2046
+ padding: 1px 5px;
2047
+ border-radius: 4px;
2048
+ text-shadow: none;
2049
+ box-shadow: 0 0 6px rgba(251, 191, 36, 0.8);
2050
+ }
2051
+ .precise-video-tile .pv-time {
2052
+ position: absolute;
2053
+ left: 6px;
2054
+ top: 24px;
2055
+ font-size: 11px;
2056
+ color: #cfcfcf;
2057
+ text-shadow: 0 1px 2px #000;
2058
+ }
2059
+ .precise-video-tile .pv-ad {
2060
+ position: absolute;
2061
+ right: 6px;
2062
+ top: 6px;
2063
+ font-size: 10px;
2064
+ background: rgba(254, 44, 85, 0.9);
2065
+ color: #fff;
2066
+ padding: 1px 5px;
2067
+ border-radius: 4px;
2068
+ }
2069
+ /* 商品名悬浮在图片上,最多 2 行、固定高度以容纳 2 行文字 */
2070
+ .precise-video-item .pv-product {
2071
+ position: absolute;
2072
+ left: 0;
2073
+ right: 0;
2074
+ bottom: 0;
2075
+ padding: 30px 8px 8px;
2076
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.92), rgba(0, 0, 0, 0));
2077
+ color: #fbbf24;
2078
+ font-size: 12px;
2079
+ line-height: 1.35;
2080
+ text-decoration: none;
2081
+ text-align: left;
2082
+ display: -webkit-box;
2083
+ -webkit-box-orient: vertical;
2084
+ -webkit-line-clamp: 2;
2085
+ line-clamp: 2;
2086
+ overflow: hidden;
2087
+ box-sizing: border-box;
2088
+ height: 71px;
2089
+ }
2090
+ .precise-video-item .pv-product:hover {
2091
+ color: #fff;
2092
+ text-decoration: underline;
2093
+ }
2094
+ .precise-video-item .pv-product .pv-product-icon {
2095
+ margin-right: 4px;
2096
+ }
@@ -8,7 +8,7 @@ import { fileURLToPath } from "url";
8
8
  import { spawn } from "child_process";
9
9
  import { createStore } from "./data-store.js";
10
10
  import { getTagDb } from "./db-schema.js";
11
- import { DEFAULT_TARGET_LOCATIONS } from "../lib/target-locations.js";
11
+ import { getTargetLocations } from "../lib/app-config.js";
12
12
  import { tryHandleWatchdogRoute } from "./watchdog-server.js";
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
@@ -44,11 +44,17 @@ const SLOW_STATS_TTL = 300000; // 5 分钟
44
44
  const usersCache = new Map(); // key -> { result, time }
45
45
  const USERS_CACHE_TTL = 3000; // 3 秒
46
46
 
47
- // 目标国家占位符(jobs 表聚合需要 location_created IN (...))
48
- const TARGET_PLACEHOLDERS = DEFAULT_TARGET_LOCATIONS.map(() => "?").join(", ");
49
- const TARGET_IN_SQL = DEFAULT_TARGET_LOCATIONS.length
50
- ? `location_created IN (${TARGET_PLACEHOLDERS})`
51
- : "1 = 0";
47
+ // 目标国家占位符(jobs 表聚合需要 location_created IN (...),运行时按 getTargetLocations() 动态生成)
48
+ function targetPlaceholders() {
49
+ const locs = getTargetLocations();
50
+ return locs.map(() => "?").join(", ");
51
+ }
52
+ function targetInSql() {
53
+ const locs = getTargetLocations();
54
+ return locs.length
55
+ ? `location_created IN (${targetPlaceholders()})`
56
+ : "1 = 0";
57
+ }
52
58
 
53
59
  // 统计分组定义:每组包含若干 SQL(分片执行)与一个聚合函数
54
60
  // sqls 项:{ key, sql, mode: 'one'|'all', params: 数组或返回数组的函数 }
@@ -80,8 +86,8 @@ const STAT_GROUPS = {
80
86
  {
81
87
  key: "jobsAgg",
82
88
  mode: "one",
83
- params: DEFAULT_TARGET_LOCATIONS,
84
- sql: `SELECT
89
+ params: () => getTargetLocations(),
90
+ sql: () => `SELECT
85
91
  SUM(jobsTotal) as jobsTotal,
86
92
  SUM(done) as done,
87
93
  SUM(error) as error,
@@ -94,7 +100,7 @@ const STAT_GROUPS = {
94
100
  SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
95
101
  SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
96
102
  SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
97
- SUM(CASE WHEN tt_seller = 1 AND verified = 0 AND ${TARGET_IN_SQL} THEN 1 ELSE 0 END) as targetUsers,
103
+ SUM(CASE WHEN tt_seller = 1 AND verified = 0 AND ${targetInSql()} THEN 1 ELSE 0 END) as targetUsers,
98
104
  SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo
99
105
  FROM jobs
100
106
  UNION ALL
@@ -108,6 +114,22 @@ const STAT_GROUPS = {
108
114
  FROM jobs_done
109
115
  )`,
110
116
  },
117
+ {
118
+ // 精确商家计数:目标商家 + 7 天 EC 视频播放>1000 + 有商品 + 评论0 + 销量>10
119
+ key: "preciseMerchants",
120
+ mode: "one",
121
+ params: () => getTargetLocations(),
122
+ sql: () => `SELECT COUNT(*) AS n FROM jobs
123
+ WHERE tt_seller = 1 AND verified = 0 AND ${targetInSql()}
124
+ AND top_video_seo_url IS NOT NULL AND top_video_seo_url != ''
125
+ AND product_review_count = 0
126
+ AND product_sold_count > 10
127
+ AND recent_ec_videos IS NOT NULL AND recent_ec_videos != ''
128
+ AND EXISTS (
129
+ SELECT 1 FROM json_each(recent_ec_videos)
130
+ WHERE CAST(json_extract(value, '$.playCount') AS INTEGER) > 1000
131
+ )`,
132
+ },
111
133
  ],
112
134
  combine(results) {
113
135
  const tq = results.tqAgg || {};
@@ -123,6 +145,7 @@ const STAT_GROUPS = {
123
145
  restrictedUsers: jb.restricted || 0,
124
146
  errorUsers: jb.error || 0,
125
147
  targetUsers: jb.targetUsers || 0,
148
+ preciseMerchants: results.preciseMerchants?.n || 0,
126
149
  sourceStats: {
127
150
  seed: tq.seed || 0,
128
151
  video: tq.video || 0,
@@ -177,10 +200,10 @@ const STAT_GROUPS = {
177
200
  {
178
201
  key: "targetCountryStats",
179
202
  mode: "all",
180
- params: DEFAULT_TARGET_LOCATIONS,
181
- sql: `SELECT location_created as country, COUNT(*) as count
203
+ params: () => getTargetLocations(),
204
+ sql: () => `SELECT location_created as country, COUNT(*) as count
182
205
  FROM jobs
183
- WHERE tt_seller = 1 AND verified = 0 AND ${TARGET_IN_SQL}
206
+ WHERE tt_seller = 1 AND verified = 0 AND ${targetInSql()}
184
207
  GROUP BY location_created
185
208
  ORDER BY count DESC`,
186
209
  },
@@ -250,6 +273,7 @@ const EMPTY_GROUP = {
250
273
  restrictedUsers: 0,
251
274
  errorUsers: 0,
252
275
  targetUsers: 0,
276
+ preciseMerchants: 0,
253
277
  sourceStats: {
254
278
  seed: 0,
255
279
  video: 0,
@@ -284,9 +308,10 @@ function refreshGroupInShards(st, name) {
284
308
  if (idx < spec.sqls.length) {
285
309
  const s = spec.sqls[idx++];
286
310
  try {
311
+ const sql = typeof s.sql === "function" ? s.sql() : s.sql;
287
312
  const params =
288
313
  typeof s.params === "function" ? s.params() : s.params || [];
289
- const r = st.rawQuery(s.sql, params);
314
+ const r = st.rawQuery(sql, params);
290
315
  if (r?.error) {
291
316
  console.error(`[stats] ${name}.${s.key} 查询失败: ${r.error}`);
292
317
  results[s.key] = null;
@@ -334,9 +359,10 @@ function computeGroupStats(st, name) {
334
359
  try {
335
360
  const results = {};
336
361
  for (const s of STAT_GROUPS[name].sqls) {
362
+ const sql = typeof s.sql === "function" ? s.sql() : s.sql;
337
363
  const params =
338
364
  typeof s.params === "function" ? s.params() : s.params || [];
339
- const r = st.rawQuery(s.sql, params);
365
+ const r = st.rawQuery(sql, params);
340
366
  results[s.key] =
341
367
  s.mode === "all" ? r?.rows || [] : r?.rows?.[0] || null;
342
368
  }
@@ -641,7 +667,7 @@ export function startWatchServer(
641
667
  // 按国家维度判断:只为 new 标签不足的国家发现
642
668
  const countriesToDiscover = [];
643
669
  const { getTagsByStatus } = await import("./db-tags.js");
644
- for (const country of DEFAULT_TARGET_LOCATIONS) {
670
+ for (const country of getTargetLocations()) {
645
671
  const newTags = getTagsByStatus("new", 100, 0, country, null, "llm");
646
672
  if (newTags.length < DISCOVER_PER_COUNTRY_THRESHOLD) {
647
673
  countriesToDiscover.push(country);
@@ -1137,7 +1163,7 @@ export function startWatchServer(
1137
1163
 
1138
1164
  if (req.method === "GET" && routePath === "/api/stats") {
1139
1165
  const stats = computeStatsIncremental(store);
1140
- stats.targetLocations = DEFAULT_TARGET_LOCATIONS;
1166
+ stats.targetLocations = getTargetLocations();
1141
1167
  stats.clientLoginStatus = store.getClientLoginStatus();
1142
1168
  stats.activeClients = store.getActiveClients();
1143
1169
  sendJSON(res, 200, stats);
@@ -1494,7 +1520,7 @@ export function startWatchServer(
1494
1520
  }
1495
1521
 
1496
1522
  if (req.method === "GET" && routePath === "/api/target-users") {
1497
- const targetResult = store.getTargetUsers(DEFAULT_TARGET_LOCATIONS);
1523
+ const targetResult = store.getTargetUsers(getTargetLocations());
1498
1524
  const targets = targetResult.users;
1499
1525
  if (req.headers["accept"]?.includes("text/csv")) {
1500
1526
  const columns = [
@@ -1543,25 +1569,64 @@ export function startWatchServer(
1543
1569
  return;
1544
1570
  }
1545
1571
 
1572
+ // GET /api/precise-merchants — 精确商家列表(支持分页+搜索)
1573
+ if (req.method === "GET" && routePath === "/api/precise-merchants") {
1574
+ const result = store.getPreciseMerchants(getTargetLocations(), {
1575
+ search: params.search || undefined,
1576
+ limit: params.limit ? parseInt(params.limit, 10) : undefined,
1577
+ offset: params.offset ? parseInt(params.offset, 10) : undefined,
1578
+ });
1579
+ if (result && result.users) {
1580
+ result.users = result.users.map((u) => ({
1581
+ uniqueId: u.uniqueId,
1582
+ nickname: u.nickname || "",
1583
+ locationCreated: u.locationCreated || null,
1584
+ followerCount: u.followerCount || 0,
1585
+ videoCount: u.videoCount || 0,
1586
+ latestVideoTime: u.latestVideoTime || null,
1587
+ topVideoPlayCount: u.topVideoPlayCount || null,
1588
+ topVideoHref: u.topVideoHref || null,
1589
+ topVideoIsAd: u.topVideoIsAd ?? null,
1590
+ topVideoProductId: u.topVideoProductId || null,
1591
+ topVideoProductTitle: u.topVideoProductTitle || null,
1592
+ topVideoSeoUrl: u.topVideoSeoUrl || null,
1593
+ productRating: u.productRating ?? null,
1594
+ productReviewCount: u.productReviewCount ?? null,
1595
+ productSoldCount: u.productSoldCount ?? null,
1596
+ productName: u.productName || null,
1597
+ ecOver1k: u.ecOver1k || 0,
1598
+ maxEcPlay: u.maxEcPlay || null,
1599
+ recentEcVideos: (() => {
1600
+ try {
1601
+ return Array.isArray(u.recentEcVideos)
1602
+ ? u.recentEcVideos
1603
+ : JSON.parse(u.recentEcVideos || "[]");
1604
+ } catch {
1605
+ return [];
1606
+ }
1607
+ })(),
1608
+ }));
1609
+ }
1610
+ sendJSON(res, 200, result);
1611
+ return;
1612
+ }
1613
+
1546
1614
  if (
1547
1615
  req.method === "GET" &&
1548
1616
  routePath === "/api/target-users-by-country"
1549
1617
  ) {
1550
1618
  // 摘要模式:只返回各国统计数
1551
1619
  if (params.summary === "1") {
1552
- const result = store.getTargetUsersByCountry(
1553
- DEFAULT_TARGET_LOCATIONS,
1554
- { summaryOnly: true },
1555
- );
1620
+ const result = store.getTargetUsersByCountry(getTargetLocations(), {
1621
+ summaryOnly: true,
1622
+ });
1556
1623
  sendJSON(res, 200, result);
1557
1624
  return;
1558
1625
  }
1559
1626
 
1560
1627
  // CSV 导出:全量数据
1561
1628
  if (req.headers["accept"]?.includes("text/csv")) {
1562
- const result = store.getTargetUsersByCountry(
1563
- DEFAULT_TARGET_LOCATIONS,
1564
- );
1629
+ const result = store.getTargetUsersByCountry(getTargetLocations());
1565
1630
  const columns = [
1566
1631
  "uniqueId",
1567
1632
  "nickname",
@@ -1615,17 +1680,14 @@ export function startWatchServer(
1615
1680
 
1616
1681
  // 分页模式:按国家+搜索+分页查询用户
1617
1682
  if (params.limit) {
1618
- const result = store.getTargetUsersByCountry(
1619
- DEFAULT_TARGET_LOCATIONS,
1620
- {
1621
- country: params.country || undefined,
1622
- search: params.search || undefined,
1623
- limit: parseInt(params.limit, 10),
1624
- offset: parseInt(params.offset || "0", 10),
1625
- sort: params.sort || undefined,
1626
- sortOrder: params.sortOrder || "desc",
1627
- },
1628
- );
1683
+ const result = store.getTargetUsersByCountry(getTargetLocations(), {
1684
+ country: params.country || undefined,
1685
+ search: params.search || undefined,
1686
+ limit: parseInt(params.limit, 10),
1687
+ offset: parseInt(params.offset || "0", 10),
1688
+ sort: params.sort || undefined,
1689
+ sortOrder: params.sortOrder || "desc",
1690
+ });
1629
1691
  // 确保每个用户对象包含 topVideoPlayCount、topVideoHref 和 topVideoIsAd
1630
1692
  if (result.users && Array.isArray(result.users)) {
1631
1693
  result.users = result.users.map((u) => ({
@@ -1640,7 +1702,7 @@ export function startWatchServer(
1640
1702
  }
1641
1703
 
1642
1704
  // 默认:全量(兼容旧调用)
1643
- const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
1705
+ const result = store.getTargetUsersByCountry(getTargetLocations());
1644
1706
  // 确保每个用户对象包含 topVideoPlayCount、topVideoHref 和 topVideoIsAd
1645
1707
  if (result.countries && Array.isArray(result.countries)) {
1646
1708
  for (const country of result.countries) {
@@ -1662,9 +1724,8 @@ export function startWatchServer(
1662
1724
  req.method === "GET" &&
1663
1725
  routePath === "/api/target-users-other-countries"
1664
1726
  ) {
1665
- const result = store.getModifiedTargetUsersOutsideLocations(
1666
- DEFAULT_TARGET_LOCATIONS,
1667
- );
1727
+ const result =
1728
+ store.getModifiedTargetUsersOutsideLocations(getTargetLocations());
1668
1729
  sendJSON(res, 200, result);
1669
1730
  return;
1670
1731
  }
@@ -1842,6 +1903,49 @@ export function startWatchServer(
1842
1903
  return;
1843
1904
  }
1844
1905
 
1906
+ // ====== 目标国家配置(通用 app-config 落盘) ======
1907
+
1908
+ // GET /api/settings/target-locations — 读取当前目标国家
1909
+ if (
1910
+ req.method === "GET" &&
1911
+ routePath === "/api/settings/target-locations"
1912
+ ) {
1913
+ try {
1914
+ const { getTargetLocations } = await import("../lib/app-config.js");
1915
+ sendJSON(res, 200, { locations: getTargetLocations() });
1916
+ } catch (e) {
1917
+ sendJSON(res, 500, { error: e.message });
1918
+ }
1919
+ return;
1920
+ }
1921
+
1922
+ // POST /api/settings/target-locations — 保存目标国家(body: { locations: ["CZ","GR",...] })
1923
+ if (
1924
+ req.method === "POST" &&
1925
+ routePath === "/api/settings/target-locations"
1926
+ ) {
1927
+ try {
1928
+ const body = await readBody(req);
1929
+ const { setTargetLocations } = await import("../lib/app-config.js");
1930
+ const saved = setTargetLocations(body.locations);
1931
+ sendJSON(res, 200, { locations: saved });
1932
+ } catch (e) {
1933
+ sendJSON(res, 400, { error: e.message });
1934
+ }
1935
+ return;
1936
+ }
1937
+
1938
+ // GET /api/settings/config — 读取整份通用配置(便于后续扩展其他配置)
1939
+ if (req.method === "GET" && routePath === "/api/settings/config") {
1940
+ try {
1941
+ const { getConfig } = await import("../lib/app-config.js");
1942
+ sendJSON(res, 200, { config: getConfig() });
1943
+ } catch (e) {
1944
+ sendJSON(res, 500, { error: e.message });
1945
+ }
1946
+ return;
1947
+ }
1948
+
1845
1949
  // GET /api/llm-test — 直接测试 LLM API 连通性
1846
1950
  if (req.method === "GET" && routePath === "/api/llm-test") {
1847
1951
  try {
@@ -1939,7 +2043,7 @@ export function startWatchServer(
1939
2043
  targetLocation: params.targetLocation,
1940
2044
  limit: params.limit,
1941
2045
  offset: params.offset,
1942
- targetLocations: DEFAULT_TARGET_LOCATIONS,
2046
+ targetLocations: getTargetLocations(),
1943
2047
  });
1944
2048
  usersCache.set(cacheKey, { result, time: Date.now() });
1945
2049
  }
@@ -22,8 +22,11 @@ import { pushSelfLog } from "./self-log-buffer.js";
22
22
  const MAX_RESTART = 3;
23
23
  const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
24
24
  const IS_WIN = process.platform === "win32";
25
- const SPAWN_STAGGER_MS = 30000; // 批量启动时每个进程间隔 30 秒,避免抢网络
26
- const STOP_STAGGER_MS = 10000; // 强制重启时每个命令停止间隔 10 秒(杀 Edge/进程树会瞬时占资源,错峰释放)
25
+ const SPAWN_STAGGER_MS = 30000; // syncCommands 批量启动时每个进程间隔 30 秒,避免抢网络
26
+ // 代理切换后强制重启(restartAll)专用错峰间隔:切换出口后所有命令集中重建连接,
27
+ // 间隔给长一点,避免瞬时抢网络/触发风控(2026-08-29 按需加长)
28
+ const RESTART_STOP_STAGGER_MS = 20000; // 停止错峰 20s(杀 Edge/进程树瞬时占资源,错峰释放)
29
+ const RESTART_SPAWN_STAGGER_MS = 60000; // 启动错峰 60s
27
30
  const STALE_LOG_TIMEOUT_MS = 15 * 60 * 1000; // 15 分钟无日志输出视为假死
28
31
 
29
32
  // === 控制台日志格式化(仅显示层增强,不影响任何功能链路) ===
@@ -739,9 +742,10 @@ export class ProcessManager extends EventEmitter {
739
742
  console.error(
740
743
  `[watchdog] ${reason},强制重启全部命令 (${toRestart.length}个)...`,
741
744
  );
742
- // 错峰停止:第一个立即停,之后每个间隔 STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
745
+ // 错峰停止:第一个立即停,之后每个间隔 RESTART_STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
743
746
  for (let i = 0; i < toRestart.length; i++) {
744
- if (i > 0) await new Promise((r) => setTimeout(r, STOP_STAGGER_MS));
747
+ if (i > 0)
748
+ await new Promise((r) => setTimeout(r, RESTART_STOP_STAGGER_MS));
745
749
  if (this.shutdown) {
746
750
  console.error("[watchdog] 看门狗已停止,中止停止命令");
747
751
  break;
@@ -751,9 +755,10 @@ export class ProcessManager extends EventEmitter {
751
755
  `[watchdog] 命令 ${toRestart[i].id} 已停止 (${i + 1}/${toRestart.length})`,
752
756
  );
753
757
  }
754
- // 错峰逐个重启:第一个立即起,之后每个间隔 SPAWN_STAGGER_MS
758
+ // 错峰逐个重启:第一个立即起,之后每个间隔 RESTART_SPAWN_STAGGER_MS
755
759
  for (let i = 0; i < toRestart.length; i++) {
756
- if (i > 0) await new Promise((r) => setTimeout(r, SPAWN_STAGGER_MS));
760
+ if (i > 0)
761
+ await new Promise((r) => setTimeout(r, RESTART_SPAWN_STAGGER_MS));
757
762
  if (this.shutdown) {
758
763
  console.error("[watchdog] 看门狗已停止,中止重启命令");
759
764
  break;