ad-pool.service.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { Injectable, Logger } from '@nestjs/common';
  2. import { randomUUID } from 'crypto';
  3. import { CacheKeys } from '@box/common/cache/cache-keys';
  4. import type { AdType } from '@box/common/ads/ad-types';
  5. import { AdType as PrismaAdType } from '@prisma/mongo/client';
  6. import { RedisService } from '@box/db/redis/redis.service';
  7. import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
  8. export interface AdPayload {
  9. id: string;
  10. channelId: string;
  11. channelName?: string;
  12. adsModuleId: string;
  13. adType: AdType;
  14. advertiser: string;
  15. title: string;
  16. adsContent?: string | null;
  17. adsCoverImg?: string | null;
  18. adsUrl?: string | null;
  19. trackingId?: string;
  20. }
  21. @Injectable()
  22. export class AdPoolService {
  23. private readonly logger = new Logger(AdPoolService.name);
  24. constructor(
  25. private readonly redis: RedisService,
  26. private readonly mongoPrisma: MongoPrismaService,
  27. ) {}
  28. /** Generate a unique tracking ID for ad impression tracking. */
  29. private generateTrackingId(): string {
  30. try {
  31. return randomUUID();
  32. } catch {
  33. // Fallback: generate a simple UUID-like string if crypto fails
  34. return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  35. }
  36. }
  37. /** Rebuild all ad pools keyed by AdType. */
  38. async rebuildAllAdPools(): Promise<void> {
  39. const adTypes = Object.values(PrismaAdType) as AdType[];
  40. for (const adType of adTypes) {
  41. try {
  42. const count = await this.rebuildPoolForType(adType);
  43. this.logger.log(`AdPool rebuild: adType=${adType}, ads=${count}`);
  44. } catch (err) {
  45. this.logger.error(
  46. `AdPool rebuild failed for adType=${adType}`,
  47. err instanceof Error ? err.stack : String(err),
  48. );
  49. }
  50. }
  51. }
  52. /** Rebuild a single ad pool for an AdType. Returns number of ads written. */
  53. async rebuildPoolForType(adType: AdType): Promise<number> {
  54. const now = BigInt(Date.now());
  55. const ads = await this.mongoPrisma.ads.findMany({
  56. where: {
  57. status: 1,
  58. startDt: { lte: now },
  59. OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
  60. adsModule: { is: { adType } },
  61. },
  62. orderBy: { seq: 'asc' },
  63. include: {
  64. channel: { select: { name: true } },
  65. adsModule: { select: { adType: true } },
  66. },
  67. });
  68. const payloads: AdPayload[] = ads.map((ad) => ({
  69. id: ad.id,
  70. channelId: ad.channelId,
  71. channelName: ad.channel?.name,
  72. adsModuleId: ad.adsModuleId,
  73. adType: ad.adsModule.adType as AdType,
  74. advertiser: ad.advertiser,
  75. title: ad.title,
  76. adsContent: ad.adsContent ?? null,
  77. adsCoverImg: ad.adsCoverImg ?? null,
  78. adsUrl: ad.adsUrl ?? null,
  79. }));
  80. const key = CacheKeys.appAdPoolByType(adType);
  81. await this.redis.del(key);
  82. if (!payloads.length) {
  83. // Ensure the key exists (as an empty SET) so checklist doesn't fail
  84. // Use a placeholder that will be ignored by consumers
  85. await this.redis.sadd(key, '__empty__');
  86. return 0;
  87. }
  88. const members = payloads.map((p) => JSON.stringify(p));
  89. await this.redis.sadd(key, ...members);
  90. return payloads.length;
  91. }
  92. /** Fetch one random ad payload from Redis SET. */
  93. async getRandomFromRedisPool(adType: AdType): Promise<AdPayload | null> {
  94. try {
  95. const key = CacheKeys.appAdPoolByType(adType);
  96. const raw = await this.redis.srandmember(key);
  97. if (!raw) return null;
  98. // Skip the empty placeholder marker
  99. if (raw === '__empty__') return null;
  100. const parsed = JSON.parse(raw) as Partial<AdPayload>;
  101. if (!parsed || typeof parsed !== 'object' || !parsed.id) return null;
  102. return parsed as AdPayload;
  103. } catch (err) {
  104. this.logger.warn(
  105. `getRandomFromRedisPool error for adType=${adType}`,
  106. err instanceof Error ? err.stack : String(err),
  107. );
  108. return null;
  109. }
  110. }
  111. /** Fallback: pick a random ad directly from MongoDB. */
  112. async getRandomFromDb(adType: AdType): Promise<AdPayload | null> {
  113. try {
  114. const now = BigInt(Date.now());
  115. const ads = await this.mongoPrisma.ads.findMany({
  116. where: {
  117. status: 1,
  118. startDt: { lte: now },
  119. OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
  120. adsModule: { is: { adType } },
  121. },
  122. include: {
  123. channel: { select: { name: true } },
  124. adsModule: { select: { adType: true } },
  125. },
  126. });
  127. if (!ads.length) return null;
  128. const pickIndex = Math.floor(Math.random() * ads.length);
  129. const ad = ads[pickIndex];
  130. const payload: AdPayload = {
  131. id: ad.id,
  132. channelId: ad.channelId,
  133. channelName: ad.channel?.name,
  134. adsModuleId: ad.adsModuleId,
  135. adType: ad.adsModule.adType as AdType,
  136. advertiser: ad.advertiser,
  137. title: ad.title,
  138. adsContent: ad.adsContent ?? null,
  139. adsCoverImg: ad.adsCoverImg ?? null,
  140. adsUrl: ad.adsUrl ?? null,
  141. trackingId: this.generateTrackingId(),
  142. };
  143. // Fire-and-forget rebuild for freshness.
  144. void this.rebuildPoolForType(adType).catch(() => {
  145. /* ignore */
  146. });
  147. return payload;
  148. } catch (err) {
  149. this.logger.warn(
  150. `getRandomFromDb error for adType=${adType}`,
  151. err instanceof Error ? err.stack : String(err),
  152. );
  153. return null;
  154. }
  155. }
  156. /** Prefer Redis; fall back to MongoDB. Never throws. */
  157. async getRandomAdByType(adType: AdType): Promise<AdPayload | null> {
  158. // Try Redis first
  159. const fromRedis = await this.getRandomFromRedisPool(adType);
  160. if (fromRedis) return fromRedis;
  161. // Redis miss or error already logged; fall back to Mongo
  162. const fromDb = await this.getRandomFromDb(adType);
  163. if (!fromDb) {
  164. this.logger.warn(
  165. `No ads available for adType=${adType} from Redis or Mongo`,
  166. );
  167. return null;
  168. }
  169. // Fire-and-forget rebuild to refresh Redis cache
  170. void this.rebuildPoolForType(adType).catch(() => {
  171. /* ignore */
  172. });
  173. return fromDb;
  174. }
  175. }