Files
TankIC/3DTank/tools/import_db.js
T
TermiNexus b6f2cb71ee feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)
工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。

服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。

同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。

src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
2026-09-17 20:00:41 +08:00

809 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// import_db.js - 把抓包里解出来的「对象仓库」全量导入本机 MySQL,并自证可逆
//
// 数据来源:packets/plain/<目录>/s2c_*.bin(每个文件 = 恰好一整帧的帧体)。
// 解码范式:逐字照 tools/rebase_space_init.js:100-146 ——
// Link.ParseFrames(readFileSync(F)) → Link.DecodeFrame(Parsed[0]) → Sem.DecodeFrameBody(Body)。
// 容器记录 → { Layout, Value };字段记录 → { Fields };解不动 → Bad(**丢弃并统计**,不猜)。
//
// 落库口径(与 tools/server/schema.sql 的头注一一对应):
// Containers 每条容器记录(pt/zt/lt)的原文:Layout + ValueJson(zt 另填 X)
// SpaceObjects pt.Value.Objects = (ObjectId, GameTypeId)
// ObjectModels pt.Value.Items 按对象分组后的切片(分组规则见下),Ord 从 0 保序
// DependencyBatches / DependencyItems zt.Value.Batches / zt.Value.Items(身份 = 该条 zt 记录,BatchId = zt 首字段 X)
// Items itemsOnDepot(3996477900528000389) / itemsOnMarket(5236393599344825589)
// / mountedItems(3692495084100053385) 三个旗标的并集
// DeviceSlots / SlotDevices / Mounted devicesLoaded(7615142300796401818) 的三块
// Accounts 抓包账号(Uid 的出处见 ACCOUNT_UID 的注释)
//
// ---------------------------------------------------------------------------
// 分组规则(**按源码,不许猜**):src/module_45686.js:420-434 的 `x(_t).i1wd`
// ★ 结论先说:**组首项 = Id 恰为 0 的那一项**(container.js 里 Id === '0'),它的 Value 是**单一个 i64**
// (= 对象 id,即 container.js 的 Value 就是 i64 十进制字符串),等于 Objects[i].ObjectId;
// 组尾 = 下一个 Id === 0 的项之前。**不是 (objId, gameTypeId) 对** —— 帧里根本没随组首项下发 gameTypeId。
// i1wd 遍历 pt.Value.Items(源码里的 n.h1wc(),元素是 lt.p7g 解出来的 ModelData,即 I(data, id)):
// u = item(e).b85() ← 组首项的 data = I 的第 1 个构造参数 = container.js 的 Value
// s = u instanceof p ? u : w() p = module_37036.$_$.qj,是「i64 包装类」(new p(0,0) 就是 0,
// 且它有 q3_1/r3_1 两半 + 8 字节/64 位的 hashCode,见 module_37036.js:11119);
// 不在 p 上就调 w()(= 37036.$_$.vj = Jr,直接抛)→ 组首项的 Value 必须是 i64
// o = r.x7o(s) ← 用这个 i64 在空间注册表里取对象 ⇒ 它只能是该对象自己的 ObjectId
// _ = e + 1;while (_ < len && item(_).o1wc_1 != new p(0,0)) _++
// ← 内层扫描到「下一项的 id(= I 的第 2 个构造参数 = container.js 的 Id)
// 等于 0」的哨兵为止;e.._-1 这些项按 a.o1wc_1(= modelId)挂到 o 上
// 硬断言因此写作:「组数 == Objects.length」+「组首项 Value === Objects[i].ObjectId」(实测全部相等)。
// ⚠ 那个 (objId, gameTypeId) 对**无法断言**:i1wd 只把组首项的 Value 当 i64 用,不读第二个 id。
// 断言不过的容器**不硬塞**:该容器的 Items 全部以 ObjectId = NULL 落库(Ord 保序),并统计占比。
//
// 幂等 & 主键冲突口径:
// 写入一律 INSERT ... ON DUPLICATE KEY UPDATE,且**统一 KeepFirst** —— 同一主键在抓包里出现多份
// 不同内容时保留**首次出现**的版本(ObjectModels 的 ObjectId IS NULL 那批不受唯一键保护,先 DELETE 再插)。
// 撞主键的行数一律用 CountKeyCollisions 在主键上自己算;被丢的行**不在库里**,所以「到底丢没丢数据」
// 在同一处再按**整行**去重算一次,报告里拆成两列:原样重发(整行逐字节相同)/ 内容不同(真冲突)。
// 依赖批次的身份 = **哪一条容器记录**,不是 X:主键 (SpaceId, SourceFile, RecordIndex, Ord),与 Containers
// 同一套自然键;BatchId(zt 首字段 X)只是列 + 索引 DependencyBatchesLookupIdx,按 X 查走索引即可。
// ObjectModels 的唯一键是 (SpaceId, ObjectId, Ord)(同一对象在不同空间可以内容不同,见 schema.sql 头注 9)。
// 本次实测(推入 → 入库 / 撞主键,其中「原样重发」= 整行与已入库那份逐字节相同):
// ObjectModels 28835 → 28827 / 8(原样重发 8 / 内容不同 0)—— 残余 8 行是「同一空间内同一对象
// 被原样重发」,整行逐字节相同 ⇒ 唯一键不允许重复是**正确行为**,没丢数据;
// DependencyBatches 528 → 528 / 0 —— 按记录身份落库后不再丢行;
// DependencyItems 4501 → 4501 / 0 —— 同上。
// ⚠ schema.sql 头注 8 的「zt 的 X 只在空间内唯一」**不成立**:实测空间 100073070 的 X=1 被 11 条 zt
// 以互不相同的对象表重发(s2c_0000#0 带 8 个 modelId、s2c_0003#0 带 16 个、s2c_0006#0 带 4 个……)。
// 所以 X **不能**当唯一键 —— 旧版 (SpaceId, BatchId, Ord) 只能 KeepFirst,白丢 46 + 66 行;
// 现在批次身份是容器记录,这些 zt 一条不丢地各自成行,原文也一字不少地留在 Containers。
// 反过来说,ObjectModels 上不存在「同空间内同对象不同切片」(实测 0 个),故验证② 的拼回必然逐项相等。
//
// 持久化精度口径(本次已改,不再是边界):Containers.ValueJson / ObjectModels.ValueJson /
// DependencyBatches.ModelIds / DependencyItems.Skipped 四列一律 **LONGTEXT + CHECK(JSON_VALID(...))**,
// 写入传 JSON.stringify 的原文、读出自己 JSON.parse —— 全程只走文本,不经过 MySQL 的 binary JSON,
// 所以 double 往返逐位可逆(旧版用 JSON 列时实测 -10.321807861328125 → -10.321807861328123,差 1 个 ulp,
// 见 schema.sql 头注 7a);大 i64 也只以 JSON 字符串形态存在,不丢精度。
//
// 用法: node import_db.js [--reset] [--space 100073070] [--owner <uid>] [--dry] [--verify] [--plain <dir>]
// --reset 先清本次要写的表再导入(默认 upsert,重复跑不产生重复行)
// --space 只导一个空间(调试用);默认导 packets/plain 下全部含 s2c_*.bin 的目录
// --owner 覆盖抓包账号 uid(默认用下面确定的那个)
// --dry 只解析与统计(含分组断言与撞主键分析),不连库、不写库
// --verify 不导入,只跑四项验证(逐字节重建 / 切片可重组 / 行数 / MySQL 侧核对)
// 不带 --dry / --verify 时:解析 → 写库 → 依次打印上面四项验证的结果。
'use strict';
const fs = require('fs');
const path = require('path');
const Crypto = require('crypto');
const Link = require('./server/link');
const SemanticModule = require('./server/semantic');
const Db = require('./server/db');
// ---------------------------------------------------------------- 命令行
const Argv = process.argv.slice(2);
function Arg(Name, Def) {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
}
function Has(Name) {
return Argv.indexOf('--' + Name) >= 0;
}
const ToolsDir = __dirname;
const PlainDir = path.resolve(ToolsDir, Arg('plain', path.join('..', 'packets', 'plain')));
const NamesDir = path.resolve(ToolsDir, Arg('names', path.join('..', 'names')));
const OnlySpace = Arg('space', null);
const Dry = Has('dry');
const Reset = Has('reset');
const VerifyOnly = Has('verify');
const Sem = SemanticModule.Load(NamesDir);
// 抓包账号的 uid:以「hash 登录」那条链上最权威的一处确定 ——
// packets/plain-relay/session_008/s2c_0006.bin(就是 names/space-init-login-100045260.json 的 s2c_0006.bin)
// 一帧两条记录:RememberLoginModel.RememberUsersHash(字段 hash,64 字符 token)
// + EntranceRequestModelCC.ReceiveLong(唯一字段是一个匿名 i64 = 3074457345817168740)
// 依据三条:① 该 i64 与登录 token 同帧、紧随其后,是服务端告诉客户端「你是哪个对象」;
// ② tools/server/dispatch.js:49 独立把它记成「用户对象 id(实测 3074457345817168740 = 0x2AAAAAAAAAB685CB64)」;
// ③ 它在 packets/plain/space-100072911 与 space-3074457345817190050 里大量作为**记录头 objectId** 出现
// (客户端拿它当目标 id 发空间命令),与「本机玩家的对象 id」这个角色吻合。
// LoginHash 留 NULL(真登录时再绑);所有 OwnerUid 列都填这个 uid —— 这是「抓包账号」的归属,
// **不是多账号隔离**:本次抓包只对应这一个账号,任何 OwnerUid 上的差异都只是数据来源的区别。
const DEFAULT_ACCOUNT_UID = '3074457345817168740';
const AccountUid = String(Arg('owner', DEFAULT_ACCOUNT_UID));
// 要用的字段型下行 methodId(名字以 names/downlink_table.json 为准)
const METHOD = {
ItemsOnDepot: '3996477900528000389', // itemsOnDepot: list<i64>
ItemsOnMarket: '5236393599344825589', // itemsOnMarket: list<i64>
MountedItems: '3692495084100053385', // mountedItems: list<i64>
DevicesLoaded: '7615142300796401818', // devices: list<i64> + mountedDevice: opt<i64>
};
// 本次要写的 9 张表(Accounts 不在内:它是 upsert 的账号行,--reset 不清它,免得把已绑的 LoginHash 抹掉)
const TARGET_TABLES = [
'Containers', 'SpaceObjects', 'ObjectModels', 'DependencyBatches', 'DependencyItems',
'Items', 'DeviceSlots', 'SlotDevices', 'Mounted',
];
// ---------------------------------------------------------------- 小工具
function FirstDiffOffset(A, B) {
for (let I = 0; I < Math.max(A.length, B.length); I++) if (A[I] !== B[I]) return I;
return -1;
}
// 逐项比较用的规范化序列化。列现在是 LONGTEXT,文本里的键序本就原样保留,不再像 JSON 列那样被重排;
// 这里仍统一按键序排序,只是为了让比较结果**不依赖任何一方的键序**(键序不是 JSON 语义,规范化是安全的)。
function StableJson(V) {
if (Array.isArray(V)) return '[' + V.map(StableJson).join(',') + ']';
if (V && typeof V === 'object') {
const Keys = Object.keys(V).sort();
return '{' + Keys.map((K) => JSON.stringify(K) + ':' + StableJson(V[K])).join(',') + '}';
}
return JSON.stringify(V === undefined ? null : V);
}
// 四个 JSON 列现在是 LONGTEXT + CHECK(JSON_VALID(...)):写进去的是 JSON.stringify 的原文,
// mysql2 读回来一定是**文本**(不再像 JSON 列那样自动解析),所以这里一律自己 JSON.parse。
// 为什么可以无条件 parse:文本本身就是 JSON 的完整编码 —— 字符串值带引号(ObjectModels 里 Id==0 那种
// "100073070" 在库里是带引号的 `"100073070"`,parse 回来仍是不丢精度的十进制字符串)、
// 数字值不带引号(parse 回来是 double,逐位还原)。
function JsonValue(V) {
return typeof V === 'string' ? JSON.parse(V) : V;
}
// 批量 upsert。db.js 的 InsertMany 是纯 INSERT,不满足「重复跑不产生重复行」,所以在工具内自备一份。
// KeepFirst=true ⇒ 主键已存在时**不改**(`ON DUPLICATE KEY UPDATE 首列 = VALUES(首列)` 是空操作):
// 同一主键在抓包里出现多份不同内容时以**首次出现**为准。为什么不用 INSERT IGNORE:它连
// 「字段值非法」这类错误也一起吞掉并塞默认值,那是静默改数据,不允许。
// ⚠ 不要用 affectedRows 判断「撞了多少主键」:mysql2 默认带 CLIENT_FOUND_ROWS,
// ON DUPLICATE KEY UPDATE 的 affectedRows 返回的是**匹配行数**(实测 = 推入行数,撞了也数 1),
// 所以撞主键的行数一律用 CountKeyCollisions 在主键上自己算(见下)。
async function UpsertMany(Pool, Table, Columns, Values, KeepFirst, ChunkSize) {
if (!Values.length) return 0;
const Size = ChunkSize || 200;
const Cols = Columns.map((C) => '`' + C + '`').join(', ');
const Sets = KeepFirst
? '`' + Columns[0] + '` = VALUES(`' + Columns[0] + '`)'
: Columns.map((C) => '`' + C + '` = VALUES(`' + C + '`)').join(', ');
let Done = 0;
for (let I = 0; I < Values.length; I += Size) {
const Chunk = Values.slice(I, I + Size);
const Placeholders = Chunk.map(() => '(' + Columns.map(() => '?').join(', ') + ')').join(', ');
const Flat = [];
for (const Row of Chunk) for (const V of Row) Flat.push(V);
await Pool.query(
'INSERT INTO `' + Table + '` (' + Cols + ') VALUES ' + Placeholders + ' ON DUPLICATE KEY UPDATE ' + Sets,
Flat,
);
Done += Chunk.length;
}
return Done;
}
// 主键去重统计:KeepFirst 下真正入库的行数 = 去重后的主键个数(+ 主键带 NULL 的行,MySQL 的唯一键管不住 NULL)。
// KeyIndexes = 主键在行数组里的列下标;NullKeyIndex = 允许为 NULL 的那一列(ObjectModels.ObjectId)。
// ★ 撞主键被丢的行**不在库里**,所以「到底丢没丢数据」只能在这里算:同时按**整行**(全部列)去重
// (DistinctFull),于是 Collisions 可拆成两半 ——
// Pushed - DistinctFull = 原样重发(整行与已入库那份逐字节相同,丢的不是数据)
// DistinctFull - Distinct = 内容不同(真冲突,只留了首次出现那份)
function CountKeyCollisions(Rows2, KeyIndexes, NullKeyIndex) {
const Seen = new Set();
const Full = new Set();
let Nulls = 0;
for (const R of Rows2) {
Full.add(StableJson(R.map((V) => (V === null ? null : String(V)))));
if (NullKeyIndex !== undefined && R[NullKeyIndex] === null) {
Nulls++;
continue;
}
Seen.add(KeyIndexes.map((I) => String(R[I])).join('\u0000'));
}
return {
Distinct: Seen.size + Nulls,
Collisions: Rows2.length - Nulls - Seen.size,
Nulls,
DistinctFull: Full.size,
SameRowDropped: Rows2.length - Full.size,
};
}
// ---------------------------------------------------------------- 扫描目标目录
// 目录名 space-<真实空间号> ⇒ SpaceId = 那个号;其它目录(lobby-session_*)用**目录名**当 SpaceId
// (那些帧里没有可解码的空间号,编一个号只会更难查;目录名恰好满足 (SpaceId, SourceFile) 的唯一性)。
function TargetDirs() {
const Out = [];
for (const E of fs.readdirSync(PlainDir, { withFileTypes: true })) {
if (!E.isDirectory()) continue;
const Full = path.join(PlainDir, E.name);
const Files = fs.readdirSync(Full).filter((F) => /^s2c_.*\.bin$/.test(F)).sort();
if (!Files.length) continue;
const SpaceId = E.name.indexOf('space-') === 0 ? E.name.slice('space-'.length) : E.name;
if (OnlySpace && SpaceId !== String(OnlySpace)) continue;
Out.push({ Dir: Full, Name: E.name, SpaceId, Files });
}
return Out;
}
// 一个 .bin = 恰好一整帧;不是就返回 null(不猜、不切)
function ReadFrameBody(File) {
const Parsed = Link.ParseFrames(fs.readFileSync(File));
if (!Parsed || Parsed.length !== 1) return null;
return Link.DecodeFrame(Parsed[0]);
}
// ---------------------------------------------------------------- 分组(见文件头「分组规则」)
function GroupPtItems(Items, Objects) {
const Groups = [];
let At = 0;
while (At < Items.length) {
const Head = Items[At];
if (String(Head.Id) !== '0') {
return { Ok: false, Why: '第 ' + At + ' 项本应是组首(Id 应为 0),实际 Id=' + Head.Id, Groups: null };
}
let End = At + 1;
while (End < Items.length && String(Items[End].Id) !== '0') End++;
Groups.push({ Ref: String(Head.Value), Items: Items.slice(At, End) });
At = End;
}
if (Groups.length !== Objects.length) {
return { Ok: false, Why: '组数 ' + Groups.length + ' ≠ Objects 个数 ' + Objects.length, Groups: null };
}
for (let I = 0; I < Groups.length; I++) {
if (Groups[I].Ref !== String(Objects[I].ObjectId)) {
return {
Ok: false,
Why: '第 ' + I + ' 组组首 Value=' + Groups[I].Ref + ' ≠ Objects[' + I + '].ObjectId=' + Objects[I].ObjectId,
Groups: null,
};
}
}
return { Ok: true, Groups, Why: '' };
}
// ---------------------------------------------------------------- 导入
const Stat = {
Dirs: 0, Files: 0, NotSingleFrame: 0, Frames: 0, FramesPartBad: 0,
Records: 0, ContainerRecords: 0, FieldRecords: 0, BadRecords: 0,
Pt: 0, Zt: 0, Lt: 0,
PtOk: 0, PtFail: 0, NullModelItems: 0, PtFailExamples: [],
ObjSliceVersions: new Map(), // ObjectId → Set(切片指纹):同一个对象在多条 pt 里有几份不同内容
FlagRecords: 0, DevicesLoadedRecords: 0, ZtBatchIds: new Set(),
SpaceObjConflicts: 0, JsonBytes: 0,
PerDir: [],
};
const Rows = {
Containers: [],
SpaceObjects: new Map(), // ObjectId → [ObjectId, GameTypeId, OwnerUid, SourceSpace]
ObjectModels: [],
DependencyBatches: [],
DependencyItems: [],
Items: new Map(), // ItemId → { Depot, Market, Mounted }
DeviceSlots: new Map(), // SlotObjectId → [SlotObjectId, OwnerUid]
SlotDevices: new Map(), // SlotObjectId|DeviceId → [SlotObjectId, Ord, DeviceId]
Mounted: new Map(), // SlotObjectId → [SlotObjectId, OwnerUid, DeviceId|null]
};
function MarkItem(Id, Flag) {
const Key = String(Id);
let Row = Rows.Items.get(Key);
if (!Row) {
Row = { Depot: 0, Market: 0, Mounted: 0 };
Rows.Items.set(Key, Row);
}
Row[Flag] = 1;
}
function PutContainer(SpaceId, File, Index, R) {
Stat.ContainerRecords++;
const IsZt = R.Layout === 'zt';
if (IsZt) Stat.Zt++;
else if (R.Layout === 'pt') Stat.Pt++;
else Stat.Lt++;
const Json = JSON.stringify(R.Value);
Stat.JsonBytes += Json.length;
Rows.Containers.push([
SpaceId, File, Index, String(R.ObjectId), String(R.MethodId), R.Layout,
IsZt ? R.Value.X : null, Json,
]);
}
function PutPt(SpaceId, File, Index, R) {
const V = R.Value;
// SpaceObjects:同一 ObjectId 可能出现在多个空间/多条容器里,取首次出现(冲突只统计,不覆盖)
for (const O of V.Objects) {
const Key = String(O.ObjectId);
const Prev = Rows.SpaceObjects.get(Key);
if (Prev && String(Prev[1]) !== String(O.GameTypeId)) Stat.SpaceObjConflicts++;
if (!Prev) Rows.SpaceObjects.set(Key, [Key, String(O.GameTypeId), AccountUid, SpaceId]);
}
const G = GroupPtItems(V.Items, V.Objects);
if (G.Ok) {
Stat.PtOk++;
for (let I = 0; I < G.Groups.length; I++) {
const ObjId = String(V.Objects[I].ObjectId);
const Slice = G.Groups[I].Items;
// 记录「这个对象的切片指纹」:唯一键是 (SpaceId, ObjectId, Ord),**同一空间内**同一对象若被多条 pt
// 重发且内容不同,只能留住首次出现的那份;指纹用于在报告里如实报出受影响的到底是哪几个对象。
const Fp = Crypto.createHash('sha1').update(JSON.stringify(Slice)).digest('hex');
const FpKey = SpaceId + '/' + ObjId;
if (!Stat.ObjSliceVersions.has(FpKey)) Stat.ObjSliceVersions.set(FpKey, new Set());
Stat.ObjSliceVersions.get(FpKey).add(Fp);
for (let M = 0; M < Slice.length; M++) {
Rows.ObjectModels.push([SpaceId, ObjId, M, String(Slice[M].Id), JSON.stringify(Slice[M].Value)]);
}
}
} else {
Stat.PtFail++;
Stat.NullModelItems += V.Items.length;
if (Stat.PtFailExamples.length < 3) {
Stat.PtFailExamples.push({ SpaceId, File, Index, Why: G.Why, Items: V.Items.length, Objects: V.Objects.length });
}
// 不硬塞:整条容器的 Items 以 ObjectId = NULL 落库,Ord 保序
for (let M = 0; M < V.Items.length; M++) {
Rows.ObjectModels.push([SpaceId, null, M, String(V.Items[M].Id), JSON.stringify(V.Items[M].Value)]);
}
}
}
// zt 的 X(客户端回调号)**不是唯一键**(会被复用),一条依赖批次的身份是「哪一条容器记录」:
// 主键 = (SpaceId, SourceFile, RecordIndex, Ord),与 Containers 同一套自然键;X 只是列 + 索引。
// RecordIndex = 该 zt 记录在帧内是第几条记录(从 0 开始,与 Containers.RecordIndex 同一口径)。
function PutZt(SpaceId, File, Index, R) {
const V = R.Value;
const BatchId = V.X;
Stat.ZtBatchIds.add(SpaceId + '/' + BatchId);
for (let I = 0; I < (V.Batches || []).length; I++) {
const B = V.Batches[I];
Rows.DependencyBatches.push([
SpaceId, File, Index, I, BatchId, String(B.ObjectId), JSON.stringify((B.ModelIds || []).map(String)),
]);
}
for (let I = 0; I < (V.Items || []).length; I++) {
const X = V.Items[I];
Rows.DependencyItems.push([
SpaceId, File, Index, I, BatchId, String(X.R), X.E, X.U, String(X.S), X.O,
JSON.stringify((X.Skipped || []).map(String)),
]);
}
}
function PutFields(R) {
Stat.FieldRecords++;
const Get = (Name) => {
const F = (R.Fields || []).find((X) => X.Name === Name);
return F ? F.Value : undefined;
};
if (R.MethodId === METHOD.ItemsOnDepot) {
Stat.FlagRecords++;
const V = Get('itemsOnDepot');
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Depot');
} else if (R.MethodId === METHOD.ItemsOnMarket) {
Stat.FlagRecords++;
const V = Get('itemsOnMarket');
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Market');
} else if (R.MethodId === METHOD.MountedItems) {
Stat.FlagRecords++;
const V = Get('mountedItems');
if (Array.isArray(V)) for (const Id of V) MarkItem(Id, 'Mounted');
} else if (R.MethodId === METHOD.DevicesLoaded) {
Stat.DevicesLoadedRecords++;
const Slot = String(R.ObjectId);
Rows.DeviceSlots.set(Slot, [Slot, AccountUid]);
const Devices = Get('devices');
if (Array.isArray(Devices)) {
for (let I = 0; I < Devices.length; I++) {
const Key = Slot + '|' + String(Devices[I]);
Rows.SlotDevices.set(Key, [Slot, I, String(Devices[I])]);
}
}
const M = Get('mountedDevice');
Rows.Mounted.set(Slot, [Slot, AccountUid, M === undefined || M === null ? null : String(M)]);
}
}
function ScanAll(Dirs) {
for (const D of Dirs) {
Stat.Dirs++;
const Cnt = { Files: 0, Containers: 0, Fields: 0, Bad: 0, Pt: 0, Zt: 0, Lt: 0 };
for (const File of D.Files) {
Stat.Files++;
Cnt.Files++;
const Body = ReadFrameBody(path.join(D.Dir, File));
if (!Body) {
Stat.NotSingleFrame++;
continue;
}
Stat.Frames++;
const Decoded = Sem.DecodeFrameBody(Body);
if (Decoded.Bad) Stat.FramesPartBad++;
for (let I = 0; I < Decoded.Records.length; I++) {
const R = Decoded.Records[I];
Stat.Records++;
// Bad / 短命令形态:没有可用的布局或字段,**丢弃并统计**(不写 Containers,避免把猜的东西当原文)
if (R.Bad || R.ObjectId === undefined) {
Stat.BadRecords++;
Cnt.Bad++;
continue;
}
if (R.Kind === 'container') {
PutContainer(D.SpaceId, File, I, R);
Cnt.Containers++;
if (R.Layout === 'pt') {
Cnt.Pt++;
PutPt(D.SpaceId, File, I, R);
} else if (R.Layout === 'zt') {
Cnt.Zt++;
PutZt(D.SpaceId, File, I, R);
} else {
Cnt.Lt++;
}
} else {
PutFields(R);
Cnt.Fields++;
}
}
}
Stat.PerDir.push({ Dir: D.Name, SpaceId: D.SpaceId, ...Cnt });
}
}
// ---------------------------------------------------------------- 写库
// 逐表的落库计划(列序 / 主键列 / 允许 NULL 的主键列)。写库与行数统计共用同一份,避免两处口径漂移。
// 全表统一 KeepFirst:同一主键在抓包里出现多份不同内容时保留**首次出现**的版本(撞主键的行数在报告里给出)。
function TablePlans() {
return [
{ Table: 'Containers', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'ObjectId', 'MethodId', 'Layout', 'X', 'ValueJson'], Keys: [0, 1, 2], Values: Rows.Containers },
{ Table: 'SpaceObjects', Columns: ['ObjectId', 'GameTypeId', 'OwnerUid', 'SourceSpace'], Keys: [0], Values: [...Rows.SpaceObjects.values()] },
{ Table: 'ObjectModels', Columns: ['SpaceId', 'ObjectId', 'Ord', 'ModelId', 'ValueJson'], Keys: [0, 1, 2], NullKey: 1, Values: Rows.ObjectModels },
{ Table: 'DependencyBatches', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'Ord', 'BatchId', 'ObjectId', 'ModelIds'], Keys: [0, 1, 2, 3], Values: Rows.DependencyBatches },
{ Table: 'DependencyItems', Columns: ['SpaceId', 'SourceFile', 'RecordIndex', 'Ord', 'BatchId', 'R', 'E', 'U', 'S', 'O', 'Skipped'], Keys: [0, 1, 2, 3], Values: Rows.DependencyItems },
{ Table: 'Items', Columns: ['ItemId', 'OwnerUid', 'InDepot', 'InMarket', 'Mounted'], Keys: [0], Values: [...Rows.Items.entries()].map(([K, V]) => [K, AccountUid, V.Depot, V.Market, V.Mounted]) },
{ Table: 'DeviceSlots', Columns: ['SlotObjectId', 'OwnerUid'], Keys: [0], Values: [...Rows.DeviceSlots.values()] },
{ Table: 'SlotDevices', Columns: ['SlotObjectId', 'Ord', 'DeviceId'], Keys: [0, 2], Values: [...Rows.SlotDevices.values()] },
{ Table: 'Mounted', Columns: ['SlotObjectId', 'OwnerUid', 'DeviceId'], Keys: [0], Values: [...Rows.Mounted.values()] },
];
}
// 每张表的「推入 / 入库(主键去重后)/ 撞主键被留住原值」——全部在主键上自己算(见 UpsertMany 的注释)
function RowsReport() {
return TablePlans().map((P) => Object.assign({}, P, CountKeyCollisions(P.Values, P.Keys, P.NullKey), { Pushed: P.Values.length }));
}
async function WriteAll(Pool) {
if (Reset) {
const Order = ['ObjectModels', 'DependencyItems', 'DependencyBatches', 'SlotDevices', 'Mounted', 'DeviceSlots', 'Items', 'SpaceObjects', 'Containers'];
for (const T of Order) await Pool.query('DELETE FROM `' + T + '`');
console.log('--reset:已清空 ' + Order.join(' / '));
}
// ObjectModels 里 ObjectId IS NULL 那批不受唯一键保护(MySQL 允许唯一键上多个 NULL)→ 先删再插,保证幂等
const [DelNull] = await Pool.query('DELETE FROM ObjectModels WHERE ObjectId IS NULL');
if (DelNull.affectedRows) console.log('清理上一轮的 ObjectId IS NULL 行:' + DelNull.affectedRows);
const Out = RowsReport();
for (const P of Out) P.Written = await UpsertMany(Pool, P.Table, P.Columns, P.Values, true);
await Pool.query('INSERT INTO Accounts (Uid, LoginHash) VALUES (?, NULL) ON DUPLICATE KEY UPDATE Uid = VALUES(Uid)', [AccountUid]);
return Out;
}
// ---------------------------------------------------------------- 验证
// ① 逐字节重建:从 Containers 表读出 ValueJson → container.Write(Layout, Value) → 与帧里那条负载比
async function VerifyRebuild(Pool, Dirs) {
let Same = 0;
let Diff = 0;
const Worst = [];
for (const D of Dirs) {
for (const File of D.Files) {
const DbRows = await Db.Many(Pool,
'SELECT Seq, RecordIndex, Layout, ValueJson FROM Containers WHERE SpaceId = ? AND SourceFile = ? ORDER BY RecordIndex',
[D.SpaceId, File]);
if (!DbRows.length) continue;
const Body = ReadFrameBody(path.join(D.Dir, File));
if (!Body) continue;
const Decoded = Sem.DecodeFrameBody(Body);
const ByIndex = new Map(DbRows.map((R) => [Number(R.RecordIndex), R]));
for (let I = 0; I < Decoded.Records.length; I++) {
const R = Decoded.Records[I];
const Row = ByIndex.get(I);
if (!Row || R.Kind !== 'container' || R.Bad) continue;
const Orig = Body.slice(R.At + 16, R.At + 16 + R.Consumed);
let Built = null;
let Err = null;
try {
Built = Sem.Container.Write(R.Layout, JsonValue(Row.ValueJson));
} catch (E) {
Err = E.message;
}
if (Built && Built.equals(Orig)) Same++;
else {
Diff++;
if (Worst.length < 3) {
Worst.push({
Seq: Row.Seq, SpaceId: D.SpaceId, File, Index: I, Layout: R.Layout,
DiffAt: Built ? FirstDiffOffset(Built, Orig) : -1, Below: Built ? Built.length : -1, Orig: Orig.length, Err,
});
}
}
}
}
}
return { Same, Diff, Worst };
}
// ② 对象切片可重组:随机取 3 个 pt 容器,用 ObjectModels 的切片拼回 Items 全序列,再整体重编码比字节。
// ⚠ 必须按 SpaceId 限定:同一个对象在不同空间可以各有一份不同内容,不限定就会把别的空间的切片拼进来。
async function LoadSlices(Pool, SpaceId, ObjectIds) {
const Out = new Map();
for (let I = 0; I < ObjectIds.length; I += 200) {
const Chunk = ObjectIds.slice(I, I + 200);
const Rows2 = await Db.Many(Pool,
'SELECT ObjectId, Ord, ModelId, ValueJson FROM ObjectModels WHERE SpaceId = ? AND ObjectId IN (' +
Chunk.map(() => '?').join(',') + ') ORDER BY ObjectId, Ord', [SpaceId].concat(Chunk));
for (const R of Rows2) {
const Key = String(R.ObjectId);
if (!Out.has(Key)) Out.set(Key, []);
Out.get(Key).push({ Id: String(R.ModelId), Value: JsonValue(R.ValueJson) });
}
}
return Out;
}
// 找出两棵 JSON 树的第一处差异(路径 + 两侧值)。用途:验证② 里「Items 逐项相等 ✗」时
// 把差异定位到**具体字段**,而不是只丢一句 ✗。
// 历史:旧版四个 JSON 列用 MySQL 的 JSON 类型,double 往返差 1 个 ulp,这里就是当初用来定位它的;
// 现在列是 LONGTEXT(见文件头「持久化精度口径」),这条路径预期不会再被触发,保留作为定位工具。
function FirstJsonDiff(A, B, At) {
const P = At || '';
if (Array.isArray(A) && Array.isArray(B)) {
if (A.length !== B.length) return { Path: P, A: A.length, B: B.length };
for (let I = 0; I < A.length; I++) {
const R = FirstJsonDiff(A[I], B[I], P + '/' + I);
if (R) return R;
}
return null;
}
if (A && B && typeof A === 'object' && typeof B === 'object') {
for (const K of [...new Set([...Object.keys(A), ...Object.keys(B)])].sort()) {
const R = FirstJsonDiff(A[K], B[K], P + '/' + K);
if (R) return R;
}
return null;
}
return StableJson(A) === StableJson(B) ? null : { Path: P, A, B };
}
// 随机取样用的 PRNG(固定种子,mulberry32):**随机取 3 条**,但同一次抓包每次跑都抽到同样 3 条,
// 报告里的三个样本号才可复算 —— 用 Math.random 的话这份报告就没法被第二个人重现。
function SeededRandom(Seed) {
let A = Seed >>> 0;
return function () {
A = (A + 0x6d2b79f5) >>> 0;
let T = A;
T = Math.imul(T ^ (T >>> 15), T | 1);
T ^= T + Math.imul(T ^ (T >>> 7), T | 61);
return ((T ^ (T >>> 14)) >>> 0) / 4294967296;
};
}
async function VerifyReassemble(Pool, Dirs) {
const Total = Number((await Db.One(Pool, "SELECT COUNT(*) AS N FROM Containers WHERE Layout = 'pt'")).N);
const Rand = SeededRandom(20260917);
const Picked = [];
const Seen = new Set();
while (Picked.length < Math.min(3, Total)) {
const Off = Math.floor(Rand() * Total);
if (Seen.has(Off)) continue;
Seen.add(Off);
const R = await Db.One(Pool,
"SELECT Seq, SpaceId, SourceFile, RecordIndex FROM Containers WHERE Layout = 'pt' ORDER BY Seq LIMIT 1 OFFSET " + Off);
if (R) Picked.push(R);
}
const Out = [];
for (const P of Picked) {
const D = Dirs.find((X) => X.SpaceId === String(P.SpaceId));
const File = D ? path.join(D.Dir, String(P.SourceFile)) : null;
const Rec = { Seq: P.Seq, SpaceId: P.SpaceId, File: P.SourceFile, Index: Number(P.RecordIndex) };
const Body = File && fs.existsSync(File) ? ReadFrameBody(File) : null;
if (!Body) {
Out.push(Object.assign(Rec, { Ok: false, Why: '找不到原始帧' }));
continue;
}
const Decoded = Sem.DecodeFrameBody(Body);
const R = Decoded.Records[Number(P.RecordIndex)];
if (!R || R.Kind !== 'container' || R.Layout !== 'pt') {
Out.push(Object.assign(Rec, { Ok: false, Why: '帧里第 ' + P.RecordIndex + ' 条不是 pt 容器记录' }));
continue;
}
const V = R.Value;
const ObjIds = V.Objects.map((O) => String(O.ObjectId));
const Slices = await LoadSlices(Pool, P.SpaceId, ObjIds);
const Rebuilt = [];
let Missing = 0;
for (const Id of ObjIds) {
const Slice = Slices.get(Id);
if (!Slice) {
Missing++;
continue;
}
for (const It of Slice) Rebuilt.push(It);
}
const ItemsSame = StableJson(Rebuilt) === StableJson(V.Items);
// 逐对象定位:若某对象的切片与帧里不一致,把对象 id 点出来。唯一键是 (SpaceId, ObjectId, Ord),
// 所以只有「同一空间内同一对象被多条 pt 重发且内容不同」才会对不上(实测为 0,见报告)。
const Bad = [];
const G = GroupPtItems(V.Items, V.Objects);
if (G.Ok) {
for (let K = 0; K < G.Groups.length; K++) {
const Id = ObjIds[K];
const Stored = Slices.get(Id) || [];
if (StableJson(Stored) !== StableJson(G.Groups[K].Items)) {
Bad.push(Object.assign(
{ ObjectId: Id, Ord: K, InFrame: G.Groups[K].Items.length, InDb: Stored.length },
FirstJsonDiff(G.Groups[K].Items, Stored) || {},
));
}
}
}
let BytesSame = false;
let Err = null;
try {
const Built = Sem.Container.Write('pt', { Objects: V.Objects, Items: Rebuilt });
BytesSame = Built.equals(Body.slice(R.At + 16, R.At + 16 + R.Consumed));
} catch (E) {
Err = E.message;
}
Out.push(Object.assign(Rec, {
Objects: V.Objects.length, Items: V.Items.length, Rebuilt: Rebuilt.length,
MissingObjects: Missing, ItemsSame, BytesSame, Err, Bad,
Loaded: [...Slices.values()].reduce((A, B) => A + B.length, 0),
}));
}
return Out;
}
// ③ 行数统计(含派生的那 9 个数)
async function CountAll(Pool) {
const Count = {};
for (const T of TARGET_TABLES.concat(['Accounts'])) {
Count[T] = Number((await Db.One(Pool, 'SELECT COUNT(*) AS N FROM `' + T + '`')).N);
}
const One2 = async (Sql) => Number((await Db.One(Pool, Sql)).N);
Count.DepotCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE InDepot = 1');
Count.MarketCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE InMarket = 1');
Count.MountedCount = await One2('SELECT COUNT(*) AS N FROM Items WHERE Mounted = 1');
Count.DeviceSlotCount = Count.DeviceSlots;
Count.DeviceCount = Count.SlotDevices;
Count.ObjectCount = Count.SpaceObjects;
Count.ModelItemCount = Count.ObjectModels;
Count.BatchCount = Count.DependencyBatches;
Count.ContainerCount = Count.Containers;
return Count;
}
// ④ MySQL 侧核对(直接查,原始输出原样贴报告)
async function VerifyMysql(Pool) {
const A = await Db.One(Pool, 'SELECT COUNT(*) AS N, MIN(ObjectId) AS Lo, MAX(ObjectId) AS Hi FROM SpaceObjects');
const B = await Db.One(Pool, 'SELECT COUNT(*) AS N FROM Mounted WHERE DeviceId IS NOT NULL');
const C = await Db.One(Pool, 'SELECT COUNT(*) AS N FROM ObjectModels WHERE ObjectId IS NULL');
return { SpaceObjects: A, MountedWithDevice: B, NullObjectModels: C };
}
function Report() {
console.log('');
console.log('=== 解析统计 ===');
console.log('目录 ' + Stat.Dirs + ' / 文件 ' + Stat.Files + '(非整帧 ' + Stat.NotSingleFrame + ')→ 帧 ' + Stat.Frames +
'(含解不动记录的帧 ' + Stat.FramesPartBad + ')');
console.log('记录 ' + Stat.Records + ' 条:容器 ' + Stat.ContainerRecords +
'(pt ' + Stat.Pt + ' / zt ' + Stat.Zt + ' / lt ' + Stat.Lt + ')/ 字段 ' + Stat.FieldRecords +
' / 丢弃(Bad·短命令) ' + Stat.BadRecords);
console.log('ValueJson 合计 ' + (Stat.JsonBytes / 1048576).toFixed(2) + ' MB');
console.log('pt 分组:通过 ' + Stat.PtOk + ' / 失败 ' + Stat.PtFail +
(Stat.PtFail ? '(占 ' + (100 * Stat.PtFail / Math.max(1, Stat.PtOk + Stat.PtFail)).toFixed(2) + '%,NULL 落库项 ' + Stat.NullModelItems + ')' : ''));
for (const E of Stat.PtFailExamples) {
console.log(' 例:' + E.SpaceId + '/' + E.File + ' 第 ' + E.Index + ' 条 Items=' + E.Items + ' Objects=' + E.Objects + ' → ' + E.Why);
}
const MultiVersion = [...Stat.ObjSliceVersions.entries()].filter(([, S]) => S.size > 1);
console.log('同空间内切片有多份不同内容的「空间/对象」:' + MultiVersion.length + ' 个' +
(MultiVersion.length ? '(' + MultiVersion.slice(0, 3).map(([K, S]) => K + '×' + S.size).join(',') + ')' : '') +
' —— 只有这些才会在 ObjectModels 上撞 (SpaceId, ObjectId, Ord) 而只留首次出现的那份');
console.log('字段记录用到:三旗标 ' + Stat.FlagRecords + ' 条 / devicesLoaded ' + Stat.DevicesLoadedRecords + ' 条');
console.log('');
console.log('=== 目录明细 ===');
for (const D of Stat.PerDir) {
console.log(' ' + D.Dir.padEnd(26) + ' SpaceId=' + String(D.SpaceId).padEnd(22) +
' 文件 ' + String(D.Files).padStart(5) + ' 容器 ' + String(D.Containers).padStart(5) +
'(pt ' + D.Pt + ' / zt ' + D.Zt + ' / lt ' + D.Lt + ')字段 ' + String(D.Fields).padStart(4) + ' 丢弃 ' + D.Bad);
}
console.log('');
console.log('=== 待写入行数(KeepFirst:撞主键保留首次出现;被丢的行按整行是否原样重发再分一次)===');
console.log(' ' + '表'.padEnd(20) + '推入'.padEnd(8) + '入库'.padEnd(8) + '撞主键'.padEnd(8) + '原样重发'.padEnd(10) + '内容不同');
for (const P of RowsReport()) {
console.log(' ' + P.Table.padEnd(20) + String(P.Pushed).padEnd(8) + String(P.Distinct).padEnd(8) +
String(P.Collisions).padEnd(8) + String(P.SameRowDropped).padEnd(10) + (P.DistinctFull - P.Distinct));
}
console.log(' zt 的 (SpaceId, X) 去重后 ' + Stat.ZtBatchIds.size + ' 个');
console.log(' 账号 Uid=' + AccountUid);
if (Stat.SpaceObjConflicts) console.log(' ⚠ SpaceObjects 同 ObjectId 撞不同 GameTypeId:' + Stat.SpaceObjConflicts + ' 次(保留首次出现)');
}
// ---------------------------------------------------------------- 主流程
async function Main() {
const Dirs = TargetDirs();
console.log('import_db.js 明文目录=' + PlainDir + ' 名字目录=' + NamesDir);
console.log('目标目录 ' + Dirs.length + ' 个' + (OnlySpace ? '(--space ' + OnlySpace + ')' : '') + (Dry ? '(--dry:不连库、不写库)' : ''));
if (!VerifyOnly) {
ScanAll(Dirs);
Report();
if (Dry) return;
}
const Pool = Db.Connect(Db.LoadConfig());
try {
const Tables = await Db.EnsureSchema(Pool);
console.log('');
console.log('库 ' + Db.LoadConfig().Database + ' 已有表 ' + Tables.length + ' 张:' + Tables.join(', '));
if (!VerifyOnly) {
const Written = await WriteAll(Pool);
console.log('');
console.log('=== 已写入(KeepFirst upsert;被丢的行按整行是否原样重发再分一次)===');
console.log(' ' + '表'.padEnd(20) + '推入'.padEnd(8) + '入库'.padEnd(8) + '撞主键'.padEnd(8) + '原样重发'.padEnd(10) + '内容不同');
for (const P of Written) {
console.log(' ' + P.Table.padEnd(20) + String(P.Pushed).padEnd(8) + String(P.Distinct).padEnd(8) +
String(P.Collisions).padEnd(8) + String(P.SameRowDropped).padEnd(10) + (P.DistinctFull - P.Distinct));
}
console.log(' ' + 'Accounts'.padEnd(20) + String(1).padEnd(8) + String(1).padEnd(8) + String(0).padEnd(8) + String(0).padEnd(10) + 0 + ' (Uid=' + AccountUid + ',LoginHash=NULL)');
}
console.log('');
console.log('=== 验证① 逐字节重建(Containers.ValueJson → container.Write 现编 vs 帧里原始负载)===');
const V1 = await VerifyRebuild(Pool, Dirs);
console.log(' 一致 ' + V1.Same + ' / 不一致 ' + V1.Diff);
for (const W of V1.Worst) {
console.log(' 不一致:Seq=' + W.Seq + ' ' + W.SpaceId + '/' + W.File + ' 第 ' + W.Index + ' 条 Layout=' + W.Layout +
' 差异偏移=' + W.DiffAt + ' 现编 ' + W.Below + 'B vs 原始 ' + W.Orig + 'B' + (W.Err ? ' 抛错:' + W.Err : ''));
}
console.log('');
console.log('=== 验证② 对象切片可重组(随机 3 个 pt 容器,种子固定 20260917 → 可复算)===');
const V2 = await VerifyReassemble(Pool, Dirs);
for (const R of V2) {
console.log(' Seq=' + R.Seq + ' ' + R.SpaceId + '/' + R.File + ' 第 ' + R.Index + ' 条 对象 ' + R.Objects +
' Items ' + R.Items + ' → 拼回 ' + R.Rebuilt + '(从库读 ' + R.Loaded + ',缺对象 ' + R.MissingObjects + ')' +
' Items 逐项相等 ' + (R.ItemsSame ? '✓' : '✗') + ' 整段 Write 逐字节一致 ' + (R.BytesSame ? '✓' : '✗') +
(R.Why ? ' ' + R.Why : '') + (R.Err ? ' 抛错:' + R.Err : ''));
for (const B of (R.Bad || []).slice(0, 3)) {
console.log(' 不一致对象 ObjectId=' + B.ObjectId + '(第 ' + B.Ord + ' 组):帧里 ' + B.InFrame + ' 项 vs 库里 ' + B.InDb + ' 项' +
(B.Path !== undefined ? ';首处差异 ' + (B.Path || '/') + ':帧 ' + JSON.stringify(B.A) + ' vs 库 ' + JSON.stringify(B.B) : ''));
}
}
console.log('');
console.log('=== 验证③ 行数统计 ===');
const C = await CountAll(Pool);
for (const T of TARGET_TABLES.concat(['Accounts'])) console.log(' ' + T.padEnd(20) + C[T]);
console.log(' ---- 派生 ----');
for (const K of ['DepotCount', 'MarketCount', 'MountedCount', 'DeviceSlotCount', 'DeviceCount', 'ObjectCount', 'ModelItemCount', 'BatchCount', 'ContainerCount']) {
console.log(' ' + K.padEnd(20) + C[K]);
}
console.log('');
console.log('=== 验证④ MySQL 侧核对(原始输出)===');
const M = await VerifyMysql(Pool);
console.log(' SELECT COUNT(*), MIN(ObjectId), MAX(ObjectId) FROM SpaceObjects; → ' +
M.SpaceObjects.N + ' | ' + M.SpaceObjects.Lo + ' | ' + M.SpaceObjects.Hi);
console.log(' SELECT COUNT(*) FROM Mounted WHERE DeviceId IS NOT NULL; → ' + M.MountedWithDevice.N);
console.log(' SELECT COUNT(*) FROM ObjectModels WHERE ObjectId IS NULL; → ' + M.NullObjectModels.N + '(分组失败的兜底行,应为 0)');
} finally {
await Pool.end();
}
}
Main().catch((Err) => {
console.error('失败:' + (Err && Err.stack ? Err.stack : Err));
process.exit(1);
});