|
|
@@ -0,0 +1,181 @@
|
|
|
+import { Injectable, Logger } from '@nestjs/common';
|
|
|
+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;
|
|
|
+}
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class AdPoolService {
|
|
|
+ private readonly logger = new Logger(AdPoolService.name);
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private readonly redis: RedisService,
|
|
|
+ private readonly mongoPrisma: MongoPrismaService,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ /** 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) {
|
|
|
+ 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;
|
|
|
+ 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,
|
|
|
+ };
|
|
|
+
|
|
|
+ // 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;
|
|
|
+ }
|
|
|
+}
|