|
|
@@ -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)
|
|
|
*/
|