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
Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# 调试:宝龙导视牌 logo 裁切 · 掩膜参数迭代
# 目标:7 行连通域计数 = [11,11,10,11,11,11,11](r2 行牌子上只有 10 格)
# 用法:python dev_test_scripts\debug\debug_mall_crop_mask.py
import os
import sys
import cv2
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
from tool_crop_mall_logos import imread_u, imwrite_u, detect_board_quad, warp_board # noqa: E402
SRC = r'D:\Temp文件\baolong-mall\照片.jpg'
HERE = os.path.dirname(os.path.abspath(__file__))
img = imread_u(SRC)
quad = detect_board_quad(img)
warped = warp_board(img, quad)
wh, ww = warped.shape[:2]
gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
hsv = cv2.cvtColor(warped, cv2.COLOR_BGR2HSV)
S = hsv[:, :, 1].astype(np.float32)
V = hsv[:, :, 2].astype(np.float32)
# 局部纹理(box filter 标准差):过曝区颜色失效时靠纹理兜底
gf = gray.astype(np.float32)
m1 = cv2.boxFilter(gf, -1, (9, 9))
m2 = cv2.boxFilter(gf * gf, -1, (9, 9))
STD = np.sqrt(np.maximum(m2 - m1 * m1, 0))
def build_mask(s_th, v_th, t_th, close_k, open_k):
mask = (((S > s_th) | (V < v_th) | (STD > t_th)).astype(np.uint8)) * 255
mask[:60, :] = 0
mask[-60:, :] = 0
mask[:, :60] = 0
mask[:, -60:] = 0
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((close_k, close_k), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((open_k, open_k), np.uint8))
return mask
def row_counts(mask):
n, labels, stats, cents = cv2.connectedComponentsWithStats(mask, 8)
blocks = []
for i in range(1, n):
x, y, bw, bh, area = stats[i]
cx, cy = cents[i]
if area < 8000 or bw < 100 or bh < 80 or bw > 0.35 * ww or bh > 0.25 * wh:
continue
if cy < 0.17 * wh or cx < 0.06 * ww:
continue
blocks.append((cy, cx, x, y, bw, bh, area))
blocks.sort()
rows = []
for b in blocks:
if rows and abs(b[0] - rows[-1][-1][0]) < 60:
rows[-1].append(b)
else:
rows.append([b])
return [len(r) for r in rows], blocks, rows
combos = [
(45, 140, 999, 9, 7), # 旧参数(仅颜色)
(30, 170, 12, 21, 9),
(25, 180, 10, 25, 11),
(25, 180, 14, 25, 13),
(20, 190, 12, 29, 13),
(30, 170, 16, 21, 11),
]
best = None
for c in combos:
mask = build_mask(*c)
counts, blocks, rows = row_counts(mask)
print(c, '->', counts)
if counts == [11, 11, 10, 11, 11, 11, 11]:
best = (c, mask, rows)
break
if best:
c, mask, rows = best
scale = 1600.0 / ww
vis = cv2.resize(warped, (1600, int(wh * scale))).copy()
for r in rows:
for (cy, cx, x, y, bw, bh, area) in r:
cv2.rectangle(vis, (int(x * scale), int(y * scale)),
(int((x + bw) * scale), int((y + bh) * scale)), (0, 255, 0), 2)
imwrite_u(os.path.join(HERE, 'blocks_preview.jpg'), vis)
imwrite_u(os.path.join(HERE, 'mask_preview.jpg'), cv2.resize(mask, (1600, int(wh * scale))))
print('BEST', c)
else:
print('无完美组合,取最后一组出预览')
mask = build_mask(*combos[-1])
counts, blocks, rows = row_counts(mask)
scale = 1600.0 / ww
vis = cv2.resize(warped, (1600, int(wh * scale))).copy()
for r in rows:
for (cy, cx, x, y, bw, bh, area) in r:
cv2.rectangle(vis, (int(x * scale), int(y * scale)),
(int((x + bw) * scale), int((y + bh) * scale)), (0, 255, 0), 2)
imwrite_u(os.path.join(HERE, 'blocks_preview.jpg'), vis)
imwrite_u(os.path.join(HERE, 'mask_preview.jpg'), cv2.resize(mask, (1600, int(wh * scale))))
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# 调试:trim_label 单格验证(r0c0 / r3c0 / r2c8)
import os
import sys
import cv2
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
import tool_crop_mall_logos as M # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
img = M.imread_u(M.DEFAULT_SRC)
warped = M.warp_board(img, M.detect_board_quad(img))
blocks = M.detect_confident_blocks(warped)
g = M.fit_grid(blocks)
print('row_top', [round(v) for v in g['row_top']])
print('col_center', [round(v) for v in g['col_center']])
for (ri, ci) in [(4, 1), (3, 8), (0, 0), (2, 8), (3, 6), (6, 10), (5, 0), (6, 0)]:
b = g['grid'].get((ri, ci))
if b is not None:
x0, y0, x1, y1 = b['x'] - 2, b['y'] - 2, b['x'] + b['w'] + 2, b['y'] + b['h'] + 2
kind = 'detect'
else:
cx = g['col_center'][ci]
x0, x1 = int(cx - g['pw'] / 2 - 2), int(cx + g['pw'] / 2 + 2)
y0, y1 = int(g['row_top'][ri] - 2), int(g['row_top'][ri] + g['ph'] + 2)
kind = 'fallback'
crop = warped[y0:y1, x0:x1]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
h = gray.shape[0]
print('--- r%dc%d %s bbox h=%d' % (ri, ci, kind, h))
for y in range(h - 1, max(0, h - 90), -6):
row = gray[y]
print(' y=%3d dark=%.3f mean=%.0f' % (y, float(np.mean(row < 160)), float(np.mean(row))))
t = M.trim_label(crop)
print(' trim: %d -> %d' % (crop.shape[0], t.shape[0]))
if (ri, ci) in [(6, 10), (6, 0)]:
import numpy as _np
gg = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(_np.float32)
dk = (gg < 160).mean(axis=1)
mn = gg.mean(axis=1)
ww = gg.shape[1]
a0, a1 = int(ww * 0.2), int(ww * 0.8)
dc = (gg[:, a0:a1] < 160).mean(axis=1)
do = ((gg[:, :a0] < 160).sum(axis=1) + (gg[:, a1:] < 160).sum(axis=1)) / float(ww - (a1 - a0))
rt = (dc + 0.004) / (do + 0.004)
it = (dk >= 0.04) & (mn <= 220) & (rt > 2.5)
ig = (dk < 0.12) & (mn >= 165) & (~it)
for y in range(crop.shape[0] - 1, int(crop.shape[0] * 0.4), -4):
print(' y=%3d dark=%.3f mean=%3.0f ratio=%5.2f text=%d gap=%d' % (y, dk[y], mn[y], rt[y], it[y], ig[y]))
M.imwrite_u(os.path.join(HERE, 'trim_r%dc%d.jpg' % (ri, ci)), t)
@@ -0,0 +1,119 @@
/**
* 诊断:判定「当前首页 token 到底由哪把密钥签发」
*
* 背景:config/ 下有两把候选公钥参与首页 token 验证:
* - nav-private.jwk.json(含私钥)
* - nav.jwk.json(仅公钥)
* 若不清楚旧 token 实际由哪把私钥签出,就贸然轮换其中一把,
* 可能出现「以为作废了、其实旧的还能用」的假安全。
*
* 本脚本只读,不修改任何密钥或数据:
* 1. 从 data/short_link.db 取当前首页 token
* 2. 用 config 下每一把候选公钥逐一验签,指出「签名来源」
* 3. 打印 token 负载与过期情况
*
* 运行:node dev_test_scripts/debug/debug_nav_token_key_audit.js
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const CONFIG_DIR = path.join(process.cwd(), 'config')
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const b64urlToBuf = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64')
const decodePart = s => JSON.parse(b64urlToBuf(s).toString('utf-8'))
const readJwk = name => {
try {
const p = path.join(CONFIG_DIR, name)
if (!fs.existsSync(p)) return null
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return null }
}
const fingerprint = jwk => {
const n = String((jwk && jwk.n) || '')
if (!n) return '(空)'
return crypto.createHash('sha256').update(n).digest('hex').slice(0, 16)
}
// 从短链库里取一条指向首页(带 token)的目标
const pickHomepageToken = () => {
const db = new Database(DB_PATH, { readonly: true })
const rows = db.prepare('SELECT code, title, target_url FROM short_links').all()
db.close()
for (const r of rows) {
const m = /[?&]token=([^&\s]+)/.exec(String(r.target_url || ''))
if (m) return { code: r.code, title: r.title, url: r.target_url, token: decodeURIComponent(m[1]) }
}
return null
}
;(async () => {
console.log('=== 首页 token 签发来源审计(只读) ===\n')
const found = pickHomepageToken()
if (!found) {
console.log('未在 data/short_link.db 里找到任何带 token 的短链接目标。')
process.exit(0)
}
console.log(`短链接:/${found.code} (${found.title || '无标题'})`)
console.log(`目标:${String(found.url).slice(0, 60)}...`)
console.log(`token 长度:${found.token.length}\n`)
const parts = String(found.token).split('.')
if (parts.length !== 3) {
console.log('✘ token 不是标准三段式 JWT,无法审计')
process.exit(1)
}
const header = decodePart(parts[0])
const payload = decodePart(parts[1])
console.log('--- 负载 ---')
console.log(JSON.stringify(payload, null, 2))
const now = Math.floor(Date.now() / 1000)
console.log(`\n过期:${new Date(payload.exp * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`)
console.log(`剩余:${((payload.exp - now) / 86400).toFixed(1)} 天(${payload.exp > now ? '仍有效' : '已过期'})`)
console.log(`算法:${header.alg}\n`)
console.log('--- 用 config 下每把候选公钥验签 ---')
const data = parts[0] + '.' + parts[1]
const sig = b64urlToBuf(parts[2])
const candidates = [
'nav-private.jwk.json',
'nav.jwk.json',
]
let matched = []
for (const name of candidates) {
const jwk = readJwk(name)
if (!jwk || !jwk.n) {
console.log(` ${name.padEnd(24)} 公钥不可用(无 n),跳过`)
continue
}
let ok = false
try {
const pub = crypto.createPublicKey({ key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, format: 'jwk' })
ok = crypto.verify('RSA-SHA256', Buffer.from(data), pub, sig)
} catch (e) {
console.log(` ${name.padEnd(24)} 验签异常:${String(e.message || e)}`)
continue
}
if (ok) matched.push(name)
console.log(` ${name.padEnd(24)} 指纹=${fingerprint(jwk)} 验签=${ok ? '通过 ← 就是这把签的' : '未通过'}`)
}
console.log('\n--- 结论 ---')
if (matched.length === 0) {
console.log('✘ 没有任何 config 下的公钥能验通该 token。')
console.log(' 说明它是由「其它地方保存的私钥」签出的(例如 token_lab 浏览器里导入的私钥 XML)。')
console.log(' 轮换 config 里的密钥无法作废它 —— 需要先找到那把私钥。')
} else {
console.log(`✔ 该 token 由这些公钥对应私钥签发:${matched.join('、')}`)
console.log(` → 要让旧 token 立即失效,必须轮换${matched.length > 1 ? '全部这些' : '这把'}密钥。`)
matched.forEach(n => console.log(` · ${n}(指纹 ${fingerprint(readJwk(n))})`))
}
})()
@@ -0,0 +1,355 @@
/**
* 作息报时器(schedule_reminder)前端端到端验证(一次性调试脚本)
*
* 做法:
* 1. 在 8979 起一个精简 express(静态 public + app_order_box API),不碰 8976 主服务;
* 2. 用项目自带的 puppeteer 打开 web_order_box 页面,走完整用户链路:
* 进入报时器 → 新建日程(时刻设为"当前北京时间分钟",使其立刻到点)→ 保存 →
* 用这套待机 → 校验到点播报弹窗 + 播报记录 → 点「我知道了」→ 校验记录变"已确认";
* 3. 收集页面 pageerror / console.error,最后打印结果。
*
* 运行:node dev_test_scripts/debug/debug_schedule_reminder_frontend.js
*/
const fs = require('fs')
const path = require('path')
const express = require('express')
const puppeteer = require('puppeteer')
const PORT = 8979
const BASE = `http://127.0.0.1:${PORT}`
const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE)
const sleep = ms => new Promise(r => setTimeout(r, ms))
// 本机未下载 puppeteer 自带 Chrome,改用系统已安装的 Chrome / Edge
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 beijingHHmm = (offsetMs) => new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit',
}).format(new Date(Date.now() + (offsetMs || 0)))
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 buildServer = () => {
const app = express()
app.use(express.json())
const pub = path.join(process.cwd(), 'public')
app.use(express.static(pub))
app.get('/tools/web_order_box', (req, res) => res.sendFile(path.join(pub, 'tools', 'web_order_box', 'index.html')))
const { bindRoutes } = require('../../src/server/app_order_box')
bindRoutes(app)
return app.listen(PORT)
}
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('(测试库为本次新建,已清理:' + path.basename(DB_FILE) + ')')
}
const clickByText = (page, selector, text) => page.evaluate((sel, txt) => {
const btns = Array.from(document.querySelectorAll(sel))
const target = btns.find(b => String(b.textContent || '').indexOf(txt) >= 0)
if (target) { target.click(); return true }
return false
}, selector, text)
const visible = (page, selector) => page.evaluate(sel => {
const el = document.querySelector(sel)
if (!el) return false
const st = window.getComputedStyle(el)
return st.display !== 'none' && st.visibility !== 'hidden'
}, selector)
const textOf = (page, selector) => page.evaluate(sel => {
const el = document.querySelector(sel)
return el ? String(el.textContent || '').trim() : ''
}, selector)
/** 模拟用户点掉浮层(语音检查弹窗 / 交互详情弹窗),避免其遮挡后续点击 */
const dismissOverlays = async (page) => {
await page.evaluate(() => {
const pick = (id, re) => {
const root = document.getElementById(id)
if (!root || window.getComputedStyle(root).display === 'none') return
const b = Array.from(root.querySelectorAll('button')).find(x => re.test(String(x.textContent || '')))
if (b) b.click()
}
pick('dialogOverlay', /听到了|知道了|确定|取消|没听到/)
pick('interactionOverlay', /关闭/)
})
await sleep(250)
}
const run = async (page) => {
console.log('=== 作息报时器 · 前端端到端验证 ===\n')
// ---------- 1. 进入报时器 ----------
console.log('[1] 进入作息报时器')
// 先确保框子停在 Hub(否则 activeKit 残留会让页面自动进入 kit,Hub 卡片不可点)
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kitId: '' }),
})
await page.goto(`${BASE}/tools/web_order_box/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const b = document.getElementById('btnEnterSchedule')
return b && !b.disabled
}, { timeout: 15000 })
// ⚠️ 安全底线:快照已有日程 id,清理阶段只删本次新建的,
// 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。
const existingRes = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
const existingIds = (existingRes.data || []).map(s => s.id)
await page.evaluate(ids => { window.__PRE_EXISTING_IDS = ids }, existingIds)
if (existingIds.length) console.log(`(检测到已有 ${existingIds.length} 套日程,本次不会删除它们)\n`)
check('Hub 上作息报时器卡片已启用', true)
await page.click('#btnEnterSchedule')
await sleep(600)
check('切换到报时器视图', await visible(page, '#viewSchedule'))
await sleep(3600) // 等交互详情弹窗(normal 2s / debug 3s)收起
await dismissOverlays(page)
await dismissOverlays(page)
const clock = await textOf(page, '#schedClock')
check('大字时钟已按 HH:mm:ss 走字', /^\d{2}:\d{2}:\d{2}$/.test(clock), clock)
const dateText = await textOf(page, '#schedDate')
check('日期为北京时间文案', dateText.indexOf('星期') >= 0, dateText)
// ---------- 2. 新建日程 ----------
console.log('\n[2] 新建作息日程(时刻设为当前分钟,便于立刻到点)')
await page.click('#btnNewSchedule')
await sleep(400)
check('编辑器已打开', await visible(page, '#schedEditorModal'))
// 生效星期 chip:默认全选 → 每个都带 √;取消勾选 √ 消失;chip 不换行
const readWeekdayChips = () => page.$$eval('.weekday-chip', els => els.map(e => ({
text: String(e.textContent || '').trim(),
active: e.classList.contains('active'),
height: e.offsetHeight,
})))
const clickWeekday = (w) => page.evaluate(n => {
const b = document.querySelector('.weekday-chip[data-weekday="' + n + '"]')
if (b) b.click()
}, w)
let chips = await readWeekdayChips()
check('星期 chip 共 7 个', chips.length === 7, chips.length)
check('默认全选:7 个都带 √', chips.every(c => c.text.indexOf('√') === 0), JSON.stringify(chips.map(c => c.text)))
check('星期 chip 未换行(高度一致)', new Set(chips.map(c => c.height)).size === 1, chips.map(c => c.height).join(','))
await clickWeekday(3)
await sleep(200)
chips = await readWeekdayChips()
let wed = chips.find(c => c.text.indexOf('周三') >= 0)
check('取消周三后不再带 √ 且不为选中态', !!wed && wed.text.indexOf('√') < 0 && !wed.active, JSON.stringify(wed))
await clickWeekday(3)
await sleep(200)
chips = await readWeekdayChips()
wed = chips.find(c => c.text.indexOf('周三') >= 0)
check('重新勾选周三后恢复 √ 与选中态', !!wed && wed.text.indexOf('√') === 0 && wed.active, JSON.stringify(wed))
// 目标时刻取"当前北京时间分钟":启动待机时可立即判定为到点(60 秒内 < 90 秒错过阈值)
const targetTime = beijingHHmm()
console.log(` 目标时刻 = ${targetTime}`)
await page.type('#schedEditorTitleInput', '自动化验证日程')
await page.$eval('.sched-item-editor input[data-field="time"]', (el, v) => {
el.value = v
el.dispatchEvent(new Event('input', { bubbles: true }))
}, targetTime)
await page.$eval('.sched-item-editor input[data-field="name"]', (el) => {
el.value = '验证事项'
el.dispatchEvent(new Event('input', { bubbles: true }))
})
await page.$eval('.sched-item-editor input[data-field="repeatTimes"]', (el) => {
el.value = '5'
el.dispatchEvent(new Event('input', { bubbles: true }))
})
// 间隔压到最小值 5 秒,便于在测试里观察到"自动复播"
await page.$eval('.sched-item-editor input[data-field="intervalSec"]', (el) => {
el.value = '5'
el.dispatchEvent(new Event('input', { bubbles: true }))
})
const hintText = await textOf(page, '.sched-item-editor .sched-item-hint')
check('编辑器实时预览播报文案', hintText.indexOf('现在是') >= 0 && hintText.indexOf('验证事项') >= 0, hintText)
const statText = await textOf(page, '#schedEditorStats')
check('编辑器统计已渲染', statText.indexOf('生效星期') >= 0, statText)
await clickByText(page, '#schedEditorBody button', '保存日程')
await sleep(3200) // 等交互详情弹窗收起 + 列表刷新
await dismissOverlays(page)
check('编辑器已关闭', !(await visible(page, '#schedEditorModal')))
const listText = await textOf(page, '#schedulesList')
check('列表出现新日程', listText.indexOf('自动化验证日程') >= 0, listText.slice(0, 80))
check('列表展示时刻预览', listText.indexOf(targetTime) >= 0 && listText.indexOf('验证事项') >= 0)
// ---------- 3. 开始待机并验证到点播报 ----------
console.log('\n[3] 用这套待机 → 验证到点播报')
await clickByText(page, '#schedulesList button', '用这套待机')
await sleep(800)
const statusText = await textOf(page, '#schedStatus')
check('状态进入待机中', statusText.indexOf('待机中') >= 0, statusText)
await page.waitForSelector('#schedAlertModal', { visible: true, timeout: 8000 }).then(
() => check('到点触发全屏播报弹窗', true),
() => check('到点触发全屏播报弹窗', false, '弹窗未出现')
)
const alertName = await textOf(page, '#schedAlertName')
const alertTime = await textOf(page, '#schedAlertTime')
const alertRepeat = await textOf(page, '#schedAlertRepeat')
check('播报弹窗显示事项名', alertName === '验证事项', alertName)
check('播报弹窗显示时刻', alertTime === targetTime, alertTime)
check('播报弹窗显示第 1/5 次', alertRepeat.indexOf('第 1 / 5 次') >= 0, alertRepeat)
// ---------- 4. 自动重复播报 + 我知道了 ----------
console.log('\n[4] 验证按间隔自动复播,再点「我知道了」提前结束本条')
await sleep(6500) // 间隔 5 秒,等第 2 次自动播报
const repeat2 = await textOf(page, '#schedAlertRepeat')
check('到点后按间隔自动复播(第 2 次)', repeat2.indexOf('第 2 / 5 次') >= 0, repeat2)
await page.click('#btnSchedAck')
await sleep(600)
check('点「我知道了」后弹窗关闭', !(await visible(page, '#schedAlertModal')))
await sleep(6500) // 再等一个完整间隔,确认已彻底停止
check('确认后不再继续复播', !(await visible(page, '#schedAlertModal')))
// ---------- 5. 播报记录 ----------
console.log('\n[5] 播报记录')
await clickByText(page, '#schedTabBar button', '记录')
await sleep(3200)
check('切到记录页', await visible(page, '#pageSchedLogs'))
const logsText = await textOf(page, '#schedLogsList')
check('记录页出现播报条目', logsText.indexOf('验证事项') >= 0, logsText.slice(0, 100))
check('记录状态为已确认', logsText.indexOf('已确认') >= 0, logsText.slice(0, 100))
// ---------- 6. 错过显示条 ----------
console.log('\n[6] 错过显示条(把时刻改成"已过 2.5 分钟")')
const schedApiRes = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
const targetSchedule = (schedApiRes.data || [])[0]
const pastHHmm = beijingHHmm(-150 * 1000)
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: targetSchedule.id,
title: targetSchedule.title,
weekdays: targetSchedule.weekdays,
items: [{ id: 'missed-item', time: pastHHmm, name: '错过测试', repeatTimes: 1, intervalSec: 30 }],
}),
})
// 切回"日程"页会触发 loadSchedules 刷新前端缓存,随后 tick 把该时刻判定为错过
await clickByText(page, '#schedTabBar button', '日程')
await sleep(3000)
check('错过显示条已出现', await visible(page, '#schedMissed'))
const missedText = await textOf(page, '#schedMissedDesc')
check('错过条列出该时刻', missedText.indexOf('错过测试') >= 0, missedText)
await clickByText(page, '#btnSchedMissedClear', '知道了')
await sleep(400)
check('点「知道了」后错过条消失', !(await visible(page, '#schedMissed')))
// ---------- 7. 停止待机 ----------
console.log('\n[7] 停止待机')
await page.click('#btnSchedStandby')
await sleep(800)
const stoppedText = await textOf(page, '#schedStatus')
check('已停止待机', stoppedText.indexOf('未待机') >= 0, stoppedText)
// ---------- 8. 清理测试数据(只删本次新建的那一套,绝不碰已有数据) ----------
console.log('\n[8] 清理测试日程')
// 从列表里精确定位"本脚本创建的那张卡片",再点它的删除按钮;
// 绝不能靠"列表里第一个删除按钮"这种位置假设(曾因无差别删除误删过用户真实日程)
const newIds = await page.evaluate(() => {
const ids = Array.from(document.querySelectorAll('#schedulesList .card-item'))
.map(el => el.dataset.id)
return ids.filter(id => !(window.__PRE_EXISTING_IDS || []).includes(id))
})
const createdId = newIds.length ? newIds[0] : null
check('定位到本次新建的日程', !!createdId, JSON.stringify(newIds))
if (createdId) {
await page.evaluate(id => {
const card = document.querySelector('#schedulesList .card-item[data-id="' + id + '"]')
const target = card && Array.from(card.querySelectorAll('button'))
.find(b => String(b.textContent || '').indexOf('删除') >= 0)
if (target) target.click()
}, createdId)
await sleep(300)
await clickByText(page, '#dialogActions button', '确认')
await sleep(3200)
}
const afterDel = await textOf(page, '#schedulesList')
check('测试日程已删除', afterDel.indexOf('自动化验证日程') < 0, afterDel.slice(0, 80))
const preserved = await page.evaluate(ids =>
ids.every(id => !!document.querySelector('#schedulesList .card-item[data-id="' + id + '"]')),
(await page.evaluate(() => window.__PRE_EXISTING_IDS || [])))
check('原有日程未被误删', preserved)
// ---------- 9. 还原框子激活态 ----------
console.log('\n[9] 还原框子激活 kit')
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kitId: '' }),
})
check('active-kit 已清空(回到 Hub)', true)
}
;(async () => {
const server = buildServer()
await sleep(500)
const browser = await puppeteer.launch({
headless: true,
executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio'],
})
const page = await browser.newPage()
await page.setViewport({ width: 414, height: 900 })
const pageErrors = []
page.on('pageerror', e => pageErrors.push('pageerror: ' + String(e && e.message || e)))
page.on('console', m => { if (m.type() === 'error') pageErrors.push('console.error: ' + m.text()) })
// 用 normal 模式跑:查看类操作降级为 Toast,减少弹窗对点击的遮挡
await page.evaluateOnNewDocument(() => {
try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ }
})
let ok = false
try {
await run(page)
// 语音探测弹窗若还在,点掉它,避免影响后续判断
await sleep(300)
ok = failed === 0
} catch (e) {
console.log('\n✘ 验证执行异常:' + String((e && e.stack) || e))
failed += 1
}
console.log('\n--- 页面错误 ---')
if (!pageErrors.length) console.log(' (无 pageerror / console.error)')
else pageErrors.slice(0, 20).forEach(t => console.log(' ! ' + t))
check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '))
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
await browser.close()
server.close(() => {
cleanupDb()
process.exit(ok ? 0 : 1)
})
})()
@@ -0,0 +1,320 @@
/**
* 作息报时器 · iPhone 6s(375×667)布局体检(一次性调试脚本)
*
* 目标:在目标设备尺寸下量化检查布局是否真的有问题,而不是凭截图猜。
* 检查项:
* 1. 是否存在横向溢出(页面左右被撑破)
* 2. 滚到底时,卡片操作按钮是否被底部固定 Tab 栏遮挡
* 3. 待机大屏 / 播报全屏 是否有元素超出容器宽度
* 4. 内容是否真的能滚到底(有无被裁掉、滚不到的区域)
*
* 运行:node dev_test_scripts/debug/debug_schedule_reminder_iphone6s_layout.js
*/
const fs = require('fs')
const path = require('path')
const express = require('express')
const puppeteer = require('puppeteer')
const PORT = 8980
const BASE = `http://127.0.0.1:${PORT}`
const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE)
// iPhone 6s:CSS 视口 375 × 667,DPR 2
const VIEWPORT = { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true }
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 problems = 0
// 脚本启动时已存在的日程 id(要保护的数据,清理阶段一律跳过)
let PRE_EXISTING_IDS = new Set()
const report = (name, ok, detail) => {
if (!ok) problems += 1
console.log(` ${ok ? '✔' : '✘'} ${name}${detail ? '\n ' + detail : ''}`)
}
const buildServer = () => {
const app = express()
app.use(express.json())
const pub = path.join(process.cwd(), 'public')
app.use(express.static(pub))
const { bindRoutes } = require('../../src/server/app_order_box')
bindRoutes(app)
return app.listen(PORT)
}
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 (_) { /* 静默 */ }
})
}
const run = async (page) => {
console.log(`=== 布局体检:iPhone 6s ${VIEWPORT.width}×${VIEWPORT.height} ===\n`)
// ⚠️ 安全底线:先快照已有日程 id,清理时只删"本次新建"的,
// 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。
const existing = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
PRE_EXISTING_IDS = new Set((existing.data || []).map(s => s.id))
if (PRE_EXISTING_IDS.size) {
console.log(`(已有 ${PRE_EXISTING_IDS.size} 套日程,本次不会删除它们)\n`)
}
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }),
})
// 造一套 6 个时刻的日程,保证列表足够长(复现截图里的场景)
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '体检日程(周三在公司)', weekdays: [3],
items: [
{ time: '15:00', name: '看下股票收盘' },
{ time: '15:50', name: '起来走动下' },
{ time: '16:29', name: '看下钉钉,有没有审批单' },
{ time: '17:30', name: '看下 hermes,自动任务执行情况' },
{ time: '17:50', name: '准备下班,上个厕所' },
{ time: '18:30', name: '下班回家路上' },
],
}),
})
await page.goto(`${BASE}/tools/web_order_box/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const b = document.getElementById('btnEnterSchedule')
return b && !b.disabled
}, { timeout: 15000 })
await page.click('#btnEnterSchedule')
await sleep(4200)
// ---------- 1. 横向溢出 ----------
console.log('[1] 横向溢出检查')
const overflow = await page.evaluate(() => {
const de = document.documentElement
const offenders = []
document.querySelectorAll('#viewSchedule *').forEach(el => {
const r = el.getBoundingClientRect()
if (r.width === 0) return
if (r.right > de.clientWidth + 0.5 || r.left < -0.5) {
offenders.push({
cls: (el.className && String(el.className)) || el.tagName,
left: Math.round(r.left), right: Math.round(r.right),
})
}
})
return { clientWidth: de.clientWidth, scrollWidth: de.scrollWidth, offenders: offenders.slice(0, 8) }
})
report('页面无横向滚动', overflow.scrollWidth <= overflow.clientWidth, `clientWidth=${overflow.clientWidth} scrollWidth=${overflow.scrollWidth}`)
report('无元素超出右边界', overflow.offenders.length === 0, JSON.stringify(overflow.offenders))
// ---------- 2. 大字时钟是否溢出 ----------
console.log('\n[2] 待机大屏检查')
const clockInfo = await page.evaluate(() => {
const clock = document.getElementById('schedClock')
const panel = document.getElementById('schedStandbyPanel')
const next = document.getElementById('schedNext')
const cs = window.getComputedStyle(clock)
return {
clockFontSize: cs.fontSize,
clockScrollW: clock.scrollWidth,
clockClientW: clock.clientWidth,
panelH: Math.round(panel.getBoundingClientRect().height),
nextH: Math.round(next.getBoundingClientRect().height),
nextText: next.textContent,
viewportH: window.innerHeight,
}
})
report('时钟文字未被裁切', clockInfo.clockScrollW <= clockInfo.clockClientW + 1,
`font=${clockInfo.clockFontSize} scrollW=${clockInfo.clockScrollW} clientW=${clockInfo.clockClientW}`)
console.log(` 待机面板高 ${clockInfo.panelH}px / 视口高 ${clockInfo.viewportH}px(占 ${Math.round(clockInfo.panelH / clockInfo.viewportH * 100)}%)`)
console.log(` "下一条"文案高 ${clockInfo.nextH}px:${clockInfo.nextText}`)
// ---------- 3. 底部 Tab 栏是否遮挡内容 ----------
console.log('\n[3] 待机中状态 + 底部 Tab 栏遮挡检查')
// 先点"用这套待机",复现截图里的真实状态(大屏显示"下一条…"倒计时)
await page.evaluate(() => {
window.scrollTo(0, 0)
const b = Array.from(document.querySelectorAll('#schedulesList button'))
.find(x => String(x.textContent || '').indexOf('用这套待机') >= 0)
if (b) b.click()
})
await sleep(1200)
const nextInfo = await page.evaluate(() => {
const el = document.getElementById('schedNext')
const cs = window.getComputedStyle(el)
const lineH = parseFloat(cs.lineHeight) || 25
const h = el.getBoundingClientRect().height
return {
text: el.textContent,
height: Math.round(h),
lines: Math.max(1, Math.round(h / lineH)),
status: document.getElementById('schedStatus').textContent,
}
})
console.log(` "下一条"占 ${nextInfo.lines} 行:${nextInfo.text}`)
console.log(` 状态栏:${nextInfo.status}`)
report('"下一条"文案 2 行以内(短屏不会被撑太高)', nextInfo.lines <= 2, `实际 ${nextInfo.lines} 行`)
await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight) })
await sleep(400)
// 把内部滚动容器也拉到底
await page.evaluate(() => {
document.querySelectorAll('.motion-body').forEach(el => { el.scrollTop = el.scrollHeight })
})
await sleep(400)
const overlap = await page.evaluate(() => {
const tabBar = document.getElementById('schedTabBar')
const tabTop = tabBar.getBoundingClientRect().top
const lastCard = document.querySelector('#schedulesList .card-item:last-child')
const actions = lastCard ? lastCard.querySelector('.card-actions') : null
const body = document.querySelector('#viewSchedule .motion-body')
const cs = body ? window.getComputedStyle(body) : null
return {
tabTop: Math.round(tabTop),
tabH: Math.round(tabBar.getBoundingClientRect().height),
actionsBottom: actions ? Math.round(actions.getBoundingClientRect().bottom) : null,
bodyPadBottom: cs ? cs.paddingBottom : null,
bodyScrollTop: body ? Math.round(body.scrollTop) : null,
bodyScrollH: body ? body.scrollHeight : null,
bodyClientH: body ? body.clientHeight : null,
docScrollTop: Math.round(window.scrollY),
docScrollH: document.documentElement.scrollHeight,
docClientH: document.documentElement.clientHeight,
}
})
console.log(' ' + JSON.stringify(overlap))
report('滚到底后,卡片操作按钮完整露出(不被 Tab 栏遮挡)',
overlap.actionsBottom !== null && overlap.actionsBottom <= overlap.tabTop,
`按钮底 ${overlap.actionsBottom}px vs Tab栏顶 ${overlap.tabTop}px(Tab栏高 ${overlap.tabH}px,body padding-bottom ${overlap.bodyPadBottom})`)
// ---------- 4. 播报全屏检查 ----------
console.log('\n[4] 播报全屏弹窗检查')
await page.evaluate(() => {
const modal = document.getElementById('schedAlertModal')
modal.style.display = 'flex'
document.getElementById('schedAlertTime').textContent = '18:30'
document.getElementById('schedAlertName').textContent = '下班回家路上'
document.getElementById('schedAlertRepeat').textContent = '第 1 / 5 次播报 · 每 30 秒一次'
})
await sleep(300)
const alertInfo = await page.evaluate(() => {
const container = document.querySelector('.sched-alert-container')
const ack = document.getElementById('btnSchedAck')
const time = document.getElementById('schedAlertTime')
const r = ack.getBoundingClientRect()
return {
containerH: Math.round(container.getBoundingClientRect().height),
viewportH: window.innerHeight,
ackBottom: Math.round(r.bottom),
ackVisible: r.bottom <= window.innerHeight + 1 && r.top >= 0,
timeScrollW: time.scrollWidth,
timeClientW: time.clientWidth,
}
})
report('「我知道了」按钮在视口内可见(不用滚动)', alertInfo.ackVisible,
`按钮底 ${alertInfo.ackBottom}px / 视口高 ${alertInfo.viewportH}px,容器高 ${alertInfo.containerH}px`)
report('播报时刻大字未被裁切', alertInfo.timeScrollW <= alertInfo.timeClientW + 1,
`scrollW=${alertInfo.timeScrollW} clientW=${alertInfo.timeClientW}`)
// ---------- 5. 编辑器检查 ----------
console.log('\n[5] 日程编辑器检查')
await page.evaluate(() => {
document.getElementById('schedAlertModal').style.display = 'none'
window.scrollTo(0, 0)
document.getElementById('btnNewSchedule').click()
})
await sleep(500)
const editorOpened = await page.evaluate(() =>
window.getComputedStyle(document.getElementById('schedEditorModal')).display !== 'none')
report('编辑器已打开(测试前置条件)', editorOpened)
const editorInfo = await page.evaluate(() => {
const sheet = document.querySelector('#schedEditorModal .modal-sheet')
const saveBtn = document.getElementById('btnSchedEditorSave')
const bodyEl = document.getElementById('schedEditorBody')
const chips = Array.from(document.querySelectorAll('.weekday-chip'))
const rows = new Set(chips.map(c => Math.round(c.getBoundingClientRect().top)))
// 关键:带 √ 的 chip 文字是否比 chip 自身还宽(会被 nowrap 撑破、压到邻居)
const chipDetails = chips.map(c => {
const r = c.getBoundingClientRect()
return {
text: String(c.textContent || '').trim(),
boxW: Math.round(r.width),
// scrollWidth > clientWidth 说明内容被裁/溢出
overflow: c.scrollWidth - c.clientWidth,
}
})
return {
sheetH: Math.round(sheet.getBoundingClientRect().height),
viewportH: window.innerHeight,
weekdayRows: rows.size,
chipDetails: chipDetails,
overflowChips: chipDetails.filter(c => c.overflow > 1),
saveBtnBottom: saveBtn ? Math.round(saveBtn.getBoundingClientRect().bottom) : null,
bodyScrollH: bodyEl.scrollHeight,
bodyClientH: bodyEl.clientHeight,
docScrollW: document.documentElement.scrollWidth,
docClientW: document.documentElement.clientWidth,
}
})
report('星期 chip 单行显示', editorInfo.weekdayRows === 1, `实际占用 ${editorInfo.weekdayRows} 行`)
report('星期 chip 文字未溢出自身边框(带 √ 也放得下)', editorInfo.overflowChips.length === 0,
JSON.stringify(editorInfo.chipDetails.map(c => c.text + '=' + c.boxW + 'px,溢出' + c.overflow)))
report('编辑器打开后页面仍无横向滚动', editorInfo.docScrollW <= editorInfo.docClientW,
`scrollWidth=${editorInfo.docScrollW} clientWidth=${editorInfo.docClientW}`)
console.log(` 编辑器面板高 ${editorInfo.sheetH}px(视口 ${editorInfo.viewportH}px),内容可滚动 ${editorInfo.bodyScrollH} > ${editorInfo.bodyClientH}`)
await page.evaluate(() => { document.getElementById('schedEditorModal').style.display = 'none' })
// ---------- 6. 清理(只删本脚本新建的,绝不碰已有数据) ----------
const list = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
let removed = 0
for (const s of (list.data || [])) {
if (PRE_EXISTING_IDS.has(s.id)) continue
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules?id=${encodeURIComponent(s.id)}`, { method: 'DELETE' })
removed += 1
}
console.log(`\n[6] 清理:删除本次新建的 ${removed} 套日程,保留原有 ${PRE_EXISTING_IDS.size} 套`)
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }),
})
console.log(`\n=== 体检完成:发现 ${problems} 处问题 ===`)
}
;(async () => {
const server = buildServer()
await sleep(500)
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio'],
})
const page = await browser.newPage()
await page.setViewport(VIEWPORT)
const pageErrors = []
page.on('pageerror', e => pageErrors.push(String(e && e.message || e)))
await page.evaluateOnNewDocument(() => {
try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ }
})
try {
await run(page)
} catch (e) {
console.log('\n✘ 体检异常:' + String((e && e.stack) || e))
problems += 1
}
if (pageErrors.length) console.log('\n页面报错:\n ' + pageErrors.join('\n '))
await browser.close()
server.close(() => { cleanupDb(); process.exit(0) })
})()
@@ -0,0 +1,230 @@
/**
* 作息报时器 · 老 Safari CSS 兼容性复现与验证(一次性调试脚本)
*
* 背景:iPhone 6s 上反馈两个现象:
* 1. 待机大屏的时间没有大字体
* 2. 到点循环播报了 5 次,但屏幕上没有「我知道了」按钮可点
*
* 假设:该机 Safari 版本较老,不支持 clamp() / dvh / inset 简写。
* 浏览器遇到「不支持的属性值」时会**只丢弃那一条声明**,同一条规则里的其它声明照常生效。
* → 于是 .standby-clock 的 font-size 被丢弃(字体退回默认 16px,但金色/粗体仍在)
* → .modal-overlay 的 inset:0 被丢弃(遮罩没有定位到四边,跑到文档静态位置,即视口外)
*
* 本脚本用请求拦截把这三处改成「浏览器不认识的值」,等同于老 Safari 的行为,
* 以此复现现象、验证因果;修完后同脚本应转为全部通过。
*
* 运行:node dev_test_scripts/debug/debug_schedule_reminder_legacy_safari.js
*/
const fs = require('fs')
const path = require('path')
const express = require('express')
const puppeteer = require('puppeteer')
const PORT = 8981
const BASE = `http://127.0.0.1:${PORT}`
const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE)
const VIEWPORT = { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true }
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))
// ⚠️ 安全底线:主流程开始前快照已有日程 id,清理时只删"本次新建"的,
// 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。
let PRE_EXISTING_IDS = new Set()
/**
* 把"老 Safari 不支持的写法"替换成浏览器不认识的值 → 该声明被丢弃。
* 这是对老浏览器行为的高保真模拟:只丢那一条,同规则其它声明不受影响。
*/
const degradeForLegacySafari = css => String(css)
.replace(/\bclamp\(/g, 'clampx(') // 不支持 clamp() → 整条 font-size 被丢弃
.replace(/\b(\d+)dvh\b/g, '$1dvhx') // 不支持 dvh 单位 → 整条 height 被丢弃
.replace(/inset:\s*0;/g, 'inset-legacy: 0;') // 不支持 inset 简写 → 整条被丢弃
const beijingHHmm = () => new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit',
}).format(new Date())
const buildServer = () => {
const app = express()
app.use(express.json())
app.use(express.static(path.join(process.cwd(), 'public')))
const { bindRoutes } = require('../../src/server/app_order_box')
bindRoutes(app)
return app.listen(PORT)
}
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 (_) { /* 静默 */ }
})
}
/** 造一套"当前时刻"日程并进入待机,返回观测结果 */
const probe = async (browser, legacy) => {
const page = await browser.newPage()
await page.setViewport(VIEWPORT)
await page.setCacheEnabled(false)
const pageErrors = []
page.on('pageerror', e => pageErrors.push(String(e && e.message || e)))
await page.setRequestInterception(true)
page.on('request', async req => {
const url = req.url()
if (legacy && url.indexOf('order_box.css') >= 0) {
try {
const css = await (await fetch(url)).text()
return req.respond({ status: 200, contentType: 'text/css; charset=utf-8', body: degradeForLegacySafari(css) })
} catch (e) { return req.continue() }
}
return req.continue()
})
// 准备数据:当前分钟到点的日程
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }),
})
const targetTime = beijingHHmm()
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '兼容性验证日程', weekdays: [1, 2, 3, 4, 5, 6, 7],
items: [{ time: targetTime, name: '验证事项', repeatTimes: 5, intervalSec: 30 }],
}),
})
await page.evaluateOnNewDocument(() => {
try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ }
})
await page.goto(`${BASE}/tools/web_order_box/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const b = document.getElementById('btnEnterSchedule')
return b && !b.disabled
}, { timeout: 15000 })
await page.click('#btnEnterSchedule')
await sleep(4000)
await page.evaluate(() => {
const modal = document.getElementById('dialogOverlay')
if (modal && window.getComputedStyle(modal).display !== 'none') {
const b = Array.from(modal.querySelectorAll('button')).find(x => /听到了|知道了|确定/.test(x.textContent || ''))
if (b) b.click()
}
})
await sleep(300)
// 进入待机 → 触发到点播报
await page.evaluate(() => { window.scrollTo(0, 0) })
await page.evaluate(() => {
const b = Array.from(document.querySelectorAll('#schedulesList button'))
.find(x => String(x.textContent || '').indexOf('用这套待机') >= 0)
if (b) b.click()
})
// 等播报弹窗出现
let alertAppeared = false
for (let i = 0; i < 24; i += 1) {
alertAppeared = await page.evaluate(() =>
window.getComputedStyle(document.getElementById('schedAlertModal')).display !== 'none')
if (alertAppeared) break
await sleep(500)
}
await sleep(600)
const result = await page.evaluate(() => {
const clock = document.getElementById('schedClock')
const modal = document.getElementById('schedAlertModal')
const ack = document.getElementById('btnSchedAck')
const overlay = window.getComputedStyle(modal)
const modalRect = modal.getBoundingClientRect()
const ackRect = ack.getBoundingClientRect()
const vh = window.innerHeight
const vw = window.innerWidth
// 遮罩是否真的覆盖了视口(老 Safari 丢 inset 后,遮罩会停在文档静态位置、跑出视口)
const modalOverlapsViewport = modalRect.bottom > 0 && modalRect.top < vh &&
modalRect.right > 0 && modalRect.left < vw
const ackVisible = ackRect.top >= 0 && ackRect.bottom <= vh && ackRect.width > 0
return {
clockFontSize: window.getComputedStyle(clock).fontSize,
clockText: clock.textContent,
modalDisplay: overlay.display,
modalRect: { top: Math.round(modalRect.top), bottom: Math.round(modalRect.bottom), h: Math.round(modalRect.height) },
ackRect: { top: Math.round(ackRect.top), bottom: Math.round(ackRect.bottom), w: Math.round(ackRect.width) },
viewport: { vw, vh },
docScrollH: document.documentElement.scrollHeight,
modalOverlapsViewport, ackVisible,
}
})
// 清理
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/logs`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId: 'x', itemName: 'x', status: 'fired' }),
}).catch(() => {})
// 清理:只删本次新建的日程,绝不删除数据库里原本就存在的用户数据
const list = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
for (const s of (list.data || [])) {
if (PRE_EXISTING_IDS.has(s.id)) continue
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules?id=${encodeURIComponent(s.id)}`, { method: 'DELETE' })
}
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }),
})
await page.close()
return { result, pageErrors, targetTime }
}
;(async () => {
const server = buildServer()
await sleep(500)
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio'],
})
// 先快照已有日程,保护用户数据不被本脚本的清理步骤误删
const existing = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
PRE_EXISTING_IDS = new Set((existing.data || []).map(s => s.id))
console.log('=== 老 Safari CSS 兼容性复现 / 验证 ===')
if (PRE_EXISTING_IDS.size) {
console.log(`(检测到已有 ${PRE_EXISTING_IDS.size} 套日程,本次不会删除它们)`)
}
console.log('')
console.log('[A] 模拟老 Safari(clamp / dvh / inset 均不被支持)')
const legacy = await probe(browser, true)
console.log(` 目标时刻 ${legacy.targetTime}`)
console.log(` 待机时钟字号 = ${legacy.result.clockFontSize}(文字 ${legacy.result.clockText})`)
console.log(` 播报弹窗 display=${legacy.result.modalDisplay},位置 ${JSON.stringify(legacy.result.modalRect)},视口高 ${legacy.result.viewport.vh}`)
console.log(` 「我知道了」按钮 ${JSON.stringify(legacy.result.ackRect)}`)
console.log(` 弹窗是否落在视口内 = ${legacy.result.modalOverlapsViewport},按钮是否可见 = ${legacy.result.ackVisible}`)
const clockSmall = parseFloat(legacy.result.clockFontSize) < 30
console.log(`\n → 现象1(时间没有大字体)${clockSmall ? '已复现 ✔' : '未复现 ✘'}`)
console.log(` → 现象2(没有「我知道了」按钮)${(!legacy.result.ackVisible || !legacy.result.modalOverlapsViewport) ? '已复现 ✔' : '未复现 ✘'}`)
console.log('\n[B] 正常模式(现代浏览器,不降级)')
const modern = await probe(browser, false)
console.log(` 待机时钟字号 = ${modern.result.clockFontSize}`)
console.log(` 弹窗落视口内 = ${modern.result.modalOverlapsViewport},按钮可见 = ${modern.result.ackVisible}`)
console.log(` → 与 iPhone 18 等新机表现一致:${parseFloat(modern.result.clockFontSize) >= 30 && modern.result.ackVisible ? '正常 ✔' : '异常 ✘'}`)
if (legacy.pageErrors.length || modern.pageErrors.length) {
console.log('\n页面报错:')
;[...legacy.pageErrors, ...modern.pageErrors].slice(0, 10).forEach(t => console.log(' ! ' + t))
}
console.log('\n判定标准:修完后,[A] 段的时钟字号应 >= 30px,且「我知道了」按钮应在视口内可见。')
console.log(`当前 [A] 是否已修复:${!clockSmall && legacy.result.ackVisible && legacy.result.modalOverlapsViewport ? '是 ✔' : '否 ✘(仍需修复)'}`)
await browser.close()
server.close(() => { cleanupDb(); process.exit(0) })
})()
@@ -0,0 +1,110 @@
/**
* 短链接后台页面 · 前缀渲染确认(一次性调试脚本)
*
* 目的:确认后台页面在界面上真正展示 /to/<短码>(而不是只改了代码但页面没生效)。
* 做法:自建一条一次性短链接 → 打开后台页 → 断言列表与复制按钮里出现 /to/ 且不再出现 /l/ → 删除。
*
* ⚠️ 数据安全:不使用、不点击用户既有短链接;自建的一条测完即删。
*
* 运行:node dev_test_scripts/debug/debug_short_link_admin_render.js
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const puppeteer = require('puppeteer')
const BASE = 'http://localhost:8976'
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const CODE = 'zz' + crypto.randomBytes(3).toString('hex')
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 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() }
}
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 : ''}`) }
}
;(async () => {
const db = new Database(DB_PATH)
const userCodes = db.prepare('SELECT code FROM short_links').all().map(r => r.code)
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, created_at) VALUES (?, ?, ?, ?, ?)`)
.run(CODE, 'https://example.com/admin-render-test', '后台渲染确认(临时)', 'manual', beijingNow())
db.close()
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio'],
})
const page = await browser.newPage()
await page.setViewport({ width: 1280, height: 900 })
// 带齐两层门禁,直接进后台页
await page.setCookie(
{ name: 'nav_gate', value: '1', domain: 'localhost', path: '/' },
{ name: 'short_link_gate', value: '1', domain: 'localhost', path: '/' },
)
console.log('=== 短链接后台页面 · 前缀渲染确认 ===\n')
await page.goto(`${BASE}/tools/short_link/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const el = document.getElementById('link-tbody')
return el && el.innerText.trim().length > 0
}, { timeout: 15000 })
const rendered = await page.evaluate(() => {
const tbody = document.getElementById('link-tbody')
const aliasList = document.getElementById('alias-list')
const copyBtn = tbody.querySelector('.short-url[data-copy]')
return {
tableText: String((tbody && tbody.innerText) || ''),
copyData: copyBtn ? String(copyBtn.getAttribute('data-copy') || '') : '',
aliasText: String((aliasList && aliasList.innerText) || ''),
aliasCopyData: (() => {
const b = aliasList && aliasList.querySelector('[data-copy]')
return b ? String(b.getAttribute('data-copy') || '') : ''
})(),
hasTo: String(tbody.innerHTML || '').indexOf('/to/') >= 0,
hasLegacyL: /(^|[^a-zA-Z])maise\.pro\/l\//.test(String(tbody.innerHTML || '')),
}
})
check('列表里出现 /to/ 前缀', rendered.hasTo, rendered.tableText.slice(0, 120))
check('列表里不再出现 /l/ 前缀', !rendered.hasLegacyL, rendered.tableText.slice(0, 120))
check('复制按钮携带的地址含 /to/', rendered.copyData.indexOf('/to/') >= 0, rendered.copyData)
check('复制按钮地址形如 https://maise.pro/to/<短码>',
/^https:\/\/maise\.pro\/to\/[A-Za-z0-9]+$/.test(rendered.copyData), rendered.copyData)
check('工具别名区也展示 /to/ 前缀', rendered.aliasCopyData.indexOf('/to/') >= 0, rendered.aliasCopyData)
check(`本次自建短码 ${CODE} 能被界面看到`, rendered.tableText.indexOf(CODE) >= 0, rendered.tableText.slice(0, 120))
await browser.close()
// 清理并确认用户数据未被改动
const db2 = new Database(DB_PATH)
db2.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(CODE)
db2.prepare('DELETE FROM short_links WHERE code = ?').run(CODE)
const remain = db2.prepare('SELECT code FROM short_links').all().map(r => r.code)
db2.close()
check('临时短码已删除', remain.indexOf(CODE) < 0)
check('用户既有短链接一个都没少', userCodes.every(c => remain.indexOf(c) >= 0),
`前 ${userCodes.length} 条 → 后 ${remain.length} 条`)
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})()
@@ -0,0 +1,123 @@
/**
* 复现:iPhone 6s 打开短链接 https://maise.pro/to/<短码> 的完整跳转链路
* (一次性调试脚本)
*
* 目的:用户反馈 iPhone 6s 打开短链接时"提示一个 JSON 而不是跳转"。
* 本脚本用 iPhone 6s 的 UA + 375×667 视口真实走一遍,
* 打印每一次跳转的 URL、状态码、响应类型,以及最终页面渲染出的文本前若干字符,
* 以确定 JSON 究竟出现在哪一跳、是什么内容。
*
* 运行:node dev_test_scripts/debug/debug_short_link_iphone_redirect.js
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const puppeteer = require('puppeteer')
// ⚠️ 数据安全:不使用、不点击用户的真实短链接(点击会累加 click_count 并写 clicks 表)。
// 本脚本自建一条一次性短链接走完整跳转链路,结束后删除。
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const CODE = 'zz' + crypto.randomBytes(3).toString('hex')
const TEST_TARGET = 'https://example.com/redirect-chain-test'
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() }
}
const createTempLink = () => {
const db = new Database(DB_PATH)
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, created_at) VALUES (?, ?, ?, ?, ?)`)
.run(CODE, TEST_TARGET, '跳转链路调试(临时)', 'manual', beijingNow())
db.close()
}
const cleanupTempLink = () => {
try {
const db = new Database(DB_PATH)
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(CODE)
db.prepare('DELETE FROM short_links WHERE code = ?').run(CODE)
db.close()
console.log(`\n[清理] 已删除临时短码 ${CODE} 及其点击记录`)
} catch (e) {
console.log(`\n[清理] 失败:${String(e && e.message || e)}`)
}
}
const START_URL = `https://maise.pro/to/${CODE}`
// iPhone 6s 最高可升级到的系统版本
const IPHONE_6S_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1'
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 short = s => String(s || '').replace(/\s+/g, ' ').slice(0, 220)
;(async () => {
createTempLink()
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio', '--ignore-certificate-errors'],
})
const page = await browser.newPage()
await page.setUserAgent(IPHONE_6S_UA)
await page.setViewport({ width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
console.log(`模拟 iPhone 6s 打开:${START_URL}`)
console.log(`(临时短码 ${CODE} → ${TEST_TARGET},测完自动删除)\n`)
console.log('--- 跳转链路 ---')
page.on('response', async res => {
const status = res.status()
if (status >= 300 && status < 400) {
console.log(` ${status} ${short(res.url())}`)
console.log(` → Location: ${short(res.headers()['location'])}`)
} else if (res.url().indexOf('maise.pro') >= 0 || res.url().indexOf('/to/') >= 0 || res.url().indexOf('/l/') >= 0) {
console.log(` ${status} ${short(res.url())} [${String(res.headers()['content-type'] || '').split(';')[0]}]`)
}
})
let navError = null
try {
await page.goto(START_URL, { waitUntil: 'networkidle2', timeout: 30000 })
} catch (e) {
navError = String((e && e.message) || e)
console.log(`\n 导航异常:${navError}`)
}
await new Promise(r => setTimeout(r, 2500))
const info = await page.evaluate(() => {
const bodyText = String((document.body && document.body.innerText) || '').trim()
return {
url: location.href,
title: document.title,
contentType: document.contentType,
hasBodyEl: !!document.body,
bodyLen: bodyText.length,
text: bodyText.slice(0, 400),
htmlHead: String(document.documentElement.innerHTML || '').slice(0, 400),
}
})
console.log('\n--- 最终落点 ---')
console.log(` URL: ${info.url}`)
console.log(` 文档类型: ${info.contentType}`)
console.log(` 标题: ${info.title}`)
console.log(` body 文本长度: ${info.bodyLen}`)
console.log(` body 文本: ${short(info.text)}`)
console.log(` 是否像 JSON: ${/^\s*[\[{]/.test(info.text) ? '是 ← 复现到了' : '否'}`)
console.log(` 是否落到目标链接: ${info.url.indexOf('example.com/redirect-chain-test') >= 0 ? '是 ✔' : '否 ✘'}`)
await browser.close()
cleanupTempLink()
process.exit(0)
})()
@@ -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)
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

@@ -0,0 +1,186 @@
{
"warped_size": [
3944,
2819
],
"quad": [
[
87,
36
],
[
4031,
54
],
[
3893,
2870
],
[
117,
2800
]
],
"confident": 27,
"detected": [
"r0c0",
"r0c1",
"r1c0",
"r4c3",
"r4c4",
"r4c5",
"r4c6",
"r4c7",
"r4c8",
"r4c9",
"r4c10",
"r5c3",
"r5c4",
"r5c5",
"r5c6",
"r5c7",
"r5c8",
"r5c9",
"r5c10",
"r6c2",
"r6c3",
"r6c4",
"r6c5",
"r6c6",
"r6c7",
"r6c8",
"r6c9"
],
"fallback": [
"r0c2",
"r0c3",
"r0c4",
"r0c5",
"r0c6",
"r0c7",
"r0c8",
"r0c9",
"r0c10",
"r1c1",
"r1c2",
"r1c3",
"r1c4",
"r1c5",
"r1c6",
"r1c7",
"r1c8",
"r1c9",
"r1c10",
"r2c0",
"r2c1",
"r2c2",
"r2c3",
"r2c4",
"r2c5",
"r2c6",
"r2c7",
"r2c8",
"r2c9",
"r3c0",
"r3c1",
"r3c2",
"r3c3",
"r3c4",
"r3c5",
"r3c6",
"r3c7",
"r3c8",
"r3c9",
"r3c10",
"r4c0",
"r4c1",
"r4c2",
"r5c0",
"r5c1",
"r5c2",
"r6c0",
"r6c1",
"r6c10"
],
"skipped": [
"r2c10"
],
"written": [
"r0c0",
"r0c1",
"r0c10",
"r0c2",
"r0c3",
"r0c4",
"r0c5",
"r0c6",
"r0c7",
"r0c8",
"r0c9",
"r1c0",
"r1c1",
"r1c10",
"r1c2",
"r1c3",
"r1c4",
"r1c5",
"r1c6",
"r1c7",
"r1c8",
"r1c9",
"r2c0",
"r2c1",
"r2c2",
"r2c3",
"r2c4",
"r2c5",
"r2c6",
"r2c7",
"r2c8",
"r2c9",
"r3c0",
"r3c1",
"r3c10",
"r3c2",
"r3c3",
"r3c4",
"r3c5",
"r3c6",
"r3c7",
"r3c8",
"r3c9",
"r4c0",
"r4c1",
"r4c10",
"r4c2",
"r4c3",
"r4c4",
"r4c5",
"r4c6",
"r4c7",
"r4c8",
"r4c9",
"r5c0",
"r5c1",
"r5c10",
"r5c2",
"r5c3",
"r5c4",
"r5c5",
"r5c6",
"r5c7",
"r5c8",
"r5c9",
"r6c0",
"r6c1",
"r6c10",
"r6c2",
"r6c3",
"r6c4",
"r6c5",
"r6c6",
"r6c7",
"r6c8",
"r6c9"
]
}
+430
View File
@@ -0,0 +1,430 @@
{
"model": "qwen-vl-plus",
"batches": [
{
"ids": [
"r0c0",
"r0c1",
"r0c2",
"r0c3"
],
"bad": [
{
"id": "r0c0",
"problem": "mismatch"
},
{
"id": "r0c1",
"problem": "mismatch"
},
{
"id": "r0c2",
"problem": "mismatch"
},
{
"id": "r0c3",
"problem": "mismatch"
}
]
},
{
"ids": [
"r0c4",
"r0c5",
"r0c6",
"r0c7"
],
"bad": [
{
"id": "r0c4",
"problem": "mismatch"
},
{
"id": "r0c5",
"problem": "mismatch"
},
{
"id": "r0c6",
"problem": "mismatch"
},
{
"id": "r0c7",
"problem": "mismatch"
}
]
},
{
"ids": [
"r0c8",
"r0c9",
"r0c10",
"r1c0"
],
"bad": [
{
"id": "r0c10",
"problem": "cut"
}
]
},
{
"ids": [
"r1c1",
"r1c2",
"r1c3",
"r1c4"
],
"bad": [
{
"id": "r1c1",
"problem": "cut"
},
{
"id": "r1c2",
"problem": "cut"
},
{
"id": "r1c3",
"problem": "cut"
},
{
"id": "r1c4",
"problem": "cut"
}
]
},
{
"ids": [
"r1c5",
"r1c6",
"r1c7",
"r1c8"
],
"bad": [
{
"id": "r1c5",
"problem": null
},
{
"id": "r1c6",
"problem": null
},
{
"id": "r1c7",
"problem": null
},
{
"id": "r1c8",
"problem": null
}
]
},
{
"ids": [
"r1c9",
"r1c10",
"r2c0",
"r2c1"
],
"bad": [
{
"id": "r1c10",
"problem": "mismatch"
},
{
"id": "r2c0",
"problem": "mismatch"
}
]
},
{
"ids": [
"r2c2",
"r2c3",
"r2c4",
"r2c5"
],
"bad": [
{
"id": "r2c4",
"problem": "cut"
}
]
},
{
"ids": [
"r2c6",
"r2c7",
"r2c8",
"r2c9"
],
"bad": [
{
"id": "r2c7",
"problem": "mismatch"
},
{
"id": "r2c8",
"problem": "label"
}
]
},
{
"ids": [
"r2c10",
"r3c0",
"r3c1",
"r3c2"
],
"bad": [
{
"id": "r3c1",
"problem": "mismatch"
},
{
"id": "r3c2",
"problem": "mismatch"
}
]
},
{
"ids": [
"r3c3",
"r3c4",
"r3c5",
"r3c6"
],
"bad": [
{
"id": "r3c6",
"problem": "mismatch"
}
]
},
{
"ids": [
"r3c7",
"r3c8",
"r3c9",
"r3c10"
],
"bad": [
{
"id": "r3c7",
"problem": "cut"
},
{
"id": "r3c8",
"problem": "mismatch"
},
{
"id": "r3c9",
"problem": "mismatch"
},
{
"id": "r3c10",
"problem": "mismatch"
}
]
},
{
"ids": [
"r4c0",
"r4c1",
"r4c2",
"r4c3"
],
"bad": [
{
"id": "r4c0",
"problem": "mismatch"
},
{
"id": "r4c1",
"problem": "mismatch"
},
{
"id": "r4c2",
"problem": "mismatch"
},
{
"id": "r4c3",
"problem": "mismatch"
}
]
},
{
"ids": [
"r4c4",
"r4c5",
"r4c6",
"r4c7"
],
"bad": [
{
"id": "r4c4",
"problem": "mismatch"
},
{
"id": "r4c5",
"problem": "mismatch"
},
{
"id": "r4c6",
"problem": "mismatch"
},
{
"id": "r4c7",
"problem": "mismatch"
}
]
},
{
"ids": [
"r4c8",
"r4c9",
"r4c10",
"r5c0"
],
"bad": [
{
"id": "r4c8",
"problem": "mismatch"
},
{
"id": "r5c0",
"problem": "label"
}
]
},
{
"ids": [
"r5c1",
"r5c2",
"r5c3",
"r5c4"
],
"bad": [
{
"id": "r5c1",
"problem": "mismatch"
},
{
"id": "r5c2",
"problem": "mismatch"
},
{
"id": "r5c3",
"problem": "mismatch"
},
{
"id": "r5c4",
"problem": "mismatch"
}
]
},
{
"ids": [
"r5c5",
"r5c6",
"r5c7",
"r5c8"
],
"bad": [
{
"id": "r5c5",
"problem": "mismatch"
},
{
"id": "r5c6",
"problem": "mismatch"
},
{
"id": "r5c7",
"problem": "mismatch"
},
{
"id": "r5c8",
"problem": "mismatch"
}
]
},
{
"ids": [
"r5c9",
"r5c10",
"r6c0",
"r6c1"
],
"bad": [
{
"id": "r6c0",
"problem": "label"
}
]
},
{
"ids": [
"r6c2",
"r6c3",
"r6c4",
"r6c5"
],
"bad": [
{
"id": "r6c2",
"problem": "mismatch"
},
{
"id": "r6c3",
"problem": "mismatch"
},
{
"id": "r6c4",
"problem": "mismatch"
},
{
"id": "r6c5",
"problem": "mismatch"
}
]
},
{
"ids": [
"r6c6",
"r6c7",
"r6c8",
"r6c9"
],
"bad": [
{
"id": "r6c6",
"problem": "mismatch"
},
{
"id": "r6c7",
"problem": "mismatch"
},
{
"id": "r6c8",
"problem": "mismatch"
},
{
"id": "r6c9",
"problem": "mismatch"
}
]
},
{
"ids": [
"r6c10"
],
"bad": [
{
"id": "r6c10",
"problem": "mismatch"
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

@@ -0,0 +1,56 @@
/**
* 恢复被误删的作息日程(一次性数据恢复脚本)
*
* 背景:调试脚本的"清理"步骤误删了用户真实创建的日程。
* 作息日程的删除是**软删除**(schedules.deleted = 1),数据行与 items_json 都还在,
* 因此可直接把 deleted 改回 0 完成恢复,无需重建。
*
* 用法:
* node dev_test_scripts/debug/tool_restore_deleted_schedule.js # 列出所有已软删的日程
* node dev_test_scripts/debug/tool_restore_deleted_schedule.js <scheduleId> # 恢复指定 id
*/
const path = require('path')
const Database = require('better-sqlite3')
const DB_PATH = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
const db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
const targetId = String(process.argv[2] || '').trim()
const safeParse = raw => {
try { return JSON.parse(raw || '[]') } catch { return [] }
}
if (!targetId) {
const rows = db.prepare(`
SELECT id, title, weekdays, items_json, created_at, updated_at
FROM schedules WHERE deleted = 1 ORDER BY datetime(created_at) DESC
`).all()
console.log(`已软删除的日程共 ${rows.length} 条:\n`)
rows.forEach(r => {
const items = safeParse(r.items_json)
console.log(`id=${r.id}`)
console.log(` 标题: ${r.title} 生效星期: [${r.weekdays}] 时刻数: ${items.length}`)
console.log(` 创建: ${r.created_at} 最近更新: ${r.updated_at}`)
console.log(` 时刻: ${items.map(i => i.time + ' ' + i.name).join(' | ')}`)
console.log('')
})
console.log('恢复某一条:node dev_test_scripts/debug/tool_restore_deleted_schedule.js <id>')
} else {
const row = db.prepare('SELECT id, title, deleted FROM schedules WHERE id = ?').get(targetId)
if (!row) {
console.log(`未找到 id=${targetId}`)
process.exit(1)
}
if (!row.deleted) {
console.log(`id=${targetId}「${row.title}」本来就是正常状态,无需恢复`)
process.exit(0)
}
db.prepare('UPDATE schedules SET deleted = 0 WHERE id = ?').run(targetId)
const after = db.prepare('SELECT id, title, deleted, items_json FROM schedules WHERE id = ?').get(targetId)
console.log(`已恢复:id=${after.id} 标题=${after.title} deleted=${after.deleted}`)
console.log(`时刻:${safeParse(after.items_json).map(i => i.time + ' ' + i.name).join(' | ')}`)
}
db.close()
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -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)
})()
@@ -0,0 +1,89 @@
/**
* 数据体检工具:确认关键私有数据是否完好、有无测试残留
*
* 由来:2026-09-30 调试脚本曾误删用户真实日程;此后每次涉及数据的操作,
* 都跑一遍本工具确认「用户数据没少、测试垃圾没留」。只读,不修改任何数据。
*
* 运行:node dev_test_scripts/tools/tool_check_data_integrity.js
*/
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const dataDir = path.join(process.cwd(), 'data')
let problems = 0
const report = (label, value, ok = true) => {
if (!ok) problems += 1
console.log(` ${ok ? '✔' : '✘'} ${label}: ${value}`)
}
const openReadonly = name => {
const p = path.join(dataDir, name)
if (!fs.existsSync(p)) return null
return new Database(p, { readonly: true })
}
console.log('=== 数据体检(只读) ===\n')
// ---------- 1. 短链接 ----------
console.log('[1] 短链接 data/short_link.db')
const sdb = openReadonly('short_link.db')
if (!sdb) {
console.log(' (数据库不存在,跳过)')
} else {
const links = sdb.prepare('SELECT code, title, click_count, is_active FROM short_links ORDER BY id').all()
const clicks = sdb.prepare('SELECT COUNT(1) AS c FROM short_link_clicks').get().c
console.log(` 共 ${links.length} 条短链接:`)
links.forEach(r => console.log(` [${r.code}] ${r.title || '(无标题)'} clicks=${r.click_count} active=${r.is_active}`))
console.log(` 点击记录总数:${clicks}`)
// 测试残留短码:本工具测过的临时短码统一以 zz 开头
const stray = links.filter(r => String(r.code).startsWith('zz'))
report('测试残留短码(应为 0)', stray.length, stray.length === 0)
if (stray.length) stray.forEach(r => console.log(` → 残留 ${r.code}`))
// 点击记录里的孤立短码(短链已删但点击记录还在)
const codes = links.map(r => r.code)
const clickCodes = sdb.prepare('SELECT DISTINCT code FROM short_link_clicks').all().map(r => r.code)
const orphans = clickCodes.filter(c => codes.indexOf(c) < 0)
report('点击记录中的孤立短码(应为 0)', orphans.length, orphans.length === 0)
if (orphans.length) orphans.forEach(c => console.log(` → 孤立 ${c}`))
sdb.close()
}
// ---------- 2. 作息报时器 ----------
console.log('\n[2] 作息报时器 data/app_order_box_schedule_reminder.db')
const odb = openReadonly('app_order_box_schedule_reminder.db')
if (!odb) {
console.log(' (数据库不存在,跳过)')
} else {
const alive = odb.prepare('SELECT title, items_json FROM schedules WHERE deleted = 0').all()
const softDeleted = odb.prepare('SELECT COUNT(1) AS c FROM schedules WHERE deleted = 1').get().c
console.log(` 未删除的日程 ${alive.length} 套:`)
alive.forEach(r => {
let n = 0
try { n = JSON.parse(r.items_json || '[]').length } catch (_) {}
console.log(` ${r.title}(${n} 个时刻)`)
})
console.log(` 已软删除的历史行:${softDeleted}(这些是测试残留或用户主动删除,不影响使用)`)
const logs = odb.prepare('SELECT COUNT(1) AS c FROM logs').get().c
const testLogs = odb.prepare('SELECT COUNT(1) AS c FROM logs WHERE schedule_title != ?').get('周三在公司').c
console.log(` 播报记录:${logs} 条(非用户日程的:${testLogs})`)
report('用户日程仍存在(至少 1 套)', alive.length, alive.length >= 1)
odb.close()
}
// ---------- 3. 运动提醒宝(框子另一个 kit) ----------
console.log('\n[3] 运动提醒宝 data/app_order_box_motion_reminder.db')
const mdb = openReadonly('app_order_box_motion_reminder.db')
if (!mdb) {
console.log(' (数据库不存在,跳过)')
} else {
const cards = mdb.prepare('SELECT title FROM cards WHERE deleted = 0').all()
console.log(` 未删除的流程卡片 ${cards.length} 张:${cards.map(c => c.title).join(' / ') || '(无)'}`)
mdb.close()
}
console.log(`\n=== 体检完成:${problems === 0 ? '未发现异常' : '发现 ' + problems + ' 处异常'} ===`)
process.exit(problems === 0 ? 0 : 1)
@@ -0,0 +1,360 @@
# -*- coding: utf-8 -*-
# ============================================================
# tool_crop_mall_logos.py - 宝龙美食打卡 lab · 导视牌 logo 重裁脚本(方案 A)
#
# 病根:原 images/ 是按"等分网格"从斜拍照片硬裁的,透视变形导致越靠边错位越大,
# 裁切图带白底店名标签、串邻格、切边。
#
# 算法流程(经典 CV,离线确定可重复跑):
# 1) 展板四边形检测(亮板 vs 暗背景 OTSU + 最大轮廓 approxPolyDP)→ 四点透视矫正拉正
# 2) 置信照片块检测:HSV 颜色掩膜 (饱和度高 OR 暗) → 闭/开运算 → 连通域,
# 只保留尺寸落在照片先验区间内的"置信块"(过曝区检不出就检不出,不强求)
# 3) 全局网格拟合:导视牌是印刷规整网格,矫正后行/列等距 ——
# 用置信块的最小二乘拟合 行顶线 row_top(r) 与 列中心线 col_center(c),
# 过曝检不出的格子直接按几何矩形取,天然不错位
# 4) 几何兜底格先做"内容密度"校验:空白板面(如 r2c10「待确认」牌上不存在)跳过,
# 有边框/文字/图案的(如商业街白框)保留
# 5) 输出统一宽度 jpg + 拼版预览图 + report.json,供人工 / qwen-vl 抽检
#
# 用法:
# python tool_crop_mall_logos.py # 默认参数直接跑
# python tool_crop_mall_logos.py --pad 4 # 调整裁切外扩像素
# python tool_crop_mall_logos.py --dry-run # 只出预览和报告,不写 images/
# ============================================================
import argparse
import json
import os
import sys
import cv2
import numpy as np
DEFAULT_SRC = r'D:\Temp文件\baolong-mall\照片.jpg'
DEFAULT_OUT = r'd:\Trae_Files\TRAE-Toolbox\public\tools\thought_lab\labs\mall_food_checkin\images'
DEFAULT_SEED = r'd:\Trae_Files\TRAE-Toolbox\src\server\thought_lab\labs\mall_food_checkin\shops_seed.json'
DEFAULT_PREVIEW = r'd:\Trae_Files\TRAE-Toolbox\dev_test_scripts\debug\mall_crop_contact.jpg'
DEFAULT_REPORT = r'd:\Trae_Files\TRAE-Toolbox\dev_test_scripts\debug\mall_crop_report.json'
ROWS, COLS = 7, 11
def imread_u(path):
# Windows 下 cv2.imread 不支持中文路径,用 np.fromfile + imdecode 兜底
data = np.fromfile(path, dtype=np.uint8)
return cv2.imdecode(data, cv2.IMREAD_COLOR)
def imwrite_u(path, img, params=None):
ext = os.path.splitext(path)[1] or '.jpg'
ok, buf = cv2.imencode(ext, img, params or [])
if not ok:
return False
buf.tofile(path)
return True
def order_points(pts):
# 排序为 tl, tr, br, bl
pts = np.array(pts, dtype='float32')
s = pts.sum(axis=1)
d = np.diff(pts, axis=1).ravel()
tl = pts[np.argmin(s)]
br = pts[np.argmax(s)]
tr = pts[np.argmin(d)]
bl = pts[np.argmax(d)]
return np.array([tl, tr, br, bl], dtype='float32')
def detect_board_quad(img):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (7, 7), 0)
_, th = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
th = cv2.morphologyEx(th, cv2.MORPH_CLOSE, np.ones((25, 25), np.uint8))
cnts, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not cnts:
raise RuntimeError('未找到展板轮廓')
c = max(cnts, key=cv2.contourArea)
peri = cv2.arcLength(c, True)
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
if len(approx) == 4:
return order_points(approx.reshape(4, 2))
rect = cv2.minAreaRect(c)
return order_points(cv2.boxPoints(rect))
def warp_board(img, quad):
(tl, tr, br, bl) = quad
w = int(max(np.linalg.norm(tr - tl), np.linalg.norm(br - bl)))
h = int(max(np.linalg.norm(bl - tl), np.linalg.norm(br - tr)))
m = cv2.getPerspectiveTransform(quad, np.array([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtype='float32'))
return cv2.warpPerspective(img, m, (w, h))
def detect_confident_blocks(warped):
"""颜色掩膜 + 尺寸先验,只返回高置信照片块(过曝区检不出不强求)"""
hsv = cv2.cvtColor(warped, cv2.COLOR_BGR2HSV)
s = hsv[:, :, 1]
v = hsv[:, :, 2]
mask = (((s > 45) | (v < 140)).astype(np.uint8)) * 255
mask[:60, :] = 0
mask[-60:, :] = 0
mask[:, :60] = 0
mask[:, -60:] = 0
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((7, 7), np.uint8))
n, labels, stats, cents = cv2.connectedComponentsWithStats(mask, 8)
h, w = warped.shape[:2]
blocks = []
for i in range(1, n):
x, y, bw, bh, area = stats[i]
cx, cy = cents[i]
if area < 30000:
continue
if cy < 0.17 * h or cx < 0.06 * w:
continue
if not (180 <= bw <= 360 and 150 <= bh <= 300):
continue
blocks.append({'x': int(x), 'y': int(y), 'w': int(bw), 'h': int(bh),
'cx': float(cx), 'cy': float(cy), 'area': int(area)})
return blocks
def fit_grid(blocks):
"""用置信块拟合全局网格:行顶线 / 列中心线 / 照片宽高
列:全局 cx 聚类(不依赖"检满 11 格的行",过曝行缺块也不错位)
行:cy 聚类 + 行距推算行号(缺行不影响编号)"""
# ---- 列:全局 cx 聚类 ----
by_cx = sorted(blocks, key=lambda b: b['cx'])
col_clusters = []
for b in by_cx:
if col_clusters and b['cx'] - col_clusters[-1][-1]['cx'] < 120:
col_clusters[-1].append(b)
else:
col_clusters.append([b])
if len(col_clusters) != COLS:
raise RuntimeError('列聚类=%d(期望 %d)' % (len(col_clusters), COLS))
col_center = [float(np.mean([b['cx'] for b in c])) for c in col_clusters]
# ---- 行:cy 聚类 + 行距推号 ----
by_cy = sorted(blocks, key=lambda b: b['cy'])
row_clusters = []
for b in by_cy:
if row_clusters and b['cy'] - row_clusters[-1][-1]['cy'] < 120:
row_clusters[-1].append(b)
else:
row_clusters.append([b])
cys = [float(np.mean([b['cy'] for b in c])) for c in row_clusters]
diffs = [cys[i + 1] - cys[i] for i in range(len(cys) - 1)]
step = float(np.median([d for d in diffs if d < 400])) if diffs else 290.0
row_idx = [int(round((cy - cys[0]) / step)) for cy in cys]
if len(set(row_idx)) != len(row_idx) or max(row_idx) >= ROWS or min(row_idx) < 0:
raise RuntimeError('行聚类异常: %s' % row_idx)
# ---- 行顶线最小二乘拟合(检出的行 -> 预测全部 7 行)----
pts = [(float(ri), float(np.mean([b['y'] for b in c]))) for ri, c in zip(row_idx, row_clusters)]
if len(pts) >= 2:
ra, rb = np.polyfit([p[0] for p in pts], [p[1] for p in pts], 1)
row_top = [float(rb + ra * r) for r in range(ROWS)]
else:
row_top = [pts[0][1] + step * r for r in range(ROWS)]
pw = float(np.median([b['w'] for b in blocks]))
ph = float(np.median([b['h'] for b in blocks]))
grid = {}
for ri, c in zip(row_idx, row_clusters):
for b in c:
ci = int(np.argmin([abs(cc - b['cx']) for cc in col_center]))
key = (ri, ci)
if key not in grid or b['area'] > grid[key]['area']:
grid[key] = b
return {'row_top': row_top, 'col_center': col_center, 'pw': pw, 'ph': ph,
'grid': grid, 'rows_found': sorted(set(row_idx)), 'step': step}
def trim_label(crop):
"""裁掉底部白底店名标签条 —— 间隙定位法(逐行扫描对死区/剖面重叠太脆弱):
1) 向量化行剖面:gap 行=全宽白(dark<0.03 且 mean>=180);text 行=dark 0.08~0.7 且 mean 100~215
2) 在底部 50% 内找连续 gap 段,自底向上取第一个同时满足以下条件的段作为裁切线:
a. 段下方有 >=8 个 text 行(标签文字)
b. 最后一个 text 行距裁切图底边 <=20 行(标签贴着底边;商业街白框的文字在格子中部,被排除)
c. 段上方 10 行内 gap 行 <5(上面是照片内容,不是另一段白)
找不到合格间隙 → 不裁。"""
g = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
h = g.shape[0]
if h < 40:
return crop
gf = g.astype(np.float32)
dark = (gf < 160).mean(axis=1)
mean = gf.mean(axis=1)
# 列结构指标:文字行 dark 集中在中部(字),间隙/阴影行全宽均匀分布
w = g.shape[1]
c0, c1 = int(w * 0.2), int(w * 0.8)
dark_c = (gf[:, c0:c1] < 160).mean(axis=1)
dark_o = ((gf[:, :c0] < 160).sum(axis=1) + (gf[:, c1:] < 160).sum(axis=1)) / float(w - (c1 - c0))
ratio = (dark_c + 0.004) / (dark_o + 0.004)
# 阈值按实测剖面放宽:角落阴影区标签白底 mean 仅 120~180、间隙行 dark 到 0.09;
# 稀疏字行(DQ 两字 dark~0.05)靠 ratio 与阴影白区分
is_text = (dark >= 0.04) & (mean <= 220) & (ratio > 2.5)
is_gap = (dark < 0.12) & (mean >= 165) & (~is_text)
lo = h - 1 - int(h * 0.5)
runs = []
y = h - 1
while y > lo:
if is_gap[y]:
y2 = y
while y2 > lo and is_gap[y2]:
y2 -= 1
runs.append((y2 + 1, y))
y = y2
else:
y -= 1
for (start, end) in runs: # runs 自底向上收集,天然从最低段开始
if end - start + 1 < 4: # 标签内部笔画间的假间隙通常只有 1~3 行
continue
below = is_text[end + 1:h] if end + 1 < h else np.zeros(0, bool)
if below.sum() < 8:
continue
last_text = end + 1 + int(np.max(np.nonzero(below)))
below_any = (is_text | is_gap)[end + 1:h] if end + 1 < h else np.zeros(0, bool)
if not below_any.any():
continue
last_below = end + 1 + int(np.max(np.nonzero(below_any)))
# 底边允许一段阴影带(既非 text 也非 gap);标签文字与底边之间只允许留白/阴影
if (h - 1) - last_below > 20:
continue
# 文字与底边内容之间允许留白/阴影:实测最大 32 行(r6c0 兜底矩形探到板面);
# 商业街白框的文字距底边 60 行,仍被排除
if last_below - last_text > 35:
continue
above = is_gap[max(0, start - 10):start]
if above.sum() >= 5:
continue
return crop[:start]
return crop
def region_has_content(warped, x, y, w, h):
# 空白板面平滑(边缘密度/标准差低);有 logo/边框/文字的区域高
hh, ww = warped.shape[:2]
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(ww, x + w), min(hh, y + h)
if x1 - x0 < 20 or y1 - y0 < 20:
return False
roi = cv2.cvtColor(warped[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(roi, 60, 160)
density = float(np.count_nonzero(edges)) / float(edges.size)
return density > 0.006 or float(np.std(roi)) > 24
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--src', default=DEFAULT_SRC)
ap.add_argument('--out', default=DEFAULT_OUT)
ap.add_argument('--seed', default=DEFAULT_SEED)
ap.add_argument('--preview', default=DEFAULT_PREVIEW)
ap.add_argument('--report', default=DEFAULT_REPORT)
ap.add_argument('--pad', type=int, default=2)
ap.add_argument('--width', type=int, default=440)
ap.add_argument('--dry-run', action='store_true')
args = ap.parse_args()
img = imread_u(args.src)
if img is None:
print('读取源照片失败:', args.src)
sys.exit(1)
seed = json.load(open(args.seed, encoding='utf-8'))
ids = [s['id'] for s in seed['shops']]
quad = detect_board_quad(img)
warped = warp_board(img, quad)
wh, ww = warped.shape[:2]
print('透视矫正完成: %dx%d' % (ww, wh))
blocks = detect_confident_blocks(warped)
print('置信照片块: %d 个' % len(blocks))
if len(blocks) < 20:
print('置信块太少,终止(避免误裁覆盖好图)')
sys.exit(2)
g = fit_grid(blocks)
print('网格拟合: 行=%s 行距=%.1f 照片=%.0fx%.0f' % (g['rows_found'], g['step'], g['pw'], g['ph']))
report = {'warped_size': [ww, wh], 'quad': np.round(quad).astype(int).tolist(),
'confident': len(blocks), 'detected': [], 'fallback': [], 'skipped': []}
os.makedirs(args.out, exist_ok=True)
crops = {}
half_w = g['pw'] / 2.0
for ri in range(ROWS):
for ci in range(COLS):
sid = 'r%dc%d' % (ri, ci)
b = g['grid'].get((ri, ci))
if b is not None:
x0 = max(0, b['x'] - args.pad)
y0 = max(0, b['y'] - args.pad)
x1 = min(ww, b['x'] + b['w'] + args.pad)
y1 = min(wh, b['y'] + b['h'] + args.pad)
report['detected'].append(sid)
else:
cx = g['col_center'][ci]
if cx is None:
report['skipped'].append(sid)
continue
x0 = int(max(0, cx - half_w - args.pad))
x1 = int(min(ww, cx + half_w + args.pad))
y0 = int(max(0, g['row_top'][ri] - args.pad))
y1 = int(min(wh, g['row_top'][ri] + g['ph'] + args.pad))
if not region_has_content(warped, x0, y0, x1 - x0, y1 - y0):
report['skipped'].append(sid)
continue
report['fallback'].append(sid)
crop = warped[y0:y1, x0:x1]
if crop.size == 0:
report['skipped'].append(sid)
continue
crop = trim_label(crop)
scale = args.width / float(crop.shape[1])
crop = cv2.resize(crop, (args.width, max(1, int(crop.shape[0] * scale))), interpolation=cv2.INTER_AREA)
crops[sid] = crop
if not args.dry_run:
imwrite_u(os.path.join(args.out, sid + '.jpg'), crop, [cv2.IMWRITE_JPEG_QUALITY, 88])
# 种子中存在但牌子上不存在的 id:清掉旧误裁图,前端显示占位符
if not args.dry_run:
for sid in ids:
if sid not in crops:
p = os.path.join(args.out, sid + '.jpg')
if os.path.exists(p):
os.remove(p)
print('清除旧误裁图:', sid)
# 拼版预览(11 列 x 7 行,带 id 标注)
cw, ch = 160, 118
lab_h = 18
sheet = np.full((ROWS * (ch + lab_h), COLS * cw, 3), 255, np.uint8)
for ri in range(ROWS):
for ci in range(COLS):
sid = 'r%dc%d' % (ri, ci)
x, y = ci * cw, ri * (ch + lab_h)
cv2.putText(sheet, sid, (x + 4, y + 13), cv2.FONT_HERSHEY_SIMPLEX, 0.42, (0, 0, 0), 1)
c = crops.get(sid)
if c is None:
cv2.putText(sheet, 'MISS', (x + 40, y + 70), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
continue
sc = min((cw - 4) / float(c.shape[1]), (ch - 4) / float(c.shape[0]))
cc = cv2.resize(c, (max(1, int(c.shape[1] * sc)), max(1, int(c.shape[0] * sc))))
sheet[y + lab_h:y + lab_h + cc.shape[0], x + 2:x + 2 + cc.shape[1]] = cc
os.makedirs(os.path.dirname(args.preview), exist_ok=True)
imwrite_u(args.preview, sheet, [cv2.IMWRITE_JPEG_QUALITY, 90])
report['written'] = sorted(crops.keys())
os.makedirs(os.path.dirname(args.report), exist_ok=True)
json.dump(report, open(args.report, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
print('裁切完成: 直检 %d / 兜底 %d / 跳过 %d -> %s' % (
len(report['detected']), len(report['fallback']), len(report['skipped']), args.preview))
if report['fallback']:
print('兜底裁切:', report['fallback'])
if report['skipped']:
print('跳过(牌子上不存在):', report['skipped'])
if __name__ == '__main__':
main()
@@ -0,0 +1,151 @@
# -*- coding: utf-8 -*-
# ============================================================
# tool_qa_mall_logos.py - 宝龙美食打卡 lab · logo 裁切抽检(方案 C)
#
# 用视觉大模型 qwen-vl-plus-latest(Rule 11 视觉/多模态选型)对方案 A 的裁切结果做抽检:
# 按行拼版(每行 11 格,格顶标注 id),连同 id->店名 清单一起发给模型,
# 让它逐格检查:logo 是否被切边 / 串入邻格 / 带店名文字条 / 基本空白 / 与店名明显不符。
# 模型只读抽检、不改图;结果落 report.json 供人工复核决定是否重裁。
#
# 凭据:从 ~/Toolbox_local_creds.env.local 读 DASHSCOPE_API_KEY / DASHSCOPE_API_HOST_FOR_OPENAI
# (零硬编码,Rule 13)
# 用法:python tool_qa_mall_logos.py
# ============================================================
import base64
import json
import os
import sys
import urllib.request
import cv2
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
IMAGES_DIR = r'd:\Trae_Files\TRAE-Toolbox\public\tools\thought_lab\labs\mall_food_checkin\images'
SEED = r'd:\Trae_Files\TRAE-Toolbox\src\server\thought_lab\labs\mall_food_checkin\shops_seed.json'
REPORT = r'd:\Trae_Files\TRAE-Toolbox\dev_test_scripts\debug\mall_qa_report.json'
CREDS = os.path.join(os.path.expanduser('~'), 'Toolbox_local_creds.env.local')
# 视觉模型:Rule 11 推荐 qwen-vl-plus-latest,但本 key 实测 403;
# 与项目现网口径(plant_home/style_check/yuanzhupai)一致用 qwen-vl-plus,可用 --model 覆盖
MODEL = 'qwen-vl-plus'
ROWS, COLS = 7, 11
def load_creds():
env = {}
try:
for line in open(CREDS, encoding='utf-8'):
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, v = line.split('=', 1)
env[k.strip()] = v.strip().strip('"').strip("'")
except OSError:
pass
return env
def imread_u(path):
data = np.fromfile(path, dtype=np.uint8)
return cv2.imdecode(data, cv2.IMREAD_COLOR)
def imencode_b64(img):
ok, buf = cv2.imencode('.jpg', img, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
return ''
return base64.b64encode(buf.tobytes()).decode('ascii')
def build_row_sheet(ids, crops):
cw, ch, lab_h = 400, 260, 26
sheet = np.full((ch + lab_h, cw * len(ids), 3), 255, np.uint8)
for i, sid in enumerate(ids):
x = i * cw
cv2.putText(sheet, sid, (x + 8, 19), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 0, 0), 2)
c = crops.get(sid)
if c is None:
cv2.putText(sheet, 'MISS', (x + 150, 150), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 3)
continue
sc = min((cw - 10) / float(c.shape[1]), (ch - 10) / float(c.shape[0]))
cc = cv2.resize(c, (max(1, int(c.shape[1] * sc)), max(1, int(c.shape[0] * sc))))
sheet[lab_h:lab_h + cc.shape[0], x + 5:x + 5 + cc.shape[1]] = cc
return sheet
def ask_vl(api_base, api_key, sheet, names_text):
body = {
'model': MODEL,
'messages': [{
'role': 'user',
'content': [
{'type': 'text', 'text': (
'图中是若干张饭店 logo 裁切图,从左到右每格顶部标注了 id。'
'id 与店名对照:' + names_text + '。'
'判定口径(严格,宁漏报勿误报):允许轻微白边、允许画面偏暗或过曝、允许构图不完美;'
'只有以下明显缺陷才报告:cut(logo 主体被裁掉超过 1/4) / neighbor(明显串入邻格的另一张图) / '
'label(底部带白底黑字店名文字条) / blank(整格基本是空白板面) / mismatch(画面内容与店名完全对不上,'
'例如咖啡店格子里是火锅)。拿不准的一律视为合格。'
'只输出 JSON 数组,元素形如 {"id":"r0c1","problem":"cut"};全部合格则输出 []。'
'不要输出任何其他文字。')},
{'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,' + imencode_b64(sheet)}}
]
}],
'temperature': 0.1
}
req = urllib.request.Request(
api_base.rstrip('/') + '/chat/completions',
data=json.dumps(body).encode('utf-8'),
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + api_key},
method='POST')
with urllib.request.urlopen(req, timeout=120) as r:
data = json.loads(r.read().decode('utf-8'))
text = data['choices'][0]['message']['content']
text = text.strip()
if text.startswith('```'):
text = text.strip('`')
if text.startswith('json'):
text = text[4:]
return json.loads(text.strip())
def main():
global MODEL
if '--model' in sys.argv:
MODEL = sys.argv[sys.argv.index('--model') + 1]
creds = load_creds()
api_key = creds.get('DASHSCOPE_API_KEY', '') or os.environ.get('DASHSCOPE_API_KEY', '')
api_base = creds.get('DASHSCOPE_API_HOST_FOR_OPENAI', '') or os.environ.get('DASHSCOPE_API_HOST_FOR_OPENAI', '')
if not api_key or not api_base:
print('缺少 DASHSCOPE_API_KEY / DASHSCOPE_API_HOST_FOR_OPENAI(检查 ~/Toolbox_local_creds.env.local)')
sys.exit(1)
seed = json.load(open(SEED, encoding='utf-8'))
name_of = {s['id']: s['name'] for s in seed['shops']}
crops = {}
for sid in name_of:
p = os.path.join(IMAGES_DIR, sid + '.jpg')
if os.path.exists(p):
crops[sid] = imread_u(p)
all_ids = ['r%dc%d' % (ri, ci) for ri in range(ROWS) for ci in range(COLS)]
report = {'model': MODEL, 'batches': []}
for bi in range(0, len(all_ids), 4):
ids = all_ids[bi:bi + 4]
names_text = '、'.join('%s=%s' % (i, name_of.get(i, '?')) for i in ids)
sheet = build_row_sheet(ids, crops)
try:
bad = ask_vl(api_base, api_key, sheet, names_text)
except Exception as e: # noqa: BLE001
print('%s 抽检失败: %s' % (ids[0], e))
report['batches'].append({'ids': ids, 'error': str(e)})
continue
print('%s -> %s' % (ids[0], json.dumps(bad, ensure_ascii=False)))
report['batches'].append({'ids': ids, 'bad': bad})
json.dump(report, open(REPORT, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
total = sum(len(b.get('bad', [])) for b in report['batches'] if isinstance(b.get('bad'), list))
print('抽检完成,问题格合计 %d,报告: %s' % (total, REPORT))
if __name__ == '__main__':
main()
@@ -0,0 +1,195 @@
/**
* 轮换「首页导航」密钥对,并同步更换首页 token(旧 token 立即作废)
*
* 为什么需要轮换两把:
* 服务端校验首页 token 时会组成一个「信任池」,池中含
* · config/nav-private.jwk.json 的公钥部分
* · config/nav.jwk.json
* 经审计,线上那枚旧 token 实际是由 nav.jwk.json 对应私钥签发的。
* 因此只换 nav-private 无效 —— 必须把两者都换成同一把新密钥,
* 旧公钥才会彻底离开信任池,旧 token 才真正失效。
*
* 本脚本动作:
* 1) 备份现有两个密钥文件(时间戳目录,可回滚)
* 2) 生成新的 RSA-2048 密钥对,写入上述两个文件(同一把新密钥)
* 3) 用新私钥签发一枚新的首页 token
* 4) 把短链接 PSq6xv 的目标地址替换为新 token(其它字段不动)
* 5) 就地做黑盒校验:旧 token 必须 401,新 token 必须 302 并能进首页
*
* 用法:
* node dev_test_scripts/tools/tool_rotate_nav_key.js # 默认有效期 365 天
* node dev_test_scripts/tools/tool_rotate_nav_key.js 180 # 指定天数
* node dev_test_scripts/tools/tool_rotate_nav_key.js --dry-run # 只看计划,不写入
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const CONFIG_DIR = path.join(process.cwd(), 'config')
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const PRIV_PATH = path.join(CONFIG_DIR, 'nav-private.jwk.json')
const PUB_PATH = path.join(CONFIG_DIR, 'nav.jwk.json')
const BASE_URL = 'https://traesite.umersoft.com:8975/'
const TARGET_CODE = 'PSq6xv'
const LOCAL = 'http://localhost:8976'
const args = process.argv.slice(2)
const dryRun = args.includes('--dry-run')
const daysArg = args.find(a => /^\d+$/.test(a))
const EXPIRE_DAYS = daysArg ? Math.max(1, Math.min(730, Number(daysArg))) : 365
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 fp = jwk => {
const n = String((jwk && jwk.n) || '')
return n ? crypto.createHash('sha256').update(n).digest('hex').slice(0, 16) : '(空)'
}
const readJson = p => JSON.parse(fs.readFileSync(p, 'utf-8'))
const signJwtRS256 = (payload, jwk) => {
const enc = v => Buffer.from(JSON.stringify(v)).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const data = enc({ alg: 'RS256', typ: 'JWT' }) + '.' + enc(payload)
const sig = crypto.sign('RSA-SHA256', Buffer.from(data), crypto.createPrivateKey({ key: jwk, format: 'jwk' }))
.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return data + '.' + sig
}
/** 取短链接当前目标里的 token(用于轮换后验证"旧的确实失效了") */
const readCurrentTarget = () => {
const db = new Database(DB_PATH, { readonly: true })
const row = db.prepare('SELECT target_url FROM short_links WHERE code = ?').get(TARGET_CODE)
db.close()
if (!row) throw new Error(`短链接 ${TARGET_CODE} 不存在`)
const m = /[?&]token=([^&\s]+)/.exec(String(row.target_url || ''))
return { url: String(row.target_url || ''), token: m ? decodeURIComponent(m[1]) : '' }
}
const updateShortLinkTarget = newUrl => {
const db = new Database(DB_PATH)
const info = db.prepare('UPDATE short_links SET target_url = ? WHERE code = ?').run(newUrl, TARGET_CODE)
db.close()
return info.changes
}
const getTokenFromUrl = async url => {
// 跟随一次 302,取 Set-Cookie 与状态
const res = await fetch(url, { redirect: 'manual' })
return { status: res.status, setCookie: res.headers.get('set-cookie') || '', location: res.headers.get('location') || '' }
}
const probeWithToken = async token => {
const res = await fetch(`${LOCAL}/?token=${encodeURIComponent(token)}`, { redirect: 'manual' })
return { status: res.status, setCookie: res.headers.get('set-cookie') || '' }
}
;(async () => {
console.log('=== 首页导航密钥轮换 ===')
console.log(`有效期:${EXPIRE_DAYS} 天${dryRun ? '(dry-run,不写入)' : ''}\n`)
// ---------- 0. 现状 ----------
const oldPriv = readJson(PRIV_PATH)
const oldPub = readJson(PUB_PATH)
const before = readCurrentTarget()
console.log('[0] 现状')
console.log(` nav-private.jwk.json 指纹 = ${fp(oldPriv)}(含私钥=${oldPriv.d ? '是' : '否'})`)
console.log(` nav.jwk.json 指纹 = ${fp(oldPub)}(含私钥=${oldPub.d ? '是' : '否'})`)
console.log(` 短链接 ${TARGET_CODE} 目标 = ${before.url.slice(0, 55)}...`)
console.log(` 旧 token 长度 = ${before.token.length}`)
check('旧 token 在轮换前是有效的(基线)', (await probeWithToken(before.token)).status === 302,
'旧 token 当前不可用,请先确认现状')
if (dryRun) {
console.log('\n[dry-run] 将执行:备份密钥 → 生成新密钥对 → 覆写两个文件 → 签发新 token → 更新短链接 → 校验')
console.log(` 新 token 过期时间:${new Date((Math.floor(Date.now() / 1000) + EXPIRE_DAYS * 86400) * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`)
process.exit(0)
}
// ---------- 1. 备份 ----------
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const backupDir = path.join(CONFIG_DIR, `_nav-key-backup-${stamp}`)
fs.mkdirSync(backupDir, { recursive: true })
fs.copyFileSync(PRIV_PATH, path.join(backupDir, 'nav-private.jwk.json'))
fs.copyFileSync(PUB_PATH, path.join(backupDir, 'nav.jwk.json'))
// 一并备份短链接库,便于回滚目标地址
try { fs.copyFileSync(DB_PATH, path.join(backupDir, 'short_link.db')) } catch (_) { /* 非致命 */ }
console.log(`\n[1] 已备份到 config/${path.basename(backupDir)}/`)
check('备份目录创建成功', fs.existsSync(path.join(backupDir, 'nav-private.jwk.json')))
// ---------- 2. 生成新密钥对并写入 ----------
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
const newPriv = privateKey.export({ format: 'jwk' })
const newPub = publicKey.export({ format: 'jwk' })
// 公钥文件只保留公开字段,避免私钥混进 nav.jwk.json(历史上它就是纯公钥文件)
const newPubOnly = { kty: newPub.kty, n: newPub.n, e: newPub.e }
fs.writeFileSync(PRIV_PATH, JSON.stringify(newPriv, null, 2))
fs.writeFileSync(PUB_PATH, JSON.stringify(newPubOnly, null, 2))
console.log('\n[2] 已写入新密钥对')
console.log(` nav-private.jwk.json 新指纹 = ${fp(newPriv)}(含私钥=${newPriv.d ? '是' : '否'})`)
console.log(` nav.jwk.json 新指纹 = ${fp(newPubOnly)}`)
check('两个文件的公钥一致(同一把新密钥)', fp(newPriv) === fp(newPubOnly), `${fp(newPriv)} vs ${fp(newPubOnly)}`)
check('nav.jwk.json 未混入私钥字段', !newPubOnly.d)
check('新密钥已替换旧公钥(旧公钥离开信任池)', fp(newPubOnly) !== fp(oldPriv) && fp(newPubOnly) !== fp(oldPub))
// ---------- 3. 签发新 token ----------
let iss = 'TRAE-NAV'
try { iss = String(readJson(path.join(CONFIG_DIR, 'flags.json')).navAuth.iss || iss) } catch (_) { /* 用默认 */ }
const now = Math.floor(Date.now() / 1000)
const payload = { iss, iat: now, exp: now + EXPIRE_DAYS * 86400, jti: crypto.randomUUID() }
const newToken = signJwtRS256(payload, newPriv)
const newUrl = `${BASE_URL}?token=${encodeURIComponent(newToken)}`
console.log('\n[3] 已用新私钥签发新 token')
console.log(` iss=${payload.iss} jti=${payload.jti}`)
console.log(` 过期:${new Date(payload.exp * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`)
// ---------- 4. 更新短链接目标 ----------
const changed = updateShortLinkTarget(newUrl)
console.log(`\n[4] 短链接 ${TARGET_CODE} 目标已更新(影响行数 ${changed})`)
check('短链接目标已成功更新', changed === 1, `影响 ${changed} 行`)
// ---------- 5. 黑盒校验 ----------
console.log('\n[5] 黑盒校验(服务端按请求实时读密钥文件,无需重启)')
const oldProbe = await probeWithToken(before.token)
check('旧 token 已失效(401)', oldProbe.status === 401, `实际 ${oldProbe.status}`)
const newProbe = await probeWithToken(newToken)
check('新 token 可用(302,进入握手)', newProbe.status === 302, `实际 ${newProbe.status}`)
check('新 token 能种下 nav_gate cookie', newProbe.setCookie.indexOf('nav_gate=1') >= 0, newProbe.setCookie.slice(0, 60))
// 带 cookie 取首页,确认真的能进
const homeRes = await fetch(`${LOCAL}/`, { headers: { Cookie: 'nav_gate=1' } })
const homeText = await homeRes.text()
check('带 cookie 可打开九宫格首页(200)', homeRes.status === 200, `实际 ${homeRes.status}`)
check('首页内容正确', homeText.indexOf("Yang's Toolbox") >= 0)
// 公网链路:新 token 从公网进也应 302
try {
const pubProbe = await getTokenFromUrl(`${BASE_URL}?token=${encodeURIComponent(newToken)}`)
check('公网地址可用(302)', pubProbe.status === 302, `实际 ${pubProbe.status}`)
} catch (e) {
check('公网地址可用(302)', false, String(e.message || e))
}
// ---------- 汇总 ----------
console.log('\n--- 结果 ---')
console.log(` 新短链长链接:${BASE_URL}?token=<新token>(共 ${newUrl.length} 字符)`)
console.log(` 短链接:https://maise.pro/to/${TARGET_CODE}`)
console.log(` 备份目录:config/${path.basename(backupDir)}/`)
console.log(' 回滚方法:把备份目录里的两个 jwk 文件覆盖回 config/,并从 short_link.db 备份恢复目标地址。')
if (failed > 0) {
console.log(`\n⚠️ 有 ${failed} 项未通过,请按上面提示检查;密钥与数据都已备份,可回滚。`)
}
console.log(`\n=== 校验:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})().catch(e => {
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
console.log('密钥与数据在写入前已备份,可在 config/_nav-key-backup-*/ 回滚。')
process.exit(1)
})
+2 -1
View File
@@ -16,9 +16,10 @@
// ===== 工具函数 ===== // ===== 工具函数 =====
const escHtml = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') const escHtml = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
// 短链接前缀统一为 /to/:旧前缀 /l/ 在手机小屏字体下与 I、i、1 字形难分,已弃用
const fullShortUrl = code => { const fullShortUrl = code => {
const base = DOMAIN || (location.origin) const base = DOMAIN || (location.origin)
return `${base}/l/${code}` return `${base}/to/${code}`
} }
const copyText = async (text) => { const copyText = async (text) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Some files were not shown because too many files have changed in this diff Show More