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