This commit is contained in:
dlandy
2026-03-30 09:36:36 +08:00
parent 40be11adbf
commit b627f8c020
29 changed files with 1881 additions and 95 deletions

View File

@@ -5,6 +5,8 @@ import { AccessModule } from './modules/access/access.module';
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminCategoriesModule } from './modules/admin-categories/admin-categories.module';
import { AdminTagsModule } from './modules/admin-tags/admin-tags.module';
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { DownloadsModule } from './modules/downloads/downloads.module';
@@ -30,6 +32,8 @@ import { ToolsModule } from './modules/tools/tools.module';
GitlabStorageModule,
DownloadsModule,
AdminAuthModule,
AdminCategoriesModule,
AdminTagsModule,
AdminToolsModule,
AdminArtifactsModule,
AdminAuditModule,

View File

@@ -0,0 +1,45 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Audit } from '../../common/decorators/audit.decorator';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
import { AdminCategoriesService } from './admin-categories.service';
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@ApiTags('admin-categories')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@UseInterceptors(AdminAuditInterceptor)
@Controller('admin/categories')
export class AdminCategoriesController {
constructor(private readonly adminCategoriesService: AdminCategoriesService) {}
@Get()
@ApiOperation({ summary: 'Admin list categories' })
getCategories(@Query() query: AdminCategoriesQueryDto) {
return this.adminCategoriesService.getCategories(query);
}
@Post()
@Audit({ action: 'category.create', resourceType: 'category' })
@ApiOperation({ summary: 'Admin create category' })
createCategory(@Body() body: CreateCategoryDto) {
return this.adminCategoriesService.createCategory(body);
}
@Patch(':id')
@Audit({ action: 'category.update', resourceType: 'category' })
@ApiOperation({ summary: 'Admin update category' })
updateCategory(@Param('id') id: string, @Body() body: UpdateCategoryDto) {
return this.adminCategoriesService.updateCategory(id, body);
}
@Delete(':id')
@Audit({ action: 'category.delete', resourceType: 'category' })
@ApiOperation({ summary: 'Admin delete category' })
deleteCategory(@Param('id') id: string) {
return this.adminCategoriesService.deleteCategory(id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminCategoriesController } from './admin-categories.controller';
import { AdminCategoriesService } from './admin-categories.service';
@Module({
controllers: [AdminCategoriesController],
providers: [AdminCategoriesService],
})
export class AdminCategoriesModule {}

View File

@@ -0,0 +1,207 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import { PrismaService } from '../../prisma/prisma.service';
import { AdminCategoriesQueryDto } from './dto/admin-categories-query.dto';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
@Injectable()
export class AdminCategoriesService {
constructor(private readonly prisma: PrismaService) {}
async getCategories(query: AdminCategoriesQueryDto) {
const keyword = query.query?.trim();
const categories = await this.prisma.category.findMany({
where: {
isDeleted: false,
...(keyword
? {
name: {
contains: keyword,
},
}
: {}),
},
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
});
return categories.map((item) => ({
id: item.id,
name: item.name,
sortOrder: item.sortOrder,
toolCount: item.tools.length,
}));
}
async createCategory(body: CreateCategoryDto) {
const name = body.name.trim();
const existing = await this.prisma.category.findUnique({
where: { name },
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
});
if (existing) {
if (!existing.isDeleted) {
throw new AppException(
ERROR_CODES.CONFLICT,
'category name already exists',
HttpStatus.CONFLICT,
);
}
const restored = await this.prisma.category.update({
where: { id: existing.id },
data: {
isDeleted: false,
sortOrder: body.sortOrder ?? existing.sortOrder,
},
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
});
return {
id: restored.id,
name: restored.name,
sortOrder: restored.sortOrder,
toolCount: restored.tools.length,
};
}
const category = await this.prisma.category.create({
data: {
id: this.generateBusinessId('cat'),
name,
sortOrder: body.sortOrder ?? 100,
},
});
return {
id: category.id,
name: category.name,
sortOrder: category.sortOrder,
toolCount: 0,
};
}
async updateCategory(id: string, body: UpdateCategoryDto) {
const existing = await this.getCategoryEntity(id);
const name =
body.name !== undefined
? body.name.trim()
: existing.name;
await this.assertNameAvailable(name, id);
const updated = await this.prisma.category.update({
where: { id },
data: {
name: body.name !== undefined ? name : undefined,
sortOrder: body.sortOrder,
},
include: {
tools: {
where: {
isDeleted: false,
},
select: {
id: true,
},
},
},
});
return {
id: updated.id,
name: updated.name,
sortOrder: updated.sortOrder,
toolCount: updated.tools.length,
};
}
async deleteCategory(id: string) {
await this.getCategoryEntity(id);
const usedCount = await this.prisma.tool.count({
where: {
categoryId: id,
isDeleted: false,
},
});
if (usedCount > 0) {
throw new AppException(
ERROR_CODES.CONFLICT,
'category is still used by tools',
HttpStatus.CONFLICT,
);
}
await this.prisma.category.update({
where: { id },
data: {
isDeleted: true,
},
});
return {
success: true,
id,
};
}
private async getCategoryEntity(id: string) {
const category = await this.prisma.category.findFirst({
where: {
id,
isDeleted: false,
},
});
if (!category) {
throw new AppException(ERROR_CODES.NOT_FOUND, 'category not found', HttpStatus.NOT_FOUND);
}
return category;
}
private async assertNameAvailable(name: string, excludeId?: string) {
const conflict = await this.prisma.category.findUnique({
where: { name },
select: {
id: true,
},
});
if (conflict && conflict.id !== excludeId) {
throw new AppException(ERROR_CODES.CONFLICT, 'category name already exists', HttpStatus.CONFLICT);
}
}
private generateBusinessId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
}

View File

@@ -0,0 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class AdminCategoriesQueryDto {
@ApiPropertyOptional({ description: 'Category name query' })
@IsOptional()
@IsString()
@MaxLength(80)
query?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
export class CreateCategoryDto {
@ApiProperty({ description: 'Category name' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(80)
name!: string;
@ApiPropertyOptional({ description: 'Sort order', default: 100, minimum: 0, maximum: 9999 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(9999)
sortOrder?: number;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@@ -0,0 +1,29 @@
import { Body, Controller, Get, Post, UseGuards, UseInterceptors } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Audit } from '../../common/decorators/audit.decorator';
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
import { AdminTagsService } from './admin-tags.service';
import { CreateTagDto } from './dto/create-tag.dto';
@ApiTags('admin-tags')
@ApiBearerAuth('admin-access-token')
@UseGuards(AdminJwtGuard)
@UseInterceptors(AdminAuditInterceptor)
@Controller('admin/tags')
export class AdminTagsController {
constructor(private readonly adminTagsService: AdminTagsService) {}
@Get()
@ApiOperation({ summary: 'Admin list tags' })
getTags() {
return this.adminTagsService.getTags();
}
@Post()
@Audit({ action: 'tag.create', resourceType: 'tag' })
@ApiOperation({ summary: 'Admin create tag' })
createTag(@Body() body: CreateTagDto) {
return this.adminTagsService.createTag(body);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AdminTagsController } from './admin-tags.controller';
import { AdminTagsService } from './admin-tags.service';
@Module({
controllers: [AdminTagsController],
providers: [AdminTagsService],
})
export class AdminTagsModule {}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTagDto } from './dto/create-tag.dto';
@Injectable()
export class AdminTagsService {
constructor(private readonly prisma: PrismaService) {}
async getTags() {
const tags = await this.prisma.tag.findMany({
where: {
isDeleted: false,
},
orderBy: {
name: 'asc',
},
});
return tags.map((tag) => ({
id: tag.id,
name: tag.name,
}));
}
async createTag(body: CreateTagDto) {
const name = body.name.trim();
const existing = await this.prisma.tag.findUnique({
where: { name },
});
if (existing) {
if (existing.isDeleted) {
const restored = await this.prisma.tag.update({
where: { id: existing.id },
data: { isDeleted: false },
});
return {
id: restored.id,
name: restored.name,
};
}
return {
id: existing.id,
name: existing.name,
};
}
const created = await this.prisma.tag.create({
data: {
id: this.generateBusinessId('tag'),
name,
},
});
return {
id: created.id,
name: created.name,
};
}
private generateBusinessId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
}
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CreateTagDto {
@ApiProperty({ description: 'Tag display name' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@MaxLength(50)
name!: string;
}