feat(web): T-WEB-005 模型上传 + VRM 优先 + 版本识别

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent ff8c4dd26a
commit 73e793318e
7 changed files with 517 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { Type } from 'class-transformer';
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { ModelFormat, PublishStatus } from '../common/enums';
import { PageQueryDto } from '../common/pagination';
import { PartialSourceFieldsDto } from '../common/source.dto';
export class UpdateModelDto extends PartialSourceFieldsDto {
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@IsOptional()
@IsEnum(PublishStatus)
status?: PublishStatus;
}
export class QueryModelDto extends PageQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
q?: string;
@IsOptional()
@IsEnum(ModelFormat)
format?: ModelFormat;
@IsOptional()
@IsEnum(PublishStatus)
status?: PublishStatus;
@IsOptional()
@Type(() => Boolean)
primaryOnly?: boolean;
}
+49
View File
@@ -0,0 +1,49 @@
import { ModelFormat, PublishStatus, SourceType } from '../common/enums';
import { CharacterModel } from '../entities/model.entity';
/** 契约 data-models.md §4 的对外表示 */
export interface ModelDto {
id: number;
name: string;
format: ModelFormat;
vrmVersion: string | null;
isPrimary: boolean;
fileUrl: string | null;
fileSize: number | null;
thumbnailUrl: string | null;
sourceType: SourceType;
sourceName: string;
sourceUrl: string;
license: string;
author: string;
status: PublishStatus;
createdAt: string;
updatedAt: string;
}
export interface ModelUrlLookup {
fileUrl: string | null;
thumbnailUrl: string | null;
fileSize?: number | null;
}
export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDto {
return {
id: model.id,
name: model.name,
format: model.format,
vrmVersion: model.vrmVersion,
isPrimary: model.isPrimary,
fileUrl: urls.fileUrl,
fileSize: urls.fileSize ?? null,
thumbnailUrl: urls.thumbnailUrl,
sourceType: model.sourceType,
sourceName: model.sourceName,
sourceUrl: model.sourceUrl,
license: model.license,
author: model.author,
status: model.status,
createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(),
};
}
@@ -0,0 +1,63 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Paged } from '../common/pagination';
import { UploadedFileLike } from '../common/upload.util';
import { QueryModelDto, UpdateModelDto } from './model.dto';
import { ModelDto } from './model.serializer';
import { ModelsService } from './models.service';
@Controller('api/models')
export class ModelsController {
constructor(private readonly models: ModelsService) {}
@Get()
list(@Query() query: QueryModelDto): Promise<Paged<ModelDto>> {
return this.models.list(query);
}
/** multipart/form-data:file + name + 来源五元组 + isPrimary/status */
@Post()
@UseInterceptors(FileInterceptor('file'))
create(
@Body() body: Record<string, unknown>,
@UploadedFile() file: UploadedFileLike | undefined,
): Promise<ModelDto> {
return this.models.create(body, file);
}
@Get(':id')
detail(@Param('id', ParseIntPipe) id: number): Promise<ModelDto> {
return this.models.findOne(id);
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateModelDto): Promise<ModelDto> {
return this.models.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.models.remove(id);
}
@Post(':id/primary')
@HttpCode(HttpStatus.OK)
setPrimary(@Param('id', ParseIntPipe) id: number): Promise<ModelDto> {
return this.models.setPrimary(id);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterModel } from '../entities/model.entity';
import { StorageModule } from '../storage/storage.module';
import { ModelsController } from './models.controller';
import { ModelsService } from './models.service';
@Module({
imports: [TypeOrmModule.forFeature([CharacterModel]), StorageModule],
controllers: [ModelsController],
providers: [ModelsService],
exports: [ModelsService],
})
export class ModelsModule {}
+212
View File
@@ -0,0 +1,212 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import {
AssetKind,
MODEL_EXTENSION_MAP,
ModelFormat,
PublishStatus,
canTransition,
} from '../common/enums';
import { Paged, toPaged } from '../common/pagination';
import { SourceTuple, assertSourceTupleComplete } from '../common/source.dto';
import {
UploadedFileLike,
parseOptionalBool,
requireUploadedFile,
resolveFormatByExtension,
} from '../common/upload.util';
import { CharacterModel } from '../entities/model.entity';
import { StorageService } from '../storage/storage.service';
import { QueryModelDto, UpdateModelDto } from './model.dto';
import { ModelDto, toModelDto } from './model.serializer';
import { inspectVrm } from './vrm-inspector';
@Injectable()
export class ModelsService {
constructor(
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
private readonly storage: StorageService,
private readonly dataSource: DataSource,
) {}
async list(query: QueryModelDto): Promise<Paged<ModelDto>> {
const qb = this.models.createQueryBuilder('m').orderBy('m.id', 'DESC');
if (query.status) qb.andWhere('m.status = :status', { status: query.status });
if (query.format) qb.andWhere('m.format = :format', { format: query.format });
if (query.primaryOnly) qb.andWhere('m.is_primary = 1');
if (query.q) {
const pattern = `%${query.q.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
qb.andWhere('(m.name LIKE :pattern OR m.author LIKE :pattern)', { pattern });
}
const [rows, total] = await qb.skip(query.skip).take(query.pageSize).getManyAndCount();
return toPaged(await this.toDtos(rows), total, query);
}
async findOne(id: number): Promise<ModelDto> {
const model = await this.getEntity(id);
const [dto] = await this.toDtos([model]);
return dto;
}
async getEntity(id: number): Promise<CharacterModel> {
const model = await this.models.findOne({ where: { id } });
if (!model) throw new NotFoundException(`模型 #${id} 不存在`);
return model;
}
/** T-WEB-005:上传模型 + 来源字段,VRM 优先;自动识别 VRM 版本 */
async create(body: Record<string, unknown>, file: UploadedFileLike | undefined): Promise<ModelDto> {
const upload = requireUploadedFile(file);
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) throw new BadRequestException('name 不能为空');
if (name.length > 200) throw new BadRequestException('name 长度不能超过 200');
const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件');
const tuple = assertSourceTupleComplete({
sourceType: body.sourceType as SourceTuple['sourceType'],
sourceName: body.sourceName as string,
sourceUrl: body.sourceUrl as string,
license: body.license as string,
author: body.author as string,
});
if (!tuple) {
throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)');
}
const stored = await this.storage.persistTempFile(
AssetKind.Model,
upload.path,
upload.originalname,
upload.mimetype,
);
// VRM 版本识别(GLB 容器内检查 VRM / VRMC_vrm 扩展)
let vrmVersion: string | null = null;
if (format === ModelFormat.Vrm || format === ModelFormat.Glb) {
try {
const inspection = await inspectVrm(this.storage.absolutePath(stored.asset));
vrmVersion = inspection.vrmVersion;
} catch {
vrmVersion = null;
}
}
if (format === ModelFormat.Vrm && !vrmVersion) {
vrmVersion = 'unknown';
}
const status = this.parseStatus(body.status);
const model = this.models.create({
name,
format: format as ModelFormat,
vrmVersion,
isPrimary: false,
fileAssetId: stored.asset.id,
sourceType: tuple.sourceType as CharacterModel['sourceType'],
sourceName: tuple.sourceName,
sourceUrl: tuple.sourceUrl,
license: tuple.license,
author: tuple.author,
status,
});
const saved = await this.models.save(model);
if (parseOptionalBool(body.isPrimary)) {
return this.setPrimary(saved.id);
}
const [dto] = await this.toDtos([saved]);
return dto;
}
async update(id: number, dto: UpdateModelDto): Promise<ModelDto> {
const model = await this.getEntity(id);
if (dto.status && dto.status !== model.status && !canTransition(model.status, dto.status)) {
throw new ConflictException(
`状态不允许从 ${model.status} 直接变更为 ${dto.status},请使用 /api/reviews 接口`,
);
}
const tuple = assertSourceTupleComplete({
sourceType: dto.sourceType,
sourceName: dto.sourceName,
sourceUrl: dto.sourceUrl,
license: dto.license,
author: dto.author,
});
if (tuple) Object.assign(model, tuple);
if (dto.name !== undefined) model.name = dto.name.trim();
if (dto.status !== undefined) model.status = dto.status;
const saved = await this.models.save(model);
const [result] = await this.toDtos([saved]);
return result;
}
async remove(id: number): Promise<void> {
const model = await this.getEntity(id);
await this.models.softRemove(model);
}
/**
* 设置全局默认模型。
* 契约 data-models.md §4.1:同一事务内先清零其余,保证互斥。
*/
async setPrimary(id: number): Promise<ModelDto> {
const model = await this.getEntity(id);
await this.dataSource.transaction(async (manager) => {
await manager.update(CharacterModel, { isPrimary: true }, { isPrimary: false });
await manager.update(CharacterModel, { id }, { isPrimary: true });
});
model.isPrimary = true;
const [dto] = await this.toDtos([model]);
return dto;
}
/** 客户端默认模型解析 —— 契约 data-models.md §4.1 的 2~4 步 */
async resolveDefaultModelId(): Promise<number | null> {
const published = await this.models.find({
where: { status: PublishStatus.Published },
order: { id: 'ASC' },
});
if (published.length === 0) return null;
const primaryVrm = published.find((m) => m.format === ModelFormat.Vrm && m.isPrimary);
if (primaryVrm) return primaryVrm.id;
const anyVrm = published.find((m) => m.format === ModelFormat.Vrm);
if (anyVrm) return anyVrm.id;
return published[0].id;
}
private parseStatus(value: unknown): PublishStatus {
if (typeof value !== 'string' || value === '') return PublishStatus.Draft;
if (!Object.values(PublishStatus).includes(value as PublishStatus)) {
throw new BadRequestException(`status 非法:${value}`);
}
return value as PublishStatus;
}
async toDtos(rows: CharacterModel[]): Promise<ModelDto[]> {
const assets = await this.storage.findMany(
rows.flatMap((row) => [row.fileAssetId, row.thumbnailAssetId]),
);
return rows.map((row) => {
const file = row.fileAssetId ? assets.get(row.fileAssetId) : undefined;
const thumb = row.thumbnailAssetId ? assets.get(row.thumbnailAssetId) : undefined;
return toModelDto(row, {
fileUrl: file ? this.storage.url(file) : null,
thumbnailUrl: thumb ? this.storage.url(thumb) : null,
fileSize: file?.size ?? null,
});
});
}
}
+68
View File
@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import { open } from 'node:fs/promises';
export interface VrmInspection {
isGlb: boolean;
vrmVersion: string | null;
generator: string | null;
}
const GLB_MAGIC = 0x46546c67; // 'glTF'
const CHUNK_JSON = 0x4e4f534a; // 'JSON'
/**
* 读取 GLB/VRM 文件头,识别 VRM 版本。
* VRM 0.x 使用 `extensions.VRM`,VRM 1.0 使用 `extensions.VRMC_vrm`。
*/
export async function inspectVrm(path: string): Promise<VrmInspection> {
const handle = await open(path, 'r');
try {
const header = Buffer.alloc(12);
const { bytesRead } = await handle.read(header, 0, 12, 0);
if (bytesRead < 12) throw new BadRequestException('文件过小,不是合法的 GLB/VRM');
if (header.readUInt32LE(0) !== GLB_MAGIC) {
// 可能是文本 glTF(.gltf),交由调用方按扩展名处理
return { isGlb: false, vrmVersion: null, generator: null };
}
const chunkHeader = Buffer.alloc(8);
await handle.read(chunkHeader, 0, 8, 12);
const chunkLength = chunkHeader.readUInt32LE(0);
const chunkType = chunkHeader.readUInt32LE(4);
if (chunkType !== CHUNK_JSON) {
throw new BadRequestException('GLB 首个 chunk 不是 JSON,文件可能已损坏');
}
if (chunkLength <= 0 || chunkLength > 64 * 1024 * 1024) {
throw new BadRequestException('GLB JSON chunk 长度异常');
}
const jsonBuf = Buffer.alloc(chunkLength);
await handle.read(jsonBuf, 0, chunkLength, 20);
let gltf: { extensions?: Record<string, unknown>; asset?: { generator?: string } };
try {
gltf = JSON.parse(jsonBuf.toString('utf8'));
} catch {
throw new BadRequestException('GLB JSON chunk 解析失败');
}
const extensions = gltf.extensions ?? {};
let vrmVersion: string | null = null;
if (extensions['VRMC_vrm']) {
const spec = extensions['VRMC_vrm'] as { specVersion?: string };
vrmVersion = spec?.specVersion ?? '1.0';
} else if (extensions['VRM']) {
const spec = extensions['VRM'] as { specVersion?: string; exporterVersion?: string };
vrmVersion = spec?.specVersion ?? spec?.exporterVersion ?? '0.x';
}
return {
isGlb: true,
vrmVersion,
generator: gltf.asset?.generator ?? null,
};
} finally {
await handle.close();
}
}
+76
View File
@@ -0,0 +1,76 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { inspectVrm } from '../src/models/vrm-inspector';
const dir = mkdtempSync(join(tmpdir(), 'opendance-vrm-'));
/** 构造合法 GLB:magic + version + length + JSON chunk */
function buildGlb(json: unknown): Buffer {
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const pad = (4 - (jsonBuf.length % 4)) % 4;
const padded = Buffer.concat([jsonBuf, Buffer.alloc(pad, 0x20)]);
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + padded.length, 8);
const chunkHeader = Buffer.alloc(8);
chunkHeader.writeUInt32LE(padded.length, 0);
chunkHeader.writeUInt32LE(0x4e4f534a, 4);
return Buffer.concat([header, chunkHeader, padded]);
}
function write(name: string, content: Buffer): string {
const path = join(dir, name);
writeFileSync(path, content);
return path;
}
describe('VRM 版本识别', () => {
it('识别 VRM 1.0(VRMC_vrm 扩展)', async () => {
const path = write('v1.vrm', buildGlb({ extensions: { VRMC_vrm: { specVersion: '1.0' } } }));
const result = await inspectVrm(path);
expect(result.isGlb).toBe(true);
expect(result.vrmVersion).toBe('1.0');
});
it('识别 VRM 0.x(VRM 扩展)', async () => {
const path = write('v0.vrm', buildGlb({ extensions: { VRM: { specVersion: '0.0', exporterVersion: '0.79' } } }));
const result = await inspectVrm(path);
expect(result.vrmVersion).toBe('0.0');
});
it('VRM 扩展缺失时版本为 null(普通 GLB)', async () => {
const path = write('plain.glb', buildGlb({ asset: { version: '2.0' }, meshes: [] }));
const result = await inspectVrm(path);
expect(result.isGlb).toBe(true);
expect(result.vrmVersion).toBeNull();
expect(result.generator).toBeNull();
});
it('文本 glTF 不按 GLB 处理', async () => {
const path = write('scene.gltf', Buffer.from('{"asset":{"version":"2.0"}}', 'utf8'));
const result = await inspectVrm(path);
expect(result.isGlb).toBe(false);
expect(result.vrmVersion).toBeNull();
});
it('文件过小时报错', async () => {
const path = write('tiny.glb', Buffer.from('glTF', 'ascii'));
await expect(inspectVrm(path)).rejects.toThrow(/过小/);
});
it('首 chunk 非 JSON 时报错', async () => {
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
header.writeUInt32LE(32, 8);
const chunk = Buffer.alloc(8);
chunk.writeUInt32LE(4, 0);
chunk.writeUInt32LE(0x004e4942, 4); // 'BIN\0'
await expect(inspectVrm(write('bin-first.glb', Buffer.concat([header, chunk, Buffer.alloc(4)])))).rejects.toThrow(
/JSON/,
);
});
});