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:
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user