fix(models): 修复上传 PMX 返回 500(models.stats 超 TEXT 上限)与 GBK 丢贴图
现象(真实故障):导入示例文件/【奥黛塔】_by_原神.zip 返回 500 Internal server error。 根因一(500): 该 PMX 643 根骨骼、93,350 面,转换本身成功(GLB 23.7MB),但分析结果里的 骨骼树按骨骼逐条展开,JSON 实测 70,980 字节,而 models.stats 是 TEXT (上限 65,535)。MySQL/MariaDB 严格模式直接拒绝写入(ER_DATA_TOO_LONG / 1406),而这一步没有兜底 -> 未捕获 -> 500。 根因二(丢贴图): tex/髮.png 的 GBK 字节是 F3 8C。文件名解码先试 Shift-JIS,而 WHATWG 的 shift_jis 把未定义码位映射成私用区不可见字符(不是 U+FFFD),于是被判为 「解码成功」,永不回退 GBK;贴图名对不上,该模型 18 张贴图只内嵌了 17 张。 修复: 1. models.stats -> MEDIUMTEXT(实体 mediumtext + transformer,保留 JSON 语义; 不用 simple-json 以免元数据与库列型不一致),新增迁移 AddModelStatsMediumText1789550000000; 2. 新增 shrinkStatsForStorage() 兜底:即使库未迁移,也只截断骨骼树 (父在前,保留祖先链),面数/骨骼数/humanoid 覆盖/可驱动性/warnings 全部保留; 服务层 saveWithStatsFallback() 仅在 1406/DATA_TOO_LONG 时裁剪重试一次, 其它数据库错误照旧抛出; 3. 压缩包文件名解码把「私用区字符」也算作解错信号,回退 GBK;实测该模型 贴图由 17/18 变为 18/18 全部内嵌; 4. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项); 5. 版本 0.2.3,README(.md/.txt)/DSH.md 补修订说明、500 的 FAQ 与 stats 容量约束。
This commit is contained in:
@@ -667,3 +667,70 @@ export async function inspectModelFile(absPath: string, fileSize?: number): Prom
|
||||
|
||||
return analyzeGltfDocument(gltf, glb ? 'glb' : 'gltf', size, warnings);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- 存储兜底:裁剪超大的 stats */
|
||||
|
||||
/** MySQL / MariaDB 的 TEXT 上限(未迁移 MEDIUMTEXT 的老库仍是这个值) */
|
||||
export const MYSQL_TEXT_LIMIT = 65535;
|
||||
|
||||
/** 裁剪后至少保留的骨骼条目数:太少审核界面就看不出结构了 */
|
||||
const MIN_BONE_TREE_KEPT = 24;
|
||||
|
||||
/**
|
||||
* 把过大的 `ModelStats` 裁到能安全写进 TEXT 的大小(纯函数,永不抛异常)。
|
||||
*
|
||||
* 为什么需要:`models.stats` 里最占地方的是骨骼树,按骨骼逐条展开。
|
||||
* 实测一个 643 骨骼的 PMX 分析结果 70,980 字节 > TEXT 上限 65,535,
|
||||
* MySQL 严格模式会直接拒绝写入(1406)。迁移已把列扩到 MEDIUMTEXT,
|
||||
* 这里再兜一层:万一跑在没迁移的库上,也宁可少存骨骼明细,不能让上传 500。
|
||||
*
|
||||
* 保留策略:面数 / 骨骼数 / humanoid 覆盖 / 可驱动性 / warnings 一律保留,
|
||||
* 只裁骨骼树(父在前,从头截断即保留祖先链),并追加一条 warning 说明被裁过。
|
||||
*/
|
||||
export function shrinkStatsForStorage(
|
||||
stats: ModelStats,
|
||||
maxBytes: number = MYSQL_TEXT_LIMIT,
|
||||
): ModelStats {
|
||||
const fits = (candidate: ModelStats): boolean => {
|
||||
try {
|
||||
return JSON.stringify(candidate).length <= maxBytes;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (fits(stats)) return stats;
|
||||
|
||||
const note = `骨骼树过大(原 ${stats.boneTree.length} 条),存储前已截断`;
|
||||
const withNote = (boneTree: BoneNode[]): ModelStats => ({
|
||||
...stats,
|
||||
boneTree,
|
||||
warnings: stats.warnings.includes(note) ? stats.warnings : [...stats.warnings, note],
|
||||
});
|
||||
|
||||
// 二分找能放下的最大前缀长度(骨骼树父在前,前缀即祖先链完整的子集)
|
||||
let low = Math.min(MIN_BONE_TREE_KEPT, stats.boneTree.length);
|
||||
let high = stats.boneTree.length;
|
||||
let best = withNote(stats.boneTree.slice(0, low));
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const candidate = withNote(stats.boneTree.slice(0, mid));
|
||||
if (fits(candidate)) {
|
||||
best = candidate;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 极端情况:连最小骨骼树都放不下 -> 只留汇总字段
|
||||
if (!fits(best)) {
|
||||
const summary = withNote([]);
|
||||
if (fits(summary)) return summary;
|
||||
return {
|
||||
...summary,
|
||||
missingRequiredBones: summary.missingRequiredBones.slice(0, 16),
|
||||
warnings: [note],
|
||||
};
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user