diff --git a/web-server/src/reviews/review.dto.ts b/web-server/src/reviews/review.dto.ts new file mode 100644 index 0000000..5d907d3 --- /dev/null +++ b/web-server/src/reviews/review.dto.ts @@ -0,0 +1,29 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { PublishStatus, TargetType } from '../common/enums'; +import { PageQueryDto } from '../common/pagination'; + +export class ReviewActionDto { + @IsOptional() + @IsString() + @MaxLength(100) + reviewer?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + comment?: string; +} + +export class QueryReviewDto extends PageQueryDto { + @IsOptional() + @IsEnum(TargetType) + targetType?: TargetType; + + @IsOptional() + targetId?: number; + + /** 传入时返回该状态的待审资产列表(合并三类),否则返回审核流水 */ + @IsOptional() + @IsEnum(PublishStatus) + status?: PublishStatus; +} diff --git a/web-server/src/reviews/reviews.controller.ts b/web-server/src/reviews/reviews.controller.ts new file mode 100644 index 0000000..abce374 --- /dev/null +++ b/web-server/src/reviews/reviews.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseEnumPipe, + ParseIntPipe, + Post, + Query, +} from '@nestjs/common'; +import { ReviewAction, TargetType } from '../common/enums'; +import { Paged } from '../common/pagination'; +import { QueryReviewDto, ReviewActionDto } from './review.dto'; +import { PendingItemDto, ReviewRecordDto, ReviewsService } from './reviews.service'; + +@Controller('api/reviews') +export class ReviewsController { + constructor(private readonly reviews: ReviewsService) {} + + /** + * 不传 status:返回审核流水。 + * 传 status:返回该状态的待审资产列表(合并 song/motion/model 三类)。 + */ + @Get() + async list(@Query() query: QueryReviewDto): Promise | Paged> { + if (query.status) return this.reviews.listByStatus(query.status, query); + return this.reviews.listRecords(query); + } + + @Get(':targetType/:id/history') + history( + @Param('targetType', new ParseEnumPipe(TargetType)) targetType: TargetType, + @Param('id', ParseIntPipe) id: number, + ): Promise { + return this.reviews.history(targetType, id); + } + + @Post(':targetType/:id/submit') + @HttpCode(HttpStatus.OK) + submit( + @Param('targetType', new ParseEnumPipe(TargetType)) targetType: TargetType, + @Param('id', ParseIntPipe) id: number, + @Body() dto: ReviewActionDto, + ): Promise { + return this.reviews.transition(targetType, id, ReviewAction.Submit, dto); + } + + @Post(':targetType/:id/approve') + @HttpCode(HttpStatus.OK) + approve( + @Param('targetType', new ParseEnumPipe(TargetType)) targetType: TargetType, + @Param('id', ParseIntPipe) id: number, + @Body() dto: ReviewActionDto, + ): Promise { + return this.reviews.transition(targetType, id, ReviewAction.Approve, dto); + } + + @Post(':targetType/:id/reject') + @HttpCode(HttpStatus.OK) + reject( + @Param('targetType', new ParseEnumPipe(TargetType)) targetType: TargetType, + @Param('id', ParseIntPipe) id: number, + @Body() dto: ReviewActionDto, + ): Promise { + return this.reviews.transition(targetType, id, ReviewAction.Reject, dto); + } + + @Post(':targetType/:id/withdraw') + @HttpCode(HttpStatus.OK) + withdraw( + @Param('targetType', new ParseEnumPipe(TargetType)) targetType: TargetType, + @Param('id', ParseIntPipe) id: number, + @Body() dto: ReviewActionDto, + ): Promise { + return this.reviews.transition(targetType, id, ReviewAction.Withdraw, dto); + } +} diff --git a/web-server/src/reviews/reviews.module.ts b/web-server/src/reviews/reviews.module.ts new file mode 100644 index 0000000..dcd136f --- /dev/null +++ b/web-server/src/reviews/reviews.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharacterModel } from '../entities/model.entity'; +import { Motion } from '../entities/motion.entity'; +import { ReviewRecord } from '../entities/review-record.entity'; +import { Song } from '../entities/song.entity'; +import { ReviewsController } from './reviews.controller'; +import { ReviewsService } from './reviews.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel])], + controllers: [ReviewsController], + providers: [ReviewsService], + exports: [ReviewsService], +}) +export class ReviewsModule {} diff --git a/web-server/src/reviews/reviews.service.ts b/web-server/src/reviews/reviews.service.ts new file mode 100644 index 0000000..f7c8dc5 --- /dev/null +++ b/web-server/src/reviews/reviews.service.ts @@ -0,0 +1,211 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + ACTION_TARGET_STATUS, + PublishStatus, + ReviewAction, + SourceType, + TargetType, + canTransition, +} from '../common/enums'; +import { Paged, toPaged } from '../common/pagination'; +import { CharacterModel } from '../entities/model.entity'; +import { Motion } from '../entities/motion.entity'; +import { ReviewRecord } from '../entities/review-record.entity'; +import { Song } from '../entities/song.entity'; +import { QueryReviewDto, ReviewActionDto } from './review.dto'; + +export interface ReviewRecordDto { + id: number; + targetType: TargetType; + targetId: number; + action: ReviewAction; + fromStatus: PublishStatus; + toStatus: PublishStatus; + reviewer: string | null; + comment: string | null; + createdAt: string; +} + +export interface PendingItemDto { + targetType: TargetType; + targetId: number; + name: string; + status: PublishStatus; + source: { + sourceType: SourceType; + sourceName: string; + sourceUrl: string; + license: string; + author: string; + }; + updatedAt: string; +} + +/** 三类可审核资产的公共形状 */ +interface Reviewable { + id: number; + status: PublishStatus; + sourceType: SourceType; + sourceName: string; + sourceUrl: string; + license: string; + author: string; + updatedAt: Date; +} + +@Injectable() +export class ReviewsService { + constructor( + @InjectRepository(ReviewRecord) private readonly records: Repository, + @InjectRepository(Song) private readonly songs: Repository, + @InjectRepository(Motion) private readonly motions: Repository, + @InjectRepository(CharacterModel) private readonly models: Repository, + ) {} + + async listRecords(query: QueryReviewDto): Promise> { + const qb = this.records.createQueryBuilder('r').orderBy('r.id', 'DESC'); + if (query.targetType) qb.andWhere('r.target_type = :tt', { tt: query.targetType }); + if (query.targetId) qb.andWhere('r.target_id = :tid', { tid: query.targetId }); + + const [rows, total] = await qb.skip(query.skip).take(query.pageSize).getManyAndCount(); + return toPaged(rows.map(toRecordDto), total, query); + } + + /** 合并三类资产的待审列表 */ + async listByStatus(status: PublishStatus, query: QueryReviewDto): Promise> { + const rows: PendingItemDto[] = []; + + const [songs, motions, models] = await Promise.all([ + this.songs.find({ where: { status } }), + this.motions.find({ where: { status } }), + this.models.find({ where: { status } }), + ]); + + rows.push(...songs.map((s) => toPending(TargetType.Song, s, s.title))); + rows.push(...motions.map((m) => toPending(TargetType.Motion, m, m.name))); + rows.push(...models.map((m) => toPending(TargetType.Model, m, m.name))); + + rows.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); + const total = rows.length; + const start = query.skip; + return toPaged(rows.slice(start, start + query.pageSize), total, query); + } + + /** + * 状态流转 + 写审核流水。 + * 契约 database.md 状态机;驳回必须填写理由。 + */ + async transition( + targetType: TargetType, + id: number, + action: ReviewAction, + dto: ReviewActionDto, + ): Promise { + const to = ACTION_TARGET_STATUS[action]; + + let entity: Reviewable; + let name: string; + let save: () => Promise; + + switch (targetType) { + case TargetType.Song: { + const song = await this.songs.findOne({ where: { id } }); + if (!song) throw new NotFoundException(`曲目 #${id} 不存在`); + entity = song; + name = song.title; + save = () => this.songs.save(song); + break; + } + case TargetType.Motion: { + const motion = await this.motions.findOne({ where: { id } }); + if (!motion) throw new NotFoundException(`动作 #${id} 不存在`); + entity = motion; + name = motion.name; + save = () => this.motions.save(motion); + break; + } + case TargetType.Model: { + const model = await this.models.findOne({ where: { id } }); + if (!model) throw new NotFoundException(`模型 #${id} 不存在`); + entity = model; + name = model.name; + save = () => this.models.save(model); + break; + } + default: + throw new BadRequestException(`未知的 targetType:${targetType as string}`); + } + + const from = entity.status; + if (from === to) throw new ConflictException(`资产已处于 ${to} 状态`); + if (!canTransition(from, to)) { + throw new ConflictException(`状态不允许从 ${from} 流转到 ${to}`); + } + if (action === ReviewAction.Reject && !(dto.comment ?? '').trim()) { + throw new BadRequestException('驳回必须填写审核意见'); + } + + entity.status = to; + await save(); + + await this.records.save( + this.records.create({ + targetType, + targetId: id, + action, + fromStatus: from, + toStatus: to, + reviewer: dto.reviewer?.trim() || null, + comment: dto.comment?.trim() || null, + }), + ); + + return toPending(targetType, entity, name); + } + + async history(targetType: TargetType, targetId: number): Promise { + const rows = await this.records.find({ + where: { targetType, targetId }, + order: { id: 'DESC' }, + }); + return rows.map(toRecordDto); + } +} + +function toRecordDto(record: ReviewRecord): ReviewRecordDto { + return { + id: record.id, + targetType: record.targetType, + targetId: record.targetId, + action: record.action, + fromStatus: record.fromStatus, + toStatus: record.toStatus, + reviewer: record.reviewer, + comment: record.comment, + createdAt: record.createdAt.toISOString(), + }; +} + +function toPending(targetType: TargetType, entity: Reviewable, name: string): PendingItemDto { + return { + targetType, + targetId: entity.id, + name, + status: entity.status, + source: { + sourceType: entity.sourceType, + sourceName: entity.sourceName, + sourceUrl: entity.sourceUrl, + license: entity.license, + author: entity.author, + }, + updatedAt: entity.updatedAt.toISOString(), + }; +}