feat(web): T-WEB-007 上传审核流程(状态机 + 审核流水)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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<ReviewRecordDto> | Paged<PendingItemDto>> {
|
||||
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<ReviewRecordDto[]> {
|
||||
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<PendingItemDto> {
|
||||
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<PendingItemDto> {
|
||||
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<PendingItemDto> {
|
||||
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<PendingItemDto> {
|
||||
return this.reviews.transition(targetType, id, ReviewAction.Withdraw, dto);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<ReviewRecord>,
|
||||
@InjectRepository(Song) private readonly songs: Repository<Song>,
|
||||
@InjectRepository(Motion) private readonly motions: Repository<Motion>,
|
||||
@InjectRepository(CharacterModel) private readonly models: Repository<CharacterModel>,
|
||||
) {}
|
||||
|
||||
async listRecords(query: QueryReviewDto): Promise<Paged<ReviewRecordDto>> {
|
||||
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<Paged<PendingItemDto>> {
|
||||
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<PendingItemDto> {
|
||||
const to = ACTION_TARGET_STATUS[action];
|
||||
|
||||
let entity: Reviewable;
|
||||
let name: string;
|
||||
let save: () => Promise<unknown>;
|
||||
|
||||
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<ReviewRecordDto[]> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user