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:
opendance
2026-09-16 03:30:12 +08:00
parent afed67e175
commit 8e8cc0475b
16 changed files with 464 additions and 35 deletions
+15 -2
View File
@@ -46,8 +46,21 @@ function decoderFor(encoding: string): TextDecoder | null {
return decoders.get(encoding) ?? null;
}
/** 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码 */
/**
* 半角片假名:Shift-JIS 被误用时会出现,用来判断该不该换编码。
*
* 私用区(PUA)同样是「Shift-JIS 解错了」的信号:WHATWG 的 shift_jis 表把
* **未定义的码位**映射到私用区,而不是 U+FFFD,于是解码结果看起来「成功」,
* 却是看不见的乱码字。实测 GBK 的 `髮`(字节 F3 8C)就落在这里:
* 先试 Shift-JIS 得到 `tex/\uE0xx.png`(不可见),永远不会回退到 GBK,
* 贴图名对不上,模型入库后丢贴图。
*/
const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/;
const PRIVATE_USE_AREA = /[\uE000-\uF8FF]/;
/** Shift-JIS 解码结果是否明显解错了(该换 GBK 再试) */
function looksMisdecoded(text: string): boolean {
return text.includes('\uFFFD') || HALF_WIDTH_KATAKANA.test(text) || PRIVATE_USE_AREA.test(text);
}
/** 解压包内文件名:UTF-8 标志位优先,否则试 Shift-JIS(中文/日文压缩包常见) */
function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string {
@@ -57,7 +70,7 @@ function decodeEntryName(bytes: Buffer, utf8Flag: boolean): string {
const shiftJis = decoderFor('shift_jis');
if (shiftJis) {
const decoded = shiftJis.decode(bytes);
if (!decoded.includes('\uFFFD') && !HALF_WIDTH_KATAKANA.test(decoded)) return decoded;
if (!looksMisdecoded(decoded)) return decoded;
}
const gbk = decoderFor('gbk');
if (gbk) {
+28 -1
View File
@@ -52,8 +52,35 @@ export class CharacterModel {
/**
* 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性…),见 model-inspector.ts。
* 存 JSON 文本:审核界面要展示,且骨骼树可能上千行,不适合拆列。
*
* 必须是 **MEDIUMTEXT**(16MB)而不是 TEXT(64KB):骨骼树按骨骼逐条展开,
* 实测一个 643 骨骼的 PMX(【奥黛塔】)分析结果就有 70,980 字节,超过 TEXT 上限,
* MySQL 严格模式下写入直接报 1406,上传接口 500。
* 用 mediumtext + transformer 而不是 simple-json:simple-json 固定映射到 text,
* 实体元数据会和数据库列型不一致(后续 migration:generate 会把列改回 text)。
*/
@Column({ name: 'stats', type: 'simple-json', nullable: true })
@Column({
name: 'stats',
type: 'mediumtext',
nullable: true,
transformer: {
to: (value: Record<string, unknown> | null): string | null =>
value === null || value === undefined
? null
: typeof value === 'string'
? value
: JSON.stringify(value),
from: (value: string | null): Record<string, unknown> | null => {
if (value === null || value === undefined || value === '') return null;
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
// 历史脏数据不应该让整个接口 500
return { parseError: true, raw: value.slice(0, 200) };
}
},
},
})
stats!: Record<string, unknown> | null;
// ---- 来源五元组(必填)----
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* `models.stats` 由 TEXT(64KB)扩到 MEDIUMTEXT(16MB)。
*
* 起因(真实故障):上传含 PMX 的压缩包(【奥黛塔】_by_原神.zip,643 根骨骼)
* 时接口返回 500。模型转换本身成功,但分析结果里的骨骼树按骨骼逐条展开,
* JSON 实测 70,980 字节 > TEXT 上限 65,535,MySQL / MariaDB 严格模式直接拒绝
* 写入(ER_DATA_TOO_LONG / 1406),而写入这一步没有兜底。
*
* MMD / VRM 模型骨骼上千根很常见,MEDIUMTEXT 给到 16MB 足够;
* 服务端另有 shrinkStatsForStorage 兜底,避免在未迁移的库上再次 500。
*/
export class AddModelStatsMediumText1789550000000 implements MigrationInterface {
name = 'AddModelStatsMediumText1789550000000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`models\` MODIFY \`stats\` mediumtext NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 回退可能截断超长数据:这里只改列型,不做内容裁剪
await queryRunner.query(`ALTER TABLE \`models\` MODIFY \`stats\` text NULL`);
}
}
+67
View File
@@ -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;
}
+42 -3
View File
@@ -29,7 +29,7 @@ import {
convertModelFile,
importModelFile,
} from './model-convert';
import { ModelStats, inspectModelFile } from './model-inspector';
import { ModelStats, inspectModelFile, shrinkStatsForStorage } from './model-inspector';
import { renderModelThumbnail } from './model-thumbnail';
import { CharacterModel } from '../entities/model.entity';
import { AppConfig } from '../config/configuration';
@@ -38,6 +38,21 @@ import { QueryModelDto, UpdateModelDto } from './model.dto';
import { ModelDto, toModelDto } from './model.serializer';
import { inspectVrm } from './vrm-inspector';
/** 判断是否为「数据超长」类数据库错误(MySQL 1406 / MariaDB ER_DATA_TOO_LONG) */
function isDataTooLongError(error: unknown): boolean {
const err = error as {
code?: string;
errno?: number;
driverError?: { code?: string; errno?: number };
message?: string;
};
const code = err.driverError?.code ?? err.code;
const errno = err.driverError?.errno ?? err.errno;
if (errno === 1406) return true;
if (typeof code === 'string' && /DATA_TOO_LONG/i.test(code)) return true;
return typeof err.message === 'string' && /Data too long/i.test(err.message);
}
@Injectable()
export class ModelsService {
private readonly logger = new Logger(ModelsService.name);
@@ -202,7 +217,7 @@ export class ModelsService {
// 上传一律待审核:模型必须过审核才可能被客户端消费
status: PublishStatus.Pending,
});
const saved = await this.models.save(model);
const saved = await this.saveWithStatsFallback(model);
this.logger.log(
`模型已入库待审:#${saved.id} ${name}(${imported.format},${imported.direct ? '直接导入' : '转换'})` +
` 面数=${stats.triangles} 骨骼=${stats.bones} humanoid=${stats.humanoidMapped}/${stats.humanoidTotal}` +
@@ -241,7 +256,7 @@ export class ModelsService {
if (thumbnailAssetId !== null) model.thumbnailAssetId = thumbnailAssetId;
if (stats.vrmVersion) model.vrmVersion = stats.vrmVersion;
const saved = await this.models.save(model);
const saved = await this.saveWithStatsFallback(model);
this.logger.log(
`模型 #${id} 重新分析完成:面数=${stats.triangles} 骨骼=${stats.bones} ` +
`humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}` +
@@ -251,6 +266,30 @@ export class ModelsService {
return dto;
}
/**
* 保存模型;若因 `stats` 超长被数据库拒绝,裁剪后重试一次。
*
* 骨骼树上千根的大模型并不罕见:TEXT(64KB) 时代实测 643 骨骼的 PMX 就会
* 触发 1406 报错,而这一步抛出去就是 500。列已迁到 MEDIUMTEXT,
* 这里再兜一层,保证「分析结果太大」永远不会让上传失败。
*/
private async saveWithStatsFallback(model: CharacterModel): Promise<CharacterModel> {
try {
return await this.models.save(model);
} catch (error) {
if (!isDataTooLongError(error)) throw error;
const original = model.stats as unknown as ModelStats | null;
if (!original) throw error;
const shrunk = shrinkStatsForStorage(original);
this.logger.warn(
`模型「${model.name}」分析结果超出数据库字段上限(${JSON.stringify(original).length} 字节),` +
`已裁剪骨骼树至 ${shrunk.boneTree.length} 条后重试`,
);
model.stats = shrunk as unknown as Record<string, unknown>;
return await this.models.save(model);
}
}
/** 分析模型;任何异常都降级为一条 warning,不阻断上传 */
private async analyzeSafely(
absolutePath: string,