diff --git a/web-server/src/common/transformers.ts b/web-server/src/common/transformers.ts new file mode 100644 index 0000000..a99e96c --- /dev/null +++ b/web-server/src/common/transformers.ts @@ -0,0 +1,26 @@ +import { ValueTransformer } from 'typeorm'; + +/** + * MySQL/MariaDB 的 BIGINT 经 mysql2 默认以 string 返回。 + * 本项目的 id 规模远小于 2^53,统一转换为 number,以匹配契约中 `id: number`。 + */ +export const bigintNumber: ValueTransformer = { + to: (value?: number | null): number | null | undefined => value, + from: (value?: string | number | null): number | null => + value === null || value === undefined ? null : Number(value), +}; + +/** DECIMAL 列(如 bpm / fps)→ number */ +export const decimalNumber: ValueTransformer = { + to: (value?: number | null): number | null | undefined => value, + from: (value?: string | number | null): number | null => + value === null || value === undefined ? null : Number(value), +}; + +/** TINYINT(1) 布尔 */ +export const boolTinyint: ValueTransformer = { + to: (value?: boolean | null): number | null | undefined => + value === null || value === undefined ? value : value ? 1 : 0, + from: (value?: number | boolean | null): boolean => + value === null || value === undefined ? false : value === true || Number(value) === 1, +}; diff --git a/web-server/src/entities/asset.entity.ts b/web-server/src/entities/asset.entity.ts new file mode 100644 index 0000000..4d813fd --- /dev/null +++ b/web-server/src/entities/asset.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { AssetKind } from '../common/enums'; +import { bigintNumber } from '../common/transformers'; +/** + * 上传文件元数据。 + * 契约 database.md:表名 snake_case、主键 BIGINT、时间 DATETIME。 + * 相同 sha256 的文件只落盘一次,多条业务记录共享同一 asset。 + */ +@Entity('assets') +@Index('idx_assets_sha256', ['sha256'], { unique: true }) +@Index('idx_assets_kind', ['kind']) +export class Asset { + /** + * BIGINT 主键。DataSource 已设置 supportBigNumbers + bigNumberStrings=false, + * mysql2 在数值可被 double 精确表示时返回 number,因此这里不需要 transformer + * (TypeORM 的 PrimaryGeneratedColumn 不支持 transformer 选项)。 + */ + @PrimaryGeneratedColumn({ type: 'bigint' }) + id!: number; + + @Column({ type: 'varchar', length: 32 }) + kind!: AssetKind; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName!: string; + + /** 相对 STORAGE_DIR 的路径,永不使用原始文件名 */ + @Column({ name: 'stored_path', type: 'varchar', length: 500 }) + storedPath!: string; + + @Column({ type: 'varchar', length: 128, default: 'application/octet-stream' }) + mime!: string; + + @Column({ type: 'bigint', transformer: bigintNumber }) + size!: number; + + @Column({ type: 'char', length: 64 }) + sha256!: string; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt!: Date; +} diff --git a/web-server/src/entities/index.ts b/web-server/src/entities/index.ts new file mode 100644 index 0000000..3b81d4c --- /dev/null +++ b/web-server/src/entities/index.ts @@ -0,0 +1,10 @@ +import { Asset } from './asset.entity'; +import { CharacterModel } from './model.entity'; +import { Motion } from './motion.entity'; +import { ReviewRecord } from './review-record.entity'; +import { Song } from './song.entity'; + +export { Asset, CharacterModel, Motion, ReviewRecord, Song }; + +/** 显式列出实体:glob 在 ts-node 与编译产物下行为不一致,显式最稳 */ +export const ENTITIES = [Asset, Song, Motion, CharacterModel, ReviewRecord]; diff --git a/web-server/src/entities/model.entity.ts b/web-server/src/entities/model.entity.ts new file mode 100644 index 0000000..a6bcf48 --- /dev/null +++ b/web-server/src/entities/model.entity.ts @@ -0,0 +1,68 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { bigintNumber, boolTinyint } from '../common/transformers'; +import { ModelFormat, PublishStatus, SourceType } from '../common/enums'; + +/** 角色模型 —— 契约 data-models.md §4(VRM 优先) */ +@Entity('models') +@Index('idx_models_status', ['status']) +@Index('idx_models_format', ['format']) +@Index('idx_models_is_primary', ['isPrimary']) +export class CharacterModel { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id!: number; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + @Column({ type: 'varchar', length: 16 }) + format!: ModelFormat; + + @Column({ name: 'vrm_version', type: 'varchar', length: 16, nullable: true }) + vrmVersion!: string | null; + + /** 全局默认模型;服务端保证同一时刻至多一条为 true(见 ModelsService.setPrimary) */ + @Column({ name: 'is_primary', type: 'tinyint', width: 1, default: 0, transformer: boolTinyint }) + isPrimary!: boolean; + + @Column({ name: 'file_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + fileAssetId!: number | null; + + @Column({ name: 'thumbnail_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + thumbnailAssetId!: number | null; + + // ---- 来源五元组(必填)---- + @Column({ name: 'source_type', type: 'varchar', length: 32 }) + sourceType!: SourceType; + + @Column({ name: 'source_name', type: 'varchar', length: 200 }) + sourceName!: string; + + @Column({ name: 'source_url', type: 'varchar', length: 500 }) + sourceUrl!: string; + + @Column({ type: 'varchar', length: 100 }) + license!: string; + + @Column({ type: 'varchar', length: 200 }) + author!: string; + + @Column({ type: 'varchar', length: 16, default: PublishStatus.Draft }) + status!: PublishStatus; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'datetime', nullable: true }) + deletedAt!: Date | null; +} diff --git a/web-server/src/entities/motion.entity.ts b/web-server/src/entities/motion.entity.ts new file mode 100644 index 0000000..95076ac --- /dev/null +++ b/web-server/src/entities/motion.entity.ts @@ -0,0 +1,93 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { bigintNumber, decimalNumber } from '../common/transformers'; +import { MotionFormat, PreprocessState, PublishStatus, SourceType } from '../common/enums'; +import { Song } from './song.entity'; + +/** 动作数据 —— 契约 data-models.md §3 */ +@Entity('motions') +@Index('idx_motions_song_id', ['songId']) +@Index('idx_motions_status', ['status']) +@Index('idx_motions_format', ['format']) +export class Motion { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id!: number; + + @Column({ name: 'song_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + songId!: number | null; + + @ManyToOne(() => Song, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'song_id' }) + song?: Song | null; + + @Column({ type: 'varchar', length: 200 }) + name!: string; + + @Column({ type: 'varchar', length: 24 }) + format!: MotionFormat; + + @Column({ name: 'file_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + fileAssetId!: number | null; + + @Column({ type: 'decimal', precision: 8, scale: 3, nullable: true, transformer: decimalNumber }) + fps!: number | null; + + @Column({ name: 'frame_count', type: 'int', nullable: true }) + frameCount!: number | null; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs!: number | null; + + // ---- 预处理产物(T-WEB-006)---- + @Column({ name: 'keypoint_format', type: 'varchar', length: 32, nullable: true }) + keypointFormat!: string | null; + + @Column({ name: 'keypoint_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + keypointAssetId!: number | null; + + @Column({ name: 'keypoint_frames', type: 'int', nullable: true }) + keypointFrames!: number | null; + + @Column({ name: 'preprocess_state', type: 'varchar', length: 16, default: PreprocessState.None }) + preprocessState!: PreprocessState; + + @Column({ name: 'preprocess_error', type: 'text', nullable: true }) + preprocessError!: string | null; + + // ---- 来源五元组(必填)---- + @Column({ name: 'source_type', type: 'varchar', length: 32 }) + sourceType!: SourceType; + + @Column({ name: 'source_name', type: 'varchar', length: 200 }) + sourceName!: string; + + @Column({ name: 'source_url', type: 'varchar', length: 500 }) + sourceUrl!: string; + + @Column({ type: 'varchar', length: 100 }) + license!: string; + + @Column({ type: 'varchar', length: 200 }) + author!: string; + + @Column({ type: 'varchar', length: 16, default: PublishStatus.Draft }) + status!: PublishStatus; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'datetime', nullable: true }) + deletedAt!: Date | null; +} diff --git a/web-server/src/entities/review-record.entity.ts b/web-server/src/entities/review-record.entity.ts new file mode 100644 index 0000000..dfb0ecb --- /dev/null +++ b/web-server/src/entities/review-record.entity.ts @@ -0,0 +1,36 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { bigintNumber } from '../common/transformers'; +import { PublishStatus, ReviewAction, TargetType } from '../common/enums'; + +/** 审核流水 —— 契约 data-models.md §5 */ +@Entity('review_records') +@Index('idx_review_target', ['targetType', 'targetId']) +@Index('idx_review_created_at', ['createdAt']) +export class ReviewRecord { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id!: number; + + @Column({ name: 'target_type', type: 'varchar', length: 16 }) + targetType!: TargetType; + + @Column({ name: 'target_id', type: 'bigint', transformer: bigintNumber }) + targetId!: number; + + @Column({ type: 'varchar', length: 16 }) + action!: ReviewAction; + + @Column({ name: 'from_status', type: 'varchar', length: 16 }) + fromStatus!: PublishStatus; + + @Column({ name: 'to_status', type: 'varchar', length: 16 }) + toStatus!: PublishStatus; + + @Column({ type: 'varchar', length: 100, nullable: true }) + reviewer!: string | null; + + @Column({ type: 'text', nullable: true }) + comment!: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt!: Date; +} diff --git a/web-server/src/entities/song.entity.ts b/web-server/src/entities/song.entity.ts new file mode 100644 index 0000000..008bc7a --- /dev/null +++ b/web-server/src/entities/song.entity.ts @@ -0,0 +1,80 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { bigintNumber, decimalNumber } from '../common/transformers'; +import { PublishStatus, SourceType } from '../common/enums'; + +/** 曲目 —— 契约 data-models.md §2、database.md 表清单 */ +@Entity('songs') +@Index('idx_songs_title', ['title']) +@Index('idx_songs_artist', ['artist']) +@Index('idx_songs_status', ['status']) +export class Song { + @PrimaryGeneratedColumn({ type: 'bigint' }) + id!: number; + + @Column({ type: 'varchar', length: 200 }) + title!: string; + + @Column({ type: 'varchar', length: 200 }) + artist!: string; + + @Column({ type: 'varchar', length: 200, nullable: true }) + album!: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true }) + language!: string | null; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs!: number | null; + + @Column({ type: 'decimal', precision: 7, scale: 2, nullable: true, transformer: decimalNumber }) + bpm!: number | null; + + @Column({ type: 'tinyint', nullable: true }) + difficulty!: number | null; + + /** simple-json 在 MariaDB 上落 LONGTEXT,避免 JSON 类型兼容性差异 */ + @Column({ type: 'simple-json', nullable: true }) + tags!: string[] | null; + + @Column({ name: 'cover_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + coverAssetId!: number | null; + + @Column({ name: 'audio_asset_id', type: 'bigint', nullable: true, transformer: bigintNumber }) + audioAssetId!: number | null; + + // ---- 来源五元组(必填)---- + @Column({ name: 'source_type', type: 'varchar', length: 32 }) + sourceType!: SourceType; + + @Column({ name: 'source_name', type: 'varchar', length: 200 }) + sourceName!: string; + + @Column({ name: 'source_url', type: 'varchar', length: 500 }) + sourceUrl!: string; + + @Column({ type: 'varchar', length: 100 }) + license!: string; + + @Column({ type: 'varchar', length: 200 }) + author!: string; + + @Column({ type: 'varchar', length: 16, default: PublishStatus.Draft }) + status!: PublishStatus; + + @CreateDateColumn({ name: 'created_at', type: 'datetime' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'datetime' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'datetime', nullable: true }) + deletedAt!: Date | null; +} diff --git a/web-server/src/migrations/1789382903060-InitSchema.ts b/web-server/src/migrations/1789382903060-InitSchema.ts new file mode 100644 index 0000000..d74d2c8 --- /dev/null +++ b/web-server/src/migrations/1789382903060-InitSchema.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InitSchema1789382903060 implements MigrationInterface { + name = 'InitSchema1789382903060' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE \`assets\` (\`id\` bigint NOT NULL AUTO_INCREMENT, \`kind\` varchar(32) NOT NULL, \`original_name\` varchar(255) NOT NULL, \`stored_path\` varchar(500) NOT NULL, \`mime\` varchar(128) NOT NULL DEFAULT 'application/octet-stream', \`size\` bigint NOT NULL, \`sha256\` char(64) NOT NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), INDEX \`idx_assets_kind\` (\`kind\`), UNIQUE INDEX \`idx_assets_sha256\` (\`sha256\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`models\` (\`id\` bigint NOT NULL AUTO_INCREMENT, \`name\` varchar(200) NOT NULL, \`format\` varchar(16) NOT NULL, \`vrm_version\` varchar(16) NULL, \`is_primary\` tinyint(1) NOT NULL DEFAULT '0', \`file_asset_id\` bigint NULL, \`thumbnail_asset_id\` bigint NULL, \`source_type\` varchar(32) NOT NULL, \`source_name\` varchar(200) NOT NULL, \`source_url\` varchar(500) NOT NULL, \`license\` varchar(100) NOT NULL, \`author\` varchar(200) NOT NULL, \`status\` varchar(16) NOT NULL DEFAULT 'draft', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, INDEX \`idx_models_is_primary\` (\`is_primary\`), INDEX \`idx_models_format\` (\`format\`), INDEX \`idx_models_status\` (\`status\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`songs\` (\`id\` bigint NOT NULL AUTO_INCREMENT, \`title\` varchar(200) NOT NULL, \`artist\` varchar(200) NOT NULL, \`album\` varchar(200) NULL, \`language\` varchar(32) NULL, \`duration_ms\` int NULL, \`bpm\` decimal(7,2) NULL, \`difficulty\` tinyint NULL, \`tags\` text NULL, \`cover_asset_id\` bigint NULL, \`audio_asset_id\` bigint NULL, \`source_type\` varchar(32) NOT NULL, \`source_name\` varchar(200) NOT NULL, \`source_url\` varchar(500) NOT NULL, \`license\` varchar(100) NOT NULL, \`author\` varchar(200) NOT NULL, \`status\` varchar(16) NOT NULL DEFAULT 'draft', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, INDEX \`idx_songs_status\` (\`status\`), INDEX \`idx_songs_artist\` (\`artist\`), INDEX \`idx_songs_title\` (\`title\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`motions\` (\`id\` bigint NOT NULL AUTO_INCREMENT, \`song_id\` bigint NULL, \`name\` varchar(200) NOT NULL, \`format\` varchar(24) NOT NULL, \`file_asset_id\` bigint NULL, \`fps\` decimal(8,3) NULL, \`frame_count\` int NULL, \`duration_ms\` int NULL, \`keypoint_format\` varchar(32) NULL, \`keypoint_asset_id\` bigint NULL, \`keypoint_frames\` int NULL, \`preprocess_state\` varchar(16) NOT NULL DEFAULT 'none', \`preprocess_error\` text NULL, \`source_type\` varchar(32) NOT NULL, \`source_name\` varchar(200) NOT NULL, \`source_url\` varchar(500) NOT NULL, \`license\` varchar(100) NOT NULL, \`author\` varchar(200) NOT NULL, \`status\` varchar(16) NOT NULL DEFAULT 'draft', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, INDEX \`idx_motions_format\` (\`format\`), INDEX \`idx_motions_status\` (\`status\`), INDEX \`idx_motions_song_id\` (\`song_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`review_records\` (\`id\` bigint NOT NULL AUTO_INCREMENT, \`target_type\` varchar(16) NOT NULL, \`target_id\` bigint NOT NULL, \`action\` varchar(16) NOT NULL, \`from_status\` varchar(16) NOT NULL, \`to_status\` varchar(16) NOT NULL, \`reviewer\` varchar(100) NULL, \`comment\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), INDEX \`idx_review_created_at\` (\`created_at\`), INDEX \`idx_review_target\` (\`target_type\`, \`target_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`ALTER TABLE \`motions\` ADD CONSTRAINT \`FK_7db5ad6652cd475d5e7274fdb01\` FOREIGN KEY (\`song_id\`) REFERENCES \`songs\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`motions\` DROP FOREIGN KEY \`FK_7db5ad6652cd475d5e7274fdb01\``); + await queryRunner.query(`DROP INDEX \`idx_review_target\` ON \`review_records\``); + await queryRunner.query(`DROP INDEX \`idx_review_created_at\` ON \`review_records\``); + await queryRunner.query(`DROP TABLE \`review_records\``); + await queryRunner.query(`DROP INDEX \`idx_motions_song_id\` ON \`motions\``); + await queryRunner.query(`DROP INDEX \`idx_motions_status\` ON \`motions\``); + await queryRunner.query(`DROP INDEX \`idx_motions_format\` ON \`motions\``); + await queryRunner.query(`DROP TABLE \`motions\``); + await queryRunner.query(`DROP INDEX \`idx_songs_title\` ON \`songs\``); + await queryRunner.query(`DROP INDEX \`idx_songs_artist\` ON \`songs\``); + await queryRunner.query(`DROP INDEX \`idx_songs_status\` ON \`songs\``); + await queryRunner.query(`DROP TABLE \`songs\``); + await queryRunner.query(`DROP INDEX \`idx_models_status\` ON \`models\``); + await queryRunner.query(`DROP INDEX \`idx_models_format\` ON \`models\``); + await queryRunner.query(`DROP INDEX \`idx_models_is_primary\` ON \`models\``); + await queryRunner.query(`DROP TABLE \`models\``); + await queryRunner.query(`DROP INDEX \`idx_assets_sha256\` ON \`assets\``); + await queryRunner.query(`DROP INDEX \`idx_assets_kind\` ON \`assets\``); + await queryRunner.query(`DROP TABLE \`assets\``); + } + +}