fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)

实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464
字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。
因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。

- default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的
  sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录)
- models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 →
  名称 匹配,都落空才回落到 VRM 优先规则并告警
- ModelDto 增加 sourceSha256(非压缩包上传为 null)
- scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析
  (文件当第一个参数时识别不出,会打印用法)
- 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论;
  README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正

另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系;
PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。

验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重
导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
This commit is contained in:
2026-09-15 19:36:37 +08:00
parent e4351c5062
commit ccced34dd6
12 changed files with 164 additions and 48 deletions
+2 -1
View File
@@ -243,7 +243,8 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
`FBX-DOM no Objects dictionary found`),实际入库为 GLB。
- **默认模型**:转换成功且 `autoDefault` 不为 false(默认 true)→ `is_primary=1` +
`status=published`,并把身份写进源码种子;`GET /api/catalog/export` 的
`defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子(按产物 sha256 匹配)
`defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子(按**上传压缩包**的 sha256 匹配;
产物 sha256 每次转换都变,只作记录)
→ 已发布且 is_primary 的 VRM → 任意已发布 VRM → id 最小的已发布模型。
- **溯源**:压缩包原件存为 `model-archive` 资产,记入 `models.source_asset_id`
(迁移 `AddModelConversionFields1789450000000`);`models.conversion_note` 记转换说明。
+2 -1
View File
@@ -315,7 +315,8 @@ deploy/.env 初始化引导(deploy/init-env.sh)
0. 环境变量 DEFAULT_MODEL_ID(且模型存在且已发布)
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED——按转换产物
sha256 匹配已发布模型,换库 / 重建库后仍有效
匹配已发布模型(主键是**上传压缩包的 sha256**,用户文件永远稳定;
不能用转换产物的 sha256 —— Assimp 每次导出的字节都略有不同),换库 / 重建库后仍有效
2. format='vrm' 且 published 且 is_primary=1
3. 任意 published 的 VRM(id 升序取第一个)
4. id 最小的已发布模型
+6 -2
View File
@@ -103,7 +103,8 @@ rejected 已驳回
| `fileSize` | number | 读 | |
| `thumbnailUrl` | string\|null | 读 | |
| `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`);直传模型为 null |
| `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256,用于换库后定位默认模型 |
| `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 —— **每次转换都会变**,仅作记录 |
| `sourceSha256` | string\|null | 读 | 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null |
| `source*` | | ✅ | 来源五元组 |
| `status` | `PublishStatus` | | |
| `createdAt` / `updatedAt` | string | 读 | |
@@ -115,7 +116,10 @@ rejected 已驳回
```
0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
1. 源码内写死的默认模型种子(web-server/src/config/default-model.ts 的
DEFAULT_MODEL_SEED):按转换产物 sha256 匹配已发布模型 —— 换库/重建库后仍然有效
DEFAULT_MODEL_SEED):按 **sourceSha256(上传压缩包的 sha256)** 匹配已发布模型,
换库/重建库后仍然有效;没有压缩包时退回按产物 sha256,最后按名称兜底。
⚠ 不能用产物 sha256 当主键:实测 Assimp 的 GLB 导出不是字节级确定的,
同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 有十几字节浮动)
2. models 中 format = 'vrm' 且 status = 'published' 且 is_primary = 1
3. models 中 format = 'vrm' 且 status = 'published',按 id 升序取第一个
4. models 中 status = 'published',按 (format='vrm' DESC, id ASC) 取第一个
+1
View File
@@ -23,6 +23,7 @@
| 贴图 | GLB 产物里 Assimp 会把贴图写成外部 URI(还带 `\` 反斜杠),服务端把压缩包内的贴图**内嵌**进 GLB 的二进制块,保证单文件可加载 |
| 溯源 | 压缩包原件以 `model-archive` 资产留存,写入 `models.source_asset_id` |
| 默认模型 | 转换成功且 `autoDefault` 不为 false:`is_primary=1` + `status=published`,并把身份写进源码 `web-server/src/config/default-model.ts`,见 `data-models.md` §4.1 |
| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换三次得到三个不同的 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**压缩包 sha256** 为匹配键,产物 sha256 只作记录 |
| 失败 | 压缩包里没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,不产生模型记录 |
安全:解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE。
+8 -2
View File
@@ -331,8 +331,10 @@ paths:
压缩包原件作为 `model-archive` 资产留存以便溯源。
转换成功且 `autoDefault` 不为 false 时,该模型会被置为全局默认模型
(`isPrimary=1` + `status=published`),并把身份(名称/格式/产物 sha256)
(`isPrimary=1` + `status=published`),并把身份(名称/格式/**压缩包 sha256**)
写进源码 `web-server/src/config/default-model.ts`。
注意:**转换产物不是字节级确定的**(同一份 zip 每次转换的产物 sha256 都不同),
所以默认模型种子以压缩包 sha256 为匹配键,产物 sha256 只作记录。
requestBody:
required: true
content:
@@ -774,7 +776,11 @@ components:
fileSha256:
type: string
nullable: true
description: 模型文件(转换产物)的 sha256,用于换库后定位默认模型
description: 模型文件(转换产物)的 sha256 —— 每次转换都会变(Assimp 导出非字节级确定),仅作记录
sourceSha256:
type: string
nullable: true
description: 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null
status: { $ref: '#/components/schemas/PublishStatus' }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
+2 -1
View File
@@ -40,7 +40,8 @@ web-server/scripts/import-default-model.mjs
```
0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED(按转换产物 sha256 匹配)
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED(按**上传压缩包**的 sha256 匹配;
转换产物 sha256 每次不同,只作记录)
2. format='vrm' 且 published 且 is_primary=1
3. format='vrm' 且 published,按 id 升序取第一个
4. published,按 (format='vrm' DESC, id ASC) 取第一个
+36 -5
View File
@@ -41,8 +41,33 @@ const flag = (name, fallback = undefined) => {
};
const has = (name) => args.includes(name);
const positional = args.find((a) => !a.startsWith('--') && args[args.indexOf(a) - 1]?.startsWith('--') === false);
const file = positional;
/** 需要跟一个取值的开关(其余 --x 视为布尔开关) */
const VALUE_FLAGS = new Set([
'--url',
'--name',
'--author',
'--license',
'--source-name',
'--source-url',
'--source-type',
'--model-id',
'--dir',
'--env-file',
]);
/** 第一个"不是开关、也不是开关取值"的参数即输入文件 */
function positionalFile(argv) {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg.startsWith('--')) {
if (VALUE_FLAGS.has(arg)) i += 1;
continue;
}
return arg;
}
return undefined;
}
const file = positionalFile(args);
const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, '');
const writeSourceOnly = has('--write-source-only');
@@ -118,8 +143,8 @@ async function main() {
console.log(`转换说明:${model.conversionNote ?? '(无)'}`);
}
if (!model.fileSha256) {
console.error('该模型没有 fileSha256(文件缺失?),无法写入默认模型种子');
if (!model.fileSha256 && !model.sourceSha256) {
console.error('该模型没有 fileSha256 / sourceSha256(文件缺失?),无法写入默认模型种子');
process.exit(1);
}
@@ -127,10 +152,16 @@ async function main() {
id: model.id,
name: model.name,
format: model.format,
sha256: model.fileSha256,
// 主匹配键用上传压缩包的 sha256(稳定);直接上传模型文件时退回产物 sha256
sourceSha256: model.sourceSha256 ?? model.fileSha256,
fileSha256: model.fileSha256 ?? '',
convertedFrom: (model.conversionNote ?? '').split(',')[0] || null,
updatedAt: new Date().toISOString(),
};
console.log(` 种子匹配键:sourceSha256=${String(seed.sourceSha256).slice(0, 16)}…`);
if (model.sourceSha256) {
console.log(' (用的是压缩包 sha256:Assimp 转换产物每次都不一样,只有它稳定)');
}
const content = renderDefaultModelSource(seed);
if (dryRun) {
+19 -8
View File
@@ -5,8 +5,9 @@
* 运行时只读:`ModelsService.resolveDefaultModelId()` 会先看这里,
* 再回落到契约 `data-models.md` §4.1 的 VRM 优先规则。
*
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
* 用转换产物的 sha256 才能稳定指回同一个模型。
* 为什么记上传压缩包的 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变;
* 而**转换产物的 sha256 每次转换都不一样**(Assimp 的 GLB 导出非字节级确定),
* 只有用户上传的那份压缩包才是稳定标识。
*
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
*/
@@ -18,8 +19,17 @@ export interface DefaultModelSeed {
name: string;
/** 模型格式:fbx / glb / vrm */
format: string;
/** 转换产物文件的 sha256(定位默认模型的依据) */
sha256: string;
/**
* 定位默认模型的主依据:**上传压缩包的 sha256**(用户文件,永远稳定)。
* 注意不能用转换产物的 sha256:实测 Assimp 的 GLB 导出不是字节级确定的,
* 同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 里有十几字节浮动)。
*/
sourceSha256: string;
/**
* 转换产物文件的 sha256 —— **仅作记录,不用于匹配**(每次转换都会变)。
* 直接上传模型文件(非压缩包)时,它同时就是 sourceSha256。
*/
fileSha256: string;
/** 转换来源说明,例如 `Hatsune Miku.pmx → GLB`;非转换模型为 null */
convertedFrom: string | null;
/** 最近一次写入时间(ISO 8601) */
@@ -28,15 +38,16 @@ export interface DefaultModelSeed {
/** 由 Hatsune Miku.pmx → GLB 转换得到,已设为默认模型 */
export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
id: 16,
id: 20,
name: "初音未来 Hatsune Miku",
format: "glb",
sha256: "6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464",
sourceSha256: "d1113cf074e19d3a5b4e7025973604c615d6e038ad222070e4748a805ac3c73f",
fileSha256: "696c90c7e8514b759f764312d41f9b9069027e62f3aaa2d83ce2c3e475945028",
convertedFrom: "Hatsune Miku.pmx → GLB",
updatedAt: "2026-09-15T05:45:19.168Z",
updatedAt: "2026-09-15T11:34:23.976Z",
};
/** 该种子是否指向一个真实模型 */
export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean {
return seed.sha256.trim().length > 0;
return seed.sourceSha256.trim().length > 0 || seed.fileSha256.trim().length > 0;
}
+18 -7
View File
@@ -7,7 +7,7 @@ import { DefaultModelSeed } from '../config/default-model';
* 把默认模型写进源码(`web-server/src/config/default-model.ts`)。
*
* 需求原文是"转换成功则把这个模型设置为默认模型写进源码中",因此这里不是只改数据库,
* 而是把种子(名称 / 格式 / 产物 sha256)落进源码文件,随代码一起提交、一起部署。
* 而是把种子(名称 / 格式 / **上传压缩包的 sha256**)落进源码文件,随代码一起提交、一起部署。
*
* 注意:生产环境 systemd 单元带 `ProtectSystem=strict`,服务进程**没有**程序目录的写权限,
* 因此写入通常由 `scripts/import-default-model.mjs`(在服务沙箱之外、以 root 运行)完成;
@@ -27,8 +27,9 @@ export function renderDefaultModelSource(seed: DefaultModelSeed): string {
* 运行时只读:\`ModelsService.resolveDefaultModelId()\` 会先看这里,
* 再回落到契约 \`data-models.md\` §4.1 的 VRM 优先规则。
*
* 为什么记 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变,
* 用转换产物的 sha256 才能稳定指回同一个模型。
* 为什么记上传压缩包的 sha256 而不是只记 id:换库 / 重建库之后自增 id 会变;
* 而**转换产物的 sha256 每次转换都不一样**(Assimp 的 GLB 导出非字节级确定),
* 只有用户上传的那份压缩包才是稳定标识。
*
* 本文件由脚本重新生成,手改请注意保持结构(键名与类型不要变)。
*/
@@ -40,8 +41,17 @@ export interface DefaultModelSeed {
name: string;
/** 模型格式:fbx / glb / vrm */
format: string;
/** 转换产物文件的 sha256(定位默认模型的依据) */
sha256: string;
/**
* 定位默认模型的主依据:**上传压缩包的 sha256**(用户文件,永远稳定)。
* 注意不能用转换产物的 sha256:实测 Assimp 的 GLB 导出不是字节级确定的,
* 同一份 zip 每次转换得到的文件 sha256 都不同(BIN chunk 里有十几字节浮动)。
*/
sourceSha256: string;
/**
* 转换产物文件的 sha256 —— **仅作记录,不用于匹配**(每次转换都会变)。
* 直接上传模型文件(非压缩包)时,它同时就是 sourceSha256。
*/
fileSha256: string;
/** 转换来源说明,例如 \`Hatsune Miku.pmx → GLB\`;非转换模型为 null */
convertedFrom: string | null;
/** 最近一次写入时间(ISO 8601) */
@@ -53,14 +63,15 @@ export const DEFAULT_MODEL_SEED: DefaultModelSeed = {
id: ${seed.id === null ? 'null' : seed.id},
name: ${quote(seed.name)},
format: ${quote(seed.format)},
sha256: ${quote(seed.sha256)},
sourceSha256: ${quote(seed.sourceSha256)},
fileSha256: ${quote(seed.fileSha256)},
convertedFrom: ${seed.convertedFrom === null ? 'null' : quote(seed.convertedFrom)},
updatedAt: ${seed.updatedAt === null ? 'null' : quote(seed.updatedAt)},
};
/** 该种子是否指向一个真实模型 */
export function hasDefaultModelSeed(seed: DefaultModelSeed = DEFAULT_MODEL_SEED): boolean {
return seed.sha256.trim().length > 0;
return seed.sourceSha256.trim().length > 0 || seed.fileSha256.trim().length > 0;
}
`;
}
+5 -1
View File
@@ -10,8 +10,10 @@ export interface ModelDto {
isPrimary: boolean;
fileUrl: string | null;
fileSize: number | null;
/** 模型文件(转换产物)的 sha256,用于换库后定位默认模型 */
/** 模型文件(转换产物)的 sha256 —— 每次转换都会变,仅作记录 */
fileSha256: string | null;
/** 上传压缩包的 sha256(稳定);非压缩包上传为 null,此时以 fileSha256 为准 */
sourceSha256: string | null;
thumbnailUrl: string | null;
/** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */
conversionNote: string | null;
@@ -30,6 +32,7 @@ export interface ModelUrlLookup {
thumbnailUrl: string | null;
fileSize?: number | null;
fileSha256?: string | null;
sourceSha256?: string | null;
}
export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto {
@@ -42,6 +45,7 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt
fileUrl: urls.fileUrl,
fileSize: urls.fileSize ?? null,
fileSha256: urls.fileSha256 ?? null,
sourceSha256: urls.sourceSha256 ?? null,
thumbnailUrl: urls.thumbnailUrl,
conversionNote: model.conversionNote ?? null,
sourceType: model.sourceType,
+50 -12
View File
@@ -227,7 +227,11 @@ export class ModelsService {
);
if (autoDefault) {
await this.applyDefaultModel(saved.id, stored.asset.sha256, converted.note);
await this.applyDefaultModel(saved.id, {
sourceSha256: archiveAsset.asset.sha256,
fileSha256: stored.asset.sha256,
note: converted.note,
});
// setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确
const [dto] = await this.toDtos([await this.getEntity(saved.id)]);
return dto;
@@ -242,15 +246,19 @@ export class ModelsService {
* `ProtectSystem=strict` 不允许服务进程改程序目录,通常由
* `scripts/import-default-model.mjs` 在沙箱外完成写入。
*/
private async applyDefaultModel(id: number, sha256: string, note: string): Promise<void> {
private async applyDefaultModel(
id: number,
payload: { sourceSha256: string; fileSha256: string; note: string },
): Promise<void> {
await this.setPrimary(id);
const model = await this.getEntity(id);
const result = await writeDefaultModelSeed({
id,
name: model.name,
format: model.format,
sha256,
convertedFrom: note.split(',')[0] ?? null,
sourceSha256: payload.sourceSha256,
fileSha256: payload.fileSha256,
convertedFrom: payload.note.split(',')[0] ?? null,
updatedAt: new Date().toISOString(),
});
if (result.written) {
@@ -335,14 +343,42 @@ export class ModelsService {
this.logger.warn(`DEFAULT_MODEL_ID=${configured} 指向的模型不存在或未发布,已忽略`);
}
const seedSha = DEFAULT_MODEL_SEED.sha256.trim();
if (seedSha) {
const assets = await this.storage.findMany(published.map((m) => m.fileAssetId));
const seeded = published.find((m) => {
const asset = m.fileAssetId === null ? null : assets.get(m.fileAssetId);
return asset?.sha256 === seedSha;
// 主依据是"上传压缩包的 sha256"(稳定);转换产物的 sha256 每次都会变,
// 只在没有压缩包(直接上传模型文件)时才拿它当依据;最后再兜底按名字匹配。
const sourceSha = DEFAULT_MODEL_SEED.sourceSha256.trim();
const fileSha = DEFAULT_MODEL_SEED.fileSha256.trim();
if (sourceSha || fileSha) {
const assets = await this.storage.findMany(
published.flatMap((m) => [m.sourceAssetId, m.fileAssetId]),
);
const match = (wantSource: boolean): number | null => {
const wanted = wantSource ? sourceSha : fileSha;
if (!wanted) return null;
const hit = published.find((m) => {
const asset = wantSource
? m.sourceAssetId === null
? null
: assets.get(m.sourceAssetId)
: m.fileAssetId === null
? null
: assets.get(m.fileAssetId);
return asset?.sha256 === wanted;
});
if (seeded) return seeded.id;
return hit ? hit.id : null;
};
const bySource = match(true);
if (bySource !== null) return bySource;
const byFile = match(false);
if (byFile !== null) return byFile;
const seedName = DEFAULT_MODEL_SEED.name.trim();
if (seedName) {
const byName = published.find((m) => m.name === seedName);
if (byName) {
this.logger.warn('默认模型种子按 sha256 未匹配到,已按名称匹配');
return byName.id;
}
}
this.logger.warn('源码里的默认模型种子在内库中找不到对应模型,回落到 VRM 优先规则');
}
@@ -365,16 +401,18 @@ export class ModelsService {
async toDtos(rows: CharacterModel[]): Promise<ModelDto[]> {
const assets = await this.storage.findMany(
rows.flatMap((row) => [row.fileAssetId, row.thumbnailAssetId]),
rows.flatMap((row) => [row.fileAssetId, row.thumbnailAssetId, row.sourceAssetId]),
);
return rows.map((row) => {
const file = row.fileAssetId ? assets.get(row.fileAssetId) : undefined;
const thumb = row.thumbnailAssetId ? assets.get(row.thumbnailAssetId) : undefined;
const source = row.sourceAssetId ? assets.get(row.sourceAssetId) : undefined;
return toModelDto(row, {
fileUrl: file ? this.storage.url(file) : null,
thumbnailUrl: thumb ? this.storage.url(thumb) : null,
fileSize: file?.size ?? null,
fileSha256: file?.sha256 ?? null,
sourceSha256: source?.sha256 ?? null,
});
});
}
+14 -7
View File
@@ -363,14 +363,16 @@ describe('默认模型写入源码', () => {
id: 16,
name: '初音未来 Hatsune Miku',
format: 'glb',
sha256: 'a'.repeat(64),
sourceSha256: 'a'.repeat(64),
fileSha256: 'b'.repeat(64),
convertedFrom: 'Hatsune Miku.pmx → GLB',
updatedAt: '2026-09-15T00:00:00.000Z',
});
expect(content).toContain('export interface DefaultModelSeed');
expect(content).toContain("name: \"初音未来 Hatsune Miku\"");
expect(content).toContain('id: 16');
expect(content).toContain('sha256: "' + 'a'.repeat(64) + '"');
expect(content).toContain('sourceSha256: "' + 'a'.repeat(64) + '"');
expect(content).toContain('fileSha256: "' + 'b'.repeat(64) + '"');
expect(content).toContain('convertedFrom: "Hatsune Miku.pmx → GLB"');
expect(content).toContain('export function hasDefaultModelSeed');
});
@@ -380,7 +382,8 @@ describe('默认模型写入源码', () => {
id: null,
name: '',
format: '',
sha256: '',
sourceSha256: '',
fileSha256: '',
convertedFrom: null,
updatedAt: null,
});
@@ -397,20 +400,23 @@ describe('默认模型写入源码', () => {
id: null,
name: '',
format: '',
sha256: ' ',
sourceSha256: ' ',
fileSha256: '',
convertedFrom: null,
updatedAt: null,
}),
).toBe(false);
expect(hasDefaultModelSeed(DEFAULT_MODEL_SEED)).toBe(
DEFAULT_MODEL_SEED.sha256.trim().length > 0,
DEFAULT_MODEL_SEED.sourceSha256.trim().length > 0 ||
DEFAULT_MODEL_SEED.fileSha256.trim().length > 0,
);
expect(
hasDefaultModelSeed({
id: 1,
name: 'x',
format: 'glb',
sha256: 'b'.repeat(64),
sourceSha256: 'b'.repeat(64),
fileSha256: '',
convertedFrom: null,
updatedAt: null,
}),
@@ -422,7 +428,8 @@ describe('默认模型写入源码', () => {
id: 3,
name: '带"引号"与\\反斜杠的模型',
format: 'glb',
sha256: 'c'.repeat(64),
sourceSha256: 'c'.repeat(64),
fileSha256: 'd'.repeat(64),
convertedFrom: 'a.pmx → GLB',
updatedAt: '2026-09-15T00:00:00.000Z',
});