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

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