|
|
@@ -0,0 +1,356 @@
|
|
|
+// 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 { CacheKeys } from '@box/common/cache/cache-keys';
|
|
|
+import { AdType } from '@prisma/mongo/client';
|
|
|
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
|
|
|
+import {
|
|
|
+ HomepageDto,
|
|
|
+ HomeAdsDto,
|
|
|
+ HomeAdDto,
|
|
|
+ AnnouncementDto,
|
|
|
+ CategoryDto,
|
|
|
+ VideoItemDto,
|
|
|
+ WaterfallAdsDto,
|
|
|
+ PopupAdsDto,
|
|
|
+ FloatingAdsDto,
|
|
|
+ RecommendedVideosDto,
|
|
|
+} from './dto/homepage.dto';
|
|
|
+import {
|
|
|
+ AdOrder,
|
|
|
+ SystemParamSide,
|
|
|
+ ANNOUNCEMENT_KEYWORD,
|
|
|
+ CategoryType,
|
|
|
+ RECOMMENDED_CATEGORY_ID,
|
|
|
+ RECOMMENDED_CATEGORY_NAME,
|
|
|
+ AdSlot,
|
|
|
+} from './homepage.constants';
|
|
|
+
|
|
|
+interface AdPoolEntry {
|
|
|
+ id: string;
|
|
|
+ weight: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface CachedAd {
|
|
|
+ id: string;
|
|
|
+ advertiser?: string;
|
|
|
+ title?: string;
|
|
|
+ adsContent?: string | null;
|
|
|
+ adsCoverImg?: string | null;
|
|
|
+ adsUrl?: string | null;
|
|
|
+ adType?: string | null;
|
|
|
+}
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class HomepageService {
|
|
|
+ private readonly logger = new Logger(HomepageService.name);
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private readonly redis: RedisService,
|
|
|
+ private readonly prisma: PrismaMongoService,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get complete homepage data in single API call
|
|
|
+ */
|
|
|
+ async getHomepageData(): Promise<HomepageDto> {
|
|
|
+ const [ads, announcements, categories, videos] = await Promise.all([
|
|
|
+ this.getHomeAds(),
|
|
|
+ this.getAnnouncements(),
|
|
|
+ this.getCategories(),
|
|
|
+ this.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[]> {
|
|
|
+ // Get pool key - all homepage ads use 'home' scene
|
|
|
+ const poolKey = CacheKeys.appAdPool(
|
|
|
+ 'home',
|
|
|
+ this.getSlotForType(adType),
|
|
|
+ adType,
|
|
|
+ );
|
|
|
+
|
|
|
+ const pool = (await this.redis.getJson<AdPoolEntry[]>(poolKey)) ?? [];
|
|
|
+
|
|
|
+ if (!pool || pool.length === 0) {
|
|
|
+ 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> {
|
|
|
+ const cacheKey = CacheKeys.appAdById(adId);
|
|
|
+ const cached = await this.redis.getJson<CachedAd>(cacheKey);
|
|
|
+
|
|
|
+ if (!cached) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: cached.id,
|
|
|
+ adType: cached.adType ?? 'UNKNOWN',
|
|
|
+ title: cached.title ?? '',
|
|
|
+ advertiser: cached.advertiser ?? '',
|
|
|
+ content: cached.adsContent ?? undefined,
|
|
|
+ coverImg: cached.adsCoverImg ?? undefined,
|
|
|
+ targetUrl: cached.adsUrl ?? undefined,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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
|
|
|
+ */
|
|
|
+ private async getCategories(): Promise<CategoryDto[]> {
|
|
|
+ try {
|
|
|
+ const categories = await this.prisma.category.findMany({
|
|
|
+ where: {
|
|
|
+ status: 1, // active only
|
|
|
+ },
|
|
|
+ orderBy: {
|
|
|
+ seq: 'asc',
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ // Shuffle regular categories (keep recommended first)
|
|
|
+ const recommended: CategoryDto = {
|
|
|
+ id: RECOMMENDED_CATEGORY_ID,
|
|
|
+ name: RECOMMENDED_CATEGORY_NAME,
|
|
|
+ type: CategoryType.RECOMMENDED,
|
|
|
+ isDefault: true,
|
|
|
+ seq: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ const regular = this.shuffle(
|
|
|
+ categories.map((c, idx) => ({
|
|
|
+ id: c.id,
|
|
|
+ name: c.name,
|
|
|
+ type: CategoryType.REGULAR,
|
|
|
+ isDefault: false,
|
|
|
+ seq: idx + 1,
|
|
|
+ })),
|
|
|
+ );
|
|
|
+
|
|
|
+ return [recommended, ...regular];
|
|
|
+ } catch (error) {
|
|
|
+ this.logger.warn(
|
|
|
+ 'Category collection not found or error fetching categories',
|
|
|
+ );
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ id: RECOMMENDED_CATEGORY_ID,
|
|
|
+ name: RECOMMENDED_CATEGORY_NAME,
|
|
|
+ type: CategoryType.RECOMMENDED,
|
|
|
+ isDefault: true,
|
|
|
+ seq: 0,
|
|
|
+ },
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Get recommended videos (7 random videos for homepage)
|
|
|
+ */
|
|
|
+ private async getRecommendedVideos(): Promise<RecommendedVideosDto> {
|
|
|
+ try {
|
|
|
+ // Get 7 random videos
|
|
|
+ // MongoDB aggregation for random sampling
|
|
|
+ const videos = await this.prisma.videoMedia.aggregateRaw({
|
|
|
+ pipeline: [
|
|
|
+ // { $match: { status: 1 } }, // if you have status field
|
|
|
+ { $sample: { size: 7 } },
|
|
|
+ ],
|
|
|
+ });
|
|
|
+
|
|
|
+ const items = (Array.isArray(videos) ? videos : []).map((v: any) =>
|
|
|
+ this.mapVideoToDto(v),
|
|
|
+ );
|
|
|
+
|
|
|
+ return {
|
|
|
+ items,
|
|
|
+ total: items.length,
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ this.logger.warn('Error fetching recommended videos, returning empty');
|
|
|
+ return {
|
|
|
+ items: [],
|
|
|
+ total: 0,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Map raw video to DTO
|
|
|
+ */
|
|
|
+ private mapVideoToDto(video: any): VideoItemDto {
|
|
|
+ return {
|
|
|
+ id: video._id?.toString() ?? video.id,
|
|
|
+ title: video.title ?? '',
|
|
|
+ coverCdn: video.coverCdn ?? video.coverUrl ?? undefined,
|
|
|
+ duration: video.duration ?? undefined,
|
|
|
+ tags: Array.isArray(video.tags) ? video.tags : [],
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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;
|
|
|
+ }
|
|
|
+}
|