浏览代码

feat: add category and channel management features

- Implemented CategoryController, CategoryService, and related DTOs for managing categories.
- Added ChannelController, ChannelService, and related DTOs for managing channels.
- Introduced TagController, TagService, and related DTOs for managing tags.
- Created common MongoIdParamDto and CommonStatus enum for consistent ID handling and status representation.
- Updated feature module to include CategoryModule, ChannelModule, and TagModule for better organization.
Dave 1 小时之前
父节点
当前提交
5f57cb76e6
共有 21 个文件被更改,包括 1585 次插入3 次删除
  1. 3 3
      .env.mgnt.dev
  2. 77 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts
  3. 232 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts
  4. 12 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.module.ts
  5. 149 0
      apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts
  6. 85 0
      apps/box-mgnt-api/src/mgnt-backend/feature/category/category.controller.ts
  7. 138 0
      apps/box-mgnt-api/src/mgnt-backend/feature/category/category.dto.ts
  8. 12 0
      apps/box-mgnt-api/src/mgnt-backend/feature/category/category.module.ts
  9. 114 0
      apps/box-mgnt-api/src/mgnt-backend/feature/category/category.service.ts
  10. 85 0
      apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.controller.ts
  11. 173 0
      apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.dto.ts
  12. 12 0
      apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.module.ts
  13. 109 0
      apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.service.ts
  14. 8 0
      apps/box-mgnt-api/src/mgnt-backend/feature/common/mongo-id.dto.ts
  15. 4 0
      apps/box-mgnt-api/src/mgnt-backend/feature/common/status.enum.ts
  16. 8 0
      apps/box-mgnt-api/src/mgnt-backend/feature/feature.module.ts
  17. 77 0
      apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.controller.ts
  18. 137 0
      apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.dto.ts
  19. 12 0
      apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.module.ts
  20. 130 0
      apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.service.ts
  21. 8 0
      apps/box-mgnt-api/src/mgnt-backend/mgnt-backend.module.ts

+ 3 - 3
.env.mgnt.dev

@@ -5,11 +5,11 @@ APP_ENV=development
 # MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
 # MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@localhost:27017/box_admin?authSource=admin"
 
-# MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
 MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
+# MYSQL_URL="mysql://root:123456@localhost:3306/box_admin"
 
-# MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin"
-MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@localhost:27017/box_admin?authSource=admin"
+MONGO_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_admin?authSource=admin"
+# MONGO_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@localhost:27017/box_admin?authSource=admin"
 
 
 # App set to 0.0.0.0 for local LAN access

+ 77 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.controller.ts

@@ -0,0 +1,77 @@
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Param,
+  Post,
+  Put,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import { CreateAdsDto, ListAdsDto, UpdateAdsDto, AdsDto } from './ads.dto';
+import { AdsService } from './ads.service';
+import { MongoIdParamDto } from '../common/mongo-id.dto';
+
+@ApiTags('营销管理 - 广告')
+@Controller('ads')
+export class AdsController {
+  constructor(private readonly service: AdsService) {}
+
+  @Post('list')
+  @ApiOperation({ summary: 'List ads (pagination + filters)' })
+  @ApiBody({ type: ListAdsDto })
+  @ApiResponse({
+    status: 200,
+    description: 'Paged ads with total/page/size/totalPages',
+    schema: {
+      type: 'object',
+      properties: {
+        total: { type: 'integer', example: 2 },
+        page: { type: 'integer', example: 1 },
+        size: { type: 'integer', example: 10 },
+        totalPages: { type: 'integer', example: 1 },
+        data: { type: 'array', items: { $ref: '#/components/schemas/AdsDto' } },
+      },
+    },
+  })
+  list(@Body() dto: ListAdsDto) {
+    return this.service.list(dto);
+  }
+
+  @Get(':id')
+  @ApiOperation({ summary: 'Get ad detail by id' })
+  @ApiResponse({ status: 200, type: AdsDto })
+  get(@Param() { id }: MongoIdParamDto) {
+    return this.service.findOne(id);
+  }
+
+  @Post()
+  @ApiOperation({ summary: 'Create an ad' })
+  @ApiResponse({ status: 201, type: AdsDto })
+  create(@Body() dto: CreateAdsDto) {
+    return this.service.create(dto);
+  }
+
+  @Put(':id')
+  @ApiOperation({ summary: 'Update an ad' })
+  @ApiResponse({ status: 200, type: AdsDto })
+  update(@Param() { id }: MongoIdParamDto, @Body() dto: UpdateAdsDto) {
+    if (dto.id && dto.id !== id) {
+      throw new BadRequestException('ID in body must match ID in path');
+    }
+    return this.service.update({ ...dto, id });
+  }
+
+  @Delete(':id')
+  @ApiOperation({ summary: 'Delete an ad' })
+  @ApiResponse({ status: 200, description: 'Deletion result' })
+  remove(@Param() { id }: MongoIdParamDto) {
+    return this.service.remove(id);
+  }
+}

+ 232 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.dto.ts

