ad.service.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. // apps/box-app-api/src/feature/ads/ad.service.ts
  2. import { Injectable, Logger } from '@nestjs/common';
  3. import { RedisService } from '@box/db/redis/redis.service';
  4. import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
  5. import { CacheKeys } from '@box/common/cache/cache-keys';
  6. import { AdDto } from './dto/ad.dto';
  7. import { AdListResponseDto, AdItemDto } from './dto';
  8. import { AdType } from '@box/common/ads/ad-types';
  9. interface AdPoolEntry {
  10. id: string;
  11. weight: number;
  12. }
  13. // This should match what mgnt-side rebuildSingleAdCache stores.
  14. // We only care about a subset for now.
  15. interface CachedAd {
  16. id: string;
  17. channelId?: string;
  18. adsModuleId?: string;
  19. advertiser?: string;
  20. title?: string;
  21. adsContent?: string | null;
  22. adsCoverImg?: string | null;
  23. adsUrl?: string | null;
  24. adType?: string | null;
  25. // startDt?: bigint;
  26. // expiryDt?: bigint;
  27. // seq?: number;
  28. // status?: number;
  29. // createAt?: bigint;
  30. // updateAt?: bigint;
  31. }
  32. export interface GetAdForPlacementParams {
  33. scene: string; // e.g. 'home' | 'detail' | 'player' | 'global'
  34. slot: string; // e.g. 'top' | 'carousel' | 'popup' | 'preroll' | ...
  35. adType: string; // e.g. 'BANNER' | 'CAROUSEL' | 'POPUP_IMAGE' | ...
  36. maxTries?: number; // optional, default 3
  37. }
  38. /**
  39. * Handles ad selection for app clients using prebuilt Redis pools.
  40. * Reads the ad pool for a given (scene, slot, adType), picks a candidate,
  41. * and maps the cached payload back into the public AdDto shape.
  42. */
  43. @Injectable()
  44. export class AdService {
  45. private readonly logger = new Logger(AdService.name);
  46. constructor(
  47. private readonly redis: RedisService,
  48. private readonly mongoPrisma: MongoPrismaService,
  49. ) {}
  50. /**
  51. * Core method for app-api:
  52. * Given a (scene, slot, adType), try to pick one ad from the prebuilt pool
  53. * and return its details as AdDto. Returns null if no suitable ad is found.
  54. */
  55. async getAdForPlacement(
  56. params: GetAdForPlacementParams,
  57. ): Promise<AdDto | null> {
  58. const { scene, slot, adType } = params;
  59. const maxTries = params.maxTries ?? 3;
  60. const poolKey = CacheKeys.appAdPoolByType(adType);
  61. const pool = await this.readPoolWithDiagnostics(poolKey, {
  62. scene,
  63. slot,
  64. adType,
  65. });
  66. if (!pool) {
  67. return null;
  68. }
  69. // Limit attempts so we don't loop too much if some ad entries are stale.
  70. const attempts = Math.min(maxTries, pool.length);
  71. // We'll try up to `attempts` random entries from the pool.
  72. const usedIndexes = new Set<number>();
  73. for (let i = 0; i < attempts; i++) {
  74. const idx = this.pickRandomIndex(pool.length, usedIndexes);
  75. if (idx === -1) break;
  76. usedIndexes.add(idx);
  77. const entry = pool[idx];
  78. const adKey = CacheKeys.appAdById(entry.id);
  79. const cachedAd =
  80. (await this.redis.getJson<CachedAd | null>(adKey)) ?? null;
  81. if (!cachedAd) {
  82. this.logger.debug(
  83. `getAdForPlacement: missing per-ad cache for adId=${entry.id}, key=${adKey}, poolKey=${poolKey}`,
  84. );
  85. continue;
  86. }
  87. const dto = this.mapCachedAdToDto(cachedAd, adType);
  88. return dto;
  89. }
  90. // All attempts failed to find a valid cached ad
  91. this.logger.debug(
  92. `getAdForPlacement: no usable ad found after ${attempts} attempt(s) for scene=${scene}, slot=${slot}, adType=${adType}, poolKey=${poolKey}`,
  93. );
  94. return null;
  95. }
  96. /**
  97. * Fetch and parse a pool entry list, while emitting useful diagnostics when
  98. * the pool is missing/empty or contains malformed JSON. Keeps API behavior
  99. * unchanged (returns null when the pool is not usable).
  100. */
  101. private async readPoolWithDiagnostics(
  102. poolKey: string,
  103. placement: Pick<GetAdForPlacementParams, 'scene' | 'slot' | 'adType'>,
  104. ): Promise<AdPoolEntry[] | null> {
  105. const raw = await this.redis.get(poolKey);
  106. if (raw === null) {
  107. this.logger.warn(
  108. `Ad pool cache miss for scene=${placement.scene}, slot=${placement.slot}, adType=${placement.adType}, key=${poolKey}. Cache may be cold; ensure cache-sync rebuilt pools.`,
  109. );
  110. return null;
  111. }
  112. let parsed: unknown;
  113. try {
  114. parsed = JSON.parse(raw);
  115. } catch (err) {
  116. const message =
  117. err instanceof Error ? err.message : JSON.stringify(err ?? 'Unknown');
  118. this.logger.error(
  119. `Failed to parse ad pool JSON for key=${poolKey}: ${message}`,
  120. );
  121. return null;
  122. }
  123. if (!Array.isArray(parsed) || parsed.length === 0) {
  124. this.logger.warn(
  125. `Ad pool empty or invalid shape for scene=${placement.scene}, slot=${placement.slot}, adType=${placement.adType}, key=${poolKey}`,
  126. );
  127. return null;
  128. }
  129. return parsed as AdPoolEntry[];
  130. }
  131. /**
  132. * Pick a random index in [0, length-1] that is not in usedIndexes.
  133. * Returns -1 if all indexes are already used.
  134. */
  135. private pickRandomIndex(length: number, usedIndexes: Set<number>): number {
  136. if (usedIndexes.size >= length) return -1;
  137. // Simple approach: try a few times to find an unused index.
  138. // Since length is small in most pools, this is fine.
  139. for (let attempts = 0; attempts < 5; attempts++) {
  140. const idx = Math.floor(Math.random() * length);
  141. if (!usedIndexes.has(idx)) {
  142. return idx;
  143. }
  144. }
  145. // Fallback: linear scan for the first unused index
  146. for (let i = 0; i < length; i++) {
  147. if (!usedIndexes.has(i)) return i;
  148. }
  149. return -1;
  150. }
  151. /**
  152. * Map cached ad (as stored by mgnt-api) to the AdDto exposed to frontend.
  153. */
  154. private mapCachedAdToDto(cachedAd: CachedAd, fallbackAdType: string): AdDto {
  155. return {
  156. id: cachedAd.id,
  157. adType: cachedAd.adType ?? fallbackAdType,
  158. title: cachedAd.title ?? '',
  159. advertiser: cachedAd.advertiser ?? '',
  160. content: cachedAd.adsContent ?? undefined,
  161. coverImg: cachedAd.adsCoverImg ?? undefined,
  162. targetUrl: cachedAd.adsUrl ?? undefined,
  163. };
  164. }
  165. /**
  166. * Get paginated list of ads by type from Redis pool.
  167. * Reads the prebuilt ad pool from Redis, applies pagination, and fetches full ad details.
  168. *
  169. * Flow:
  170. * 1. Get total count from pool
  171. * 2. Compute start/stop indices for LRANGE
  172. * 3. Fetch poolEntries (AdPoolEntry array as JSON)
  173. * 4. Query MongoDB to fetch full ad details for the entries
  174. * 5. Reorder results to match Redis pool order
  175. * 6. Map to AdItemDto and return response
  176. */
  177. async listAdsByType(
  178. adType: string,
  179. page: number,
  180. size: number,
  181. ): Promise<AdListResponseDto> {
  182. const poolKey = CacheKeys.appAdPoolByType(adType);
  183. // Step 1: Get the entire pool from Redis
  184. // Note: The key should be a STRING (JSON), but might be a LIST from old implementation
  185. let poolEntries: AdPoolEntry[] = [];
  186. try {
  187. // First, try to get as JSON (STRING type)
  188. const jsonData = await this.redis.getJson<AdPoolEntry[]>(poolKey);
  189. if (jsonData && Array.isArray(jsonData)) {
  190. poolEntries = jsonData;
  191. } else {
  192. // If getJson failed or returned null, the key might not exist
  193. this.logger.warn(
  194. `Ad pool cache miss or invalid for adType=${adType}, key=${poolKey}`,
  195. );
  196. }
  197. } catch (err) {
  198. // If WRONGTYPE error, the key is stored as a different Redis type (likely from old code)
  199. // Delete the incompatible key so it can be rebuilt properly
  200. if (err instanceof Error && err.message?.includes('WRONGTYPE')) {
  201. this.logger.warn(
  202. `Ad pool key ${poolKey} has wrong type, deleting incompatible key`,
  203. );
  204. try {
  205. await this.redis.del(poolKey);
  206. this.logger.log(
  207. `Deleted incompatible ad pool key ${poolKey}. It will be rebuilt on next cache warmup.`,
  208. );
  209. } catch (delErr) {
  210. this.logger.error(
  211. `Failed to delete incompatible key ${poolKey}`,
  212. delErr instanceof Error ? delErr.stack : String(delErr),
  213. );
  214. }
  215. } else {
  216. this.logger.error(
  217. `Failed to read ad pool for adType=${adType}, key=${poolKey}`,
  218. err instanceof Error ? err.stack : String(err),
  219. );
  220. }
  221. }
  222. if (!Array.isArray(poolEntries) || poolEntries.length === 0) {
  223. this.logger.debug(
  224. `Ad pool empty or invalid for adType=${adType}, key=${poolKey}`,
  225. );
  226. return {
  227. page,
  228. size,
  229. total: 0,
  230. adType: adType as AdType,
  231. items: [],
  232. };
  233. }
  234. const total = poolEntries.length;
  235. // Step 2: Compute pagination indices
  236. const start = (page - 1) * size;
  237. const stop = start + size - 1;
  238. // Check if page is out of range
  239. if (start >= total) {
  240. this.logger.debug(
  241. `Page out of range: page=${page}, size=${size}, total=${total}`,
  242. );
  243. return {
  244. page,
  245. size,
  246. total,
  247. adType: adType as AdType,
  248. items: [],
  249. };
  250. }
  251. // Step 3: Slice the pool entries for this page
  252. const pagedEntries = poolEntries.slice(start, stop + 1);
  253. const adIds = pagedEntries.map((entry) => entry.id);
  254. // Step 4: Query MongoDB for full ad details
  255. let ads: Awaited<ReturnType<typeof this.mongoPrisma.ads.findMany>>;
  256. try {
  257. const now = BigInt(Date.now());
  258. ads = await this.mongoPrisma.ads.findMany({
  259. where: {
  260. id: { in: adIds },
  261. status: 1,
  262. startDt: { lte: now },
  263. OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
  264. },
  265. });
  266. } catch (err) {
  267. this.logger.error(
  268. `Failed to query ads from MongoDB for adIds=${adIds.join(',')}`,
  269. err instanceof Error ? err.stack : String(err),
  270. );
  271. return {
  272. page,
  273. size,
  274. total,
  275. adType: adType as AdType,
  276. items: [],
  277. };
  278. }
  279. // Step 5: Create a map of ads by ID for fast lookup
  280. const adMap = new Map(ads.map((ad) => [ad.id, ad]));
  281. // Step 6: Reorder results to match the pool order and map to AdItemDto
  282. const items: AdItemDto[] = [];
  283. for (const entry of pagedEntries) {
  284. const ad = adMap.get(entry.id);
  285. if (!ad) {
  286. this.logger.debug(
  287. `Ad not found in MongoDB for adId=${entry.id} from pool`,
  288. );
  289. continue;
  290. }
  291. items.push({
  292. id: ad.id,
  293. advertiser: ad.advertiser ?? '',
  294. title: ad.title ?? '',
  295. adsContent: ad.adsContent ?? null,
  296. adsCoverImg: ad.adsCoverImg ?? null,
  297. adsUrl: ad.adsUrl ?? null,
  298. startDt: ad.startDt.toString(),
  299. expiryDt: ad.expiryDt.toString(),
  300. seq: ad.seq ?? 0,
  301. });
  302. }
  303. return {
  304. page,
  305. size,
  306. total,
  307. adType: adType as AdType,
  308. items,
  309. };
  310. }
  311. /**
  312. * Get an ad by ID and validate it's enabled and within date range.
  313. * Returns the ad with its relationships (channel, adsModule) loaded.
  314. * Returns null if ad is not found, disabled, or outside date range.
  315. */
  316. async getAdByIdValidated(adsId: string): Promise<{
  317. id: string;
  318. channelId: string;
  319. adsModuleId: string;
  320. adType: string;
  321. adsUrl: string | null;
  322. advertiser: string;
  323. title: string;
  324. } | null> {
  325. const now = BigInt(Date.now());
  326. try {
  327. const ad = await this.mongoPrisma.ads.findUnique({
  328. where: { id: adsId },
  329. include: {
  330. channel: { select: { id: true } },
  331. adsModule: { select: { id: true, adType: true } },
  332. },
  333. });
  334. if (!ad) {
  335. this.logger.debug(`Ad not found: adsId=${adsId}`);
  336. return null;
  337. }
  338. // Validate status (1 = enabled)
  339. if (ad.status !== 1) {
  340. this.logger.debug(`Ad is disabled: adsId=${adsId}`);
  341. return null;
  342. }
  343. // Validate date range
  344. if (ad.startDt > now) {
  345. this.logger.debug(`Ad not started yet: adsId=${adsId}`);
  346. return null;
  347. }
  348. // If expiryDt is 0, it means no expiry; otherwise check if expired
  349. if (ad.expiryDt !== BigInt(0) && ad.expiryDt < now) {
  350. this.logger.debug(`Ad expired: adsId=${adsId}`);
  351. return null;
  352. }
  353. return {
  354. id: ad.id,
  355. channelId: ad.channelId,
  356. adsModuleId: ad.adsModuleId,
  357. adType: ad.adsModule.adType,
  358. adsUrl: ad.adsUrl,
  359. advertiser: ad.advertiser,
  360. title: ad.title,
  361. };
  362. } catch (err) {
  363. this.logger.error(
  364. `Error fetching ad by ID: adsId=${adsId}`,
  365. err instanceof Error ? err.stack : String(err),
  366. );
  367. return null;
  368. }
  369. }
  370. }