diff --git a/DSH.md b/DSH.md index 2471566..b4e8f3e 100644 --- a/DSH.md +++ b/DSH.md @@ -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` 记转换说明。 diff --git a/README.txt b/README.txt index 3683fb8..27d43e7 100644 --- a/README.txt +++ b/README.txt @@ -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 最小的已发布模型 diff --git a/contracts/data-models.md b/contracts/data-models.md index da64438..ea0167d 100644 --- a/contracts/data-models.md +++ b/contracts/data-models.md @@ -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) 取第一个 diff --git a/contracts/model-formats.md b/contracts/model-formats.md index 73c639e..36af55c 100644 --- a/contracts/model-formats.md +++ b/contracts/model-formats.md @@ -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。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 6d9d835..0bbe2bb 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -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 } diff --git a/tasks/005-model-upload.md b/tasks/005-model-upload.md index fa1ec89..854b613 100644 --- a/tasks/005-model-upload.md +++ b/tasks/005-model-upload.md @@ -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) 取第一个 diff --git a/web-server/scripts/import-default-model.mjs b/web-server/scripts/import-default-model.mjs index 029bd5e..9b88376 100644 --- a/web-server/scripts/import-default-model.mjs +++ b/web-server/scripts/import-default-model.mjs @@ -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) { diff --git a/web-server/src/config/default-model.ts b/web-server/src/config/default-model.ts index 65f6291..910ef7c 100644 --- a/web-server/src/config/default-model.ts +++ b/web-server/src/config/default-model.ts @@ -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; } diff --git a/web-server/src/models/default-model-source.ts b/web-server/src/models/default-model-source.ts index 2d59eee..28bd149 100644 --- a/web-server/src/models/default-model-source.ts +++ b/web-server/src/models/default-model-source.ts @@ -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; } `; } diff --git a/web-server/src/models/model.serializer.ts b/web-server/src/models/model.serializer.ts index b68e7cc..01231bc 100644 --- a/web-server/src/models/model.serializer.ts +++ b/web-server/src/models/model.serializer.ts @@ -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, diff --git a/web-server/src/models/models.service.ts b/web-server/src/models/models.service.ts index 71b04c7..dc79ad4 100644 --- a/web-server/src/models/models.service.ts +++ b/web-server/src/models/models.service.ts @@ -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 { + private async applyDefaultModel( + id: number, + payload: { sourceSha256: string; fileSha256: string; note: string }, + ): Promise { 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; - }); - if (seeded) return seeded.id; + // 主依据是"上传压缩包的 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; + }); + 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 { 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, }); }); } diff --git a/web-server/test/model-archive.spec.ts b/web-server/test/model-archive.spec.ts index 8f66fce..79b9ec5 100644 --- a/web-server/test/model-archive.spec.ts +++ b/web-server/test/model-archive.spec.ts @@ -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', });