feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user