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