Просмотр исходного кода

feat: add category and tag retrieval endpoints in HomepageController and implement related service methods

Dave 3 месяцев назад
Родитель
Сommit
ed7e2fae51

+ 3 - 0
.gitignore

@@ -72,3 +72,6 @@ src/tmp/*
 action-plans/20251222-ACT-01.md
 .codex-instructions.md
 timestamp-audit.json
+apps/box-app-api/src/feature/homepage/README.md
+libs/core/media-manager/CONTRACT.md
+libs/core/media-manager/CONTRACT.md

+ 44 - 1
apps/box-app-api/src/feature/homepage/homepage.controller.ts

@@ -1,9 +1,10 @@
 // apps/box-app-api/src/feature/homepage/homepage.controller.ts
-import { Controller, Get } from '@nestjs/common';
+import { Controller, Get, Query } from '@nestjs/common';
 import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
 import { HomepageService } from './homepage.service';
 import { HomeAdsDto, AnnouncementDto, CategoryDto } from './dto/homepage.dto';
 import { RecommendedVideosDto } from '../video/dto';
+import { HomeCategoryCacheItem, HomeTagCacheItem } from './homepage.types';
 
 @ApiTags('首页')
 @Controller('homepage')
@@ -36,4 +37,46 @@ export class HomepageController {
   }> {
     return this.homepageService.getHomepageData();
   }
+
+  @Get('categories')
+  @ApiOperation({
+    summary: '获取分类列表',
+    description:
+      '返回 Redis 中的完整分类缓存(box:app:category:all),按 seq 升序。',
+  })
+  async getCategoryList(): Promise<HomeCategoryCacheItem[]> {
+    return this.homepageService.getCategoryList();
+  }
+
+  @Get('tags')
+  @ApiOperation({
+    summary: '获取标签列表',
+    description: '返回 Redis 中的完整标签缓存(box:app:tag:all)或对象数据。',
+  })
+  async getTagList(): Promise<HomeTagCacheItem[] | Record<string, unknown>> {
+    return this.homepageService.getTagList();
+  }
+
+  @Get('search/category')
+  @ApiOperation({
+    summary: '按分类名称搜索',
+    description: '按名称模糊匹配分类(大小写不敏感),q 为空返回空数组。',
+  })
+  async searchCategories(
+    @Query('q') query: string,
+  ): Promise<HomeCategoryCacheItem[]> {
+    return this.homepageService.searchByCategoryName(query);
+  }
+
+  @Get('search/tag')
+  @ApiOperation({
+    summary: '按标签名称搜索',
+    description:
+      '按标签名称模糊匹配分类 tags 字段(大小写不敏感),q 为空返回空数组。',
+  })
+  async searchTags(
+    @Query('q') query: string,
+  ): Promise<HomeCategoryCacheItem[]> {
+    return this.homepageService.searchByTagName(query);
+  }
 }

+ 131 - 6
apps/box-app-api/src/feature/homepage/homepage.service.ts

@@ -20,11 +20,9 @@ import {
   AdOrder,
   SystemParamSide,
   ANNOUNCEMENT_KEYWORD,
-  CategoryType,
-  RECOMMENDED_CATEGORY_ID,
-  RECOMMENDED_CATEGORY_NAME,
   AdSlot,
 } from './homepage.constants';
+import type { HomeCategoryCacheItem, HomeTagCacheItem } from './homepage.types';
 
 interface CachedAd {
   id: string;
@@ -152,9 +150,7 @@ export class HomepageService {
       }
 
       if (!entries.length) {
-        this.logger.warn(
-          `Ad pool empty for adType=${adType}, key=${poolKey}`,
-        );
+        this.logger.warn(`Ad pool empty for adType=${adType}, key=${poolKey}`);
         return [];
       }
 
@@ -291,6 +287,135 @@ export class HomepageService {
     return this.videoService.getCategories();
   }
 
+  async getCategoryList(): Promise<HomeCategoryCacheItem[]> {
+    const raw = await this.redis.get(tsCacheKeys.category.all());
+    return this.parseCategoryCache(raw);
+  }
+
+  async getTagList(): Promise<HomeTagCacheItem[] | Record<string, unknown>> {
+    const raw = await this.redis.get(tsCacheKeys.tag.all());
+    if (!raw) {
+      return [];
+    }
+
+    try {
+      const parsed = JSON.parse(raw);
+      if (Array.isArray(parsed)) {
+        return parsed as HomeTagCacheItem[];
+      }
+      if (parsed && typeof parsed === 'object') {
+        return parsed as Record<string, unknown>;
+      }
+    } catch {
+      this.logger.warn('Failed to parse tag list from Redis cache');
+    }
+
+    return [];
+  }
+
+  async searchByCategoryName(q: string): Promise<HomeCategoryCacheItem[]> {
+    const term = q?.trim();
+    if (!term) {
+      return [];
+    }
+
+    const lowercaseTerm = term.toLowerCase();
+    const categories = await this.getCategoryList();
+    return categories.filter((category) =>
+      category.name.toLowerCase().includes(lowercaseTerm),
+    );
+  }
+
+  async searchByTagName(q: string): Promise<HomeCategoryCacheItem[]> {
+    const term = q?.trim();
+    if (!term) {
+      return [];
+    }
+
+    const lowercaseTerm = term.toLowerCase();
+    const categories = await this.getCategoryList();
+    return categories.filter((category) =>
+      category.tags.some((tag) => tag.toLowerCase().includes(lowercaseTerm)),
+    );
+  }
+
+  private parseCategoryCache(raw: string | null): HomeCategoryCacheItem[] {
+    if (!raw) {
+      return [];
+    }
+
+    let parsed: unknown;
+    try {
+      parsed = JSON.parse(raw);
+    } catch {
+      this.logger.warn('Failed to parse category list from Redis cache');
+      return [];
+    }
+
+    if (!Array.isArray(parsed)) {
+      return [];
+    }
+
+    const entries: HomeCategoryCacheItem[] = [];
+    let hadInvalidEntry = false;
+    for (const entry of parsed) {
+      if (!this.isValidCategoryEntry(entry)) {
+        hadInvalidEntry = true;
+        continue;
+      }
+
+      const candidate = entry as HomeCategoryCacheItem;
+      entries.push({
+        id: candidate.id,
+        name: candidate.name,
+        subtitle: candidate.subtitle,
+        seq: candidate.seq,
+        tags: candidate.tags,
+      });
+    }
+
+    if (hadInvalidEntry) {
+      this.logger.warn('Skipped invalid entries while parsing category cache');
+    }
+
+    return entries.sort((a, b) => a.seq - b.seq);
+  }
+
+  private isValidCategoryEntry(entry: unknown): entry is HomeCategoryCacheItem {
+    if (!entry || typeof entry !== 'object') {
+      return false;
+    }
+
+    const candidate = entry as Record<string, unknown>;
+    if (
+      typeof candidate.id !== 'string' ||
+      typeof candidate.name !== 'string'
+    ) {
+      return false;
+    }
+
+    const seq = candidate.seq;
+    if (typeof seq !== 'number' || Number.isNaN(seq)) {
+      return false;
+    }
+
+    const subtitle = candidate.subtitle;
+    if (
+      subtitle !== undefined &&
+      subtitle !== null &&
+      typeof subtitle !== 'string'
+    ) {
+      return false;
+    }
+
+    const tags = candidate.tags;
+    if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string')) {
+      return false;
+    }
+
+    return true;
+  }
+
   /**
    * Get recommended videos (delegated to VideoService)
    */

