diff --git a/dev_test_scripts/debug/blocks_preview.jpg b/dev_test_scripts/debug/blocks_preview.jpg new file mode 100644 index 0000000..7c43835 Binary files /dev/null and b/dev_test_scripts/debug/blocks_preview.jpg differ diff --git a/dev_test_scripts/debug/debug_mall_crop_mask.py b/dev_test_scripts/debug/debug_mall_crop_mask.py new file mode 100644 index 0000000..db6a0db --- /dev/null +++ b/dev_test_scripts/debug/debug_mall_crop_mask.py @@ -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)))) diff --git a/dev_test_scripts/debug/debug_mall_trim.py b/dev_test_scripts/debug/debug_mall_trim.py new file mode 100644 index 0000000..2677e56 --- /dev/null +++ b/dev_test_scripts/debug/debug_mall_trim.py @@ -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) diff --git a/dev_test_scripts/debug/debug_nav_token_key_audit.js b/dev_test_scripts/debug/debug_nav_token_key_audit.js new file mode 100644 index 0000000..8a361c8 --- /dev/null +++ b/dev_test_scripts/debug/debug_nav_token_key_audit.js @@ -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))})`)) + } +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_schedule_reminder_frontend.js b/dev_test_scripts/debug/debug_schedule_reminder_frontend.js new file mode 100644 index 0000000..822913d --- /dev/null +++ b/dev_test_scripts/debug/debug_schedule_reminder_frontend.js @@ -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) + }) +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_schedule_reminder_iphone6s_layout.js b/dev_test_scripts/debug/debug_schedule_reminder_iphone6s_layout.js new file mode 100644 index 0000000..28c9e07 --- /dev/null +++ b/dev_test_scripts/debug/debug_schedule_reminder_iphone6s_layout.js @@ -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) }) +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_schedule_reminder_legacy_safari.js b/dev_test_scripts/debug/debug_schedule_reminder_legacy_safari.js new file mode 100644 index 0000000..5b52a65 --- /dev/null +++ b/dev_test_scripts/debug/debug_schedule_reminder_legacy_safari.js @@ -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) }) +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_short_link_admin_render.js b/dev_test_scripts/debug/debug_short_link_admin_render.js new file mode 100644 index 0000000..5a3a620 --- /dev/null +++ b/dev_test_scripts/debug/debug_short_link_admin_render.js @@ -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) +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_short_link_iphone_redirect.js b/dev_test_scripts/debug/debug_short_link_iphone_redirect.js new file mode 100644 index 0000000..89e34e8 --- /dev/null +++ b/dev_test_scripts/debug/debug_short_link_iphone_redirect.js @@ -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) +})() \ No newline at end of file diff --git a/dev_test_scripts/debug/debug_token_lab_emergency_ui.js b/dev_test_scripts/debug/debug_token_lab_emergency_ui.js new file mode 100644 index 0000000..752a220 --- /dev/null +++ b/dev_test_scripts/debug/debug_token_lab_emergency_ui.js @@ -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) +}) \ No newline at end of file diff --git a/dev_test_scripts/debug/mall_crop_contact.jpg b/dev_test_scripts/debug/mall_crop_contact.jpg new file mode 100644 index 0000000..65a7f35 Binary files /dev/null and b/dev_test_scripts/debug/mall_crop_contact.jpg differ diff --git a/dev_test_scripts/debug/mall_crop_report.json b/dev_test_scripts/debug/mall_crop_report.json new file mode 100644 index 0000000..d5c1aae --- /dev/null +++ b/dev_test_scripts/debug/mall_crop_report.json @@ -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" + ] +} \ No newline at end of file diff --git a/dev_test_scripts/debug/mall_qa_report.json b/dev_test_scripts/debug/mall_qa_report.json new file mode 100644 index 0000000..3658c12 --- /dev/null +++ b/dev_test_scripts/debug/mall_qa_report.json @@ -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" + } + ] + } + ] +} \ No newline at end of file diff --git a/dev_test_scripts/debug/mask_preview.jpg b/dev_test_scripts/debug/mask_preview.jpg new file mode 100644 index 0000000..38d341f Binary files /dev/null and b/dev_test_scripts/debug/mask_preview.jpg differ diff --git a/dev_test_scripts/debug/tool_restore_deleted_schedule.js b/dev_test_scripts/debug/tool_restore_deleted_schedule.js new file mode 100644 index 0000000..4192173 --- /dev/null +++ b/dev_test_scripts/debug/tool_restore_deleted_schedule.js @@ -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 # 恢复指定 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 ') +} 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() \ No newline at end of file diff --git a/dev_test_scripts/debug/trim_r0c0.jpg b/dev_test_scripts/debug/trim_r0c0.jpg new file mode 100644 index 0000000..fd8e9ed Binary files /dev/null and b/dev_test_scripts/debug/trim_r0c0.jpg differ diff --git a/dev_test_scripts/debug/trim_r2c4.jpg b/dev_test_scripts/debug/trim_r2c4.jpg new file mode 100644 index 0000000..4ecc5b8 Binary files /dev/null and b/dev_test_scripts/debug/trim_r2c4.jpg differ diff --git a/dev_test_scripts/debug/trim_r2c8.jpg b/dev_test_scripts/debug/trim_r2c8.jpg new file mode 100644 index 0000000..a45b4ff Binary files /dev/null and b/dev_test_scripts/debug/trim_r2c8.jpg differ diff --git a/dev_test_scripts/debug/trim_r3c0.jpg b/dev_test_scripts/debug/trim_r3c0.jpg new file mode 100644 index 0000000..39bc3f1 Binary files /dev/null and b/dev_test_scripts/debug/trim_r3c0.jpg differ diff --git a/dev_test_scripts/debug/trim_r3c6.jpg b/dev_test_scripts/debug/trim_r3c6.jpg new file mode 100644 index 0000000..2b27b8f Binary files /dev/null and b/dev_test_scripts/debug/trim_r3c6.jpg differ diff --git a/dev_test_scripts/debug/trim_r3c8.jpg b/dev_test_scripts/debug/trim_r3c8.jpg new file mode 100644 index 0000000..85e9eed Binary files /dev/null and b/dev_test_scripts/debug/trim_r3c8.jpg differ diff --git a/dev_test_scripts/debug/trim_r4c1.jpg b/dev_test_scripts/debug/trim_r4c1.jpg new file mode 100644 index 0000000..8f1232b Binary files /dev/null and b/dev_test_scripts/debug/trim_r4c1.jpg differ diff --git a/dev_test_scripts/debug/trim_r5c0.jpg b/dev_test_scripts/debug/trim_r5c0.jpg new file mode 100644 index 0000000..f61ae8a Binary files /dev/null and b/dev_test_scripts/debug/trim_r5c0.jpg differ diff --git a/dev_test_scripts/debug/trim_r6c0.jpg b/dev_test_scripts/debug/trim_r6c0.jpg new file mode 100644 index 0000000..af535e7 Binary files /dev/null and b/dev_test_scripts/debug/trim_r6c0.jpg differ diff --git a/dev_test_scripts/debug/trim_r6c10.jpg b/dev_test_scripts/debug/trim_r6c10.jpg new file mode 100644 index 0000000..957626e Binary files /dev/null and b/dev_test_scripts/debug/trim_r6c10.jpg differ diff --git a/dev_test_scripts/integration/test_nav_auth_bypass.js b/dev_test_scripts/integration/test_nav_auth_bypass.js new file mode 100644 index 0000000..fa745c0 --- /dev/null +++ b/dev_test_scripts/integration/test_nav_auth_bypass.js @@ -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) +})() \ No newline at end of file diff --git a/dev_test_scripts/integration/test_nav_emergency_replace.js b/dev_test_scripts/integration/test_nav_emergency_replace.js new file mode 100644 index 0000000..1885647 --- /dev/null +++ b/dev_test_scripts/integration/test_nav_emergency_replace.js @@ -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) +}) \ No newline at end of file diff --git a/dev_test_scripts/integration/test_schedule_reminder_integration.js b/dev_test_scripts/integration/test_schedule_reminder_integration.js new file mode 100644 index 0000000..4100762 --- /dev/null +++ b/dev_test_scripts/integration/test_schedule_reminder_integration.js @@ -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) + }) +}) \ No newline at end of file diff --git a/dev_test_scripts/integration/test_short_link_prefix_tolerance.js b/dev_test_scripts/integration/test_short_link_prefix_tolerance.js new file mode 100644 index 0000000..40a4aa8 --- /dev/null +++ b/dev_test_scripts/integration/test_short_link_prefix_tolerance.js @@ -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) +})() \ No newline at end of file diff --git a/dev_test_scripts/tools/__pycache__/tool_crop_mall_logos.cpython-312.pyc b/dev_test_scripts/tools/__pycache__/tool_crop_mall_logos.cpython-312.pyc new file mode 100644 index 0000000..6961cd2 Binary files /dev/null and b/dev_test_scripts/tools/__pycache__/tool_crop_mall_logos.cpython-312.pyc differ diff --git a/dev_test_scripts/tools/tool_check_data_integrity.js b/dev_test_scripts/tools/tool_check_data_integrity.js new file mode 100644 index 0000000..750e91f --- /dev/null +++ b/dev_test_scripts/tools/tool_check_data_integrity.js @@ -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) \ No newline at end of file diff --git a/dev_test_scripts/tools/tool_crop_mall_logos.py b/dev_test_scripts/tools/tool_crop_mall_logos.py new file mode 100644 index 0000000..c57691b --- /dev/null +++ b/dev_test_scripts/tools/tool_crop_mall_logos.py @@ -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() diff --git a/dev_test_scripts/tools/tool_qa_mall_logos.py b/dev_test_scripts/tools/tool_qa_mall_logos.py new file mode 100644 index 0000000..91a6f2a --- /dev/null +++ b/dev_test_scripts/tools/tool_qa_mall_logos.py @@ -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() diff --git a/dev_test_scripts/tools/tool_rotate_nav_key.js b/dev_test_scripts/tools/tool_rotate_nav_key.js new file mode 100644 index 0000000..fb13260 --- /dev/null +++ b/dev_test_scripts/tools/tool_rotate_nav_key.js @@ -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) +}) \ No newline at end of file diff --git a/public/tools/short_link/short_link.js b/public/tools/short_link/short_link.js index d114873..ab92936 100644 --- a/public/tools/short_link/short_link.js +++ b/public/tools/short_link/short_link.js @@ -16,9 +16,10 @@ // ===== 工具函数 ===== const escHtml = s => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + // 短链接前缀统一为 /to/:旧前缀 /l/ 在手机小屏字体下与 I、i、1 字形难分,已弃用 const fullShortUrl = code => { const base = DOMAIN || (location.origin) - return `${base}/l/${code}` + return `${base}/to/${code}` } const copyText = async (text) => { diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c0.jpg new file mode 100644 index 0000000..96d6a60 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c1.jpg new file mode 100644 index 0000000..7c4a056 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c10.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c10.jpg new file mode 100644 index 0000000..a508dd0 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c10.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c2.jpg new file mode 100644 index 0000000..b51a4e5 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c3.jpg new file mode 100644 index 0000000..161bca1 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c4.jpg new file mode 100644 index 0000000..3a44b6c Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c5.jpg new file mode 100644 index 0000000..0571c52 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c6.jpg new file mode 100644 index 0000000..f042b02 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c7.jpg new file mode 100644 index 0000000..c38ebc6 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c8.jpg new file mode 100644 index 0000000..a42d906 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r0c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c9.jpg new file mode 100644 index 0000000..abf53a0 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r0c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c0.jpg new file mode 100644 index 0000000..da67037 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c1.jpg new file mode 100644 index 0000000..ba226b1 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c10.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c10.jpg new file mode 100644 index 0000000..4248841 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c10.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c2.jpg new file mode 100644 index 0000000..a24ccdf Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c3.jpg new file mode 100644 index 0000000..492593d Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c4.jpg new file mode 100644 index 0000000..6c47400 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c5.jpg new file mode 100644 index 0000000..793be47 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c6.jpg new file mode 100644 index 0000000..b5eaca6 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c7.jpg new file mode 100644 index 0000000..81f1f1a Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c8.jpg new file mode 100644 index 0000000..04db2f2 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r1c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c9.jpg new file mode 100644 index 0000000..de5aa25 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r1c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c0.jpg new file mode 100644 index 0000000..8c878d8 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c1.jpg new file mode 100644 index 0000000..9f27146 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c2.jpg new file mode 100644 index 0000000..9e9d1a4 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c3.jpg new file mode 100644 index 0000000..9d16731 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c4.jpg new file mode 100644 index 0000000..62d8f6d Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c5.jpg new file mode 100644 index 0000000..e285426 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c6.jpg new file mode 100644 index 0000000..ac9ad7c Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c7.jpg new file mode 100644 index 0000000..2bea529 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c8.jpg new file mode 100644 index 0000000..8a2a716 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r2c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c9.jpg new file mode 100644 index 0000000..b361111 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r2c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c0.jpg new file mode 100644 index 0000000..73f1de3 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c1.jpg new file mode 100644 index 0000000..9a7f290 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c10.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c10.jpg new file mode 100644 index 0000000..fbfadfa Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c10.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c2.jpg new file mode 100644 index 0000000..e585165 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c3.jpg new file mode 100644 index 0000000..778a6ac Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c4.jpg new file mode 100644 index 0000000..c99416b Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c5.jpg new file mode 100644 index 0000000..cca965b Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c6.jpg new file mode 100644 index 0000000..6bceafa Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c7.jpg new file mode 100644 index 0000000..3c14caf Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c8.jpg new file mode 100644 index 0000000..55c5203 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r3c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c9.jpg new file mode 100644 index 0000000..26a0475 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r3c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c0.jpg new file mode 100644 index 0000000..bd2e53a Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c1.jpg new file mode 100644 index 0000000..55a6c70 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c10.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c10.jpg new file mode 100644 index 0000000..c24a5bb Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c10.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c2.jpg new file mode 100644 index 0000000..d4e6d02 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c3.jpg new file mode 100644 index 0000000..6154d3b Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c4.jpg new file mode 100644 index 0000000..d1241a8 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c5.jpg new file mode 100644 index 0000000..e571347 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c6.jpg new file mode 100644 index 0000000..233f867 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c7.jpg new file mode 100644 index 0000000..a220fc7 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c8.jpg new file mode 100644 index 0000000..030b36f Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r4c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c9.jpg new file mode 100644 index 0000000..95e3f13 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r4c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c0.jpg new file mode 100644 index 0000000..a52e489 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c1.jpg new file mode 100644 index 0000000..54d2bb6 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c10.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c10.jpg new file mode 100644 index 0000000..bda4b72 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c10.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c2.jpg new file mode 100644 index 0000000..991ff41 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c3.jpg new file mode 100644 index 0000000..a74ab68 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c4.jpg new file mode 100644 index 0000000..2d54e3a Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c5.jpg new file mode 100644 index 0000000..192f8d9 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c6.jpg new file mode 100644 index 0000000..fd6b425 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c7.jpg new file mode 100644 index 0000000..747ac24 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c8.jpg new file mode 100644 index 0000000..999a726 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r5c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c9.jpg new file mode 100644 index 0000000..fec9547 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r5c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c0.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c0.jpg new file mode 100644 index 0000000..8b1e56d Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c0.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c1.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c1.jpg new file mode 100644 index 0000000..59141c5 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c1.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c10.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c10.jpg new file mode 100644 index 0000000..80b96cf Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c10.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c2.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c2.jpg new file mode 100644 index 0000000..753fcfc Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c2.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c3.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c3.jpg new file mode 100644 index 0000000..f9e6a2d Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c3.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c4.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c4.jpg new file mode 100644 index 0000000..20e881e Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c4.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c5.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c5.jpg new file mode 100644 index 0000000..0b9c6e5 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c5.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c6.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c6.jpg new file mode 100644 index 0000000..12ac85a Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c6.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c7.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c7.jpg new file mode 100644 index 0000000..55a937c Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c7.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c8.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c8.jpg new file mode 100644 index 0000000..73398a7 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c8.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/images/r6c9.jpg b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c9.jpg new file mode 100644 index 0000000..27debd7 Binary files /dev/null and b/public/tools/thought_lab/labs/mall_food_checkin/images/r6c9.jpg differ diff --git a/public/tools/thought_lab/labs/mall_food_checkin/index.html b/public/tools/thought_lab/labs/mall_food_checkin/index.html new file mode 100644 index 0000000..3ad50d9 --- /dev/null +++ b/public/tools/thought_lab/labs/mall_food_checkin/index.html @@ -0,0 +1,410 @@ + + + + + +宝龙美食打卡 · 思想实验室 + + + +
+

宝龙美食打卡

+

加载中…

+
+
0总店数
+
0已打卡
+
0还没吃
+
-平均分
+
+
+
+ +
+
+ +
+
全部
+
吃过的
+
没吃的
+
我的高分
+
+
+
+
加载中…
+
数据存在服务端 SQLite · 换设备自动同步 | 导出仅为你主动备份
+
+ + +
+
+
+ + + + + diff --git a/public/tools/token_lab/index.html b/public/tools/token_lab/index.html index 1ee774f..864d1e4 100644 --- a/public/tools/token_lab/index.html +++ b/public/tools/token_lab/index.html @@ -31,11 +31,66 @@
+
+
+
+
+

首页导航防爆紧急替换

+
+

+ 怀疑首页 token 或短链接被窥屏、被泄露,或有危险预感时,按顺序点下面两个按钮即可完成更换。 + 旧 token 与旧短链接都会立即失效,无需手工改文件、改数据库。 +

+ +
+ + + +
+ + + + +
+ +
+ + +
+ +
+ +
+ +
+ 说明与回滚 +
+ 第 1 步:轮换导航密钥对(`config/nav-private.jwk.json` 与 `config/nav.jwk.json`), + 并用新私钥签发一枚有效期 30 天的首页 token。旧 token 会因旧公钥离开信任池而立即失效。
+ 第 2 步:把新 token 绑到一条全新的短链接上,同时把旧的首页短链置为停用,旧短链立即失效。
+ 两步执行前都会自动备份(密钥文件 + 短链库)到 `config/_nav-key-backup-时间戳/`,需要回滚时把备份覆盖回原位即可。
+ 原有的手工做法(首页导航生成器 / Tool 令牌生成器 / 物理替换文件)全部保留有效,本页签只是快捷通道。 +
+
+
+
+
+
diff --git a/public/tools/token_lab/token_lab.css b/public/tools/token_lab/token_lab.css index e6dace4..34cc580 100644 --- a/public/tools/token_lab/token_lab.css +++ b/public/tools/token_lab/token_lab.css @@ -269,6 +269,7 @@ body { .actions.wrap { flex-wrap: wrap; + align-items: center; } .btn { @@ -289,6 +290,33 @@ body { background: linear-gradient(135deg, #6f7bf7 0%, #7c55f2 100%); } +/* 异步按钮执行中:禁用态视觉(配合 JS 的 disabled + 文案切换) */ +.btn-busy, +.btn:disabled { + opacity: 0.62; + cursor: not-allowed; +} + +/* 防爆紧急替换:有效期月份下拉,紧跟在按钮后面,宽度收窄不抢按钮视线 */ +.input.emergency-months { + width: auto; + min-width: 118px; + padding: 11px 14px; + align-self: center; +} + +/* 防爆紧急替换的执行日志:长行自动换行 + 纵向滚动,避免横向撑开 */ +.textarea.mono.emergency-log { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; + overflow-x: hidden; + overflow-y: auto; + min-height: 240px; + max-height: 380px; + line-height: 1.75; +} + .auth-submit { width: 100%; } diff --git a/public/tools/token_lab/token_lab.js b/public/tools/token_lab/token_lab.js index 61bf5bd..0c442fc 100644 --- a/public/tools/token_lab/token_lab.js +++ b/public/tools/token_lab/token_lab.js @@ -9,7 +9,9 @@ toolPresets: [], currentPresetId: '', toolKeys: null, - authenticated: false + authenticated: false, + // 防爆紧急替换:第 1 步产出的新 token,供第 2 步绑定短链接使用 + emergencyToken: '' } const el = { @@ -33,6 +35,12 @@ navVerifyMeta: $('#navVerifyMeta'), navVerifyRaw: $('#navVerifyRaw'), + emergencyLog: $('#emergencyLog'), + emergencyMonths: $('#emergencyMonths'), + emergencyTokenOut: $('#emergencyTokenOut'), + emergencyShortUrlOut: $('#emergencyShortUrlOut'), + emergencyMeta: $('#emergencyMeta'), + toolPresetSelect: $('#toolPresetSelect'), toolPresetName: $('#toolPresetName'), toolBaseUrl: $('#toolBaseUrl'), @@ -744,6 +752,163 @@ URL.revokeObjectURL(url) } + // ===== 首页导航防爆紧急替换 ===== + // 两个按钮串行:先轮换密钥+签发新 token,再把新 token 绑到新短链并让旧短链失效。 + // 执行过程按后端回传的步骤逐行滚动展示,最后把 token / 短链接单独显示,方便直接复制。 + const EMERGENCY_STEP_DELAY = 110 + const EMERGENCY_MONTH_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + const EMERGENCY_DEFAULT_MONTHS = 1 + // 每月按 30 天计算:与后端 days 参数对接,避免月初月末的天数歧义 + const EMERGENCY_DAYS_PER_MONTH = 30 + + /** 初始化月份下拉:1~12 个月,默认 1 个月,并让按钮文字跟着选中值走 */ + function initEmergencyMonths() { + el.emergencyMonths.innerHTML = EMERGENCY_MONTH_OPTIONS + .map(m => ``) + .join('') + el.emergencyMonths.value = String(EMERGENCY_DEFAULT_MONTHS) + el.emergencyMonths.addEventListener('change', renderEmergencyRotateButtonText) + renderEmergencyRotateButtonText() + } + + function currentEmergencyMonths() { + const m = Number(el.emergencyMonths.value) + return EMERGENCY_MONTH_OPTIONS.includes(m) ? m : EMERGENCY_DEFAULT_MONTHS + } + + /** 按钮文案随下拉实时变化,避免"固定写一个月、实际却按别的月份执行"的歧义 */ + function renderEmergencyRotateButtonText() { + const btn = $('#btnEmergencyRotate') + if (!btn || btn.disabled) return // 执行中不要覆盖"执行中…"的文案 + btn.textContent = `自动生成向后 ${currentEmergencyMonths()} 个月的鉴权,且当前鉴权立即失效` + } + + /** 按钮异步态:禁用 + 文字切换(项目规则:所有异步按钮都要有 loading) */ + function setButtonBusy(btn, busy, busyText) { + if (!btn) return + if (busy) { + if (btn.dataset.originalText === undefined) btn.dataset.originalText = btn.textContent + btn.disabled = true + btn.classList.add('btn-busy') + if (busyText) btn.textContent = busyText + } else { + btn.disabled = false + btn.classList.remove('btn-busy') + if (btn.dataset.originalText !== undefined) { + btn.textContent = btn.dataset.originalText + delete btn.dataset.originalText + } + } + } + + function emergencyLogClear() { + el.emergencyLog.value = '' + el.emergencyMeta.innerHTML = '' + } + + function emergencyLogAppend(line) { + const stamp = new Date().toLocaleTimeString('zh-CN', { hour12: false }) + el.emergencyLog.value += `[${stamp}] ${line}\n` + el.emergencyLog.scrollTop = el.emergencyLog.scrollHeight + } + + /** 逐行播放后端回传的步骤,营造滚动输出的效果 */ + async function emergencyLogPlay(lines) { + for (const line of lines || []) { + emergencyLogAppend(line) + await new Promise(resolve => setTimeout(resolve, EMERGENCY_STEP_DELAY)) + } + } + + /** 与 fetchJson 的区别:失败时也把后端返回的 logs 取回来展示,便于排障 */ + async function emergencyPost(url, body) { + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}) + }) + let data = null + try { data = await resp.json() } catch { data = null } + if (resp.status === 401) { + state.authenticated = false + applyAuthUi() + } + return { ok: !!(resp.ok && data && data.ok !== false), data } + } + + async function handleEmergencyRotate() { + const btn = $('#btnEmergencyRotate') + const months = currentEmergencyMonths() + setButtonBusy(btn, true, '执行中…') + try { + emergencyLogClear() + state.emergencyToken = '' + el.emergencyTokenOut.value = '' + el.emergencyShortUrlOut.value = '' + emergencyLogAppend(`开始执行:自动生成向后 ${months} 个月的鉴权,且当前鉴权立即失效`) + + const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/rotate', { months }) + await emergencyLogPlay(data && data.logs) + + if (!ok || !data) { + emergencyLogAppend('✘ 第 1 步未成功,请查看上面的执行过程') + setNotice('第 1 步失败,请查看执行过程。', 'error') + return + } + state.emergencyToken = String(data.token || '') + el.emergencyTokenOut.value = state.emergencyToken + const effectiveDays = Number(data.days) || months * EMERGENCY_DAYS_PER_MONTH + el.emergencyMeta.innerHTML = createKvHtml([ + { label: '有效期', value: `${months} 个月(${effectiveDays} 天)` }, + { label: '公钥指纹', value: data.fingerprint || '-' }, + { label: '过期(北京时间)', value: formatEpoch(data.exp) }, + { label: '剩余时长', value: formatRelative(data.exp), status: 'good' }, + { label: '备份目录', value: data.backupDir || '-' } + ]) + setNotice(`第 1 步完成:旧鉴权已失效,新的 ${months} 个月 token 已生成。`, 'success') + } catch (err) { + emergencyLogAppend(`✘ 失败:${err.message || err}`) + setNotice(`第 1 步失败:${err.message || err}`, 'error') + } finally { + setButtonBusy(btn, false) + // 按钮文案依赖下拉选中值,恢复时按当前选择重算,而不是回退到旧文案 + renderEmergencyRotateButtonText() + } + } + + async function handleEmergencyRelink() { + const btn = $('#btnEmergencyRelink') + if (!state.emergencyToken) { + emergencyLogAppend('✘ 还没有可用的新 token,请先点第 1 个按钮生成。') + setNotice('请先执行第 1 步生成新 token。', 'error') + return + } + setButtonBusy(btn, true, '执行中…') + try { + emergencyLogAppend('开始执行:将新 token 绑定到新的短链接,且旧短链接立即失效') + const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/relink', { token: state.emergencyToken }) + await emergencyLogPlay(data && data.logs) + + if (!ok || !data) { + emergencyLogAppend('✘ 第 2 步未成功,请查看上面的执行过程') + setNotice('第 2 步失败,请查看执行过程。', 'error') + return + } + el.emergencyShortUrlOut.value = String(data.shortUrl || '') + el.emergencyMeta.innerHTML = createKvHtml([ + { label: '新短码', value: data.code || '-' }, + { label: '已停用的旧短码', value: (data.disabledCodes || []).join('、') || '(无)' }, + { label: '跳转自校验', value: data.verified ? '通过' : '未通过(请手工确认)', status: data.verified ? 'good' : 'bad' } + ]) + setNotice('第 2 步完成:新短链接已生效,旧短链接已失效。', 'success') + } catch (err) { + emergencyLogAppend(`✘ 失败:${err.message || err}`) + setNotice(`第 2 步失败:${err.message || err}`, 'error') + } finally { + setButtonBusy(btn, false) + } + } + function bindEvents() { el.tabButtons.forEach((btn) => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)) @@ -752,6 +917,12 @@ $('#btnNavGenerate').addEventListener('click', handleNavGenerate) $('#btnNavCopy').addEventListener('click', () => copyText(el.navLinkOutput.value, '首页链接已复制。')) $('#btnNavVerify').addEventListener('click', handleNavVerify) + + // 首页导航防爆紧急替换 + $('#btnEmergencyRotate').addEventListener('click', handleEmergencyRotate) + $('#btnEmergencyRelink').addEventListener('click', handleEmergencyRelink) + $('#btnEmergencyCopyToken').addEventListener('click', () => copyText(el.emergencyTokenOut.value, 'token 已复制。')) + $('#btnEmergencyCopyShortUrl').addEventListener('click', () => copyText(el.emergencyShortUrlOut.value, '短链接已复制。')) $('#btnAuthLogin').addEventListener('click', handleAuthLogin) el.authUsername.addEventListener('keydown', (e) => { if (e.key === 'Enter') handleAuthLogin() @@ -801,6 +972,7 @@ loadPresets() renderPresetSelect() bindEvents() + initEmergencyMonths() el.navBaseUrl.value = `${window.location.origin}/` el.navExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 30) el.toolExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 7) diff --git a/public/tools/web_order_box/index.html b/public/tools/web_order_box/index.html index 3cc3e34..d1dc936 100644 --- a/public/tools/web_order_box/index.html +++ b/public/tools/web_order_box/index.html @@ -6,7 +6,7 @@ - 框子 - 运动提醒宝 + 框子 @@ -28,6 +28,16 @@
+ @@ -87,7 +97,7 @@ -
+
+ + + + + + + + +