feat: 新增思想实验室模块与配套功能

- 新增5个物理仿真实验室:黑洞模拟、日地轨道、3D太阳系、π推导、拉格朗日点
- 新增思想实验室后端路由与鉴权逻辑
- 首页新增思想实验室页签与前端渲染逻辑
- 修复中韩半导体监控标的配置错误
- 新增市场技能推送白名单过滤逻辑与单元测试
This commit is contained in:
yangxiangyuan
2026-08-06 13:39:48 +08:00
parent 6a7a2b089a
commit ea379116cc
19 changed files with 2831 additions and 17 deletions
+11
View File
@@ -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')
})
@@ -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."
}
+81 -9
View File
@@ -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
}
+149
View File
@@ -0,0 +1,149 @@
// ============================================================
// thought_lab/index.js - 思想实验室路由模块
// 职责:
// - /tools/thought_lab/labs/<lab_id>/ 实验页面与静态资源(仅导航鉴权 nav_gate)
// - GET /api/thought_lab/labs 返回已启用的 lab 列表(首页页签渲染用)
//
// 目录约定(参照 data_gateway 的 skills 分类思想):
// - 每个 lab 是 labs/ 下一个完全独立的子文件夹,互不干扰、互不继承
// - 后端侧 labs/<lab_id>/ 存放该 lab 的 config.json;
// 未来若该 lab 需要数据库、OSS、定时任务等后台逻辑,也落在该文件夹内
// - 前端页面对应 public/tools/thought_lab/labs/<lab_id>/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/<lab_id>/...
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 }
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}