| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518 |
- // apps/box-app-api/src/feature/homepage/homepage.service.ts
- import { Injectable, Logger } from '@nestjs/common';
- import { RedisService } from '@box/db/redis/redis.service';
- import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
- import { nowSecBigInt } from '@box/common/time/time.util';
- import { AdType } from '@prisma/mongo/client';
- import type { AdPoolEntry } from '@box/common/ads/ad-types';
- import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
- import { VideoService } from '../video/video.service';
- import {
- HomeAdsDto,
- HomeAdDto,
- AnnouncementDto,
- CategoryDto,
- WaterfallAdsDto,
- PopupAdsDto,
- FloatingAdsDto,
- } from './dto/homepage.dto';
- import { RecommendedVideosDto } from '../video/dto';
- import {
- AdOrder,
- SystemParamSide,
- ANNOUNCEMENT_KEYWORD,
- AdSlot,
- } from './homepage.constants';
- import type { HomeCategoryCacheItem, HomeTagCacheItem } from './homepage.types';
- @Injectable()
- export class HomepageService {
- private readonly logger = new Logger(HomepageService.name);
- constructor(
- private readonly redis: RedisService,
- private readonly prisma: PrismaMongoService,
- private readonly videoService: VideoService,
- ) {}
- /**
- * Get complete homepage data in single API call
- */
- async getHomepageData(): Promise<{
- ads: HomeAdsDto;
- announcements: AnnouncementDto[];
- categories: CategoryDto[];
- videos: RecommendedVideosDto;
- }> {
- const [ads, announcements, categories, videos] = await Promise.all([
- this.getHomeAds(),
- this.getAnnouncements(),
- this.getCategories(),
- this.videoService.getRecommendedVideos(),
- ]);
- return {
- ads,
- announcements,
- categories,
- videos,
- };
- }
- /**
- * Fetch all ads for homepage
- */
- private async getHomeAds(): Promise<HomeAdsDto> {
- const [carousel, banner, waterfall, popup, floating] = await Promise.all([
- this.getAdsByType(AdType.CAROUSEL, AdOrder.RANDOM),
- this.getSingleAd(AdType.BANNER),
- this.getWaterfallAds(),
- this.getPopupAds(),
- this.getFloatingAds(),
- ]);
- return {
- carousel,
- banner,
- waterfall,
- popup,
- floating,
- };
- }
- /**
- * Get waterfall ads (icons, texts, videos)
- */
- private async getWaterfallAds(): Promise<WaterfallAdsDto> {
- const [icons, texts, videos] = await Promise.all([
- this.getAdsByType(AdType.WATERFALL_ICON, AdOrder.RANDOM),
- this.getAdsByType(AdType.WATERFALL_TEXT, AdOrder.RANDOM),
- this.getAdsByType(AdType.WATERFALL_VIDEO, AdOrder.RANDOM),
- ]);
- return { icons, texts, videos };
- }
- /**
- * Get popup ads (multi-step flow)
- */
- private async getPopupAds(): Promise<PopupAdsDto> {
- const [allIcons, images, official] = await Promise.all([
- this.getAdsByType(AdType.POPUP_ICON, AdOrder.RANDOM),
- this.getAdsByType(AdType.POPUP_IMAGE, AdOrder.RANDOM),
- this.getAdsByType(AdType.POPUP_OFFICIAL, AdOrder.SEQUENTIAL),
- ]);
- // Limit icons to max 6
- const icons = allIcons.slice(0, 6);
- return { icons, images, official };
- }
- /**
- * Get floating ads (bottom & edge)
- */
- private async getFloatingAds(): Promise<FloatingAdsDto> {
- const [bottom, edge] = await Promise.all([
- this.getAdsByType(AdType.FLOATING_BOTTOM, AdOrder.RANDOM),
- this.getAdsByType(AdType.FLOATING_EDGE, AdOrder.RANDOM),
- ]);
- return { bottom, edge };
- }
- /**
- * Generic method to fetch ads by type from pool
- */
- private async getAdsByType(
- adType: AdType,
- order: AdOrder,
- ): Promise<HomeAdDto[]> {
- const poolKey = tsCacheKeys.ad.poolByType(adType);
- let pool: AdPoolEntry[] | null = null;
- try {
- const entries = await this.redis.getJson<AdPoolEntry[]>(poolKey);
- if (!entries) {
- this.logger.warn(
- `Ad pool cache miss for adType=${adType}, key=${poolKey}. Cache may be cold; ensure cache-sync rebuilt pools.`,
- );
- return [];
- }
- if (!entries.length) {
- this.logger.warn(`Ad pool empty for adType=${adType}, key=${poolKey}`);
- return [];
- }
- pool = entries;
- } catch (err) {
- if (err instanceof Error && err.message?.includes('WRONGTYPE')) {
- this.logger.warn(
- `Ad pool key ${poolKey} has wrong type, deleting incompatible key`,
- );
- try {
- await this.redis.del(poolKey);
- this.logger.log(
- `Deleted incompatible ad pool key ${poolKey}. It will be rebuilt on next cache warmup.`,
- );
- } catch (delErr) {
- this.logger.error(
- `Failed to delete incompatible key ${poolKey}`,
- delErr instanceof Error ? delErr.stack : String(delErr),
- );
- }
- } else {
- this.logger.error(
- `Failed to read ad pool for adType=${adType}, key=${poolKey}`,
- err instanceof Error ? err.stack : String(err),
- );
- }
- }
- if (!pool) {
- return [];
- }
- // Shuffle if random order
- const sortedPool = order === AdOrder.RANDOM ? this.shuffle(pool) : pool;
- // Fetch all ads in parallel
- const adPromises = sortedPool.map((entry) => this.fetchAdDetails(entry.id));
- const ads = await Promise.all(adPromises);
- // Filter out nulls and map to DTO
- return ads.filter((ad): ad is HomeAdDto => ad !== null);
- }
- /**
- * Get single ad (e.g., banner)
- */
- private async getSingleAd(adType: AdType): Promise<HomeAdDto | null> {
- const ads = await this.getAdsByType(adType, AdOrder.RANDOM);
- return ads.length > 0 ? ads[0] : null;
- }
- /**
- * Fetch ad details from per-ad cache
- */
- private async fetchAdDetails(adId: string): Promise<HomeAdDto | null> {
- try {
- const now = nowSecBigInt();
- const ad = await this.prisma.ads.findUnique({
- where: { id: adId },
- select: {
- id: true,
- adType: true,
- advertiser: true,
- title: true,
- adsContent: true,
- adsCoverImg: true,
- adsUrl: true,
- startDt: true,
- expiryDt: true,
- status: true,
- },
- });
- if (!ad) {
- this.logger.debug(`Ad not found for homepage slot: adId=${adId}`);
- return null;
- }
- if (ad.status !== 1 || ad.startDt > now) {
- return null;
- }
- if (ad.expiryDt !== BigInt(0) && ad.expiryDt < now) {
- return null;
- }
- return {
- id: ad.id,
- adType: ad.adType ?? 'UNKNOWN',
- title: ad.title ?? '',
- advertiser: ad.advertiser ?? '',
- content: ad.adsContent ?? undefined,
- coverImg: ad.adsCoverImg ?? undefined,
- targetUrl: ad.adsUrl ?? undefined,
- };
- } catch (err) {
- this.logger.error(
- `Failed to fetch ad ${adId}`,
- err instanceof Error ? err.stack : String(err),
- );
- return null;
- }
- }
- /**
- * Get slot name for ad type (maps to ADTYPE_POOLS config)
- */
- private getSlotForType(adType: AdType): string {
- const slotMap: Record<AdType, AdSlot> = {
- [AdType.STARTUP]: AdSlot.STARTUP,
- [AdType.CAROUSEL]: AdSlot.CAROUSEL,
- [AdType.POPUP_ICON]: AdSlot.MIDDLE,
- [AdType.POPUP_IMAGE]: AdSlot.MIDDLE,
- [AdType.POPUP_OFFICIAL]: AdSlot.MIDDLE,
- [AdType.WATERFALL_ICON]: AdSlot.WATERFALL,
- [AdType.WATERFALL_TEXT]: AdSlot.WATERFALL,
- [AdType.WATERFALL_VIDEO]: AdSlot.WATERFALL,
- [AdType.FLOATING_BOTTOM]: AdSlot.FLOATING,
- [AdType.FLOATING_EDGE]: AdSlot.EDGE,
- [AdType.BANNER]: AdSlot.TOP,
- [AdType.PREROLL]: AdSlot.PREROLL,
- [AdType.PAUSE]: AdSlot.PAUSE_OVERLAY,
- };
- return slotMap[adType] ?? AdSlot.UNKNOWN;
- }
- /**
- * Get announcements (marquee notices)
- * Using SystemParam with side='client' and specific naming convention
- */
- private async getAnnouncements(): Promise<AnnouncementDto[]> {
- try {
- // Fetch client-side params that could be announcements
- // Convention: params with name starting with 'announcement_' or 'notice_'
- const params = await this.prisma.systemParam.findMany({
- where: {
- side: SystemParamSide.CLIENT,
- name: {
- contains: ANNOUNCEMENT_KEYWORD,
- },
- },
- orderBy: {
- id: 'asc', // sequential order by ID
- },
- });
- return params.map((p, idx) => ({
- id: p.id.toString(),
- content: p.value ?? '', // Use 'value' field as content
- seq: idx,
- }));
- } catch (error) {
- this.logger.warn(
- 'Error fetching announcements from SystemParam, returning empty',
- );
- return [];
- }
- }
- /**
- * Get video categories (delegated to VideoService)
- */
- private async getCategories(): Promise<CategoryDto[]> {
- return this.videoService.getCategories();
- }
- async getCategoryTags(channelId: string): Promise<any[]> {
- try {
- this.logger.log(`Cache miss for category tags of channelId=${channelId}`);
- const cacheKey = `category:tag:${channelId}`;
- const cache = await this.redis.getJson<HomeCategoryCacheItem[]>(cacheKey);
- if (cache) {
- this.logger.log(
- `Cache hit for category tags of channelId=${channelId}`,
- );
- return cache;
- }
- const channel = await this.prisma.channel.findUnique({
- where: { channelId },
- select: {
- categories: true,
- },
- });
- this.logger.log(
- `Fetched channel data for channelId=${channelId}: ${JSON.stringify(channel)}`,
- );
- type ChannelCategory = {
- id: string;
- name?: string;
- };
- const categoryIds =
- (channel?.categories as ChannelCategory[] | null)?.map((c) => c.id) ??
- [];
- const categories = await this.prisma.category.findMany({
- where: {
- id: { in: categoryIds },
- },
- select: {
- name: true,
- subtitle: true,
- tagNames: true,
- },
- orderBy: {
- seq: 'desc',
- },
- });
- if (categories.length > 0) {
- await this.redis.setJson(cacheKey, categories, 24 * 3600);
- }
- return categories;
- } catch (err) {
- this.logger.error(
- `Error fetching home section videos for channelId=${channelId}`,
- err instanceof Error ? err.stack : String(err),
- );
- return [];
- }
- }
- 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)
- */
- // private async getRecommendedVideos(): Promise<RecommendedVideosDto> {
- // return this.videoService.getRecommendedVideos();
- // }
- /**
- * Fisher-Yates shuffle for random ordering
- */
- private shuffle<T>(array: T[]): T[] {
- const shuffled = [...array];
- for (let i = shuffled.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
- }
- return shuffled;
- }
- }
|