Parcourir la source

feat: enhance DTOs and services with improved error handling and documentation

Dave il y a 4 mois
Parent
commit
1b38496d09

+ 4 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts

@@ -224,7 +224,10 @@ export class ListAdsDto extends PageListDto {
   @IsMongoId()
   channelId?: string;
 
-  @ApiPropertyOptional({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @ApiPropertyOptional({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+  })
   @Type(() => Number)
   @IsOptional()
   @IsEnum(CommonStatus)

+ 73 - 30
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts

@@ -1,4 +1,8 @@
-import { Injectable } from '@nestjs/common';
+import {
+  Injectable,
+  BadRequestException,
+  NotFoundException,
+} from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import { CreateAdsDto, ListAdsDto, UpdateAdsDto } from './ads.dto';
@@ -8,7 +12,14 @@ import { CommonStatus } from '../common/status.enum';
 export class AdsService {
   constructor(private readonly mongoPrismaService: MongoPrismaService) {}
 
-  private now() {
+  /**
+   * Current epoch time in milliseconds.
+   *
+   * NOTE:
+   *  - Kept as `number` for backward compatibility.
+   *  - If you migrate to BigInt timestamps, update this and the Prisma schema.
+   */
+  private now(): number {
     return Date.now();
   }
 
@@ -18,19 +29,29 @@ export class AdsService {
     return typeof value === 'string' ? value.trim() : value;
   }
 
-  private ensureTimeRange(startDt: number, expiryDt: number) {
+  /**
+   * Validate that expiryDt is not earlier than startDt.
+   * Throws 400 if invalid.
+   */
+  private ensureTimeRange(startDt: number, expiryDt: number): void {
     if (expiryDt < startDt) {
-      throw new Error('expiryDt must be greater than or equal to startDt');
+      throw new BadRequestException(
+        'expiryDt must be greater than or equal to startDt',
+      );
     }
   }
 
-  private async assertChannelExists(channelId: string) {
+  /**
+   * Ensure the channel exists.
+   */
+  private async assertChannelExists(channelId: string): Promise<void> {
     const exists = await this.mongoPrismaService.channel.findUnique({
       where: { id: channelId },
       select: { id: true },
     });
+
     if (!exists) {
-      throw new Error('Channel not found');
+      throw new NotFoundException('Channel not found');
     }
   }
 
@@ -39,6 +60,7 @@ export class AdsService {
     this.ensureTimeRange(dto.startDt, dto.expiryDt);
 
     const now = this.now();
+
     return this.mongoPrismaService.ads.create({
       data: {
         channelId: dto.channelId,
@@ -63,27 +85,36 @@ export class AdsService {
     this.ensureTimeRange(dto.startDt, dto.expiryDt);
 
     const now = this.now();
+
+    // Build data object carefully to avoid unintended field changes
+    const data: any = {
+      channelId: dto.channelId,
+      adsModule: dto.adsModule,
+      advertiser: dto.advertiser,
+      title: dto.title,
+      adsContent: this.trimOptional(dto.adsContent) ?? null,
+      adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
+      adsUrl: this.trimOptional(dto.adsUrl) ?? null,
+      startDt: dto.startDt,
+      expiryDt: dto.expiryDt,
+      seq: dto.seq ?? 0,
+      updateAt: now,
+    };
+
+    // Only update status if explicitly provided,
+    // to avoid silently re-enabling disabled ads.
+    if (dto.status !== undefined) {
+      data.status = dto.status;
+    }
+
     try {
       return await this.mongoPrismaService.ads.update({
         where: { id: dto.id },
-        data: {
-          channelId: dto.channelId,
-          adsModule: dto.adsModule,
-          advertiser: dto.advertiser,
-          title: dto.title,
-          adsContent: this.trimOptional(dto.adsContent) ?? null,
-          adsCoverImg: this.trimOptional(dto.adsCoverImg) ?? null,
-          adsUrl: this.trimOptional(dto.adsUrl) ?? null,
-          startDt: dto.startDt,
-          expiryDt: dto.expiryDt,
-          seq: dto.seq ?? 0,
-          status: dto.status ?? CommonStatus.enabled,
-          updateAt: now,
-        },
+        data,
       });
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Ads not found');
+        throw new NotFoundException('Ads not found');
       }
       throw e;
     }
@@ -93,7 +124,11 @@ export class AdsService {
     const row = await this.mongoPrismaService.ads.findUnique({
       where: { id },
     });
-    if (!row) throw new Error('Ads not found');
+
+    if (!row) {
+      throw new NotFoundException('Ads not found');
+    }
+
     return row;
   }
 
@@ -109,25 +144,33 @@ export class AdsService {
     if (dto.adsModule) {
       where.adsModule = { contains: dto.adsModule, mode: 'insensitive' };
     }
-    if (dto.channelId) where.channelId = dto.channelId;
-    if (dto.status !== undefined) where.status = dto.status;
+    if (dto.channelId) {
+      where.channelId = dto.channelId;
+    }
+    if (dto.status !== undefined) {
+      where.status = dto.status;
+    }
+
+    // Defensive guards for pagination
+    const page = dto.page && dto.page > 0 ? dto.page : 1;
+    const size = dto.size && dto.size > 0 ? dto.size : 10;
 
     const [total, data] = await this.mongoPrismaService.$transaction([
       this.mongoPrismaService.ads.count({ where }),
       this.mongoPrismaService.ads.findMany({
         where,
         orderBy: { updateAt: 'desc' },
-        skip: (dto.page - 1) * dto.size,
-        take: dto.size,
+        skip: (page - 1) * size,
+        take: size,
       }),
     ]);
 
     return {
       total,
       data,
-      totalPages: Math.ceil(total / dto.size),
-      page: dto.page,
-      size: dto.size,
+      totalPages: Math.ceil(total / size),
+      page,
+      size,
     };
   }
 
@@ -137,7 +180,7 @@ export class AdsService {
       return { message: 'Deleted' };
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Ads not found');
+        throw new NotFoundException('Ads not found');
       }
       throw e;
     }

+ 55 - 22
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.service.ts

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import {
@@ -12,23 +12,35 @@ import { CommonStatus } from '../common/status.enum';
 export class CategoryService {
   constructor(private readonly mongoPrismaService: MongoPrismaService) {}
 
-  private now() {
+  /**
+   * Current epoch time in milliseconds.
+   *
+   * NOTE:
+   *  - Kept as `number` for backward compatibility.
+   *  - If you migrate to BigInt timestamps, change this and the Prisma schema accordingly.
+   */
+  private now(): number {
     return Date.now();
   }
 
-  private async assertChannelExists(channelId: string) {
+  /**
+   * Ensure the channel exists.
+   */
+  private async assertChannelExists(channelId: string): Promise<void> {
     const exists = await this.mongoPrismaService.channel.findUnique({
       where: { id: channelId },
       select: { id: true },
     });
+
     if (!exists) {
-      throw new Error('Channel not found');
+      throw new NotFoundException('Channel not found');
     }
   }
 
   async create(dto: CreateCategoryDto) {
     await this.assertChannelExists(dto.channelId);
     const now = this.now();
+
     return this.mongoPrismaService.category.create({
       data: {
         name: dto.name,
@@ -45,21 +57,29 @@ export class CategoryService {
   async update(dto: UpdateCategoryDto) {
     await this.assertChannelExists(dto.channelId);
     const now = this.now();
+
+    // Build data object carefully to avoid unintended field changes
+    const data: any = {
+      name: dto.name,
+      subtitle: dto.subtitle?.trim() ?? null,
+      channelId: dto.channelId,
+      seq: dto.seq ?? 0,
+      updateAt: now,
+    };
+
+    // Only update status if explicitly provided to avoid silently re-enabling
+    if (dto.status !== undefined) {
+      data.status = dto.status;
+    }
+
     try {
       return await this.mongoPrismaService.category.update({
         where: { id: dto.id },
-        data: {
-          name: dto.name,
-          subtitle: dto.subtitle?.trim() ?? null,
-          channelId: dto.channelId,
-          seq: dto.seq ?? 0,
-          status: dto.status ?? CommonStatus.enabled,
-          updateAt: now,
-        },
+        data,
       });
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Category not found');
+        throw new NotFoundException('Category not found');
       }
       throw e;
     }
@@ -69,34 +89,47 @@ export class CategoryService {
     const row = await this.mongoPrismaService.category.findUnique({
       where: { id },
     });
-    if (!row) throw new Error('Category not found');
+
+    if (!row) {
+      throw new NotFoundException('Category not found');
+    }
+
     return row;
   }
 
   async list(dto: ListCategoryDto) {
     const where: any = {};
+
     if (dto.name) {
       where.name = { contains: dto.name, mode: 'insensitive' };
     }
-    if (dto.channelId) where.channelId = dto.channelId;
-    if (dto.status !== undefined) where.status = dto.status;
+    if (dto.channelId) {
+      where.channelId = dto.channelId;
+    }
+    if (dto.status !== undefined) {
+      where.status = dto.status;
+    }
+
+    // Defensive guards – avoid weird values from callers
+    const page = dto.page && dto.page > 0 ? dto.page : 1;
+    const size = dto.size && dto.size > 0 ? dto.size : 10;
 
     const [total, data] = await this.mongoPrismaService.$transaction([
       this.mongoPrismaService.category.count({ where }),
       this.mongoPrismaService.category.findMany({
         where,
         orderBy: { updateAt: 'desc' },
-        skip: (dto.page - 1) * dto.size,
-        take: dto.size,
+        skip: (page - 1) * size,
+        take: size,
       }),
     ]);
 
     return {
       total,
       data,
-      totalPages: Math.ceil(total / dto.size),
-      page: dto.page,
-      size: dto.size,
+      totalPages: Math.ceil(total / size),
+      page,
+      size,
     };
   }
 
@@ -106,7 +139,7 @@ export class CategoryService {
       return { message: 'Deleted' };
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Category not found');
+        throw new NotFoundException('Category not found');
       }
       throw e;
     }

+ 28 - 10
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.service.ts

@@ -1,4 +1,4 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, NotFoundException } from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
 import {
@@ -11,7 +11,14 @@ import {
 export class ChannelService {
   constructor(private readonly mongoPrismaService: MongoPrismaService) {}
 
-  private now() {
+  /**
+   * Current epoch time in milliseconds.
+   *
+   * NOTE:
+   *  - Kept as `number` for backward compatibility.
+   *  - If you migrate to BigInt timestamps, update this and the Prisma schema.
+   */
+  private now(): number {
     return Date.now();
   }
 
@@ -23,6 +30,7 @@ export class ChannelService {
 
   async create(dto: CreateChannelDto) {
     const now = this.now();
+
     return this.mongoPrismaService.channel.create({
       data: {
         name: dto.name,
@@ -40,6 +48,7 @@ export class ChannelService {
 
   async update(dto: UpdateChannelDto) {
     const now = this.now();
+
     try {
       return await this.mongoPrismaService.channel.update({
         where: { id: dto.id },
@@ -56,7 +65,7 @@ export class ChannelService {
       });
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Channel not found');
+        throw new NotFoundException('Channel not found');
       }
       throw e;
     }
@@ -66,32 +75,41 @@ export class ChannelService {
     const row = await this.mongoPrismaService.channel.findUnique({
       where: { id },
     });
-    if (!row) throw new Error('Channel not found');
+
+    if (!row) {
+      throw new NotFoundException('Channel not found');
+    }
+
     return row;
   }
 
   async list(dto: ListChannelDto) {
     const where: any = {};
+
     if (dto.name) {
       where.name = { contains: dto.name, mode: 'insensitive' };
     }
 
+    // Defensive guards for pagination
+    const page = dto.page && dto.page > 0 ? dto.page : 1;
+    const size = dto.size && dto.size > 0 ? dto.size : 10;
+
     const [total, data] = await this.mongoPrismaService.$transaction([
       this.mongoPrismaService.channel.count({ where }),
       this.mongoPrismaService.channel.findMany({
         where,
         orderBy: { updateAt: 'desc' },
-        skip: (dto.page - 1) * dto.size,
-        take: dto.size,
+        skip: (page - 1) * size,
+        take: size,
       }),
     ]);
 
     return {
       total,
       data,
-      totalPages: Math.ceil(total / dto.size),
-      page: dto.page,
-      size: dto.size,
+      totalPages: Math.ceil(total / size),
+      page,
+      size,
     };
   }
 
@@ -101,7 +119,7 @@ export class ChannelService {
       return { message: 'Deleted' };
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Channel not found');
+        throw new NotFoundException('Channel not found');
       }
       throw e;
     }

+ 4 - 1
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.dto.ts

@@ -129,7 +129,10 @@ export class ListTagDto extends PageListDto {
   @IsMongoId()
   categoryId?: string;
 
-  @ApiPropertyOptional({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @ApiPropertyOptional({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+  })
   @Type(() => Number)
   @IsOptional()
   @IsEnum(CommonStatus)

+ 67 - 27
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.service.ts

@@ -1,22 +1,39 @@
-import { Injectable } from '@nestjs/common';
+import {
+  Injectable,
+  BadRequestException,
+  NotFoundException,
+} from '@nestjs/common';
 import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
 import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
-import {
-  CreateTagDto,
-  ListTagDto,
-  UpdateTagDto,
-} from './tag.dto';
+import { CreateTagDto, ListTagDto, UpdateTagDto } from './tag.dto';
 import { CommonStatus } from '../common/status.enum';
 
 @Injectable()
 export class TagService {
   constructor(private readonly mongoPrismaService: MongoPrismaService) {}
 
-  private now() {
+  /**
+   * Current epoch time in milliseconds.
+   *
+   * NOTE:
+   *  - For now we keep this as `number` for backward compatibility.
+   *  - If you migrate to BigInt timestamps later, change this to return `bigint`
+   *    and update the Prisma schema + DTOs accordingly.
+   */
+  private now(): number {
     return Date.now();
   }
 
-  private async assertRelations(channelId: string, categoryId: string) {
+  /**
+   * Ensure:
+   *  - Channel exists
+   *  - Category exists
+   *  - Category belongs to the given channel
+   */
+  private async assertRelations(
+    channelId: string,
+    categoryId: string,
+  ): Promise<void> {
     const [channel, category] = await Promise.all([
       this.mongoPrismaService.channel.findUnique({
         where: { id: channelId },
@@ -29,19 +46,25 @@ export class TagService {
     ]);
 
     if (!channel) {
-      throw new Error('Channel not found');
+      // 404 – channel does not exist
+      throw new NotFoundException('Channel not found');
     }
     if (!category) {
-      throw new Error('Category not found');
+      // 404 – category does not exist
+      throw new NotFoundException('Category not found');
     }
     if (category.channelId !== channelId) {
-      throw new Error('Category does not belong to the provided channel');
+      // 400 – invalid combination of channel/category
+      throw new BadRequestException(
+        'Category does not belong to the provided channel',
+      );
     }
   }
 
   async create(dto: CreateTagDto) {
     await this.assertRelations(dto.channelId, dto.categoryId);
     const now = this.now();
+
     return this.mongoPrismaService.tag.create({
       data: {
         name: dto.name,
@@ -59,21 +82,30 @@ export class TagService {
     await this.assertRelations(dto.channelId, dto.categoryId);
     const now = this.now();
 
+    // Build update data carefully to avoid accidentally changing fields
+    const data: any = {
+      name: dto.name,
+      channelId: dto.channelId,
+      categoryId: dto.categoryId,
+      seq: dto.seq ?? 0,
+      updateAt: now,
+    };
+
+    // Only update `status` if it is explicitly provided.
+    // This avoids silently re-enabling disabled tags.
+    if (dto.status !== undefined) {
+      data.status = dto.status;
+    }
+
     try {
       return await this.mongoPrismaService.tag.update({
         where: { id: dto.id },
-        data: {
-          name: dto.name,
-          channelId: dto.channelId,
-          categoryId: dto.categoryId,
-          seq: dto.seq ?? 0,
-          status: dto.status ?? CommonStatus.enabled,
-          updateAt: now,
-        },
+        data,
       });
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Tag not found');
+        // Tag with this ID does not exist
+        throw new NotFoundException('Tag not found');
       }
       throw e;
     }
@@ -83,7 +115,11 @@ export class TagService {
     const row = await this.mongoPrismaService.tag.findUnique({
       where: { id },
     });
-    if (!row) throw new Error('Tag not found');
+
+    if (!row) {
+      throw new NotFoundException('Tag not found');
+    }
+
     return row;
   }
 
@@ -97,22 +133,26 @@ export class TagService {
     if (dto.categoryId) where.categoryId = dto.categoryId;
     if (dto.status !== undefined) where.status = dto.status;
 
+    // Defensive guards – keep behavior sane even if caller passes 0 or negative.
+    const page = dto.page && dto.page > 0 ? dto.page : 1;
+    const size = dto.size && dto.size > 0 ? dto.size : 10;
+
     const [total, data] = await this.mongoPrismaService.$transaction([
       this.mongoPrismaService.tag.count({ where }),
       this.mongoPrismaService.tag.findMany({
         where,
         orderBy: { updateAt: 'desc' },
-        skip: (dto.page - 1) * dto.size,
-        take: dto.size,
+        skip: (page - 1) * size,
+        take: size,
       }),
     ]);
 
     return {
       total,
       data,
-      totalPages: Math.ceil(total / dto.size),
-      page: dto.page,
-      size: dto.size,
+      totalPages: Math.ceil(total / size),
+      page,
+      size,
     };
   }
 
@@ -122,7 +162,7 @@ export class TagService {
       return { message: 'Deleted' };
     } catch (e) {
       if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
-        throw new Error('Tag not found');
+        throw new NotFoundException('Tag not found');
       }
       throw e;
     }