feat(web): T-WEB-007 上传审核流程(状态机 + 审核流水)

This commit is contained in:
2026-09-14 19:00:55 +08:00
parent a2e3282603
commit 7ff3f1598d
4 changed files with 335 additions and 0 deletions
+29
View File
@@ -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);
}
}
+16
View File
@@ -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 {}
+211
View File
@@ -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(),
};
}