feat: 新增作息报时器功能并替换短链接前缀为/to/

- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
yangxiangyuan
2026-10-09 14:04:37 +08:00
parent ac475b6659
commit a3e8911deb
131 changed files with 7695 additions and 60 deletions
@@ -0,0 +1,235 @@
/**
* 「首页导航防爆紧急替换」页签 · 前端界面验证
*
* 目的:验证页签位置、两个按钮、滚动日志、最终 token / 短链接展示都正常工作。
*
* ⚠️ 本脚本不真实触发换密钥:对 /api/token_lab/nav/emergency/* 做请求拦截,
* 返回预置的假响应,只验证前端渲染与交互,绝不改动生产的密钥与短链库。
*
* 运行:node dev_test_scripts/debug/debug_token_lab_emergency_ui.js
*/
const fs = require('fs')
const puppeteer = require('puppeteer')
const BASE = process.env.TL_BASE || 'http://127.0.0.1:8976'
const CHROME_CANDIDATES = [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
]
const findChrome = () => CHROME_CANDIDATES.find(p => fs.existsSync(p)) || undefined
const sleep = ms => new Promise(r => setTimeout(r, ms))
let passed = 0
let failed = 0
const check = (name, ok, detail) => {
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) }
}
const FAKE_TOKEN = 'eyJmYWtlLXRva2VuLWZvci11aS10ZXN0.' + 'A'.repeat(120) + '.signature'
const FAKE_SHORT = 'https://maise.pro/to/UiTest1'
const ROTATE_LOGS = [
'【第 1 步】轮换导航密钥 + 签发新 token',
'目标有效期:30 天',
'已备份原有文件 → config/_nav-key-backup-2026-09-30T16-00-00/(nav-private.jwk.json、nav.jwk.json、short_link.db)',
'已生成新的 RSA-2048 密钥对(公钥指纹 abcdef1234567890)',
'已写入 config/nav-private.jwk.json(含私钥,签发用)',
'已写入 config/nav.jwk.json(仅公钥,信任池用)',
'两处已换成同一把新钥匙 → 旧公钥彻底离开信任池,旧 token 自此刻起失效',
'已用新私钥签发 token(iss=TRAE-NAV,jti=fake-jti)',
'自校验:签名=通过|有效期=正常|iss=通过',
'完成 ✅ 新 token 过期时间:2026/10/30 16:00:00(北京时间)',
]
const RELINK_LOGS = [
'【第 2 步】更换首页短链接',
'正在操作短链接数据库 data/short_link.db',
'token 校验通过(jti=fake-jti,过期 2026/10/30 16:00:00)',
'已停用旧短链 /to/PSq6xv → 立即失效',
'已创建新短链:https://maise.pro/to/UiTest1',
'自校验:新短链返回 302,跳转目标与新 token 一致 ✅',
'完成 ✅ 最终短链接:https://maise.pro/to/UiTest1',
]
;(async () => {
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
const page = await browser.newPage()
await page.setViewport({ width: 1280, height: 1000 })
await page.setCookie(
{ name: 'nav_gate', value: '1', domain: '127.0.0.1', path: '/' },
{ name: 'token_lab_gate', value: '1', domain: '127.0.0.1', path: '/' },
)
const pageErrors = []
page.on('pageerror', e => pageErrors.push(String(e && e.message || e)))
// 拦截两个紧急接口 → 返回预置响应,绝不真实执行
await page.setRequestInterception(true)
let rotateHit = 0
let relinkHit = 0
let rotateBody = null
page.on('request', req => {
const url = req.url()
if (url.includes('/api/token_lab/nav/emergency/rotate')) {
rotateHit += 1
try { rotateBody = JSON.parse(req.postData() || '{}') } catch { rotateBody = null }
return req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({
ok: true, token: FAKE_TOKEN, exp: Math.floor(Date.now() / 1000) + 30 * 86400,
months: (rotateBody && rotateBody.months) || 1,
days: ((rotateBody && rotateBody.months) || 1) * 30,
fingerprint: 'abcdef1234567890', backupDir: '_nav-key-backup-2026-09-30T16-00-00', logs: ROTATE_LOGS,
}),
})
}
if (url.includes('/api/token_lab/nav/emergency/relink')) {
relinkHit += 1
return req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({
ok: true, code: 'UiTest1', shortUrl: FAKE_SHORT, disabledCodes: ['PSq6xv'], verified: true, logs: RELINK_LOGS,
}),
})
}
return req.continue()
})
console.log('=== 防爆紧急替换页签 · 前端界面验证(接口已拦截,不会真实执行) ===\n')
await page.goto(`${BASE}/tools/token_lab/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const m = document.getElementById('appMain')
return m && !m.hidden
}, { timeout: 15000 })
// ---------- 1. 页签位置与文案 ----------
console.log('[1] 页签与按钮')
const tabs = await page.$$eval('.tab-btn', els => els.map(e => ({ text: e.textContent.trim(), tab: e.dataset.tab, active: e.classList.contains('active') })))
check('共有 3 个页签', tabs.length === 3, JSON.stringify(tabs.map(t => t.text)))
check('新页签排在第一位', tabs[0] && tabs[0].tab === 'emergency', JSON.stringify(tabs[0] || {}))
check('新页签名称正确', tabs[0] && tabs[0].text === '首页导航防爆紧急替换', tabs[0] && tabs[0].text)
check('原「首页导航生成器」仍在且为默认激活', tabs[1] && tabs[1].tab === 'nav' && tabs[1].active === true, JSON.stringify(tabs[1] || {}))
check('原「Tool 令牌生成器」仍在', tabs[2] && tabs[2].tab === 'tool')
const btnTexts = await page.evaluate(() => ({
b1: document.getElementById('btnEmergencyRotate') ? document.getElementById('btnEmergencyRotate').textContent.trim() : '',
b2: document.getElementById('btnEmergencyRelink') ? document.getElementById('btnEmergencyRelink').textContent.trim() : '',
}))
check('按钮 1 文案为动态月份(默认 1 个月)',
btnTexts.b1 === '自动生成向后 1 个月的鉴权,且当前鉴权立即失效', btnTexts.b1)
check('按钮 2 文案正确', btnTexts.b2 === '将新的首页鉴权token,自动生成新的短链接,且当前短链接立即失效', btnTexts.b2)
// ---------- 1b. 月份下拉 ----------
console.log('\n[1b] 有效期月份下拉(1~12,默认 1)')
const selInfo = await page.evaluate(() => {
const sel = document.getElementById('emergencyMonths')
if (!sel) return null
return {
exists: true,
value: sel.value,
options: Array.from(sel.options).map(o => o.value),
labels: Array.from(sel.options).map(o => o.textContent.trim()),
// 是否紧跟按钮 1 之后
afterButton: (() => {
const btn = document.getElementById('btnEmergencyRotate')
return !!(btn && btn.nextElementSibling === sel)
})(),
}
})
check('下拉存在', !!(selInfo && selInfo.exists))
check('默认选中 1 个月', selInfo && selInfo.value === '1', selInfo && selInfo.value)
check('共 12 个选项', !!(selInfo && selInfo.options.length === 12), selInfo && selInfo.options.length)
check('选项值为 1~12',
!!(selInfo && JSON.stringify(selInfo.options) === JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])),
selInfo && JSON.stringify(selInfo.options))
check('选项文案形如「N 个月」', !!(selInfo && selInfo.labels[0] === '1 个月' && selInfo.labels[11] === '12 个月'),
selInfo && JSON.stringify(selInfo.labels))
check('下拉位于按钮 1 之后(同一行)', !!(selInfo && selInfo.afterButton))
// 切换下拉 → 按钮文案应跟着变
await page.select('#emergencyMonths', '6')
await sleep(200)
const btnAfterChange = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())
check('切到 6 个月后按钮文案同步更新',
btnAfterChange === '自动生成向后 6 个月的鉴权,且当前鉴权立即失效', btnAfterChange)
await page.select('#emergencyMonths', '12')
await sleep(200)
const btnAfter12 = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())
check('切到 12 个月后按钮文案同步更新',
btnAfter12 === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效', btnAfter12)
// ---------- 2. 切到新页签 ----------
console.log('\n[2] 切换到新页签')
await page.evaluate(() => document.querySelector('.tab-btn[data-tab="emergency"]').click())
await sleep(250)
const panelShown = await page.evaluate(() => {
const p = document.querySelector('.tab-panel[data-panel="emergency"]')
return !!p && p.classList.contains('active') && window.getComputedStyle(p).display !== 'none'
})
check('新页签面板已显示', panelShown)
// ---------- 3. 点按钮 1 ----------
console.log('\n[3] 点击按钮 1(自动生成向后一个月的鉴权…)')
await page.click('#btnEmergencyRotate')
await sleep(2500) // 等日志逐行播放完
const after1 = await page.evaluate(() => ({
log: document.getElementById('emergencyLog').value,
token: document.getElementById('emergencyTokenOut').value,
meta: document.getElementById('emergencyMeta').innerText,
}))
check('确实调用了 rotate 接口', rotateHit === 1, `命中 ${rotateHit} 次`)
check('请求体带上了所选月份 months=12', !!(rotateBody && Number(rotateBody.months) === 12),
JSON.stringify(rotateBody))
check('日志框出现滚动内容', after1.log.length > 100, `长度 ${after1.log.length}`)
check('日志含第 1 步标题', after1.log.includes('【第 1 步】'))
check('日志含"完成"', after1.log.includes('完成 ✅'))
check('日志逐行带时间戳', /\[\d{2}:\d{2}:\d{2}\]/.test(after1.log))
check('最终 token 已展示', after1.token === FAKE_TOKEN, `长度 ${after1.token.length}`)
check('元信息展示公钥指纹', after1.meta.includes('abcdef1234567890'), after1.meta.slice(0, 80))
check('元信息展示有效期 12 个月', after1.meta.includes('12 个月'), after1.meta.slice(0, 120))
check('执行后按钮文案恢复为所选月份',
(await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())) === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效')
// ---------- 4. 点按钮 2 ----------
console.log('\n[4] 点击按钮 2(将新 token 绑到新短链…)')
await page.click('#btnEmergencyRelink')
await sleep(2000)
const after2 = await page.evaluate(() => ({
log: document.getElementById('emergencyLog').value,
shortUrl: document.getElementById('emergencyShortUrlOut').value,
meta: document.getElementById('emergencyMeta').innerText,
}))
check('确实调用了 relink 接口', relinkHit === 1, `命中 ${relinkHit} 次`)
check('日志含第 2 步标题', after2.log.includes('【第 2 步】'))
check('日志含停用旧短链步骤', after2.log.includes('已停用旧短链'))
check('最终短链接已展示', after2.shortUrl === FAKE_SHORT, after2.shortUrl)
check('元信息展示已停用的旧短码', after2.meta.includes('PSq6xv'), after2.meta.slice(0, 120))
check('元信息展示自校验结果', after2.meta.includes('通过'), after2.meta.slice(0, 120))
// ---------- 5. 其它页签未受影响 ----------
console.log('\n[5] 原有功能未受影响')
for (const t of ['nav', 'tool']) {
await page.evaluate(tab => document.querySelector(`.tab-btn[data-tab="${tab}"]`).click(), t)
await sleep(150)
const ok = await page.evaluate(tab => {
const p = document.querySelector(`.tab-panel[data-panel="${tab}"]`)
return !!p && p.classList.contains('active')
}, t)
check(`原页签 ${t} 仍可切换`, ok)
}
check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '))
await browser.close()
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})().catch(e => {
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
process.exit(1)
})