| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- import { Injectable, Logger } from '@nestjs/common';
- import { randomUUID } from 'crypto';
- import { CacheKeys } from '@box/common/cache/cache-keys';
- import type { AdType } from '@box/common/ads/ad-types';
- import { AdType as PrismaAdType } from '@prisma/mongo/client';
- import { RedisService } from '@box/db/redis/redis.service';
- import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
- export interface AdPayload {
- id: string;
- channelId: string;
- channelName?: string;
- adsModuleId: string;
- adType: AdType;
- advertiser: string;
- title: string;
- adsContent?: string | null;
- adsCoverImg?: string | null;
- adsUrl?: string | null;
- trackingId?: string;
- }
- @Injectable()
- export class AdPoolService {
- private readonly logger = new Logger(AdPoolService.name);
- constructor(
- private readonly redis: RedisService,
- private readonly mongoPrisma: MongoPrismaService,
- ) {}
- /** Generate a unique tracking ID for ad impression tracking. */
- private generateTrackingId(): string {
- try {
- return randomUUID();
- } catch {
- // Fallback: generate a simple UUID-like string if crypto fails
- return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
- }
- }
- /** Rebuild all ad pools keyed by AdType. */
- async rebuildAllAdPools(): Promise<void> {
- const adTypes = Object.values(PrismaAdType) as AdType[];
- for (const adType of adTypes) {
- try {
- const count = await this.rebuildPoolForType(adType);
- this.logger.log(`AdPool rebuild: adType=${adType}, ads=${count}`);
- } catch (err) {
- this.logger.error(
- `AdPool rebuild failed for adType=${adType}`,
- err instanceof Error ? err.stack : String(err),
- );
- }
- }
- }
- /** Rebuild a single ad pool for an AdType. Returns number of ads written. */
- async rebuildPoolForType(adType: AdType): Promise<number> {
- const now = BigInt(Date.now());
- const ads = await this.mongoPrisma.ads.findMany({
- where: {
- status: 1,
- startDt: { lte: now },
- OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
- adsModule: { is: { adType } },
- },
- orderBy: { seq: 'asc' },
- include: {
- channel: { select: { name: true } },
- adsModule: { select: { adType: true } },
- },
- });
- const payloads: AdPayload[] = ads.map((ad) => ({
- id: ad.id,
- channelId: ad.channelId,
- channelName: ad.channel?.name,
- adsModuleId: ad.adsModuleId,
- adType: ad.adsModule.adType as AdType,
- advertiser: ad.advertiser,
- title: ad.title,
- adsContent: ad.adsContent ?? null,
- adsCoverImg: ad.adsCoverImg ?? null,
- adsUrl: ad.adsUrl ?? null,
- }));
- const key = CacheKeys.appAdPoolByType(adType);
- await this.redis.del(key);
- if (!payloads.length) {
- // Ensure the key exists (as an empty SET) so checklist doesn't fail
- // Use a placeholder that will be ignored by consumers
- await this.redis.sadd(key, '__empty__');
- return 0;
- }
- const members = payloads.map((p) => JSON.stringify(p));
- await this.redis.sadd(key, ...members);
- return payloads.length;
- }
- /** Fetch one random ad payload from Redis SET. */
- async getRandomFromRedisPool(adType: AdType): Promise<AdPayload | null> {
- try {
- const key = CacheKeys.appAdPoolByType(adType);
- const raw = await this.redis.srandmember(key);
- if (!raw) return null;
- // Skip the empty placeholder marker
- if (raw === '__empty__') return null;
- const parsed = JSON.parse(raw) as Partial<AdPayload>;
- if (!parsed || typeof parsed !== 'object' || !parsed.id) return null;
- return parsed as AdPayload;
- } catch (err) {
- this.logger.warn(
- `getRandomFromRedisPool error for adType=${adType}`,
- err instanceof Error ? err.stack : String(err),
- );
- return null;
- }
- }
- /** Fallback: pick a random ad directly from MongoDB. */
- async getRandomFromDb(adType: AdType): Promise<AdPayload | null> {
- try {
- const now = BigInt(Date.now());
- const ads = await this.mongoPrisma.ads.findMany({
- where: {
- status: 1,
- startDt: { lte: now },
- OR: [{ expiryDt: BigInt(0) }, { expiryDt: { gte: now } }],
- adsModule: { is: { adType } },
- },
- include: {
- channel: { select: { name: true } },
- adsModule: { select: { adType: true } },
- },
- });
- if (!ads.length) return null;
- const pickIndex = Math.floor(Math.random() * ads.length);
- const ad = ads[pickIndex];
- const payload: AdPayload = {
- id: ad.id,
- channelId: ad.channelId,
- channelName: ad.channel?.name,
- adsModuleId: ad.adsModuleId,
- adType: ad.adsModule.adType as AdType,
- advertiser: ad.advertiser,
- title: ad.title,
- adsContent: ad.adsContent ?? null,
- adsCoverImg: ad.adsCoverImg ?? null,
- adsUrl: ad.adsUrl ?? null,
- trackingId: this.generateTrackingId(),
- };
- // Fire-and-forget rebuild for freshness.
- void this.rebuildPoolForType(adType).catch(() => {
- /* ignore */
- });
- return payload;
- } catch (err) {
- this.logger.warn(
- `getRandomFromDb error for adType=${adType}`,
- err instanceof Error ? err.stack : String(err),
- );
- return null;
- }
- }
- /** Prefer Redis; fall back to MongoDB. Never throws. */
- async getRandomAdByType(adType: AdType): Promise<AdPayload | null> {
- // Try Redis first
- const fromRedis = await this.getRandomFromRedisPool(adType);
- if (fromRedis) return fromRedis;
- // Redis miss or error already logged; fall back to Mongo
- const fromDb = await this.getRandomFromDb(adType);
- if (!fromDb) {
- this.logger.warn(
- `No ads available for adType=${adType} from Redis or Mongo`,
- );
- return null;
- }
- // Fire-and-forget rebuild to refresh Redis cache
- void this.rebuildPoolForType(adType).catch(() => {
- /* ignore */
- });
- return fromDb;
- }
- }
|