From 73e793318e6481fe02ac218062fe7db54dd548d0 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 14 Sep 2026 19:00:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20T-WEB-005=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20+=20VRM=20=E4=BC=98=E5=85=88=20+=20?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-server/src/models/model.dto.ts | 35 ++++ web-server/src/models/model.serializer.ts | 49 +++++ web-server/src/models/models.controller.ts | 63 ++++++ web-server/src/models/models.module.ts | 14 ++ web-server/src/models/models.service.ts | 212 +++++++++++++++++++++ web-server/src/models/vrm-inspector.ts | 68 +++++++ web-server/test/vrm-inspector.spec.ts | 76 ++++++++ 7 files changed, 517 insertions(+) create mode 100644 web-server/src/models/model.dto.ts create mode 100644 web-server/src/models/model.serializer.ts create mode 100644 web-server/src/models/models.controller.ts create mode 100644 web-server/src/models/models.module.ts create mode 100644 web-server/src/models/models.service.ts create mode 100644 web-server/src/models/vrm-inspector.ts create mode 100644 web-server/test/vrm-inspector.spec.ts diff --git a/web-server/src/models/model.dto.ts b/web-server/src/models/model.dto.ts new file mode 100644 index 0000000..a0ad1ec --- /dev/null +++ b/web-server/src/models/model.dto.ts @@ -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; +} diff --git a/web-server/src/models/model.serializer.ts b/web-server/src/models/model.serializer.ts new file mode 100644 index 0000000..0c3bcdc --- /dev/null +++ b/web-server/src/models/model.serializer.ts @@ -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(), + }; +} diff --git a/web-server/src/models/models.controller.ts b/web-server/src/models/models.controller.ts new file mode 100644 index 0000000..c84bb9a --- /dev/null +++ b/web-server/src/models/models.controller.ts @@ -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> { + return this.models.list(query); + } + + /** multipart/form-data:file + name + 来源五元组 + isPrimary/status */ + @Post() + @UseInterceptors(FileInterceptor('file')) + create( + @Body() body: Record, + @UploadedFile() file: UploadedFileLike | undefined, + ): Promise { + return this.models.create(body, file); + } + + @Get(':id') + detail(@Param('id', ParseIntPipe) id: number): Promise { + return this.models.findOne(id); + } + + @Patch(':id') + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateModelDto): Promise { + return this.models.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.models.remove(id); + } + + @Post(':id/primary') + @HttpCode(HttpStatus.OK) + setPrimary(@Param('id', ParseIntPipe) id: number): Promise { + return this.models.setPrimary(id); + } +} diff --git a/web-server/src/models/models.module.ts b/web-server/src/models/models.module.ts new file mode 100644 index 0000000..f5c474c --- /dev/null +++ b/web-server/src/models/models.module.ts @@ -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 {} diff --git a/web-server/src/models/models.service.ts b/web-server/src/models/models.service.ts new file mode 100644 index 0000000..e228696 --- /dev/null +++ b/web-server/src/models/models.service.ts @@ -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, + private readonly storage: StorageService, + private readonly dataSource: DataSource, + ) {} + + async list(query: QueryModelDto): Promise> { + 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 { + const model = await this.getEntity(id); + const [dto] = await this.toDtos([model]); + return dto; + } + + async getEntity(id: number): Promise { + 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, file: UploadedFileLike | undefined): Promise { + 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 { + 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 { + const model = await this.getEntity(id); + await this.models.softRemove(model); + } + + /** + * 设置全局默认模型。 + * 契约 data-models.md §4.1:同一事务内先清零其余,保证互斥。 + */ + async setPrimary(id: number): Promise { + 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 { + 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 { + 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, + }); + }); + } +} diff --git a/web-server/src/models/vrm-inspector.ts b/web-server/src/models/vrm-inspector.ts new file mode 100644 index 0000000..05a9155 --- /dev/null +++ b/web-server/src/models/vrm-inspector.ts @@ -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 { + 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; 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(); + } +} diff --git a/web-server/test/vrm-inspector.spec.ts b/web-server/test/vrm-inspector.spec.ts new file mode 100644 index 0000000..74fc939 --- /dev/null +++ b/web-server/test/vrm-inspector.spec.ts @@ -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/, + ); + }); +});