diff --git a/dev_test_scripts/unit/test_manager_filter_push_rows.js b/dev_test_scripts/unit/test_manager_filter_push_rows.js
new file mode 100644
index 0000000..91b7f7e
--- /dev/null
+++ b/dev_test_scripts/unit/test_manager_filter_push_rows.js
@@ -0,0 +1,34 @@
+/**
+ * 单元测试:market_skills manager 发送侧白名单二次过滤(filterPushRows)
+ * 运行:node dev_test_scripts/unit/test_manager_filter_push_rows.js(cwd 必须为项目根目录)
+ *
+ * 覆盖场景:
+ * 1. 中韩半导体指:symbol 误配 sh000688 与科创50 撞车(在白名单内),必须被排除名单拦截
+ * 2. 白名单内 skill(科创50/纳斯达克/COMEX黄金)放行
+ * 3. 白名单外 skill(COMEX白银 hf_SI)拦截
+ * 4. 排除名单 skill(巴菲特指)拦截
+ * 5. 无法识别 symbol 的存量记录默认拦截
+ * 6. 空数组 / null 不崩溃
+ */
+const assert = require('assert')
+const manager = require('../../src/server/market_skills/manager')
+
+const rows = [
+ { skill: '中韩半导体指', title: '涨幅预警' },
+ { skill: '科创50', title: '涨幅预警' },
+ { skill: '纳斯达克', title: '涨幅预警' },
+ { skill: 'COMEX黄金', title: '价格预警' },
+ { skill: 'COMEX白银', title: '价格预警' },
+ { skill: '巴菲特指', title: '极端高估' },
+ { skill: '不存在的skill', title: 'x' }
+]
+
+const out = manager.filterPushRows(rows)
+const names = out.map(r => r.skill)
+assert.deepStrictEqual(names, ['科创50', '纳斯达克', 'COMEX黄金'], '发送侧过滤结果不符:' + JSON.stringify(names))
+
+assert.deepStrictEqual(manager.filterPushRows([]), [])
+assert.deepStrictEqual(manager.filterPushRows(null), [])
+
+console.log('PASS test_manager_filter_push_rows')
+process.exit(0)
diff --git a/dev_test_scripts/unit/test_markets_notify_allowlist.js b/dev_test_scripts/unit/test_markets_notify_allowlist.js
index b7c8c75..f9cbaba 100644
--- a/dev_test_scripts/unit/test_markets_notify_allowlist.js
+++ b/dev_test_scripts/unit/test_markets_notify_allowlist.js
@@ -65,5 +65,24 @@ assert(r4.kept.length === mockQuotes.length, '缺省字段不过滤')
const r5 = applyNotifyAllowlist(null, { notify_symbol_allowlist: ALLOWLIST })
assert(Array.isArray(r5.kept) && r5.kept.length === 0, 'quotes 为 null 时不崩溃')
+console.log('== test: manager.isSkillPushAllowed(skill 推送通道白名单) ==')
+
+const { isSkillPushAllowed } = require(path.join(process.cwd(), 'src', 'server', 'market_skills', 'manager.js'))
+
+// 6. 白名单内 skill 放行(8 个保留项)
+assert(['sh000001', 'sz399001', 'sh000300', 'sh000688', 'sz399006', 'usINX', 'usIXIC', 'hf_GC'].every(s => isSkillPushAllowed(s, ALLOWLIST)), '白名单内 8 个 symbol 全部放行')
+
+// 7. 白名单外 skill 拦截(截图里仍推送的几个)
+assert(!isSkillPushAllowed('usEWY', ALLOWLIST), '韩国综合指数 usEWY 被拦截')
+assert(!isSkillPushAllowed('us_buffett_index', ALLOWLIST), '巴菲特指数 us_buffett_index 被拦截')
+assert(!isSkillPushAllowed('bj899050', ALLOWLIST), '宝岛加权指数 bj899050 被拦截')
+assert(!isSkillPushAllowed('sh601988_299', ALLOWLIST), '中国银行-299 sh601988_299 被拦截')
+assert(!isSkillPushAllowed('hf_SI', ALLOWLIST), 'COMEX白银 hf_SI 被拦截')
+
+// 8. 大小写不敏感 / 空名单 / null 名单
+assert(isSkillPushAllowed('USINX', ALLOWLIST), '大小写不敏感')
+assert(isSkillPushAllowed('usEWY', []), '空名单不限制')
+assert(isSkillPushAllowed('usEWY', null), 'null 名单不限制')
+
console.log(failed === 0 ? '\nALL TESTS PASSED' : `\n${failed} TEST(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
diff --git a/dev_test_scripts/unit/test_thought_lab_api.js b/dev_test_scripts/unit/test_thought_lab_api.js
new file mode 100644
index 0000000..e332c64
--- /dev/null
+++ b/dev_test_scripts/unit/test_thought_lab_api.js
@@ -0,0 +1,62 @@
+// 单元测试:thought_lab 路由鉴权行为
+// 运行:node dev_test_scripts/unit/test_thought_lab_api.js
+// 断言:
+// 1) GET /api/thought_lab/labs 无 cookie -> 200 且返回 5 个 lab(白名单公开元数据)
+// 2) lab 页面:无 cookie 无 Referer(直链/外站) -> 401
+// 3) lab 页面:无 cookie 但同源 Referer(首页导航进入) -> 200
+// 4) lab 页面:带 nav_gate -> 200
+const http = require('http')
+const path = require('path')
+const express = require(path.join(process.cwd(), 'node_modules', 'express'))
+const thoughtLab = require(path.join(process.cwd(), 'src', 'server', 'thought_lab'))
+
+const app = express()
+thoughtLab.bindRoutes(app)
+
+const server = app.listen(0, async () => {
+ const port = server.address().port
+ const get = (p, headers) => new Promise((resolve, reject) => {
+ const req = http.get({ host: '127.0.0.1', port, path: p, headers: headers || {} }, res => {
+ let body = ''
+ res.on('data', c => { body += c })
+ res.on('end', () => resolve({ status: res.statusCode, body }))
+ })
+ req.on('error', reject)
+ })
+
+ let fail = 0
+ const check = (name, cond, extra) => {
+ console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`)
+ if (!cond) fail++
+ }
+
+ try {
+ const r1 = await get('/api/thought_lab/labs')
+ let labs = []
+ try { labs = JSON.parse(r1.body).labs || [] } catch {}
+ check('labs 列表无 cookie 应 200', r1.status === 200, `status=${r1.status}`)
+ check('labs 列表应包含 5 个 lab', labs.length === 5, `count=${labs.length}`)
+
+ const page = '/tools/thought_lab/labs/blackhole_sim/index.html'
+ const host = `127.0.0.1:${port}`
+
+ const r2 = await get(page)
+ check('lab 页面直链(无 cookie 无 Referer)应 401', r2.status === 401, `status=${r2.status}`)
+
+ const r3 = await get(page, { referer: `http://${host}/`, host })
+ check('lab 页面同源 Referer(导航进入)应 200', r3.status === 200, `status=${r3.status}`)
+
+ const r4 = await get(page, { cookie: 'nav_gate=1', host })
+ check('lab 页面带 nav_gate 应 200', r4.status === 200, `status=${r4.status}`)
+
+ const r5 = await get(page, { referer: 'https://evil.example.com/', host })
+ check('lab 页面外站 Referer 应 401', r5.status === 401, `status=${r5.status}`)
+ } catch (e) {
+ fail++
+ console.log('FAIL - 请求异常 |', e.message)
+ }
+
+ server.close()
+ console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`)
+ process.exit(fail === 0 ? 0 : 1)
+})
diff --git a/public/index.js b/public/index.js
index c693b65..19e620b 100644
--- a/public/index.js
+++ b/public/index.js
@@ -100,6 +100,7 @@ class ToolboxHome {
this.loadIcons();
this.loadBookmarks();
this.loadTools();
+ this.loadThoughtLabs();
this.loadFitnessDiaryReminder();
this.loadWeeklyReportReminder();
}
@@ -143,6 +144,7 @@ class ToolboxHome {
tabBar.innerHTML = `
+
`;
@@ -167,6 +169,15 @@ class ToolboxHome {
lowToolGrid.id = 'tool-grid-low';
lowToolsPage.appendChild(lowToolGrid);
+ // 思想实验室页面(卡片形式与高/低频一致,数据来自 /api/thought_lab/labs)
+ const labsPage = document.createElement('div');
+ labsPage.className = 'tab-page';
+ labsPage.dataset.tab = 'labs';
+ const labGrid = document.createElement('div');
+ labGrid.className = 'tool-grid';
+ labGrid.id = 'tool-grid-labs';
+ labsPage.appendChild(labGrid);
+
// 书签导航页面
const bookmarksPage = document.createElement('div');
bookmarksPage.className = 'tab-page';
@@ -271,6 +282,7 @@ class ToolboxHome {
tabPages.appendChild(highToolsPage);
tabPages.appendChild(lowToolsPage);
tabPages.appendChild(bookmarksPage);
+ tabPages.appendChild(labsPage);
// 组装页面
mainContainer.appendChild(header);
@@ -362,9 +374,12 @@ class ToolboxHome {
this.tabBar = tabBar;
this.highToolGrid = highToolGrid;
this.lowToolGrid = lowToolGrid;
+ this.labGrid = labGrid;
this.toolsPage = highToolsPage;
this.bookmarksPage = bookmarksPage;
- this.tabPages = [highToolsPage, lowToolsPage, bookmarksPage];
+ this.labsPage = labsPage;
+ this.labTools = [];
+ this.tabPages = [highToolsPage, lowToolsPage, bookmarksPage, labsPage];
this.bookmarkContainer = bookmarkSections;
this.bookmarkEmpty = bookmarkEmpty;
this.bookmarkAdmin = bookmarkAdmin;
@@ -469,6 +484,35 @@ class ToolboxHome {
renderGrid(this.lowToolGrid, this.lowFreqTools);
}
+ async loadThoughtLabs() {
+ try {
+ const res = await fetch('/api/thought_lab/labs?t=' + Date.now());
+ if (!res.ok) return;
+ const data = await res.json();
+ if (!data || !data.ok) return;
+ this.labTools = Array.isArray(data.labs) ? data.labs : [];
+ this.renderLabs();
+ } catch (err) {
+ console.error('加载思想实验室列表失败:', err);
+ }
+ }
+
+ renderLabs() {
+ if (!this.labGrid) return;
+ this.labGrid.innerHTML = '';
+ this.labTools.forEach(lab => {
+ const card = document.createElement('a');
+ card.className = 'tool-card';
+ card.href = lab.href || '#';
+ if (lab.desc) card.title = lab.desc;
+ card.innerHTML = `
+
${lab.icon || '🧪'}
+ ${lab.name || lab.id || '未命名 lab'}
+ `;
+ this.labGrid.appendChild(card);
+ });
+ }
+
openAdmin() {
this.adminVisible = true;
if (this.bookmarkAdmin) this.bookmarkAdmin.classList.add('active');
diff --git a/public/tools/thought_lab/labs/blackhole_sim/index.html b/public/tools/thought_lab/labs/blackhole_sim/index.html
new file mode 100644
index 0000000..b84ec13
--- /dev/null
+++ b/public/tools/thought_lab/labs/blackhole_sim/index.html
@@ -0,0 +1,529 @@
+
+
+
+
+
+
+黑洞模拟器 - 广义相对论光线追踪 · 教学版
+
+
+
+
+
+
+
黑洞模拟器 GR Ray Tracing · 教学版
+
+
+
+
+
+
黑洞参数
+
+
+
+
+
+
+
+
+
+
+
+
+
+
物理效应开关
+
+
+
+
+
+
+
+
+
+
相机
+
+
+
+ 16 rs
+
+
+
+
+
+
+
+
+
+
+
+
鼠标左键拖拽旋转 · 滚轮缩放 · 吸积盘内侧更亮更蓝(温度 T∝r⁻³ᐟ⁴),朝向你运动的一侧因相对论性束流更亮。
+
+
结构与真实观测
+
+ 事件视界 / 光子球 / ISCO / 阴影
+ 事件视界 r=rs:光也无法逃逸的边界,rs=2GM/c²。
+ 光子球 r=1.5rs:光子可绕黑洞做(不稳定)圆轨道。画面中细亮的"光子环"即多次绕转的光。
+ ISCO r=3rs:最内稳定圆轨道,吸积盘内缘在此(物质自此螺旋坠入)。
+ 黑洞阴影:背景光被捕获形成的暗区,直径≈5.2rs,比视界大得多——这正是 EHT 拍到的"环状亮圈包着暗区"。
+
+
+ 人类如何真的"看到"黑洞
+ 事件视界望远镜 EHT:全球毫米波射电望远镜组成地球口径的 VLBI 阵列,2019 年发布 M87*(65亿M☉)首张照片,2022 年发布 Sgr A*。观测波长 1.3mm,亮环即透镜化的吸积流,暗区即阴影。
+ LIGO/Virgo:2015 年首次直接探测双黑洞并合引力波,验证强场广义相对论。
+ GRAVITY(VLT):追踪 S2 恒星绕 Sgr A* 的轨道,测得史瓦西进动,与"轨道实验室"页同一物理。
+
+
+
+
+
+
测试粒子轨道(史瓦西度规)
+
+
+
+
+
+
+
+
蓝圈=事件视界(1rs) · 青圈=光子球(1.5rs) · 黄圈=ISCO(3rs)。椭圆轨道近日点每圈前移 Δφ≈6πGM/[c²a(1−e²)],S2 恒星绕银心的进动已被 GRAVITY 实测验证。
+
+
+
+WebGL 不可用,无法运行黑洞模拟。
+
+
+
+
diff --git a/public/tools/thought_lab/labs/lagrange_points/index.html b/public/tools/thought_lab/labs/lagrange_points/index.html
new file mode 100644
index 0000000..b891fd6
--- /dev/null
+++ b/public/tools/thought_lab/labs/lagrange_points/index.html
@@ -0,0 +1,313 @@
+
+
+
+
+
+
+拉格朗日点 · 限制性三体问题交互演示
+
+
+
+拉格朗日点 · 圆形限制性三体问题(CR3BP)交互演示
+旋转坐标系下的有效势、零速度曲线与测试粒子运动 · 全部物理来自经典力学,无虚构成分 · 点击画面可放置测试粒子
+
+
+
+
+
+
系统与控制
+
+ 天体系统
+
+
+
+ 雅可比常数 C
+
+ 3.100
+
+
+
+
+
+
+
+
+
+
+
+
+ (白线=滑块 C 的零速度曲线,暗红=其禁行区;黄线=粒子自身 C₀ 的零速度曲线,粒子永不可越)
+
+
+
+
物理原理(旋转坐标系,归一化 G=M=ω=a=1)
+
+ - 有效势:
Ω = ½(x²+y²) + (1−μ)/r₁ + μ/r₂(离心势+双星引力势)
+ - 运动方程:
ẍ = 2ẏ + Ω/∂x,ÿ = −2ẋ + ∂Ω/∂y(科里奥利力使轨迹弯曲)
+ - 唯一守恒量——雅可比常数:
C = 2Ω − (ẋ²+ẏ²)。因 v²≥0,2Ω < C 的区域粒子永远无法进入(零速度曲线为边界)
+ - 五个平衡点 L1–L5:∇Ω=0。L1–L3 为鞍点(不稳定);L4/L5 虽是极大值点,但当
μ < 0.03852(Routh 判据,1875)时科里奥利力使其稳定
+
+
+
真实世界的拉格朗日点(非虚构)
+
+ | 点 | 真实驻留者 |
+ | 日-地 L1 | SOHO、ACE、DSCOVR、WIND 太阳观测器 |
+ | 日-地 L2 | JWST、Gaia、Planck、WMAP、Herschel |
+ | 地-月 L2 | 中国"鹊桥"中继星(嫦娥四号,2018);ARTEMIS 探测器曾驻留地-月 L1/L2 |
+ | 日-木 L4/L5 | 木星特洛伊小行星群(已确认上万颗) |
+ | 日-地 L4/L5 | 地球特洛伊 2010 TK7、2020 XL5;地-月 L4/L5 的 Kordylewski 尘埃云 |
+ | L3 | 不稳定且被太阳遮挡,无已知驻留天体(科幻"反地球"不存在) |
+
+
+
诚实声明
+
演示为理想化 CR3BP:忽略其他天体摄动、太阳光压、轨道偏心率。真实任务(如 JWST 的 Lissajous/halo 轨道)需在此模型上叠加摄动与轨道维持。画面中双星在旋转系中固定,这是坐标系选择而非物理近似。
+
+
+
+
+
+
diff --git a/public/tools/thought_lab/labs/pi_derivation/index.html b/public/tools/thought_lab/labs/pi_derivation/index.html
new file mode 100644
index 0000000..bb0ca6f
--- /dev/null
+++ b/public/tools/thought_lab/labs/pi_derivation/index.html
@@ -0,0 +1,826 @@
+
+
+
+
+
+
+π 推导实验室 · 随机角度统计
+
+
+
+
+
+
+
+
+ ◤ 几何动画视图 Canvas 实时模拟
+
+
+
+
+
+
速度 1.0×
+
+ 初始化…
+
+
+
+
+ ◤ 概率统计计算面板 后台实时统计
+
+
+
角度积分反推 π̂(主估计器)
+
—
+
理论 π = 3.141592653589793238…
+
+ 绝对误差 —
+ 稳定小数位 —
+ 目标 ≥ 10 位
+
+
+
+
+ π̂ = (1/N)·Σₖ(αₖ+βₖ+γₖ) = 3·∫₀^π θ·ρ(θ)dθ (ρ 为记录角度的经验分布)
+ π̂MC = 2 / E[sin θcut] (均匀随机测量角,Buffon 型对照)
+
+
+
+
+ 角度分布直方图(0°–180°,按三角形类型堆叠)
+
+
+ 独立蒙特卡洛对照(慢收敛 · 1/√N)
+
+
+
π̂MC = 2/⟨sinθ⟩
+
—
+
误差 —
+
测量角样本 0
+
+
+
+
直方图数值积分 π̂h(粗)
+
—
+
分箱 5°,演示离散化误差
+
+
+
+ 最近取走样本(每行验证 α+β+γ = π)
+
+
+
+
+
+
+
+
+
diff --git a/public/tools/thought_lab/labs/solar_system_3d/index.html b/public/tools/thought_lab/labs/solar_system_3d/index.html
new file mode 100644
index 0000000..2170aa4
--- /dev/null
+++ b/public/tools/thought_lab/labs/solar_system_3d/index.html
@@ -0,0 +1,428 @@
+
+
+
+
+
+
+3D 太阳系轨道模拟器 - 开普勒真实轨道
+
+
+
+
+
+
+
3D 太阳系轨道模拟器 Kepler / J2000
+
左键拖拽旋转视角(环绕观察倾斜轨道面)
+
滚轮缩放相机远近
+
右键拖拽平移视口
+
+
+
时间流速60 天/秒
+
+
模拟日期2000-01-01
+
+
+
+ 数据来源:NASA JPL J2000 历元轨道六要素
+ 开普勒方程 M = E − e·sinE 牛顿迭代求解
+ 椭圆轨道·太阳位于焦点·近日点快/远日点慢
+
+
+
+Three.js 加载失败。
请检查网络(需访问 cdnjs / jsdelivr CDN)后刷新页面。
+
+
+
+
+
+
+
+
diff --git a/public/tools/thought_lab/labs/sun_earth_orbit/index.html b/public/tools/thought_lab/labs/sun_earth_orbit/index.html
new file mode 100644
index 0000000..5f92864
--- /dev/null
+++ b/public/tools/thought_lab/labs/sun_earth_orbit/index.html
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+太阳-地球运行轨迹动画
+
+
+
+
+
+ 地球公转 · 开普勒第二定律(近日点快,远日点慢)
+
+ ⚠ 演示动画:轨道偏心率放大至 e=0.35(真实 0.0167),非真实扁率
+
+
+
+ 速度
+
+
+
+
+
+
diff --git a/src/server/data_gateway/skills/email_sender/email.db-shm b/src/server/data_gateway/skills/email_sender/email.db-shm
index 162527f..5f8be3b 100644
Binary files a/src/server/data_gateway/skills/email_sender/email.db-shm and b/src/server/data_gateway/skills/email_sender/email.db-shm differ
diff --git a/src/server/index.js b/src/server/index.js
index 05d69cb..c758a40 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -83,6 +83,7 @@ const boxBackup = require('./box_backup')
const yuanzhupai = require('./yuanzhupai')
const shortLink = require('./short_link')
const dataGateway = require('./data_gateway')
+const thoughtLab = require('./thought_lab')
const ossFileCabinet = require('./oss_file_cabinet')
yuanzhupai.initDb()
@@ -6495,6 +6496,16 @@ app.use('/tools/investment_ledger', (req, res, next) => {
} catch { return res.status(401).send('未授权') }
})
+// ============================================================
+// 思想实验室:纯导航鉴权(nav_gate)+ lab 静态资源 + lab 列表 API
+// 必须注册在下方全局 express.static 之前,否则静态资源会绕过鉴权中间件
+// ============================================================
+try {
+ thoughtLab.bindRoutes(app)
+} catch (e) {
+ try { logJSON('thought_lab.bind_routes.error', { error: String(e.message || e) }, 'thought_lab') } catch {}
+}
+
app.get(['/package.json', '/package-lock.json', '/pnpm-lock.yaml', '/yarn.lock', '/robots.txt'], (_req, res) => {
res.status(404).send('Not Found')
})
diff --git a/src/server/market_skills/china_korea_semiconductor/config.json b/src/server/market_skills/china_korea_semiconductor/config.json
index 5c1cea1..574a038 100644
--- a/src/server/market_skills/china_korea_semiconductor/config.json
+++ b/src/server/market_skills/china_korea_semiconductor/config.json
@@ -9,10 +9,10 @@
"display": true,
"schedule_comment": "刷新频率 (",
"schedule": "10m",
- "symbol_comment": "监控的代",
- "symbol": "sh000688",
+ "symbol_comment": "监控的代(中韩半导体ETF华泰柏瑞,跟踪中证中韩半导体指数;原误配 sh000688 科创50 已于 2026-08-06 修正)",
+ "symbol": "sh513310",
"url_comment": "点击卡片右上角房子图标跳转的地址",
- "url": "https://gu.qq.com/sh000688",
+ "url": "https://gu.qq.com/sh513310",
"source_comment": "数据来源",
"source": "tencent",
"alerts_comment": "预警阈值配",
@@ -69,14 +69,18 @@
}
],
"market_status": {
- "timezone": "Asia/Seoul",
+ "timezone": "Asia/Shanghai",
"sessions": [
{
- "start": "09:00",
- "end": "15:30"
+ "start": "09:30",
+ "end": "11:30"
+ },
+ {
+ "start": "13:00",
+ "end": "15:00"
}
],
"display_label": "中韩半导"
-},
+ },
"market_status_comment": "Defines the source market timezone and trading sessions for status display."
}
diff --git a/src/server/market_skills/manager.js b/src/server/market_skills/manager.js
index 57ee1e2..c30f7a5 100644
--- a/src/server/market_skills/manager.js
+++ b/src/server/market_skills/manager.js
@@ -583,12 +583,14 @@ const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy,
logJSON('skills.cn.summary.skip.closed_day', { skill: skillName, date: dateStr }, 'market_skills')
return
}
- const rows = buildAlertRows(skillName, alerts, dataTime)
+ const rows = filterPushRows(buildAlertRows(skillName, alerts, dataTime))
+ if (rows.length === 0) return
addSummaryRows(dateStr, rows, false)
const { slot, hhmm } = getChinaSlotAndTime(policy, parts)
const key = `skills.bucket.cn.${dateStr}.${slot}`
const prev = readStateJson(key, [])
- const merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
+ // 发送侧再过滤:清掉桶里修复前存入的存量记录
+ const merged = filterPushRows(dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows]))
writeStateJson(key, merged)
const lines = makeBucketLines(merged)
if (lines.length === 0) return
@@ -599,7 +601,8 @@ const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy,
}
const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, dataTime }) => {
- const rows = buildAlertRows(skillName, alerts, dataTime)
+ const rows = filterPushRows(buildAlertRows(skillName, alerts, dataTime))
+ if (rows.length === 0) return
addSummaryRows(dateStr, rows, false)
const dayState = getDayState(dateStr)
const sid = safeSkillId(skillId)
@@ -618,11 +621,12 @@ const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, polic
}
const key = `skills.bucket.non_cn.${dateStr}.${sid}`
const prev = readStateJson(key, [])
- let merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows])
+ // 发送侧再过滤:清掉桶里和延迟队列里修复前存入的存量记录(跨天顺延的旧记录也在此清除)
+ let merged = filterPushRows(dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows]))
if (dayState.nonChinaFirstSent && Number(dayState.nonChinaTotal || 0) === 1) {
const deferred = getDeferredList(dateStr)
if (Array.isArray(deferred) && deferred.length > 0) {
- merged = dedupeByAlert([...deferred, ...merged])
+ merged = filterPushRows(dedupeByAlert([...deferred, ...merged]))
setDeferredList(dateStr, [])
}
}
@@ -668,8 +672,9 @@ const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts
const cut = parseHHMM(policy.dailySummaryTime)
if (parts.hour < cut.hour || (parts.hour === cut.hour && parts.minute < cut.minute)) return
const pool = getSummaryPool(dateStr)
- const regular = dedupeByAlert(pool.regular || [])
- const urgent = dedupeByAlert(pool.urgent || [])
+ // 发送侧再过滤:汇总池里修复前存入的存量记录不发送
+ const regular = filterPushRows(dedupeByAlert(pool.regular || []))
+ const urgent = filterPushRows(dedupeByAlert(pool.urgent || []))
if (regular.length === 0 && urgent.length === 0) {
dayState.dailySummarySent = true
setDayState(dateStr, dayState)
@@ -693,17 +698,82 @@ const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts
setDayState(dateStr, dayState)
}
+// 只展示不推送的 skill(按 config.name 精确匹配)
+// 中韩半导体指:config.symbol 误配为 sh000688(与科创50 撞车,会被白名单放行),故用排除名单强制停推
const EXCLUDED_FROM_PUSH_SKILLS = new Set([
'中国银行-299法',
'中国神华-299法',
'巴菲特指数',
'美国战争指数',
- '流动性状态机'
+ '流动性状态机',
+ '中韩半导体指'
])
+// 推送告警白名单(与 markets.js 共用 config/markets.json 的 notify_symbol_allowlist 配置)
+// 仅 symbol 在白名单中的 skill 参与邮件/日历/周报推送,其余卡片只展示不推送
+const MARKETS_CONFIG_FILE = path.join(process.cwd(), 'config', 'markets.json')
+let notifyAllowlistCache = { mtime: 0, list: null }
+const getNotifyAllowlist = () => {
+ try {
+ const mtime = fs.statSync(MARKETS_CONFIG_FILE).mtimeMs
+ if (notifyAllowlistCache.mtime === mtime) return notifyAllowlistCache.list
+ const cfg = JSON.parse(fs.readFileSync(MARKETS_CONFIG_FILE, 'utf-8'))
+ const list = Array.isArray(cfg.notify_symbol_allowlist)
+ ? cfg.notify_symbol_allowlist.map(s => String(s || '').trim().toLowerCase()).filter(Boolean)
+ : null
+ notifyAllowlistCache = { mtime, list }
+ return list
+ } catch {
+ return null // 配置缺失/损坏时不限制(兼容旧行为)
+ }
+}
+
+// 白名单判定:list 为 null 或空数组 = 不限制;否则 symbol 必须在白名单内(双方均小写化,大小写不敏感)
+const isSkillPushAllowed = (symbol, list) => {
+ if (!Array.isArray(list) || list.length === 0) return true
+ const sym = String(symbol || '').trim().toLowerCase()
+ return list.some(s => String(s || '').trim().toLowerCase() === sym)
+}
+
+// skillName → symbol 映射(读磁盘 config,保证发送侧也能识别历史存量记录)
+const getSkillNameSymbolMap = () => {
+ const map = new Map()
+ try {
+ if (!fs.existsSync(SKILLS_DIR)) return map
+ for (const dirent of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
+ if (!dirent.isDirectory()) continue
+ try {
+ const cfg = JSON.parse(fs.readFileSync(path.join(SKILLS_DIR, dirent.name, 'config.json'), 'utf-8'))
+ if (cfg && cfg.name) map.set(String(cfg.name), String(cfg.symbol || ''))
+ } catch {}
+ }
+ } catch {}
+ return map
+}
+
+// 发送侧白名单过滤:入口过滤只挡新告警,桶/延迟队列/汇总池里的存量记录在发送前再清一遍
+// 无法识别 symbol 的记录默认拦截(白名单语义 = 只保留指定指数)
+const filterPushRows = rows => {
+ if (!Array.isArray(rows) || rows.length === 0) return []
+ const list = getNotifyAllowlist()
+ if (!Array.isArray(list) || list.length === 0) return rows
+ const nameSymbol = getSkillNameSymbolMap()
+ return rows.filter(r => {
+ const nm = String(r.skill || '')
+ if (EXCLUDED_FROM_PUSH_SKILLS.has(nm)) return false
+ const sym = nameSymbol.has(nm) ? nameSymbol.get(nm) : (r && r.symbol != null ? String(r.symbol) : '')
+ return isSkillPushAllowed(sym, list)
+ })
+}
+
const sendAlerts = async (skillId, skillName, config, rawAlerts, dataRows) => {
if (!rawAlerts || rawAlerts.length === 0) return
if (EXCLUDED_FROM_PUSH_SKILLS.has(skillName)) return
+ // 推送白名单:symbol 不在白名单的 skill 不进入邮件/日历/周报推送(也不进入日终汇总池)
+ if (!isSkillPushAllowed(config && config.symbol, getNotifyAllowlist())) {
+ logJSON('skills.notify.allowlist.skip', { skill: skillName, symbol: config && config.symbol }, 'market_skills')
+ return
+ }
const p = bjParts()
const dateStr = p.dateStr
const policy = getPolicy(config)
@@ -875,5 +945,7 @@ module.exports = {
}
logJSON('skills.manager.started', { count: skills.size }, 'market_skills')
},
- getLatestData
+ getLatestData,
+ isSkillPushAllowed,
+ filterPushRows
}
diff --git a/src/server/thought_lab/index.js b/src/server/thought_lab/index.js
new file mode 100644
index 0000000..db069d7
--- /dev/null
+++ b/src/server/thought_lab/index.js
@@ -0,0 +1,149 @@
+// ============================================================
+// thought_lab/index.js - 思想实验室路由模块
+// 职责:
+// - /tools/thought_lab/labs// 实验页面与静态资源(仅导航鉴权 nav_gate)
+// - GET /api/thought_lab/labs 返回已启用的 lab 列表(首页页签渲染用)
+//
+// 目录约定(参照 data_gateway 的 skills 分类思想):
+// - 每个 lab 是 labs/ 下一个完全独立的子文件夹,互不干扰、互不继承
+// - 后端侧 labs// 存放该 lab 的 config.json;
+// 未来若该 lab 需要数据库、OSS、定时任务等后台逻辑,也落在该文件夹内
+// - 前端页面对应 public/tools/thought_lab/labs//index.html
+// ============================================================
+const fs = require('fs')
+const path = require('path')
+const express = require('express')
+
+const LABS_DIR = path.join(__dirname, 'labs')
+const PUBLIC_DIR = path.join(process.cwd(), 'public', 'tools', 'thought_lab')
+const FLAGS_PATH = path.join(process.cwd(), 'config', 'flags.json')
+const NAV_AUTH_COOKIE = 'nav_gate'
+
+// 全局导航鉴权开关(与首页 / 其他工具同一来源 config/flags.json -> navAuth.enable_auth)
+// 开关关闭时全站放开,本模块同步放开,保持与容器行为一致
+const isNavAuthEnabled = () => {
+ try {
+ const f = JSON.parse(fs.readFileSync(FLAGS_PATH, 'utf-8'))
+ const cfg = (f && f.navAuth) || {}
+ return cfg.enable_auth !== false
+ } catch { return true }
+}
+
+// ------------------------------------------------------------
+// 鉴权:仅导航鉴权(nav_gate cookie),无自定义 gate / GUID / token
+// ------------------------------------------------------------
+const parseCookie = (raw) => {
+ const out = {}
+ try {
+ String(raw || '').split(';').forEach(item => {
+ const idx = item.indexOf('=')
+ if (idx < 1) return
+ const key = item.slice(0, idx).trim()
+ const val = item.slice(idx + 1).trim()
+ if (key) out[key] = val
+ })
+ } catch {}
+ return out
+}
+
+const hasNavAuth = (req) => {
+ try {
+ const cookies = parseCookie(req.headers.cookie || '')
+ return cookies[NAV_AUTH_COOKIE] === '1'
+ } catch { return false }
+}
+
+// 同源 Referer 判定:从首页导航点击/模态框进入 lab 页面时携带同源 Referer
+const hasSameOriginReferer = (req) => {
+ try {
+ const referer = String(req.headers.referer || '')
+ const host = String(req.headers.host || '')
+ if (!referer || !host) return false
+ const u = new URL(referer)
+ return u.host === host
+ } catch { return false }
+}
+
+const navAuthPage = (req, res, next) => {
+ if (!isNavAuthEnabled()) return next()
+ if (hasNavAuth(req)) return next()
+ // 导航鉴权语义:允许从首页导航进入(同源 Referer),直链/外站访问仍拒绝
+ if (hasSameOriginReferer(req)) return next()
+ return res.status(401).send('未授权')
+}
+
+const navAuthApi = (req, res, next) => {
+ if (!isNavAuthEnabled()) return next()
+ if (hasNavAuth(req)) return next()
+ return res.status(401).json({ ok: false, error: 'unauthorized' })
+}
+
+// ------------------------------------------------------------
+// lab 配置读取
+// ------------------------------------------------------------
+const loadLabConfig = (labId) => {
+ try {
+ const cfgPath = path.join(LABS_DIR, labId, 'config.json')
+ if (!fs.existsSync(cfgPath)) return null
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
+ if (!cfg || typeof cfg !== 'object') return null
+ return cfg
+ } catch {
+ return null
+ }
+}
+
+// 枚举所有已启用的 lab,按 sort_order 升序(同序按 id 字典序)
+const listLabs = () => {
+ const result = []
+ try {
+ if (!fs.existsSync(LABS_DIR)) return result
+ const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
+ for (const d of dirs) {
+ if (!d.isDirectory()) continue
+ const cfg = loadLabConfig(d.name)
+ if (!cfg || cfg.enabled === false) continue
+ const id = String(cfg.id || d.name)
+ result.push({
+ id,
+ name: String(cfg.name || id),
+ icon: String(cfg.icon || '🧪'),
+ desc: String(cfg.desc || ''),
+ sort_order: Number(cfg.sort_order || 0),
+ href: `/tools/thought_lab/labs/${d.name}/index.html`
+ })
+ }
+ } catch {}
+ result.sort((a, b) => {
+ if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
+ return String(a.id).localeCompare(String(b.id))
+ })
+ return result
+}
+
+// ------------------------------------------------------------
+// 路由注册
+// 顺序:静态鉴权中间件必须注册在 express.static 之前(Rule 12.4)
+// ------------------------------------------------------------
+const bindRoutes = (app) => {
+ // 1) /tools/thought_lab 全部子路径:仅导航鉴权
+ app.use('/tools/thought_lab', navAuthPage)
+
+ // 2) 静态资源:public/tools/thought_lab/labs//...
+ app.use('/tools/thought_lab', express.static(PUBLIC_DIR))
+
+ // 3) 白名单公开接口:lab 列表(仅名称/图标/简介等元数据,供首页页签渲染,
+ // 与 /api/tools 同级;按 Rule 12.8 在 API 守卫之前显式注册)
+ app.get('/api/thought_lab/labs', (req, res) => {
+ try {
+ return res.json({ ok: true, labs: listLabs() })
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: 'internal_error' })
+ }
+ })
+
+ // 4) 其余 /api/thought_lab 接口:默认导航鉴权
+ app.use('/api/thought_lab', navAuthApi)
+}
+
+module.exports = { bindRoutes, listLabs, loadLabConfig }
diff --git a/src/server/thought_lab/labs/blackhole_sim/config.json b/src/server/thought_lab/labs/blackhole_sim/config.json
new file mode 100644
index 0000000..10674ae
--- /dev/null
+++ b/src/server/thought_lab/labs/blackhole_sim/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · 黑洞模拟:基于史瓦西度规零测地线数值积分的广义相对论光线追踪教学模拟器,可观察引力透镜、黑洞阴影、光子环、多普勒束流的吸积盘,并附 2D 轨道实验室演示近日点进动。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "blackhole_sim",
+ "name_comment": "首页卡片显示名称",
+ "name": "黑洞模拟",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "🕳️",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "广义相对论光线追踪 · 引力透镜 / 吸积盘 / 轨道进动",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 10,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}
diff --git a/src/server/thought_lab/labs/lagrange_points/config.json b/src/server/thought_lab/labs/lagrange_points/config.json
new file mode 100644
index 0000000..3a158c0
--- /dev/null
+++ b/src/server/thought_lab/labs/lagrange_points/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · 拉格朗日点:圆形限制性三体问题(CR3BP)交互演示,展示旋转坐标系下的有效势、零速度曲线与禁行区,可切换地-月/日-木/日-地/冥-卡系统并点击放置测试粒子,观察 L4/L5 蝌蚪轨道、L2 准轨道等真实轨道形态。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "lagrange_points",
+ "name_comment": "首页卡片显示名称",
+ "name": "拉格朗日点",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "⚖️",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "限制性三体问题 · 有效势 / 零速度曲线 / 特洛伊轨道",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 20,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}
diff --git a/src/server/thought_lab/labs/pi_derivation/config.json b/src/server/thought_lab/labs/pi_derivation/config.json
new file mode 100644
index 0000000..9d0fc9e
--- /dev/null
+++ b/src/server/thought_lab/labs/pi_derivation/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · π 推导实验室:通过「正方形 → 随机三角剖分 → 随机取走一块 → 面积守恒重拼矩形」的循环,记录每个被取走三角形的三个内角,用角度积分公式 π̂ = 3·E[θ] 反推圆周率,并附 Buffon 型蒙特卡洛对照展示 1/√N 收敛速度。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "pi_derivation",
+ "name_comment": "首页卡片显示名称",
+ "name": "π 推导实验室",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "π",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "随机剖分采样三角形内角 · 角度积分反推圆周率",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 50,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}
diff --git a/src/server/thought_lab/labs/solar_system_3d/config.json b/src/server/thought_lab/labs/solar_system_3d/config.json
new file mode 100644
index 0000000..c806231
--- /dev/null
+++ b/src/server/thought_lab/labs/solar_system_3d/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · 3D 太阳系:基于 NASA JPL J2000 历元轨道六要素(含世纪变化率)的 3D 太阳系轨道模拟器,用牛顿迭代解开普勒方程推演真实行星位置,支持时间流速调节、行星自转与轴倾角还原。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "solar_system_3d",
+ "name_comment": "首页卡片显示名称",
+ "name": "3D 太阳系",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "🪐",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "JPL J2000 真实轨道要素 · 开普勒方程实时演算",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 30,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}
diff --git a/src/server/thought_lab/labs/sun_earth_orbit/config.json b/src/server/thought_lab/labs/sun_earth_orbit/config.json
new file mode 100644
index 0000000..6262697
--- /dev/null
+++ b/src/server/thought_lab/labs/sun_earth_orbit/config.json
@@ -0,0 +1,15 @@
+{
+ "_comment": "思想实验室 · 日地轨道:太阳-地球运行轨迹 2D 动画,以夸张化偏心率直观演示开普勒第二定律(近日点快、远日点慢),并按同一真近点角用真实偏心率 0.0167 同步换算真实日地距离。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
+ "id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
+ "id": "sun_earth_orbit",
+ "name_comment": "首页卡片显示名称",
+ "name": "日地轨道",
+ "icon_comment": "首页卡片图标(emoji)",
+ "icon": "☀️",
+ "desc_comment": "一句话简介,作为卡片 tooltip 展示",
+ "desc": "开普勒第二定律动画 · 近日点快 / 远日点慢",
+ "sort_order_comment": "卡片排序,数值越小越靠前",
+ "sort_order": 40,
+ "enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
+ "enabled": true
+}