feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑

1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置
2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议
3. 新增推送告警白名单过滤功能,限制推送指定指数告警
4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求
5. 新增多套调试探针脚本与单元测试用例
This commit is contained in:
yangxiangyuan
2026-08-04 17:33:14 +08:00
parent af5d0f43a5
commit 6a7a2b089a
25 changed files with 3499 additions and 121 deletions
+150 -2
View File
@@ -51,10 +51,12 @@ const marketSkills = require('./market_skills/manager')
const securityCalendar = require('./security_calendar')
const securityCalendarBridge = require('./security_calendar_calendar_bridge')
const marketsWeeklyReport = require('./markets_weekly_report')
const marketsDipEval = require('./markets_dip_eval')
const { upsertMarketArchive, getMarketArchive } = require('./db')
const { getConfig: getWallCfg, setConfig: setWallCfg, listCards: listWallCards, createCard: createWallCard, updateCardById: updateWallCardById, deleteCardById: deleteWallCardById } = require('./wall')
const fundsGuoxin = require('./funds_guoxin')
const fundsHuatai = require('./funds_huatai')
const freeShow = require('./free_show')
const ccbPrivateFunds = require('./ccb_private_funds')
const cloud = require('./cloud')
const cloudBalanceWatch = require('./cloud_balance_watch')
@@ -223,6 +225,7 @@ app.use((req, res, next) => {
req.path.startsWith('/api/doc_calendar_bridge') ||
req.path.startsWith('/api/markets/security_calendar') ||
req.path.startsWith('/api/markets/weekly_report') ||
req.path.startsWith('/api/markets/dip_eval') ||
req.path.startsWith('/api/global_news') ||
req.path.startsWith('/api/box_backup')
) setNoCache(res)
@@ -1212,7 +1215,7 @@ if (!fs.existsSync(settingsPath)) fs.writeFileSync(settingsPath, JSON.stringify(
const flagsPath = path.join(process.cwd(), 'config', 'flags.json')
if (!fs.existsSync(flagsPath)) fs.writeFileSync(flagsPath, JSON.stringify({
debugTool: { enabled: true, launchDate: null, autoHideAfterDays: 30 },
debug: { weibo: true, markets: true, wall: true, funds_guoxin: true, funds_huatai: true },
debug: { weibo: true, markets: true, wall: true, funds_guoxin: true, funds_huatai: true, free_show: true },
navAuth: { enable_auth: true, iss: 'TRAE-NAV', iss_strict: false }
}, null, 2))
@@ -1611,6 +1614,7 @@ const mapSystemIdToUrl = (systemId) => {
'Tools-wall-': '/tools/wall',
'Tools-funds_guoxin': '/tools/funds_guoxin',
'Tools-funds_huatai': '/tools/funds_huatai',
'Tools-free_show': '/tools/free_show',
'Tools-ccb_private_funds': '/tools/ccb_private_funds',
'Tools-cloud': '/tools/cloud',
'Tools-weekly': '/tools/weekly',
@@ -2047,6 +2051,136 @@ app.use('/api/funds_huatai', (req, res, next) => {
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
// ============ free_show(免费演出雷达)鉴权 ============
const FREE_SHOW_AUTH_COOKIE = 'free_show_gate'
const readFreeShowAuthConfig = () => {
try {
const p = path.join(process.cwd(), 'public', 'tools', 'free_show', 'auth_config.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return {}
}
const readFreeShowJwk = () => {
try {
const p = path.join(process.cwd(), 'config', 'free_show.jwk.json')
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch {}
return { kty: 'RSA', n: '', e: '' }
}
const getFreeShowCookieName = () => {
try { const n = String(readFreeShowAuthConfig().cookieName || ''); if (n) return n } catch {}
return FREE_SHOW_AUTH_COOKIE
}
const getFreeShowMaxAge = () => {
try {
const days = Number(readFreeShowAuthConfig().max_age_days || 0)
if (Number.isFinite(days) && days > 0) return Math.min(days, 365) * 24 * 3600 * 1000
} catch {}
return 30 * 24 * 3600 * 1000
}
const getFreeShowGuidConfig = () => {
try {
const cfg = readFreeShowAuthConfig()
const keyRaw = String(cfg.guidParam || cfg.guid_param || 'guid').trim()
const valueRaw = String(cfg.guidValue || cfg.guid_value || '').trim()
return { key: keyRaw || 'guid', value: valueRaw }
} catch { return { key: 'guid', value: '' } }
}
const hasValidFreeShowGuid = req => {
try {
const cfg = readFreeShowAuthConfig()
if (cfg.enable_auth === false) return false
const { key, value } = getFreeShowGuidConfig()
if (!value) return false
const q = req && req.query ? req.query : {}
const raw = q[key]
if (raw === undefined || raw === null) return false
const incoming = Array.isArray(raw) ? String(raw[0] || '') : String(raw)
return incoming === value
} catch { return false }
}
const isFreeShowForceGuid = () => {
try {
const cfg = readFreeShowAuthConfig()
if (cfg.enable_auth === false) return false
if (Object.prototype.hasOwnProperty.call(cfg, 'requireGuid')) return !!cfg.requireGuid
if (Object.prototype.hasOwnProperty.call(cfg, 'require_guid')) return !!cfg.require_guid
return false
} catch { return false }
}
const hasFreeShowAuth = req => {
try {
const config = readFreeShowAuthConfig()
if (config.enable_auth === false) return true
const cookies = parseCookie(req.headers.cookie || '')
return cookies[getFreeShowCookieName()] === '1'
} catch { return false }
}
app.get('/tools/free_show', (req, res, next) => {
setNoCache(res)
try {
const forceGuid = isFreeShowForceGuid()
if (hasFreeShowAuth(req)) return next()
const guidKey = getFreeShowGuidConfig().key
const hasGuidInput = req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (hasValidFreeShowGuid(req)) {
const maxAge = getFreeShowMaxAge()
const cookieName = getFreeShowCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/free_show/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
const token = String(req.query.token || '')
if (!token) {
if (forceGuid) return res.status(401).send('未授权')
return next()
}
const payload = verifyJwtWithKeys(token, jwkKeys(readFreeShowJwk()))
if (!payload) return res.status(401).send('未授权')
{
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-free_show')) return res.status(401).send('未授权')
}
const maxAge = getFreeShowMaxAge()
const cookieName = getFreeShowCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/free_show/index.html')
} catch (e) { return res.status(401).send('未授权') }
})
app.use('/tools/free_show', (req, res, next) => {
const forceGuid = isFreeShowForceGuid()
if (hasFreeShowAuth(req)) return next()
const isHandshake = req.method === 'GET' && (req.path === '/' || req.path === '' || req.path === '/index.html')
const guidKey = getFreeShowGuidConfig().key
const hasGuidInput = isHandshake && req && req.query && Object.prototype.hasOwnProperty.call(req.query, guidKey)
if (isHandshake && hasValidFreeShowGuid(req)) {
const maxAge = getFreeShowMaxAge()
const cookieName = getFreeShowCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/free_show/index.html')
}
if (hasGuidInput) return res.status(401).send('未授权')
if (isHandshake && String(req.query.token || '')) {
const payload = verifyJwtWithKeys(String(req.query.token), jwkKeys(readFreeShowJwk()))
if (payload) {
const iss = String(payload.iss || '')
const navIss = getNavIssFromFlags()
if (iss === navIss && !audMatch(payload, 'Tools-free_show')) return res.status(401).send('未授权')
const maxAge = getFreeShowMaxAge()
const cookieName = getFreeShowCookieName()
res.cookie(cookieName, '1', { httpOnly: true, sameSite: 'lax', maxAge, path: '/' })
return res.redirect('/tools/free_show/index.html')
}
}
if (isHandshake && forceGuid) return res.status(401).send('未授权')
return res.status(401).send('未授权')
})
app.use('/api/free_show', (req, res, next) => {
if (hasFreeShowAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
const CCB_PRIVATE_FUNDS_AUTH_COOKIE = 'ccb_private_funds_gate'
const readCcbPrivateFundsJwk = () => {
try {
@@ -4104,6 +4238,7 @@ app.get('/api/debug/jwt/verify', (req, res) => {
'cloud': () => readCloudJwk(),
'funds_guoxin': () => readFundsGuoxinJwk(),
'funds_huatai': () => readFundsHuataiJwk(),
'free_show': () => readFreeShowJwk(),
'markets': () => readMarketsJwk(),
'wall': () => readWallJwk(),
'weekly': () => readWeeklyJwk(),
@@ -4257,7 +4392,7 @@ app.post('/api/token_lab/nav/verify', (req, res) => {
app.get('/api/flags', (req, res) => {
const enabled = computeDebugEnabled()
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds_guoxin: computeToolDebug('funds_guoxin'), funds_huatai: computeToolDebug('funds_huatai'), weekly: computeToolDebug('weekly') } })
res.json({ ok: true, debugToolEnabled: enabled, debug: { weibo: computeToolDebug('weibo'), markets: computeToolDebug('markets'), wall: computeToolDebug('wall'), funds_guoxin: computeToolDebug('funds_guoxin'), funds_huatai: computeToolDebug('funds_huatai'), free_show: computeToolDebug('free_show'), weekly: computeToolDebug('weekly') } })
})
app.get('/api/weibo/devtools/status', async (req, res) => {
@@ -4625,6 +4760,17 @@ app.get('/api/markets/weekly_report', async (req, res) => {
}
})
// 建行定投评估:按需抓取四大指数 PE-TTM 与近10年分位(点击页面按钮触发,无定时任务)
app.get('/api/markets/dip_eval', async (req, res) => {
try {
const payload = await marketsDipEval.getDipEvalReport()
// 统一 200 + ok 标志:整体失败时 ok=false,前端交互弹窗读取 error 文案
res.json(payload || { ok: false, error: '评估失败' })
} catch (e) {
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
}
})
app.post('/api/markets/config', (req, res) => {
const body = req.body || {}
const cfg = getMarketsCfg()
@@ -6838,6 +6984,7 @@ app.get('/api/short_link/auth/hint', (req, res) => {
// Short Link 业务路由(/s/:code 公开跳转 + /api/short_link/* 管理接口)
shortLink.bindRoutes(app)
freeShow.bindRoutes(app)
const marketsCfgAtBoot = getMarketsCfg()
const currentPort = Number(process.env.PORT || '8976') || 8976
@@ -6845,6 +6992,7 @@ const marketsAutomationPort = Number(marketsCfgAtBoot.automation_port || 8977) |
if (currentPort === marketsAutomationPort) {
startMarketsScheduler()
marketSkills.start()
freeShow.startScheduler()
}
securityCalendar.startScheduler()
securityCalendarBridge.startScheduler(securityCalendar)