@@ -0,0 +1,232 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+  IsEnum,
+  IsInt,
+  IsMongoId,
+  IsOptional,
+  IsString,
+  IsUrl,
+  Length,
+  MaxLength,
+  Min,
+} from 'class-validator';
+import { Transform, Type } from 'class-transformer';
+import { PageListDto } from '@box/common/dto/page-list.dto';
+import { CommonStatus } from '../common/status.enum';
+
+// ---- Base DTO for returning a full record ----
+export class AdsDto {
+  @ApiProperty({
+    description: '广告ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c12345',
+  })
+  @IsMongoId()
+  id: string;
+
+  @ApiProperty({
+    description: '渠道ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c00001',
+  })
+  @IsMongoId()
+  channelId: string;
+
+  @ApiProperty({
+    description: '广告模块 (banner/startup/轮播等)',
+    example: 'banner',
+  })
+  @IsString()
+  @Length(1, 50)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  adsModule: string;
+
+  @ApiProperty({ description: '广告商', maxLength: 20, example: 'Acme' })
+  @IsString()
+  @Length(1, 20)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  advertiser: string;
+
+  @ApiProperty({ description: '标题', maxLength: 20, example: '夏季促销' })
+  @IsString()
+  @Length(1, 20)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  title: string;
+
+  @ApiPropertyOptional({
+    description: '广告文案 (≤500 字符)',
+    maxLength: 500,
+    example: '全场8折,限时3天',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  adsContent?: string | null;
+
+  @ApiPropertyOptional({
+    description: '广告图片地址',
+    example: 'https://cdn.example.com/ads/banner.png',
+  })
+  @IsOptional()
+  @IsUrl()
+  adsCoverImg?: string | null;
+
+  @ApiPropertyOptional({
+    description: '广告跳转链接',
+    example: 'https://example.com/landing',
+  })
+  @IsOptional()
+  @IsUrl()
+  adsUrl?: string | null;
+
+  @ApiProperty({
+    description: '开始时间,epoch 毫秒',
+    example: 1719830400000,
+  })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  startDt: number;
+
+  @ApiProperty({
+    description: '到期时间,epoch 毫秒',
+    example: 1719916800000,
+  })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  expiryDt: number;
+
+  @ApiProperty({ description: '排序 (越小越靠前)', example: 0 })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  seq: number;
+
+  @ApiProperty({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @Type(() => Number)
+  @IsEnum(CommonStatus)
+  status: CommonStatus;
+
+  @ApiProperty({ description: '创建时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  createAt: number;
+
+  @ApiProperty({ description: '更新时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  updateAt: number;
+}
+
+// ---- Create ----
+export class CreateAdsDto {
+  @ApiProperty({ description: '渠道ID (ObjectId)' })
+  @IsMongoId()
+  channelId: string;
+
+  @ApiProperty({ description: '广告模块 (banner/startup/轮播等)' })
+  @IsString()
+  @Length(1, 50)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  adsModule: string;
+
+  @ApiProperty({ description: '广告商', maxLength: 20 })
+  @IsString()
+  @Length(1, 20)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  advertiser: string;
+
+  @ApiProperty({ description: '标题', maxLength: 20 })
+  @IsString()
+  @Length(1, 20)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  title: string;
+
+  @ApiPropertyOptional({ description: '广告文案', maxLength: 500 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  adsContent?: string;
+
+  @ApiPropertyOptional({ description: '广告图片地址' })
+  @IsOptional()
+  @IsUrl()
+  adsCoverImg?: string;
+
+  @ApiPropertyOptional({ description: '广告跳转链接' })
+  @IsOptional()
+  @IsUrl()
+  adsUrl?: string;
+
+  @ApiProperty({ description: '开始时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  startDt: number;
+
+  @ApiProperty({ description: '到期时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  expiryDt: number;
+
+  @ApiPropertyOptional({ description: '排序 (默认0)', example: 0 })
+  @Type(() => Number)
+  @IsOptional()
+  @IsInt()
+  @Min(0)
+  seq?: number;
+
+  @ApiPropertyOptional({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+    example: CommonStatus.enabled,
+  })
+  @Type(() => Number)
+  @IsOptional()
+  @IsEnum(CommonStatus)
+  status?: CommonStatus;
+}
+
+// ---- Update ----
+export class UpdateAdsDto extends CreateAdsDto {
+  @ApiProperty({ description: '广告ID (ObjectId)' })
+  @IsMongoId()
+  id: string;
+}
+
+// ---- List ----
+export class ListAdsDto extends PageListDto {
+  @ApiPropertyOptional({ description: '模糊搜索标题', maxLength: 50 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(50)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  title?: string;
+
+  @ApiPropertyOptional({ description: '广告商过滤', maxLength: 20 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(20)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  advertiser?: string;
+
+  @ApiPropertyOptional({ description: '广告模块过滤', maxLength: 50 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(50)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  adsModule?: string;
+
+  @ApiPropertyOptional({ description: '渠道ID (ObjectId)' })
+  @IsOptional()
+  @IsMongoId()
+  channelId?: string;
+
+  @ApiPropertyOptional({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @Type(() => Number)
+  @IsOptional()
+  @IsEnum(CommonStatus)
+  status?: CommonStatus;
+}

+ 12 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { AdsService } from './ads.service';
+import { AdsController } from './ads.controller';
+
+@Module({
+  imports: [PrismaModule],
+  providers: [AdsService],
+  controllers: [AdsController],
+  exports: [AdsService],
+})
+export class AdsModule {}

+ 149 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/ads/ads.service.ts

@@ -0,0 +1,149 @@
+import { Injectable } 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';
+import { CommonStatus } from '../common/status.enum';
+
+@Injectable()
+export class AdsService {
+  constructor(private readonly mongoPrismaService: MongoPrismaService) {}
+
+  private now() {
+    return Date.now();
+  }
+
+  private trimOptional(value?: string | null) {
+    if (value === undefined) return undefined;
+    if (value === null) return null;
+    return typeof value === 'string' ? value.trim() : value;
+  }
+
+  private ensureTimeRange(startDt: number, expiryDt: number) {
+    if (expiryDt < startDt) {
+      throw new Error('expiryDt must be greater than or equal to startDt');
+    }
+  }
+
+  private async assertChannelExists(channelId: string) {
+    const exists = await this.mongoPrismaService.channel.findUnique({
+      where: { id: channelId },
+      select: { id: true },
+    });
+    if (!exists) {
+      throw new Error('Channel not found');
+    }
+  }
+
+  async create(dto: CreateAdsDto) {
+    await this.assertChannelExists(dto.channelId);
+    this.ensureTimeRange(dto.startDt, dto.expiryDt);
+
+    const now = this.now();
+    return this.mongoPrismaService.ads.create({
+      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,
+        createAt: now,
+        updateAt: now,
+      },
+    });
+  }
+
+  async update(dto: UpdateAdsDto) {
+    await this.assertChannelExists(dto.channelId);
+    this.ensureTimeRange(dto.startDt, dto.expiryDt);
+
+    const now = this.now();
+    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,
+        },
+      });
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Ads not found');
+      }
+      throw e;
+    }
+  }
+
+  async findOne(id: string) {
+    const row = await this.mongoPrismaService.ads.findUnique({
+      where: { id },
+    });
+    if (!row) throw new Error('Ads not found');
+    return row;
+  }
+
+  async list(dto: ListAdsDto) {
+    const where: any = {};
+
+    if (dto.title) {
+      where.title = { contains: dto.title, mode: 'insensitive' };
+    }
+    if (dto.advertiser) {
+      where.advertiser = { contains: dto.advertiser, mode: 'insensitive' };
+    }
+    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;
+
+    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,
+      }),
+    ]);
+
+    return {
+      total,
+      data,
+      totalPages: Math.ceil(total / dto.size),
+      page: dto.page,
+      size: dto.size,
+    };
+  }
+
+  async remove(id: string) {
+    try {
+      await this.mongoPrismaService.ads.delete({ where: { id } });
+      return { message: 'Deleted' };
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Ads not found');
+      }
+      throw e;
+    }
+  }
+}

+ 85 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.controller.ts

@@ -0,0 +1,85 @@
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Param,
+  Post,
+  Put,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import {
+  CategoryDto,
+  CreateCategoryDto,
+  ListCategoryDto,
+  UpdateCategoryDto,
+} from './category.dto';
+import { CategoryService } from './category.service';
+import { MongoIdParamDto } from '../common/mongo-id.dto';
+
+@ApiTags('营销管理 - 分类')
+@Controller('categories')
+export class CategoryController {
+  constructor(private readonly service: CategoryService) {}
+
+  @Post('list')
+  @ApiOperation({ summary: 'List categories (pagination + filters)' })
+  @ApiBody({ type: ListCategoryDto })
+  @ApiResponse({
+    status: 200,
+    description: 'Paged categories with total/page/size/totalPages',
+    schema: {
+      type: 'object',
+      properties: {
+        total: { type: 'integer', example: 5 },
+        page: { type: 'integer', example: 1 },
+        size: { type: 'integer', example: 10 },
+        totalPages: { type: 'integer', example: 1 },
+        data: {
+          type: 'array',
+          items: { $ref: '#/components/schemas/CategoryDto' },
+        },
+      },
+    },
+  })
+  list(@Body() dto: ListCategoryDto) {
+    return this.service.list(dto);
+  }
+
+  @Get(':id')
+  @ApiOperation({ summary: 'Get category detail' })
+  @ApiResponse({ status: 200, type: CategoryDto })
+  get(@Param() { id }: MongoIdParamDto) {
+    return this.service.findOne(id);
+  }
+
+  @Post()
+  @ApiOperation({ summary: 'Create category' })
+  @ApiResponse({ status: 201, type: CategoryDto })
+  create(@Body() dto: CreateCategoryDto) {
+    return this.service.create(dto);
+  }
+
+  @Put(':id')
+  @ApiOperation({ summary: 'Update category' })
+  @ApiResponse({ status: 200, type: CategoryDto })
+  update(@Param() { id }: MongoIdParamDto, @Body() dto: UpdateCategoryDto) {
+    if (dto.id && dto.id !== id) {
+      throw new BadRequestException('ID in body must match ID in path');
+    }
+    return this.service.update({ ...dto, id });
+  }
+
+  @Delete(':id')
+  @ApiOperation({ summary: 'Delete category' })
+  @ApiResponse({ status: 200, description: 'Deletion result' })
+  remove(@Param() { id }: MongoIdParamDto) {
+    return this.service.remove(id);
+  }
+}

+ 138 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.dto.ts

@@ -0,0 +1,138 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+  IsEnum,
+  IsInt,
+  IsMongoId,
+  IsOptional,
+  IsString,
+  MaxLength,
+  Min,
+} from 'class-validator';
+import { Transform, Type } from 'class-transformer';
+import { PageListDto } from '@box/common/dto/page-list.dto';
+import { CommonStatus } from '../common/status.enum';
+
+export class CategoryDto {
+  @ApiProperty({
+    description: '分类ID (Mongo ObjectId)',
+    example: '6650a0c28e4ff3f4c0c00111',
+  })
+  @IsMongoId()
+  id: string;
+
+  @ApiProperty({ description: '分类名称', example: '热门电影' })
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name: string;
+
+  @ApiPropertyOptional({
+    description: '副标题',
+    example: '暑期档精选',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(200)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  subtitle?: string | null;
+
+  @ApiProperty({
+    description: '渠道ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c00001',
+  })
+  @IsMongoId()
+  channelId: string;
+
+  @ApiProperty({ description: '排序 (越小越靠前)', example: 0 })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  seq: number;
+
+  @ApiProperty({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+    example: CommonStatus.enabled,
+  })
+  @Type(() => Number)
+  @IsEnum(CommonStatus)
+  status: CommonStatus;
+
+  @ApiProperty({ description: '创建时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  createAt: number;
+
+  @ApiProperty({ description: '更新时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  updateAt: number;
+}
+
+export class CreateCategoryDto {
+  @ApiProperty({ description: '分类名称', example: '热门电影' })
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name: string;
+
+  @ApiPropertyOptional({
+    description: '副标题',
+    example: '暑期档精选',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(200)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  subtitle?: string;
+
+  @ApiProperty({
+    description: '渠道ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c00001',
+  })
+  @IsMongoId()
+  channelId: string;
+
+  @ApiPropertyOptional({ description: '排序 (默认0)', example: 0 })
+  @Type(() => Number)
+  @IsOptional()
+  @IsInt()
+  @Min(0)
+  seq?: number;
+
+  @ApiPropertyOptional({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+    example: CommonStatus.enabled,
+  })
+  @Type(() => Number)
+  @IsOptional()
+  @IsEnum(CommonStatus)
+  status?: CommonStatus;
+}
+
+export class UpdateCategoryDto extends CreateCategoryDto {
+  @ApiProperty({ description: '分类ID (ObjectId)' })
+  @IsMongoId()
+  id: string;
+}
+
+export class ListCategoryDto extends PageListDto {
+  @ApiPropertyOptional({ description: '模糊搜索名称', maxLength: 100 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name?: string;
+
+  @ApiPropertyOptional({ description: '渠道ID (ObjectId)' })
+  @IsOptional()
+  @IsMongoId()
+  channelId?: string;
+
+  @ApiPropertyOptional({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @Type(() => Number)
+  @IsOptional()
+  @IsEnum(CommonStatus)
+  status?: CommonStatus;
+}

+ 12 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { CategoryService } from './category.service';
+import { CategoryController } from './category.controller';
+
+@Module({
+  imports: [PrismaModule],
+  providers: [CategoryService],
+  controllers: [CategoryController],
+  exports: [CategoryService],
+})
+export class CategoryModule {}

+ 114 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/category/category.service.ts

@@ -0,0 +1,114 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import {
+  CreateCategoryDto,
+  ListCategoryDto,
+  UpdateCategoryDto,
+} from './category.dto';
+import { CommonStatus } from '../common/status.enum';
+
+@Injectable()
+export class CategoryService {
+  constructor(private readonly mongoPrismaService: MongoPrismaService) {}
+
+  private now() {
+    return Date.now();
+  }
+
+  private async assertChannelExists(channelId: string) {
+    const exists = await this.mongoPrismaService.channel.findUnique({
+      where: { id: channelId },
+      select: { id: true },
+    });
+    if (!exists) {
+      throw new Error('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,
+        subtitle: dto.subtitle?.trim() ?? null,
+        channelId: dto.channelId,
+        seq: dto.seq ?? 0,
+        status: dto.status ?? CommonStatus.enabled,
+        createAt: now,
+        updateAt: now,
+      },
+    });
+  }
+
+  async update(dto: UpdateCategoryDto) {
+    await this.assertChannelExists(dto.channelId);
+    const now = this.now();
+    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,
+        },
+      });
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Category not found');
+      }
+      throw e;
+    }
+  }
+
+  async findOne(id: string) {
+    const row = await this.mongoPrismaService.category.findUnique({
+      where: { id },
+    });
+    if (!row) throw new Error('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;
+
+    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,
+      }),
+    ]);
+
+    return {
+      total,
+      data,
+      totalPages: Math.ceil(total / dto.size),
+      page: dto.page,
+      size: dto.size,
+    };
+  }
+
+  async remove(id: string) {
+    try {
+      await this.mongoPrismaService.category.delete({ where: { id } });
+      return { message: 'Deleted' };
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Category not found');
+      }
+      throw e;
+    }
+  }
+}

+ 85 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.controller.ts

@@ -0,0 +1,85 @@
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Param,
+  Post,
+  Put,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import {
+  ChannelDto,
+  CreateChannelDto,
+  ListChannelDto,
+  UpdateChannelDto,
+} from './channel.dto';
+import { ChannelService } from './channel.service';
+import { MongoIdParamDto } from '../common/mongo-id.dto';
+
+@ApiTags('营销管理 - 渠道')
+@Controller('channels')
+export class ChannelController {
+  constructor(private readonly service: ChannelService) {}
+
+  @Post('list')
+  @ApiOperation({ summary: 'List channels (pagination + filters)' })
+  @ApiBody({ type: ListChannelDto })
+  @ApiResponse({
+    status: 200,
+    description: 'Paged channels with total/page/size/totalPages',
+    schema: {
+      type: 'object',
+      properties: {
+        total: { type: 'integer', example: 2 },
+        page: { type: 'integer', example: 1 },
+        size: { type: 'integer', example: 10 },
+        totalPages: { type: 'integer', example: 1 },
+        data: {
+          type: 'array',
+          items: { $ref: '#/components/schemas/ChannelDto' },
+        },
+      },
+    },
+  })
+  list(@Body() dto: ListChannelDto) {
+    return this.service.list(dto);
+  }
+
+  @Get(':id')
+  @ApiOperation({ summary: 'Get channel detail' })
+  @ApiResponse({ status: 200, type: ChannelDto })
+  get(@Param() { id }: MongoIdParamDto) {
+    return this.service.findOne(id);
+  }
+
+  @Post()
+  @ApiOperation({ summary: 'Create channel' })
+  @ApiResponse({ status: 201, type: ChannelDto })
+  create(@Body() dto: CreateChannelDto) {
+    return this.service.create(dto);
+  }
+
+  @Put(':id')
+  @ApiOperation({ summary: 'Update channel' })
+  @ApiResponse({ status: 200, type: ChannelDto })
+  update(@Param() { id }: MongoIdParamDto, @Body() dto: UpdateChannelDto) {
+    if (dto.id && dto.id !== id) {
+      throw new BadRequestException('ID in body must match ID in path');
+    }
+    return this.service.update({ ...dto, id });
+  }
+
+  @Delete(':id')
+  @ApiOperation({ summary: 'Delete channel' })
+  @ApiResponse({ status: 200, description: 'Deletion result' })
+  remove(@Param() { id }: MongoIdParamDto) {
+    return this.service.remove(id);
+  }
+}

+ 173 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.dto.ts

@@ -0,0 +1,173 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+  IsInt,
+  IsMongoId,
+  IsOptional,
+  IsString,
+  IsUrl,
+  MaxLength,
+} from 'class-validator';
+import { Transform, Type } from 'class-transformer';
+import { PageListDto } from '@box/common/dto/page-list.dto';
+
+export class ChannelDto {
+  @ApiProperty({
+    description: '渠道ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c00001',
+  })
+  @IsMongoId()
+  id: string;
+
+  @ApiProperty({
+    description: '渠道名称',
+    maxLength: 100,
+    example: '主站渠道',
+  })
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name: string;
+
+  @ApiProperty({
+    description: '最新落地页 URL',
+    example: 'https://example.com/latest',
+  })
+  @IsUrl()
+  landingUrl: string;
+
+  @ApiPropertyOptional({
+    description: '视频CDN 基础域名',
+    example: 'https://video-cdn.example.com',
+  })
+  @IsOptional()
+  @IsUrl()
+  videoCdn?: string | null;
+
+  @ApiPropertyOptional({
+    description: '封面CDN 基础域名',
+    example: 'https://cover-cdn.example.com',
+  })
+  @IsOptional()
+  @IsUrl()
+  coverCdn?: string | null;
+
+  @ApiPropertyOptional({
+    description: '客户端名称',
+    example: 'Box App',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  clientName?: string | null;
+
+  @ApiPropertyOptional({
+    description: '客户端公告',
+    example: '本周日凌晨维护',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  clientNotice?: string | null;
+
+  @ApiPropertyOptional({
+    description: '备注',
+    example: '北美投放渠道',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  remark?: string | null;
+
+  @ApiProperty({ description: '创建时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  createAt: number;
+
+  @ApiProperty({ description: '更新时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  updateAt: number;
+}
+
+export class CreateChannelDto {
+  @ApiProperty({
+    description: '渠道名称',
+    maxLength: 100,
+    example: '主站渠道',
+  })
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name: string;
+
+  @ApiProperty({
+    description: '最新落地页 URL',
+    example: 'https://example.com/latest',
+  })
+  @IsUrl()
+  landingUrl: string;
+
+  @ApiPropertyOptional({
+    description: '视频CDN 基础域名',
+    example: 'https://video-cdn.example.com',
+  })
+  @IsOptional()
+  @IsUrl()
+  videoCdn?: string;
+
+  @ApiPropertyOptional({
+    description: '封面CDN 基础域名',
+    example: 'https://cover-cdn.example.com',
+  })
+  @IsOptional()
+  @IsUrl()
+  coverCdn?: string;
+
+  @ApiPropertyOptional({
+    description: '客户端名称',
+    example: 'Box App',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  clientName?: string;
+
+  @ApiPropertyOptional({
+    description: '客户端公告',
+    example: '本周日凌晨维护',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  clientNotice?: string;
+
+  @ApiPropertyOptional({
+    description: '备注',
+    example: '北美投放渠道',
+  })
+  @IsOptional()
+  @IsString()
+  @MaxLength(500)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  remark?: string;
+}
+
+export class UpdateChannelDto extends CreateChannelDto {
+  @ApiProperty({ description: '渠道ID (ObjectId)' })
+  @IsMongoId()
+  id: string;
+}
+
+export class ListChannelDto extends PageListDto {
+  @ApiPropertyOptional({ description: '模糊搜索名称', maxLength: 100 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name?: string;
+}

+ 12 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { ChannelService } from './channel.service';
+import { ChannelController } from './channel.controller';
+
+@Module({
+  imports: [PrismaModule],
+  providers: [ChannelService],
+  controllers: [ChannelController],
+  exports: [ChannelService],
+})
+export class ChannelModule {}

+ 109 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/channel/channel.service.ts

@@ -0,0 +1,109 @@
+import { Injectable } from '@nestjs/common';
+import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
+import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
+import {
+  CreateChannelDto,
+  ListChannelDto,
+  UpdateChannelDto,
+} from './channel.dto';
+
+@Injectable()
+export class ChannelService {
+  constructor(private readonly mongoPrismaService: MongoPrismaService) {}
+
+  private now() {
+    return Date.now();
+  }
+
+  private trimOptional(value?: string | null) {
+    if (value === undefined) return undefined;
+    if (value === null) return null;
+    return typeof value === 'string' ? value.trim() : value;
+  }
+
+  async create(dto: CreateChannelDto) {
+    const now = this.now();
+    return this.mongoPrismaService.channel.create({
+      data: {
+        name: dto.name,
+        landingUrl: dto.landingUrl,
+        videoCdn: this.trimOptional(dto.videoCdn) ?? null,
+        coverCdn: this.trimOptional(dto.coverCdn) ?? null,
+        clientName: this.trimOptional(dto.clientName) ?? null,
+        clientNotice: this.trimOptional(dto.clientNotice) ?? null,
+        remark: this.trimOptional(dto.remark) ?? null,
+        createAt: now,
+        updateAt: now,
+      },
+    });
+  }
+
+  async update(dto: UpdateChannelDto) {
+    const now = this.now();
+    try {
+      return await this.mongoPrismaService.channel.update({
+        where: { id: dto.id },
+        data: {
+          name: dto.name,
+          landingUrl: dto.landingUrl,
+          videoCdn: this.trimOptional(dto.videoCdn) ?? null,
+          coverCdn: this.trimOptional(dto.coverCdn) ?? null,
+          clientName: this.trimOptional(dto.clientName) ?? null,
+          clientNotice: this.trimOptional(dto.clientNotice) ?? null,
+          remark: this.trimOptional(dto.remark) ?? null,
+          updateAt: now,
+        },
+      });
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Channel not found');
+      }
+      throw e;
+    }
+  }
+
+  async findOne(id: string) {
+    const row = await this.mongoPrismaService.channel.findUnique({
+      where: { id },
+    });
+    if (!row) throw new Error('Channel not found');
+    return row;
+  }
+
+  async list(dto: ListChannelDto) {
+    const where: any = {};
+    if (dto.name) {
+      where.name = { contains: dto.name, mode: 'insensitive' };
+    }
+
+    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,
+      }),
+    ]);
+
+    return {
+      total,
+      data,
+      totalPages: Math.ceil(total / dto.size),
+      page: dto.page,
+      size: dto.size,
+    };
+  }
+
+  async remove(id: string) {
+    try {
+      await this.mongoPrismaService.channel.delete({ where: { id } });
+      return { message: 'Deleted' };
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Channel not found');
+      }
+      throw e;
+    }
+  }
+}

+ 8 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/common/mongo-id.dto.ts

@@ -0,0 +1,8 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsMongoId } from 'class-validator';
+
+export class MongoIdParamDto {
+  @ApiProperty({ description: 'Mongo ObjectId' })
+  @IsMongoId()
+  id: string;
+}

+ 4 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/common/status.enum.ts

@@ -0,0 +1,4 @@
+export enum CommonStatus {
+  disabled = 0,
+  enabled = 1,
+}

+ 8 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/feature.module.ts

@@ -4,12 +4,20 @@ import { S3Module } from './s3/s3.module';
 import { SystemParamsModule } from './system-params/system-params.module';
 import { MgntHttpServiceModule } from './mgnt-http-service/mgnt-http-service.module';
 import { SyncVideomediaModule } from './sync-videomedia/sync-videomedia.module';
+import { AdsModule } from './ads/ads.module';
+import { CategoryModule } from './category/category.module';
+import { ChannelModule } from './channel/channel.module';
+import { TagModule } from './tag/tag.module';
 
 @Module({
   imports: [
     // OssModule,
     S3Module,
     SystemParamsModule,
+    AdsModule,
+    CategoryModule,
+    ChannelModule,
+    TagModule,
     MgntHttpServiceModule,
     SyncVideomediaModule,
   ],

+ 77 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.controller.ts

@@ -0,0 +1,77 @@
+import {
+  BadRequestException,
+  Body,
+  Controller,
+  Delete,
+  Get,
+  Param,
+  Post,
+  Put,
+} from '@nestjs/common';
+import {
+  ApiBody,
+  ApiOperation,
+  ApiResponse,
+  ApiTags,
+} from '@nestjs/swagger';
+import { TagService } from './tag.service';
+import { MongoIdParamDto } from '../common/mongo-id.dto';
+import { CreateTagDto, ListTagDto, TagDto, UpdateTagDto } from './tag.dto';
+
+@ApiTags('营销管理 - 标签')
+@Controller('tags')
+export class TagController {
+  constructor(private readonly service: TagService) {}
+
+  @Post('list')
+  @ApiOperation({ summary: 'List tags (pagination + filters)' })
+  @ApiBody({ type: ListTagDto })
+  @ApiResponse({
+    status: 200,
+    description: 'Paged tags with total/page/size/totalPages',
+    schema: {
+      type: 'object',
+      properties: {
+        total: { type: 'integer', example: 10 },
+        page: { type: 'integer', example: 1 },
+        size: { type: 'integer', example: 10 },
+        totalPages: { type: 'integer', example: 1 },
+        data: { type: 'array', items: { $ref: '#/components/schemas/TagDto' } },
+      },
+    },
+  })
+  list(@Body() dto: ListTagDto) {
+    return this.service.list(dto);
+  }
+
+  @Get(':id')
+  @ApiOperation({ summary: 'Get tag detail' })
+  @ApiResponse({ status: 200, type: TagDto })
+  get(@Param() { id }: MongoIdParamDto) {
+    return this.service.findOne(id);
+  }
+
+  @Post()
+  @ApiOperation({ summary: 'Create tag' })
+  @ApiResponse({ status: 201, type: TagDto })
+  create(@Body() dto: CreateTagDto) {
+    return this.service.create(dto);
+  }
+
+  @Put(':id')
+  @ApiOperation({ summary: 'Update tag' })
+  @ApiResponse({ status: 200, type: TagDto })
+  update(@Param() { id }: MongoIdParamDto, @Body() dto: UpdateTagDto) {
+    if (dto.id && dto.id !== id) {
+      throw new BadRequestException('ID in body must match ID in path');
+    }
+    return this.service.update({ ...dto, id });
+  }
+
+  @Delete(':id')
+  @ApiOperation({ summary: 'Delete tag' })
+  @ApiResponse({ status: 200, description: 'Deletion result' })
+  remove(@Param() { id }: MongoIdParamDto) {
+    return this.service.remove(id);
+  }
+}

+ 137 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.dto.ts

@@ -0,0 +1,137 @@
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import {
+  IsEnum,
+  IsInt,
+  IsMongoId,
+  IsOptional,
+  IsString,
+  MaxLength,
+  Min,
+} from 'class-validator';
+import { Transform, Type } from 'class-transformer';
+import { PageListDto } from '@box/common/dto/page-list.dto';
+import { CommonStatus } from '../common/status.enum';
+
+export class TagDto {
+  @ApiProperty({
+    description: '标签ID (Mongo ObjectId)',
+    example: '6650a0c28e4ff3f4c0c00aaa',
+  })
+  @IsMongoId()
+  id: string;
+
+  @ApiProperty({ description: '标签名称', maxLength: 100, example: '动作' })
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name: string;
+
+  @ApiProperty({
+    description: '渠道ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c00001',
+  })
+  @IsMongoId()
+  channelId: string;
+
+  @ApiProperty({
+    description: '分类ID (Mongo ObjectId)',
+    example: '6650a0c28e4ff3f4c0c00111',
+  })
+  @IsMongoId()
+  categoryId: string;
+
+  @ApiProperty({ description: '排序 (越小越靠前)', example: 0 })
+  @Type(() => Number)
+  @IsInt()
+  @Min(0)
+  seq: number;
+
+  @ApiProperty({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+    example: CommonStatus.enabled,
+  })
+  @Type(() => Number)
+  @IsEnum(CommonStatus)
+  status: CommonStatus;
+
+  @ApiProperty({ description: '创建时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  createAt: number;
+
+  @ApiProperty({ description: '更新时间 epoch (ms)' })
+  @Type(() => Number)
+  @IsInt()
+  updateAt: number;
+}
+
+export class CreateTagDto {
+  @ApiProperty({ description: '标签名称', maxLength: 100, example: '动作' })
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name: string;
+
+  @ApiProperty({
+    description: '渠道ID (Mongo ObjectId)',
+    example: '664f9b5b8e4ff3f4c0c00001',
+  })
+  @IsMongoId()
+  channelId: string;
+
+  @ApiProperty({
+    description: '分类ID (Mongo ObjectId)',
+    example: '6650a0c28e4ff3f4c0c00111',
+  })
+  @IsMongoId()
+  categoryId: string;
+
+  @ApiPropertyOptional({ description: '排序 (默认0)', example: 0 })
+  @Type(() => Number)
+  @IsOptional()
+  @IsInt()
+  @Min(0)
+  seq?: number;
+
+  @ApiPropertyOptional({
+    enum: CommonStatus,
+    description: '状态: 0=禁用, 1=启用',
+    example: CommonStatus.enabled,
+  })
+  @Type(() => Number)
+  @IsOptional()
+  @IsEnum(CommonStatus)
+  status?: CommonStatus;
+}
+
+export class UpdateTagDto extends CreateTagDto {
+  @ApiProperty({ description: '标签ID (ObjectId)' })
+  @IsMongoId()
+  id: string;
+}
+
+export class ListTagDto extends PageListDto {
+  @ApiPropertyOptional({ description: '模糊搜索名称', maxLength: 100 })
+  @IsOptional()
+  @IsString()
+  @MaxLength(100)
+  @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
+  name?: string;
+
+  @ApiPropertyOptional({ description: '渠道ID (ObjectId)' })
+  @IsOptional()
+  @IsMongoId()
+  channelId?: string;
+
+  @ApiPropertyOptional({ description: '分类ID (ObjectId)' })
+  @IsOptional()
+  @IsMongoId()
+  categoryId?: string;
+
+  @ApiPropertyOptional({ enum: CommonStatus, description: '状态: 0=禁用, 1=启用' })
+  @Type(() => Number)
+  @IsOptional()
+  @IsEnum(CommonStatus)
+  status?: CommonStatus;
+}

+ 12 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.module.ts

@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { PrismaModule } from '@box/db/prisma/prisma.module';
+import { TagService } from './tag.service';
+import { TagController } from './tag.controller';
+
+@Module({
+  imports: [PrismaModule],
+  providers: [TagService],
+  controllers: [TagController],
+  exports: [TagService],
+})
+export class TagModule {}

+ 130 - 0
apps/box-mgnt-api/src/mgnt-backend/feature/tag/tag.service.ts

@@ -0,0 +1,130 @@
+import { Injectable } 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 { CommonStatus } from '../common/status.enum';
+
+@Injectable()
+export class TagService {
+  constructor(private readonly mongoPrismaService: MongoPrismaService) {}
+
+  private now() {
+    return Date.now();
+  }
+
+  private async assertRelations(channelId: string, categoryId: string) {
+    const [channel, category] = await Promise.all([
+      this.mongoPrismaService.channel.findUnique({
+        where: { id: channelId },
+        select: { id: true },
+      }),
+      this.mongoPrismaService.category.findUnique({
+        where: { id: categoryId },
+        select: { id: true, channelId: true },
+      }),
+    ]);
+
+    if (!channel) {
+      throw new Error('Channel not found');
+    }
+    if (!category) {
+      throw new Error('Category not found');
+    }
+    if (category.channelId !== channelId) {
+      throw new Error('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,
+        channelId: dto.channelId,
+        categoryId: dto.categoryId,
+        seq: dto.seq ?? 0,
+        status: dto.status ?? CommonStatus.enabled,
+        createAt: now,
+        updateAt: now,
+      },
+    });
+  }
+
+  async update(dto: UpdateTagDto) {
+    await this.assertRelations(dto.channelId, dto.categoryId);
+    const now = this.now();
+
+    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,
+        },
+      });
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Tag not found');
+      }
+      throw e;
+    }
+  }
+
+  async findOne(id: string) {
+    const row = await this.mongoPrismaService.tag.findUnique({
+      where: { id },
+    });
+    if (!row) throw new Error('Tag not found');
+    return row;
+  }
+
+  async list(dto: ListTagDto) {
+    const where: any = {};
+
+    if (dto.name) {
+      where.name = { contains: dto.name, mode: 'insensitive' };
+    }
+    if (dto.channelId) where.channelId = dto.channelId;
+    if (dto.categoryId) where.categoryId = dto.categoryId;
+    if (dto.status !== undefined) where.status = dto.status;
+
+    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,
+      }),
+    ]);
+
+    return {
+      total,
+      data,
+      totalPages: Math.ceil(total / dto.size),
+      page: dto.page,
+      size: dto.size,
+    };
+  }
+
+  async remove(id: string) {
+    try {
+      await this.mongoPrismaService.tag.delete({ where: { id } });
+      return { message: 'Deleted' };
+    } catch (e) {
+      if (e instanceof PrismaClientKnownRequestError && e.code === 'P2025') {
+        throw new Error('Tag not found');
+      }
+      throw e;
+    }
+  }
+}

+ 8 - 0
apps/box-mgnt-api/src/mgnt-backend/mgnt-backend.module.ts

@@ -13,6 +13,10 @@ import { QuotaLogModule } from './core/logging/quota-log/quota-log.module';
 import { S3Module } from './feature/s3/s3.module';
 import { SystemParamsModule } from './feature/system-params/system-params.module';
 import { SyncVideomediaModule } from './feature/sync-videomedia/sync-videomedia.module';
+import { AdsModule } from './feature/ads/ads.module';
+import { CategoryModule } from './feature/category/category.module';
+import { ChannelModule } from './feature/channel/channel.module';
+import { TagModule } from './feature/tag/tag.module';
 
 @Module({
   imports: [
@@ -31,6 +35,10 @@ import { SyncVideomediaModule } from './feature/sync-videomedia/sync-videomedia.
           QuotaLogModule,
           S3Module,
           SystemParamsModule,
+          AdsModule,
+          CategoryModule,
+          ChannelModule,
+          TagModule,
           SyncVideomediaModule,
         ],
       },