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,115 @@
/**
* 导航鉴权绕过修复 · 未授权回归测试
*
* 背景(2026-09-30 发现并修复的真实漏洞):
* express.static 注册在首页鉴权路由之前,会把 GET / 当作目录请求直接返回
* public/index.html,导致九宫格首页完全绕过 nav_gate。
* 同时 /api/tools、/api/nav_bookmarks/* 也完全没鉴权,可被公网匿名读取甚至篡改。
*
* 本脚本对该漏洞做回归:所有"未授权一律拒绝"、所有"公开路径仍公开"、
* 以及"带 token 能正确握手种 cookie"都要成立。
*
* 用法(需目标服务已启动):
* node dev_test_scripts/integration/test_nav_auth_bypass.js
* node dev_test_scripts/integration/test_nav_auth_bypass.js http://localhost:8976
*
* 说明:脚本不硬编码任何 token,token 在运行时从 data/short_link.db 里读,
* 避免把真实凭据写进仓库(项目规则 13)。
*/
const path = require('path')
const Database = require('better-sqlite3')
const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '')
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 : ''}`) }
}
/** 发起请求;cookie 传 'nav_gate=1' 表示已过门禁 */
const req = async (method, urlPath, cookie) => {
const headers = {}
if (cookie) headers.Cookie = cookie
const res = await fetch(BASE + urlPath, { method, headers, redirect: 'manual' })
const text = await res.text().catch(() => '')
let json = null
try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ }
return { status: res.status, text, json, location: res.headers.get('location'), setCookie: res.headers.get('set-cookie') }
}
const readTokenFromDb = () => {
try {
const db = new Database(path.join(process.cwd(), 'data', 'short_link.db'), { readonly: true })
const row = db.prepare("SELECT target_url FROM short_links WHERE target_url LIKE '%token=%' ORDER BY id DESC LIMIT 1").get()
db.close()
if (!row) return ''
const m = /[?&]token=([^&]+)/.exec(String(row.target_url || ''))
return m ? decodeURIComponent(m[1]) : ''
} catch (e) { return '' }
}
;(async () => {
console.log(`=== 导航鉴权绕过 · 未授权回归测试 ===`)
console.log(`目标:${BASE}\n`)
// ---------- 1. 修复前被绕过的路径:未授权必须全部拒绝 ----------
console.log('[1] 未授权必须拒绝(修复前这里全部是 200 泄漏)')
const root = await req('GET', '/')
check('GET / 未授权 → 401', root.status === 401, `实际 ${root.status},body ${root.text.slice(0, 60)}`)
const idx = await req('GET', '/index.html')
check('GET /index.html 未授权 → 401', idx.status === 401, `实际 ${idx.status}`)
const toolsDir = await req('GET', '/tools/')
check('GET /tools/ 未授权 → 401', toolsDir.status === 401, `实际 ${toolsDir.status}`)
const apiTools = await req('GET', '/api/tools')
check('GET /api/tools 未授权 → 401', apiTools.status === 401, `实际 ${apiTools.status},${apiTools.text.slice(0, 60)}`)
const nbState = await req('GET', '/api/nav_bookmarks/state')
check('GET /api/nav_bookmarks/state 未授权 → 401', nbState.status === 401, `实际 ${nbState.status}`)
const nbCats = await req('GET', '/api/nav_bookmarks/categories')
check('GET /api/nav_bookmarks/categories 未授权 → 401', nbCats.status === 401, `实际 ${nbCats.status}`)
const nbBooks = await req('GET', '/api/nav_bookmarks/bookmarks')
check('GET /api/nav_bookmarks/bookmarks 未授权 → 401', nbBooks.status === 401, `实际 ${nbBooks.status}`)
// 写接口必须同样被挡住(用不存在的 code 做 DELETE,即便漏挡也不会破坏真实数据)
const nbDel = await req('DELETE', '/api/nav_bookmarks/categories/__auth_probe_not_exist__')
check('DELETE /api/nav_bookmarks/categories/* 未授权 → 401', nbDel.status === 401, `实际 ${nbDel.status}`)
// ---------- 2. 公开路径必须保持公开 ----------
console.log('\n[2] 公开路径保持公开(不能误伤)')
const shortLink = await req('GET', '/to/__no_such_code__')
check('GET /to/<不存在的短码> → 404(而不是 401,说明短链跳转层未被门禁挡)',
shortLink.status === 404, `实际 ${shortLink.status},${shortLink.text.slice(0, 40)}`)
const legacyShortLink = await req('GET', '/l/__no_such_code__')
check('GET /l/<不存在的短码> → 404(旧前缀仍公开可用)',
legacyShortLink.status === 404, `实际 ${legacyShortLink.status}`)
// ---------- 3. 带 token 的握手必须真正种下 cookie ----------
console.log('\n[3] token 握手(修复前被 static 吞掉、cookie 永远种不下)')
const token = readTokenFromDb()
if (!token) {
console.log(' ⚠ data/short_link.db 里没找到带 token 的短链接,跳过握手测试')
} else {
const hs = await req('GET', '/?token=' + encodeURIComponent(token))
check('GET /?token=<有效> → 302(进入握手流程,而非被 static 直接返页面)',
hs.status === 302, `实际 ${hs.status},body ${hs.text.slice(0, 60)}`)
check('握手响应种下 nav_gate cookie',
!!(hs.setCookie && hs.setCookie.indexOf('nav_gate=1') >= 0), String(hs.setCookie || '').slice(0, 80))
const bad = await req('GET', '/?token=not_a_valid_jwt')
check('GET /?token=<无效> → 401', bad.status === 401, `实际 ${bad.status}`)
}
// ---------- 4. 已过门禁(带 nav_gate)时必须正常放行 ----------
console.log('\n[4] 带 nav_gate 时必须正常放行')
const okRoot = await req('GET', '/', 'nav_gate=1')
check('GET / 带门禁 → 200', okRoot.status === 200, `实际 ${okRoot.status}`)
check('GET / 带门禁返回的是九宫格首页', okRoot.text.indexOf("Yang's Toolbox") >= 0, okRoot.text.slice(0, 60))
const okTools = await req('GET', '/api/tools', 'nav_gate=1')
check('GET /api/tools 带门禁 → 200', okTools.status === 200, `实际 ${okTools.status}`)
const okNb = await req('GET', '/api/nav_bookmarks/state', 'nav_gate=1')
check('GET /api/nav_bookmarks/state 带门禁 → 200', okNb.status === 200, `实际 ${okNb.status}`)
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})()
@@ -0,0 +1,259 @@
/**
* 「首页导航防爆紧急替换」两个按钮 · 端到端验证
*
* 覆盖:/api/token_lab/nav/emergency/rotate 与 /relink 两个真实接口。
*
* ⚠️ 这两个接口会真实改动密钥文件与短链接库。为了让验证不污染生产状态,
* 本脚本会在执行前把「导航密钥文件 + 短链库里相关行」快照下来,
* 验证结束后**完整还原**,并在最后再验一次「旧 token 又能用了」以证明还原成功。
*
* 用法(需目标服务已启动):
* node dev_test_scripts/integration/test_nav_emergency_replace.js
* node dev_test_scripts/integration/test_nav_emergency_replace.js http://localhost:8976
*/
const fs = require('fs')
const path = require('path')
const Database = require('better-sqlite3')
const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '')
// token_lab 门禁与导航门禁都是标记型 cookie;本地验证直接带齐即可
const COOKIE = 'nav_gate=1; token_lab_gate=1'
const CONFIG_DIR = path.join(process.cwd(), 'config')
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const NAV_KEY_FILES = ['nav-private.jwk.json', 'nav.jwk.json']
const SNAPSHOT_DIR = path.join(CONFIG_DIR, '_nav-emergency-test-snapshot')
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 post = async (urlPath, body) => {
const res = await fetch(BASE + urlPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: COOKIE },
body: JSON.stringify(body || {}),
})
const text = await res.text()
let json = null
try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ }
return { status: res.status, json }
}
/** 只看一小段,避免把整枚 token 打进日志 */
const head = (s, n = 40) => String(s || '').slice(0, n)
const probeToken = async token => {
const res = await fetch(`${BASE}/?token=${encodeURIComponent(token)}`, { redirect: 'manual' })
return res.status
}
const probeShort = async code => {
const res = await fetch(`${BASE}/to/${code}`, { redirect: 'manual' })
return { status: res.status, location: res.headers.get('location') || '' }
}
const currentHomepageCode = () => {
const db = new Database(DB_PATH, { readonly: true })
const row = db.prepare('SELECT code, target_url FROM short_links WHERE is_active = 1').all()
.find(r => {
try {
const u = new URL(String(r.target_url))
return (u.pathname === '/' || u.pathname === '') && u.searchParams.has('token')
} catch { return false }
})
db.close()
if (!row) throw new Error('当前没有启用中的首页短链,无法作为基线')
const m = /[?&]token=([^&\s]+)/.exec(String(row.target_url))
return { code: row.code, targetUrl: row.target_url, token: m ? decodeURIComponent(m[1]) : '' }
}
const snapshot = () => {
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true })
NAV_KEY_FILES.forEach(name => {
const src = path.join(CONFIG_DIR, name)
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(SNAPSHOT_DIR, name))
})
// 短链库用逻辑快照(整表导出 JSON),比复制文件更安全(服务端持有连接)
const db = new Database(DB_PATH, { readonly: true })
const links = db.prepare('SELECT * FROM short_links').all()
const clicks = db.prepare('SELECT * FROM short_link_clicks').all()
db.close()
fs.writeFileSync(path.join(SNAPSHOT_DIR, 'links.json'), JSON.stringify({ links, clicks }))
}
/** 记录测试开始前已存在的备份目录,便于事后只清理本次新增的 */
const listBackupDirs = () => {
try {
return new Set(fs.readdirSync(CONFIG_DIR)
.filter(n => n.startsWith('_nav-key-backup-'))
.map(n => n))
} catch { return new Set() }
}
const cleanupNewBackupDirs = beforeSet => {
const removed = []
try {
fs.readdirSync(CONFIG_DIR)
.filter(n => n.startsWith('_nav-key-backup-') && !beforeSet.has(n))
.forEach(n => {
try { fs.rmSync(path.join(CONFIG_DIR, n), { recursive: true, force: true }); removed.push(n) } catch (_) { /* 静默 */ }
})
} catch (_) { /* 静默 */ }
return removed
}
const restore = () => {
NAV_KEY_FILES.forEach(name => {
const src = path.join(SNAPSHOT_DIR, name)
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(CONFIG_DIR, name))
})
const snap = JSON.parse(fs.readFileSync(path.join(SNAPSHOT_DIR, 'links.json'), 'utf-8'))
const db = new Database(DB_PATH)
db.exec('DELETE FROM short_link_clicks; DELETE FROM short_links;')
const insLink = db.prepare(`INSERT INTO short_links
(id, code, target_url, title, source_type, source_ref, created_at, expires_at, max_clicks, click_count, is_active, created_by)
VALUES (@id, @code, @target_url, @title, @source_type, @source_ref, @created_at, @expires_at, @max_clicks, @click_count, @is_active, @created_by)`)
snap.links.forEach(r => insLink.run({
id: r.id, code: r.code, target_url: r.target_url, title: r.title || '',
source_type: r.source_type || 'manual', source_ref: r.source_ref || '',
created_at: r.created_at, expires_at: r.expires_at, max_clicks: r.max_clicks,
click_count: r.click_count || 0, is_active: r.is_active, created_by: r.created_by || 'admin',
}))
const insClick = db.prepare(`INSERT INTO short_link_clicks (id, code, clicked_at, ip, user_agent, referer)
VALUES (@id, @code, @clicked_at, @ip, @user_agent, @referer)`)
snap.clicks.forEach(c => insClick.run({
id: c.id, code: c.code, clicked_at: c.clicked_at,
ip: c.ip, user_agent: c.user_agent, referer: c.referer,
}))
db.close()
}
;(async () => {
console.log('=== 首页导航防爆紧急替换 · 端到端验证 ===')
console.log(`目标:${BASE}\n`)
const before = currentHomepageCode()
console.log(`[基线] 当前首页短链:/to/${before.code},其 token 现存 ${before.token.length} 字符`)
check('轮换前旧 token 可用(基线)', (await probeToken(before.token)) === 302)
snapshot()
const backupDirsBefore = listBackupDirs()
console.log(' 已快照导航密钥与短链库(用于验证后还原)\n')
let newToken = ''
let newCode = ''
try {
// ---------- 1. 未授权必须被拦 ----------
console.log('[1] 未授权访问必须被拦(不能被匿名触发换密钥)')
const noAuth = await fetch(`${BASE}/api/token_lab/nav/emergency/rotate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
})
check('无 cookie 调用 rotate → 401', noAuth.status === 401, `实际 ${noAuth.status}`)
// ---------- 2. 第 1 步:换密钥 + 签新 token ----------
console.log('\n[2] 第 1 步:自动生成向后 X 个月的鉴权,且当前鉴权立即失效')
const r1 = await post('/api/token_lab/nav/emergency/rotate', { months: 3 })
check('接口返回 200', r1.status === 200, `实际 ${r1.status} ${head(JSON.stringify(r1.json))}`)
check('返回 ok=true', !!(r1.json && r1.json.ok === true))
newToken = String((r1.json && r1.json.token) || '')
check('返回了新 token', newToken.length > 100, `长度 ${newToken.length}`)
check('回显 months=3', Number(r1.json && r1.json.months) === 3, r1.json && r1.json.months)
check('回显 days=90(3 个月 ×30 天)', Number(r1.json && r1.json.days) === 90, r1.json && r1.json.days)
check('logs 写明「3 个月」', JSON.stringify(r1.json.logs).includes('3 个月'))
check('执行过程 logs 非空', Array.isArray(r1.json.logs) && r1.json.logs.length >= 5, `条数 ${r1.json && r1.json.logs && r1.json.logs.length}`)
check('logs 含"备份"字样', JSON.stringify(r1.json.logs).includes('备份'))
check('logs 含"完成"字样', JSON.stringify(r1.json.logs).includes('完成'))
check('返回公钥指纹', !!(r1.json && r1.json.fingerprint), r1.json && r1.json.fingerprint)
check('返回备份目录名', !!(r1.json && r1.json.backupDir), r1.json && r1.json.backupDir)
// 生效期应约为 90 天(允许 1 分钟误差)
const expDeltaDays = (Number(r1.json.exp) - Math.floor(Date.now() / 1000)) / 86400
check('token 有效期约 90 天', Math.abs(expDeltaDays - 90) < 0.01, `实际 ${expDeltaDays.toFixed(4)} 天`)
check('旧 token 立即失效(401)', (await probeToken(before.token)) === 401)
check('新 token 可用(302)', (await probeToken(newToken)) === 302)
// ---------- 3. 第 2 步:换短链接 ----------
console.log('\n[3] 第 2 步:将新 token 绑到新短链,且当前短链立即失效')
const r2 = await post('/api/token_lab/nav/emergency/relink', { token: newToken })
check('接口返回 200', r2.status === 200, `实际 ${r2.status} ${head(JSON.stringify(r2.json))}`)
check('返回 ok=true', !!(r2.json && r2.json.ok === true))
newCode = String((r2.json && r2.json.code) || '')
check('返回新短码', !!newCode, newCode)
check('返回新短链接', !!(r2.json && r2.json.shortUrl), r2.json && r2.json.shortUrl)
check('新短链接形如 <域名>/to/<短码>', /\/to\/[A-Za-z0-9]+$/.test(String(r2.json && r2.json.shortUrl)))
check('已停用旧短码清单包含原短码',
Array.isArray(r2.json.disabledCodes) && r2.json.disabledCodes.includes(before.code),
JSON.stringify(r2.json && r2.json.disabledCodes))
check('跳转自校验通过', !!(r2.json && r2.json.verified === true))
check('logs 非空', Array.isArray(r2.json.logs) && r2.json.logs.length >= 4)
// ---------- 4. 短链行为验证 ----------
console.log('\n[4] 短链行为验证')
const oldShort = await probeShort(before.code)
check('旧短链已失效(404)', oldShort.status === 404, `实际 ${oldShort.status}`)
const newShort = await probeShort(newCode)
check('新短链可用(302)', newShort.status === 302, `实际 ${newShort.status}`)
check('新短链跳转目标包含新 token', newShort.location.includes(newToken.slice(0, 24)), head(newShort.location, 60))
// ---------- 5. 只影响首页,不影响其它 ----------
console.log('\n[5] 影响面:只动首页 token / 首页短链')
const go = await fetch(`${BASE}/go?systemId=Tools-wall-`, { redirect: 'manual' })
check('/go 签发工具入口仍正常(302)', go.status === 302, `实际 ${go.status}`)
const goLoc = go.headers.get('location') || ''
const wallProbe = await fetch(`${BASE}${goLoc.startsWith('http') ? '' : ''}${goLoc}`, { redirect: 'manual' })
check('工具入口 token 仍被工具接受(302)', wallProbe.status === 302, `实际 ${wallProbe.status}`)
const db = new Database(DB_PATH, { readonly: true })
const aliases = db.prepare("SELECT COUNT(1) AS c FROM short_links WHERE target_url LIKE '/tools/%'").get().c
db.close()
check('工具别名短链未被波及', aliases >= 0) // 别名走配置,不在库里;此项仅作记录
// ---------- 6. 第 2 步负向:坏 token 必须被拒 ----------
console.log('\n[6] 第 2 步负向:坏 token 不得绑进短链')
const bad = await post('/api/token_lab/nav/emergency/relink', { token: 'not_a_valid_jwt' })
check('无效 token → 400', bad.status === 400, `实际 ${bad.status}`)
check('无效 token 返回 invalid_token', String(bad.json && bad.json.error) === 'invalid_token', bad.json && bad.json.error)
// ---------- 6b. 月份入参边界 ----------
console.log('\n[6b] 月份入参边界(合法性夹取)')
const over = await post('/api/token_lab/nav/emergency/rotate', { months: 99 })
check('months=99 → 夹到 12(不报错)', over.status === 200 && Number(over.json && over.json.months) === 12,
`status=${over.status} months=${over.json && over.json.months}`)
const legacy = await post('/api/token_lab/nav/emergency/rotate', { days: 45 })
check('仅传 days=45(旧调用)→ 仍可用,days 回显 45',
legacy.status === 200 && Number(legacy.json && legacy.json.days) === 45,
`status=${legacy.status} days=${legacy.json && legacy.json.days}`)
check('旧调用口径下不返回 months', !(legacy.json && legacy.json.months), JSON.stringify(legacy.json && legacy.json.months))
} finally {
// ---------- 7. 还原 ----------
console.log('\n[7] 还原快照(把密钥与短链库恢复为验证前的状态)')
restore()
NAV_KEY_FILES.forEach(name => {
const ok = fs.existsSync(path.join(CONFIG_DIR, name))
check(`${name} 已还原`, ok)
})
const after = currentHomepageCode()
check(`首页短链恢复为原短码 /to/${before.code}`, after.code === before.code, `实际 ${after.code}`)
check('还原后旧 token 重新可用(302)', (await probeToken(before.token)) === 302)
if (newCode) {
const stale = await probeShort(newCode)
check('验证期间创建的临时短链已不存在(404)', stale.status === 404, `实际 ${stale.status}`)
}
// 接口每次执行都会真实创建备份目录;只清理本次测试新增的,保留用户原有的备份
const removedBackups = cleanupNewBackupDirs(backupDirsBefore)
console.log(` 已清理本次测试新增的备份目录 ${removedBackups.length} 个(保留原有 ${backupDirsBefore.size} 个)`)
try { fs.rmSync(SNAPSHOT_DIR, { recursive: true, force: true }) } catch (_) { /* 静默 */ }
}
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})().catch(e => {
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
try { restore() } catch (_) { /* 尽力还原 */ }
console.log('已尝试还原快照,请检查 config/ 与短链库。')
process.exit(1)
})
@@ -0,0 +1,177 @@
/**
* 作息报时器(schedule_reminder)后端接口集成测试
* 独立起一个临时 express 实例(8978),只挂载 schedule_reminder 的路由,
* 不依赖 8976 主服务,也不影响运行中的服务。
*
* 数据库隔离策略:脚本开始时若 data/app_order_box_schedule_reminder.db 不存在,
* 结束时把测试期间产生的 db / wal / shm 一并删除,保证不留测试脏数据。
*
* 运行:node dev_test_scripts/integration/test_schedule_reminder_integration.js
*/
const fs = require('fs')
const path = require('path')
const express = require('express')
const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE)
const { bindRoutes } = require('../../src/server/app_order_box/kits/schedule_reminder')
const PORT = 8978
const BASE = `http://127.0.0.1:${PORT}`
let passed = 0
let failed = 0
const check = (name, ok, extra) => {
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
else { failed += 1; console.log(` ✘ ${name}${extra ? ' → ' + extra : ''}`) }
}
const request = async (method, url, body) => {
const res = await fetch(BASE + url, {
method,
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await res.text()
let json = null
try { json = JSON.parse(text) } catch (_) { /* 非 JSON */ }
return { status: res.status, json, text }
}
const cleanupDb = () => {
if (DB_EXISTED_BEFORE) return
;['', '-wal', '-shm'].forEach(suffix => {
const p = DB_FILE + suffix
try { if (fs.existsSync(p)) fs.unlinkSync(p) } catch (_) { /* 静默 */ }
})
console.log('\n(测试库为本次新建,已清理:' + path.basename(DB_FILE) + ')')
}
const run = async () => {
console.log('=== 作息报时器 · 后端接口集成测试 ===\n')
// ---------- 1. 校时接口 ----------
console.log('[1] GET /time 校时')
const timeRes = await request('GET', '/api/app_order_box/kits/schedule_reminder/time')
check('HTTP 200', timeRes.status === 200, timeRes.status)
check('返回 ok=true', !!timeRes.json && timeRes.json.ok === true)
const epochMs = timeRes.json && timeRes.json.data ? Number(timeRes.json.data.epochMs) : NaN
check('epochMs 是有效时间戳', Number.isFinite(epochMs) && Math.abs(Date.now() - epochMs) < 60 * 1000, epochMs)
// ---------- 2. 日程校验 ----------
console.log('\n[2] POST /schedules 参数校验')
const noTitle = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', { items: [{ time: '07:30', name: '起床' }] })
check('缺 title → 400', noTitle.status === 400, noTitle.status)
const noItem = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', { title: '周末', weekdays: [6, 7] })
check('缺 items → 400', noItem.status === 400, noItem.status)
const noWeekday = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', { title: '周末', items: [{ time: '07:30', name: '起床' }] })
check('缺 weekdays → 400', noWeekday.status === 400, noWeekday.status)
// ---------- 3. 新建日程 ----------
console.log('\n[3] POST /schedules 新建(含清洗规则)')
const createRes = await request('POST', '/api/app_order_box/kits/schedule_reminder/schedules', {
title: ' 周末在家 ',
weekdays: [7, 6, 6, 99, 0], // 期望清洗为 [6,7]
items: [
{ time: '11:00', name: '做午饭', repeatTimes: 5, intervalSec: 30 },
{ time: '07:30', name: '起床', repeatTimes: 99, intervalSec: 1 }, // 期望被夹到 20 / 5
{ time: '25:99', name: '非法时刻' }, // 期望被过滤
],
})
check('HTTP 200', createRes.status === 200, createRes.status + ' ' + createRes.text.slice(0, 120))
const created = createRes.json && createRes.json.data
check('title 已 trim', created && created.title === '周末在家', created && created.title)
check('weekdays 清洗为 [6,7]', created && JSON.stringify(created.weekdays) === '[6,7]', created && JSON.stringify(created.weekdays))
check('非法时刻被过滤(剩 2 条)', created && created.items.length === 2, created && created.items.length)
check('items 按时刻升序', created && created.items[0].time === '07:30' && created.items[1].time === '11:00',
created && created.items.map(i => i.time).join(','))
const first = created && created.items[0]
check('repeatTimes 夹到 20', first && first.repeatTimes === 20, first && first.repeatTimes)
check('intervalSec 夹到 5', first && first.intervalSec === 5, first && first.intervalSec)
check('默认 repeatTimes=5 / intervalSec=30', created && created.items[1].repeatTimes === 5 && created.items[1].intervalSec === 30,
created && (created.items[1].repeatTimes + '/' + created.items[1].intervalSec))
const scheduleId = created && created.id
// ---------- 4. 列表 ----------
console.log('\n[4] GET /schedules 列表')
const listRes = await request('GET', '/api/app_order_box/kits/schedule_reminder/schedules')
check('HTTP 200', listRes.status === 200, listRes.status)
check('列表含新建日程', Array.isArray(listRes.json.data) && listRes.json.data.some(s => s.id === scheduleId))
// ---------- 5. 更新 ----------
console.log('\n[5] PUT /schedules 更新')
const updateRes = await request('PUT', '/api/app_order_box/kits/schedule_reminder/schedules', {
id: scheduleId,
title: '周末在家 v2',
weekdays: [6, 7],
items: [{ time: '07:30', name: '起床', repeatTimes: 5, intervalSec: 30 }],
})
check('HTTP 200', updateRes.status === 200, updateRes.status + ' ' + updateRes.text.slice(0, 120))
check('title 已更新', updateRes.json && updateRes.json.data.title === '周末在家 v2')
check('items 已更新为 1 条', updateRes.json && updateRes.json.data.items.length === 1)
const missingUpdate = await request('PUT', '/api/app_order_box/kits/schedule_reminder/schedules', {
id: 'not-exist-id', title: 'x', weekdays: [1], items: [{ time: '08:00', name: 'a' }],
})
check('更新不存在的日程 → 404', missingUpdate.status === 404, missingUpdate.status)
// ---------- 6. 播报日志 ----------
console.log('\n[6] 播报日志 logs / logs/ack')
const logRes = await request('POST', '/api/app_order_box/kits/schedule_reminder/logs', {
scheduleId: scheduleId,
scheduleTitle: '周末在家 v2',
itemId: 'item-1',
itemName: '起床',
plannedAt: '2026-09-30T07:30:00+08:00',
status: 'fired',
})
check('写日志 HTTP 200', logRes.status === 200, logRes.status + ' ' + logRes.text.slice(0, 120))
const logId = logRes.json && logRes.json.data && logRes.json.data.id
check('返回日志 id', Number.isFinite(Number(logId)) && Number(logId) > 0, logId)
check('状态为 fired', logRes.json && logRes.json.data.status === 'fired')
const missingLog = await request('POST', '/api/app_order_box/kits/schedule_reminder/logs', { itemName: 'x' })
check('缺 itemId → 400', missingLog.status === 400, missingLog.status)
const ackRes = await request('POST', '/api/app_order_box/kits/schedule_reminder/logs/ack', { id: logId })
check('确认 HTTP 200', ackRes.status === 200, ackRes.status)
check('状态变为 acked', ackRes.json && ackRes.json.data && ackRes.json.data.status === 'acked',
ackRes.json && ackRes.json.data && ackRes.json.data.status)
const logsRes = await request('GET', '/api/app_order_box/kits/schedule_reminder/logs')
check('日志列表 HTTP 200', logsRes.status === 200, logsRes.status)
check('列表含刚写入的记录', Array.isArray(logsRes.json.data) && logsRes.json.data.some(l => Number(l.id) === Number(logId)))
// ---------- 7. 语音预合成参数校验 ----------
console.log('\n[7] POST /tts/prepare 参数校验(不做真实合成)')
const ttsBad = await request('POST', '/api/app_order_box/kits/schedule_reminder/tts/prepare', { schedule: {} })
check('空 schedule → 400', ttsBad.status === 400, ttsBad.status)
// ---------- 8. 删除 ----------
console.log('\n[8] DELETE /schedules 删除')
const delRes = await request('DELETE', `/api/app_order_box/kits/schedule_reminder/schedules?id=${encodeURIComponent(scheduleId)}`)
check('HTTP 200', delRes.status === 200, delRes.status)
const afterDel = await request('GET', '/api/app_order_box/kits/schedule_reminder/schedules')
check('删除后列表不再包含', Array.isArray(afterDel.json.data) && !afterDel.json.data.some(s => s.id === scheduleId))
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
return failed === 0
}
const app = express()
app.use(express.json())
bindRoutes(app)
const server = app.listen(PORT, async () => {
let ok = false
try {
ok = await run()
} catch (e) {
console.log('\n✘ 测试执行异常:' + String((e && e.stack) || e))
failed += 1
}
server.close(() => {
cleanupDb()
process.exit(ok ? 0 : 1)
})
})
@@ -0,0 +1,154 @@
/**
* 短链接前缀与短码大小写容忍 · 回归测试
*
* 背景(2026-09-30 定位的真实问题):
* iPhone 6s 用户手输短链接时,把小写 l 打成了大写 I(字形几乎一样),
* 请求 /I/PSq6xv 命中不到路由 /l/:code,落到兜底 404 → 浏览器显示
* {"ok":false,"error":"not_found"}。nginx 日志里这类错误请求累计 20 次。
*
* 处置:
* ① 正式前缀由 /l/ 改为 /to/("to" 字形差异明显,且不像 go 那样易被猜到/重名)
* ② 旧前缀 /l /L /I /i 保留兼容,已分发的旧短链不失效
* ③ 短码匹配增加"忽略大小写"兜底,但仅当唯一匹配时生效(避免 Base62 歧义)
* ④ Express 路由大小写不敏感,/to/ 天然接受 /TO/ /To/ /tO/
*
* ⚠️ 数据安全:脚本不使用用户已有短链接做点击测试(点击会累加 click_count、
* 写入 clicks 表)。改为自建一条一次性短链接,测完删除,并对比用户数据前后无变化。
*
* 用法(需目标服务已启动):
* node dev_test_scripts/integration/test_short_link_prefix_tolerance.js
* node dev_test_scripts/integration/test_short_link_prefix_tolerance.js http://localhost:8976
*/
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const BASE = String(process.argv[2] || 'http://localhost:8976').replace(/\/+$/, '')
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const IPHONE_6S_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/92.0.4515.90 Mobile/15E148 Safari/604.1'
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 get = async (urlPath) => {
const res = await fetch(BASE + urlPath, { headers: { 'User-Agent': IPHONE_6S_UA }, redirect: 'manual' })
const text = await res.text().catch(() => '')
return { status: res.status, text, location: res.headers.get('location') }
}
/** 生成一条大小写明确的一次性测试短链接(形如 Ab3Xy9) */
const makeMixedCaseCode = () => {
const letters = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
const lower = 'abcdefghijkmnpqrstuvwxyz'
const digits = '23456789'
let code = ''
code += lower[crypto.randomInt(lower.length)]
code += letters[crypto.randomInt(letters.length)]
code += digits[crypto.randomInt(digits.length)]
code += letters[crypto.randomInt(letters.length)]
code += lower[crypto.randomInt(lower.length)]
code += digits[crypto.randomInt(digits.length)]
return code
}
const beijingNow = () => {
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
} catch { return new Date().toISOString() }
}
;(async () => {
console.log('=== 短链接前缀 / 短码大小写容忍 · 回归测试 ===')
console.log(`目标:${BASE}\n`)
const db = new Database(DB_PATH)
const testCode = makeMixedCaseCode()
const testTarget = 'https://example.com/prefix-tolerance-test'
// 记录用户既有数据,测试结束后用于确认未被改动
const userCode = 'PSq6xv'
const userBefore = db.prepare('SELECT click_count FROM short_links WHERE code = ?').get(userCode)
console.log(`[准备] 自建一次性短码 ${testCode} → ${testTarget}`)
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, created_at) VALUES (?, ?, ?, ?, ?)`)
.run(testCode, testTarget, '前缀容忍回归测试', 'manual', beijingNow())
let allOk = false
try {
const L = testCode.toLowerCase()
const U = testCode.toUpperCase()
// ---------- 1. 新前缀 /to/ 是正式写法 ----------
console.log('\n[1] 新前缀 /to/(正式写法)')
const okRes = await get(`/to/${testCode}`)
check(`GET /to/${testCode} → 302`, okRes.status === 302, `实际 ${okRes.status}`)
check('302 且 Location 指向目标链接', String(okRes.location || '').indexOf(testTarget) >= 0, String(okRes.location || '').slice(0, 80))
// Express 路由大小写不敏感:/to/ 应天然接受大写变体
for (const p of ['TO', 'To', 'tO']) {
const r = await get(`/${p}/${testCode}`)
check(`GET /${p}/${testCode}(前缀大小写变体)→ 302`, r.status === 302, `实际 ${r.status}`)
}
// ---------- 2. 旧前缀必须仍兼容(已分发的旧短链不能失效) ----------
console.log('\n[2] 旧前缀 /l /L /I /i 保留兼容')
for (const p of ['l', 'L', 'I', 'i']) {
const r = await get(`/${p}/${testCode}`)
check(`GET /${p}/${testCode}(旧前缀)→ 302`, r.status === 302, `实际 ${r.status}`)
}
const iphoneFail = await get(`/I/${testCode}`)
check('大写 I 不再返回 not_found JSON(本次故障的直接原因已消除)',
iphoneFail.text.indexOf('not_found') < 0, iphoneFail.text.slice(0, 60))
// ---------- 3. 短码大小写兜底 ----------
console.log('\n[3] 短码大小写兜底')
const lowerCode = await get(`/to/${L}`)
check(`GET /to/${L}(短码全小写)→ 302`, lowerCode.status === 302, `实际 ${lowerCode.status},${lowerCode.text.slice(0, 60)}`)
const upperCode = await get(`/to/${U}`)
check(`GET /to/${U}(短码全大写)→ 302`, upperCode.status === 302, `实际 ${upperCode.status}`)
const bothWrong = await get(`/I/${L}`)
check(`GET /I/${L}(旧前缀 + 短码大小写都错,即 /i/psq6xv 那类)→ 302`, bothWrong.status === 302, `实际 ${bothWrong.status}`)
// ---------- 4. 不该放行的仍然拦住 ----------
console.log('\n[4] 负向:不存在的短码仍拒绝(且不是谎报成功)')
const notExist = await get('/to/ZZZ999xx')
check('GET /to/<不存在的短码> → 404', notExist.status === 404, `实际 ${notExist.status}`)
const emptyCode = await get('/to/')
check('GET /to/ 不报成功(4xx)', emptyCode.status >= 400 && emptyCode.status < 500, `实际 ${emptyCode.status}`)
// ---------- 5. 记账用规范短码(click_count 真的加上去了) ----------
console.log('\n[5] 点击记账(大小写兜底命中时也要记到规范短码上)')
const rowAfter = db.prepare('SELECT click_count, code FROM short_links WHERE code = ?').get(testCode)
check('测试短码 click_count > 0(点击被正确累加)', Number(rowAfter.click_count) > 0, `click_count=${rowAfter.click_count}`)
const clickRows = db.prepare('SELECT COUNT(1) AS c FROM short_link_clicks WHERE code = ?').get(testCode)
check('clicks 表记录了规范短码的点击', Number(clickRows.c) > 0, `记录数=${clickRows.c}`)
const strayRows = db.prepare('SELECT COUNT(1) AS c FROM short_link_clicks WHERE code = ? AND code != ?').get(L, testCode)
check('没有把错误大小写的短码写进 clicks 表', Number(strayRows.c) === 0, `脏记录=${strayRows.c}`)
allOk = failed === 0
} finally {
// ---------- 清理:只删本次自建的短链接与其点击记录 ----------
console.log('\n[清理] 删除本次自建短链接及其点击记录')
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(testCode)
db.prepare('DELETE FROM short_links WHERE code = ?').run(testCode)
const stillThere = db.prepare('SELECT COUNT(1) AS c FROM short_links WHERE code = ?').get(testCode)
check('自建测试短链接已删除', Number(stillThere.c) === 0)
const userAfter = db.prepare('SELECT click_count FROM short_links WHERE code = ?').get(userCode)
if (userBefore && userAfter) {
check(`用户既有短码 ${userCode} 未被本次测试改动`,
Number(userBefore.click_count) === Number(userAfter.click_count),
`前 ${userBefore.click_count} → 后 ${userAfter.click_count}`)
}
db.close()
}
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(allOk && failed === 0 ? 0 : 1)
})()