homepage.service.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. // apps/box-app-api/src/feature/homepage/homepage.service.ts
  2. import { Injectable, Logger } from '@nestjs/common';
  3. import { RedisService } from '@box/db/redis/redis.service';
  4. import { tsCacheKeys } from '@box/common/cache/ts-cache-key.provider';
  5. import { nowSecBigInt } from '@box/common/time/time.util';
  6. import { AdType } from '@prisma/mongo/client';
  7. import type { AdPoolEntry } from '@box/common/ads/ad-types';
  8. import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
  9. import { VideoService } from '../video/video.service';
  10. import {
  11. HomeAdsDto,
  12. HomeAdDto,
  13. AnnouncementDto,
  14. CategoryDto,
  15. WaterfallAdsDto,
  16. PopupAdsDto,
  17. FloatingAdsDto,
  18. } from './dto/homepage.dto';
  19. import { RecommendedVideosDto } from '../video/dto';
  20. import {
  21. AdOrder,
  22. SystemParamSide,
  23. ANNOUNCEMENT_KEYWORD,
  24. AdSlot,
  25. } from './homepage.constants';
  26. import type { HomeCategoryCacheItem, HomeTagCacheItem } from './homepage.types';
  27. @Injectable()
  28. export class HomepageService {
  29. private readonly logger = new Logger(HomepageService.name);
  30. constructor(
  31. private readonly redis: RedisService,
  32. private readonly prisma: PrismaMongoService,
  33. private readonly videoService: VideoService,
  34. ) {}
  35. /**
  36. * Get complete homepage data in single API call
  37. */
  38. async getHomepageData(): Promise<{
  39. ads: HomeAdsDto;
  40. announcements: AnnouncementDto[];
  41. categories: CategoryDto[];
  42. videos: RecommendedVideosDto;
  43. }> {
  44. const [ads, announcements, categories, videos] = await Promise.all([
  45. this.getHomeAds(),
  46. this.getAnnouncements(),
  47. this.getCategories(),
  48. this.videoService.getRecommendedVideos(),
  49. ]);
  50. return {
  51. ads,
  52. announcements,
  53. categories,
  54. videos,
  55. };
  56. }
  57. /**
  58. * Fetch all ads for homepage
  59. */
  60. private async getHomeAds(): Promise<HomeAdsDto> {
  61. const [carousel, banner, waterfall, popup, floating] = await Promise.all([
  62. this.getAdsByType(AdType.CAROUSEL, AdOrder.RANDOM),
  63. this.getSingleAd(AdType.BANNER),
  64. this.getWaterfallAds(),
  65. this.getPopupAds(),
  66. this.getFloatingAds(),
  67. ]);
  68. return {
  69. carousel,
  70. banner,
  71. waterfall,
  72. popup,
  73. floating,
  74. };
  75. }
  76. /**
  77. * Get waterfall ads (icons, texts, videos)
  78. */
  79. private async getWaterfallAds(): Promise<WaterfallAdsDto> {
  80. const [icons, texts, videos] = await Promise.all([
  81. this.getAdsByType(AdType.WATERFALL_ICON, AdOrder.RANDOM),
  82. this.getAdsByType(AdType.WATERFALL_TEXT, AdOrder.RANDOM),
  83. this.getAdsByType(AdType.WATERFALL_VIDEO, AdOrder.RANDOM),
  84. ]);
  85. return { icons, texts, videos };
  86. }
  87. /**
  88. * Get popup ads (multi-step flow)
  89. */
  90. private async getPopupAds(): Promise<PopupAdsDto> {
  91. const [allIcons, images, official] = await Promise.all([
  92. this.getAdsByType(AdType.POPUP_ICON, AdOrder.RANDOM),
  93. this.getAdsByType(AdType.POPUP_IMAGE, AdOrder.RANDOM),
  94. this.getAdsByType(AdType.POPUP_OFFICIAL, AdOrder.SEQUENTIAL),
  95. ]);
  96. // Limit icons to max 6
  97. const icons = allIcons.slice(0, 6);
  98. return { icons, images, official };
  99. }
  100. /**
  101. * Get floating ads (bottom & edge)
  102. */
  103. private async getFloatingAds(): Promise<FloatingAdsDto> {
  104. const [bottom, edge] = await Promise.all([
  105. this.getAdsByType(AdType.FLOATING_BOTTOM, AdOrder.RANDOM),
  106. this.getAdsByType(AdType.FLOATING_EDGE, AdOrder.RANDOM),
  107. ]);
  108. return { bottom, edge };
  109. }
  110. /**
  111. * Generic method to fetch ads by type from pool
  112. */
  113. private async getAdsByType(
  114. adType: AdType,
  115. order: AdOrder,
  116. ): Promise<HomeAdDto[]> {
  117. const poolKey = tsCacheKeys.ad.poolByType(adType);
  118. let pool: AdPoolEntry[] | null = null;
  119. try {
  120. const entries = await this.redis.getJson<AdPoolEntry[]>(poolKey);
  121. if (!entries) {
  122. this.logger.warn(
  123. `Ad pool cache miss for adType=${adType}, key=${poolKey}. Cache may be cold; ensure cache-sync rebuilt pools.`,
  124. );
  125. return [];
  126. }
  127. if (!entries.length) {
  128. this.logger.warn(`Ad pool empty for adType=${adType}, key=${poolKey}`);
  129. return [];
  130. }
  131. pool = entries;
  132. } catch (err) {
  133. if (err instanceof Error && err.message?.includes('WRONGTYPE')) {
  134. this.logger.warn(
  135. `Ad pool key ${poolKey} has wrong type, deleting incompatible key`,
  136. );
  137. try {
  138. await this.redis.del(poolKey);
  139. this.logger.log(
  140. `Deleted incompatible ad pool key ${poolKey}. It will be rebuilt on next cache warmup.`,
  141. );
  142. } catch (delErr) {
  143. this.logger.error(
  144. `Failed to delete incompatible key ${poolKey}`,
  145. delErr instanceof Error ? delErr.stack : String(delErr),
  146. );
  147. }
  148. } else {
  149. this.logger.error(
  150. `Failed to read ad pool for adType=${adType}, key=${poolKey}`,
  151. err instanceof Error ? err.stack : String(err),
  152. );
  153. }
  154. }
  155. if (!pool) {
  156. return [];
  157. }
  158. // Shuffle if random order
  159. const sortedPool = order === AdOrder.RANDOM ? this.shuffle(pool) : pool;
  160. // Fetch all ads in parallel
  161. const adPromises = sortedPool.map((entry) => this.fetchAdDetails(entry.id));
  162. const ads = await Promise.all(adPromises);
  163. // Filter out nulls and map to DTO
  164. return ads.filter((ad): ad is HomeAdDto => ad !== null);
  165. }
  166. /**
  167. * Get single ad (e.g., banner)
  168. */
  169. private async getSingleAd(adType: AdType): Promise<HomeAdDto | null> {
  170. const ads = await this.getAdsByType(adType, AdOrder.RANDOM);
  171. return ads.length > 0 ? ads[0] : null;
  172. }
  173. /**
  174. * Fetch ad details from per-ad cache
  175. */
  176. private async fetchAdDetails(adId: string): Promise<HomeAdDto | null> {
  177. try {
  178. const now = nowSecBigInt();
  179. const ad = await this.prisma.ads.findUnique({
  180. where: { id: adId },
  181. select: {
  182. id: true,
  183. adType: true,
  184. advertiser: true,
  185. title: true,
  186. adsContent: true,
  187. adsCoverImg: true,
  188. adsUrl: true,
  189. startDt: true,
  190. expiryDt: true,
  191. status: true,
  192. },
  193. });
  194. if (!ad) {
  195. this.logger.debug(`Ad not found for homepage slot: adId=${adId}`);
  196. return null;
  197. }
  198. if (ad.status !== 1 || ad.startDt > now) {
  199. return null;
  200. }
  201. if (ad.expiryDt !== BigInt(0) && ad.expiryDt < now) {
  202. return null;
  203. }
  204. return {
  205. id: ad.id,
  206. adType: ad.adType ?? 'UNKNOWN',
  207. title: ad.title ?? '',
  208. advertiser: ad.advertiser ?? '',
  209. content: ad.adsContent ?? undefined,
  210. coverImg: ad.adsCoverImg ?? undefined,
  211. targetUrl: ad.adsUrl ?? undefined,
  212. };
  213. } catch (err) {
  214. this.logger.error(
  215. `Failed to fetch ad ${adId}`,
  216. err instanceof Error ? err.stack : String(err),
  217. );
  218. return null;
  219. }
  220. }
  221. /**
  222. * Get slot name for ad type (maps to ADTYPE_POOLS config)
  223. */
  224. private getSlotForType(adType: AdType): string {
  225. const slotMap: Record<AdType, AdSlot> = {
  226. [AdType.STARTUP]: AdSlot.STARTUP,
  227. [AdType.CAROUSEL]: AdSlot.CAROUSEL,
  228. [AdType.POPUP_ICON]: AdSlot.MIDDLE,
  229. [AdType.POPUP_IMAGE]: AdSlot.MIDDLE,
  230. [AdType.POPUP_OFFICIAL]: AdSlot.MIDDLE,
  231. [AdType.WATERFALL_ICON]: AdSlot.WATERFALL,
  232. [AdType.WATERFALL_TEXT]: AdSlot.WATERFALL,
  233. [AdType.WATERFALL_VIDEO]: AdSlot.WATERFALL,
  234. [AdType.FLOATING_BOTTOM]: AdSlot.FLOATING,
  235. [AdType.FLOATING_EDGE]: AdSlot.EDGE,
  236. [AdType.BANNER]: AdSlot.TOP,
  237. [AdType.PREROLL]: AdSlot.PREROLL,
  238. [AdType.PAUSE]: AdSlot.PAUSE_OVERLAY,
  239. };
  240. return slotMap[adType] ?? AdSlot.UNKNOWN;
  241. }
  242. /**
  243. * Get announcements (marquee notices)
  244. * Using SystemParam with side='client' and specific naming convention
  245. */
  246. private async getAnnouncements(): Promise<AnnouncementDto[]> {
  247. try {
  248. // Fetch client-side params that could be announcements
  249. // Convention: params with name starting with 'announcement_' or 'notice_'
  250. const params = await this.prisma.systemParam.findMany({
  251. where: {
  252. side: SystemParamSide.CLIENT,
  253. name: {
  254. contains: ANNOUNCEMENT_KEYWORD,
  255. },
  256. },
  257. orderBy: {
  258. id: 'asc', // sequential order by ID
  259. },
  260. });
  261. return params.map((p, idx) => ({
  262. id: p.id.toString(),
  263. content: p.value ?? '', // Use 'value' field as content
  264. seq: idx,
  265. }));
  266. } catch (error) {
  267. this.logger.warn(
  268. 'Error fetching announcements from SystemParam, returning empty',
  269. );
  270. return [];
  271. }
  272. }
  273. /**
  274. * Get video categories (delegated to VideoService)
  275. */
  276. private async getCategories(): Promise<CategoryDto[]> {
  277. return this.videoService.getCategories();
  278. }
  279. async getCategoryTags(channelId: string): Promise<any[]> {
  280. try {
  281. this.logger.log(`Cache miss for category tags of channelId=${channelId}`);
  282. const cacheKey = `category:tag:${channelId}`;
  283. const cache = await this.redis.getJson<HomeCategoryCacheItem[]>(cacheKey);
  284. if (cache) {
  285. this.logger.log(
  286. `Cache hit for category tags of channelId=${channelId}`,
  287. );
  288. return cache;
  289. }
  290. const channel = await this.prisma.channel.findUnique({
  291. where: { channelId },
  292. select: {
  293. categories: true,
  294. },
  295. });
  296. this.logger.log(
  297. `Fetched channel data for channelId=${channelId}: ${JSON.stringify(channel)}`,
  298. );
  299. type ChannelCategory = {
  300. id: string;
  301. name?: string;
  302. };
  303. const categoryIds =
  304. (channel?.categories as ChannelCategory[] | null)?.map((c) => c.id) ??
  305. [];
  306. const categories = await this.prisma.category.findMany({
  307. where: {
  308. id: { in: categoryIds },
  309. },
  310. select: {
  311. name: true,
  312. subtitle: true,
  313. tagNames: true,
  314. },
  315. orderBy: {
  316. seq: 'desc',
  317. },
  318. });
  319. if (categories.length > 0) {
  320. await this.redis.setJson(cacheKey, categories, 24 * 3600);
  321. }
  322. return categories;
  323. } catch (err) {
  324. this.logger.error(
  325. `Error fetching home section videos for channelId=${channelId}`,
  326. err instanceof Error ? err.stack : String(err),
  327. );
  328. return [];
  329. }
  330. }
  331. async getCategoryList(): Promise<HomeCategoryCacheItem[]> {
  332. const raw = await this.redis.get(tsCacheKeys.category.all());
  333. return this.parseCategoryCache(raw);
  334. }
  335. async getTagList(): Promise<HomeTagCacheItem[] | Record<string, unknown>> {
  336. const raw = await this.redis.get(tsCacheKeys.tag.all());
  337. if (!raw) {
  338. return [];
  339. }
  340. try {
  341. const parsed = JSON.parse(raw);
  342. if (Array.isArray(parsed)) {
  343. return parsed as HomeTagCacheItem[];
  344. }
  345. if (parsed && typeof parsed === 'object') {
  346. return parsed as Record<string, unknown>;
  347. }
  348. } catch {
  349. this.logger.warn('Failed to parse tag list from Redis cache');
  350. }
  351. return [];
  352. }
  353. async searchByCategoryName(q: string): Promise<HomeCategoryCacheItem[]> {
  354. const term = q?.trim();
  355. if (!term) {
  356. return [];
  357. }
  358. const lowercaseTerm = term.toLowerCase();
  359. const categories = await this.getCategoryList();
  360. return categories.filter((category) =>
  361. category.name.toLowerCase().includes(lowercaseTerm),
  362. );
  363. }
  364. async searchByTagName(q: string): Promise<HomeCategoryCacheItem[]> {
  365. const term = q?.trim();
  366. if (!term) {
  367. return [];
  368. }
  369. const lowercaseTerm = term.toLowerCase();
  370. const categories = await this.getCategoryList();
  371. return categories.filter((category) =>
  372. category.tags.some((tag) => tag.toLowerCase().includes(lowercaseTerm)),
  373. );
  374. }
  375. private parseCategoryCache(raw: string | null): HomeCategoryCacheItem[] {
  376. if (!raw) {
  377. return [];
  378. }
  379. let parsed: unknown;
  380. try {
  381. parsed = JSON.parse(raw);
  382. } catch {
  383. this.logger.warn('Failed to parse category list from Redis cache');
  384. return [];
  385. }
  386. if (!Array.isArray(parsed)) {
  387. return [];
  388. }
  389. const entries: HomeCategoryCacheItem[] = [];
  390. let hadInvalidEntry = false;
  391. for (const entry of parsed) {
  392. if (!this.isValidCategoryEntry(entry)) {
  393. hadInvalidEntry = true;
  394. continue;
  395. }
  396. const candidate = entry as HomeCategoryCacheItem;
  397. entries.push({
  398. id: candidate.id,
  399. name: candidate.name,
  400. subtitle: candidate.subtitle,
  401. seq: candidate.seq,
  402. tags: candidate.tags,
  403. });
  404. }
  405. if (hadInvalidEntry) {
  406. this.logger.warn('Skipped invalid entries while parsing category cache');
  407. }
  408. return entries.sort((a, b) => a.seq - b.seq);
  409. }
  410. private isValidCategoryEntry(entry: unknown): entry is HomeCategoryCacheItem {
  411. if (!entry || typeof entry !== 'object') {
  412. return false;
  413. }
  414. const candidate = entry as Record<string, unknown>;
  415. if (
  416. typeof candidate.id !== 'string' ||
  417. typeof candidate.name !== 'string'
  418. ) {
  419. return false;
  420. }
  421. const seq = candidate.seq;
  422. if (typeof seq !== 'number' || Number.isNaN(seq)) {
  423. return false;
  424. }
  425. const subtitle = candidate.subtitle;
  426. if (
  427. subtitle !== undefined &&
  428. subtitle !== null &&
  429. typeof subtitle !== 'string'
  430. ) {
  431. return false;
  432. }
  433. const tags = candidate.tags;
  434. if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== 'string')) {
  435. return false;
  436. }
  437. return true;
  438. }
  439. /**
  440. * Get recommended videos (delegated to VideoService)
  441. */
  442. // private async getRecommendedVideos(): Promise<RecommendedVideosDto> {
  443. // return this.videoService.getRecommendedVideos();
  444. // }
  445. /**
  446. * Fisher-Yates shuffle for random ordering
  447. */
  448. private shuffle<T>(array: T[]): T[] {
  449. const shuffled = [...array];
  450. for (let i = shuffled.length - 1; i > 0; i--) {
  451. const j = Math.floor(Math.random() * (i + 1));
  452. [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
  453. }
  454. return shuffled;
  455. }
  456. }