feat(web): T-WEB-005 模型上传 + VRM 优先 + 版本识别
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user