+ 17 - 0
apps/box-app-api/src/feature/homepage/homepage.types.ts

@@ -0,0 +1,17 @@
+export type HomeCategoryCacheItem = {
+  id: string;
+  name: string;
+  subtitle?: string;
+  seq: number;
+  tags: string[];
+};
+
+export type HomeTagCacheItem =
+  | { id?: string; name: string; seq?: number; status?: number }
+  | string;
+
+export type HomeSearchOptions = {
+  caseInsensitive?: boolean; // default true
+  partial?: boolean; // default true
+  trim?: boolean; // default true
+};

+ 5 - 7
libs/common/src/cache/video-cache.helper.ts

@@ -329,10 +329,10 @@ export class VideoCacheHelper {
         0,
         -1,
         async (legacyData) => {
-      const tags = this.parseTagMetadataStrings(legacyData, key);
-      if (tags.length > 0) {
-        await this.saveTagList(key, tags);
-      }
+          const tags = this.parseTagMetadataStrings(legacyData, key);
+          if (tags.length > 0) {
+            await this.saveTagList(key, tags);
+          }
         },
       );
 
@@ -441,9 +441,7 @@ export class VideoCacheHelper {
         `[GetTagList] key=${key}: parsed ${tags.length} tags, skipped ${malformedCount} malformed items`,
       );
     } else {
-      this.logger.debug(
-        `[GetTagList] key=${key}: parsed ${tags.length} tags`,
-      );
+      this.logger.debug(`[GetTagList] key=${key}: parsed ${tags.length} tags`);
     }
 
     return tags;

+ 6 - 0
libs/core/src/cache/tag/tag-cache.builder.ts

@@ -20,6 +20,12 @@ export class TagCacheBuilder extends BaseCacheBuilder {
   async buildAll(): Promise<void> {
     const tags = await this.mongoPrisma.tag.findMany({
       where: { status: 1 },
+      select: {
+        id: true,
+        name: true,
+        categoryId: true,
+        seq: true,
+      },
       orderBy: [{ seq: 'asc' }, { name: 'asc' }],
     });