|
|
@@ -4,6 +4,7 @@ import { Prisma as MysqlPrisma, CacheSyncAction } from '@prisma/mysql/client';
|
|
|
import { MysqlPrismaService } from '@box/db/prisma/mysql-prisma.service';
|
|
|
import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
import { RedisService } from '@box/db/redis/redis.service';
|
|
|
+import { CacheKeys } from '@box/common/cache/cache-keys'; // 👈 new import
|
|
|
|
|
|
import {
|
|
|
CacheEntityType,
|
|
|
@@ -12,6 +13,55 @@ import {
|
|
|
CachePayload,
|
|
|
} from './cache-sync.types';
|
|
|
|
|
|
+interface AdPoolPlacement {
|
|
|
+ scene: string;
|
|
|
+ slot: string;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Mapping from adType (AdsModule.adType) → one or more pool placements.
|
|
|
+ * Each placement becomes a Redis key:
|
|
|
+ * app:adpool:<scene>:<slot>:<adType>
|
|
|
+ *
|
|
|
+ * Adjust these mappings later if your design changes.
|
|
|
+ */
|
|
|
+const ADTYPE_POOLS: Record<string, AdPoolPlacement[]> = {
|
|
|
+ // 启动页广告
|
|
|
+ STARTUP: [{ scene: 'home', slot: 'startup' }],
|
|
|
+
|
|
|
+ // 首页轮播
|
|
|
+ CAROUSEL: [{ scene: 'home', slot: 'carousel' }],
|
|
|
+
|
|
|
+ // 弹窗类(详情页)
|
|
|
+ POPUP_ICON: [{ scene: 'detail', slot: 'popup' }],
|
|
|
+ POPUP_IMAGE: [{ scene: 'detail', slot: 'popup' }],
|
|
|
+ POPUP_OFFICIAL: [{ scene: 'detail', slot: 'popup' }],
|
|
|
+
|
|
|
+ // 瀑布流(首页)
|
|
|
+ WATERFALL_ICON: [{ scene: 'home', slot: 'waterfall' }],
|
|
|
+ WATERFALL_TEXT: [{ scene: 'home', slot: 'waterfall' }],
|
|
|
+ WATERFALL_VIDEO: [{ scene: 'home', slot: 'waterfall' }],
|
|
|
+
|
|
|
+ // 悬浮(全局)
|
|
|
+ FLOATING_BOTTOM: [{ scene: 'global', slot: 'floating_bottom' }],
|
|
|
+ FLOATING_EDGE: [{ scene: 'global', slot: 'floating_edge' }],
|
|
|
+
|
|
|
+ // 顶部 banner
|
|
|
+ BANNER: [{ scene: 'home', slot: 'top' }],
|
|
|
+
|
|
|
+ // 片头(前贴片)
|
|
|
+ PREROLL: [{ scene: 'player', slot: 'preroll' }],
|
|
|
+
|
|
|
+ // 暂停广告
|
|
|
+ PAUSE: [{ scene: 'player', slot: 'pause' }],
|
|
|
+};
|
|
|
+
|
|
|
+interface AdPoolEntry {
|
|
|
+ id: string;
|
|
|
+ // You can later change this to real weight if you add a weight field.
|
|
|
+ weight: number;
|
|
|
+}
|
|
|
+
|
|
|
@Injectable()
|
|
|
export class CacheSyncService {
|
|
|
private readonly logger = new Logger(CacheSyncService.name);
|
|
|
@@ -82,21 +132,31 @@ export class CacheSyncService {
|
|
|
}
|
|
|
|
|
|
async scheduleAdRefresh(
|
|
|
- adId: number | bigint,
|
|
|
+ adId: string | number | bigint,
|
|
|
adType?: string,
|
|
|
): Promise<void> {
|
|
|
+ const adIdStr = String(adId);
|
|
|
+
|
|
|
+ // 1) Per-ad cache (AD)
|
|
|
await this.scheduleAction({
|
|
|
entityType: CacheEntityType.AD,
|
|
|
operation: CacheOperation.REFRESH,
|
|
|
- entityId: adId,
|
|
|
- payload: adType ? { type: adType } : undefined,
|
|
|
+ entityId: null, // we don't rely on MySQL BigInt for Ads, so keep null
|
|
|
+ payload: {
|
|
|
+ type: adType,
|
|
|
+ adId: adIdStr,
|
|
|
+ },
|
|
|
});
|
|
|
|
|
|
+ // 2) Pool rebuild (AD_POOL)
|
|
|
if (adType) {
|
|
|
await this.scheduleAction({
|
|
|
entityType: CacheEntityType.AD_POOL,
|
|
|
operation: CacheOperation.REBUILD_POOL,
|
|
|
- payload: { type: adType },
|
|
|
+ entityId: null,
|
|
|
+ payload: {
|
|
|
+ type: adType,
|
|
|
+ },
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
@@ -225,22 +285,18 @@ export class CacheSyncService {
|
|
|
private async rebuildChannelsAll(): Promise<void> {
|
|
|
const channels = await this.mongoPrisma.channel.findMany({
|
|
|
where: {
|
|
|
- // e.g. only active / not deleted; adjust if needed
|
|
|
// isDeleted: false,
|
|
|
},
|
|
|
orderBy: {
|
|
|
- // adjust to your schema
|
|
|
- // sortOrder: 'asc',
|
|
|
- // createdAt: 'asc',
|
|
|
id: 'asc',
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- // NOTE:
|
|
|
- // Actual Redis key will be "box:channels:all" if REDIS_KEY_PREFIX="box:".
|
|
|
- await this.redis.setJson('channels:all', channels);
|
|
|
+ await this.redis.setJson(CacheKeys.appChannelAll, channels);
|
|
|
|
|
|
- this.logger.log(`Rebuilt channels:all with ${channels.length} item(s).`);
|
|
|
+ this.logger.log(
|
|
|
+ `Rebuilt ${CacheKeys.appChannelAll} with ${channels.length} item(s).`,
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────
|
|
|
@@ -262,21 +318,17 @@ export class CacheSyncService {
|
|
|
private async rebuildCategoriesAll(): Promise<void> {
|
|
|
const categories = await this.mongoPrisma.category.findMany({
|
|
|
where: {
|
|
|
- // e.g. only active / not deleted; adjust if needed
|
|
|
// isDeleted: false,
|
|
|
},
|
|
|
orderBy: {
|
|
|
- // adjust to your schema
|
|
|
- // sortOrder: 'asc',
|
|
|
- // createdAt: 'asc',
|
|
|
- id: 'asc',
|
|
|
+ seq: 'asc',
|
|
|
},
|
|
|
});
|
|
|
|
|
|
- await this.redis.setJson('categories:all', categories);
|
|
|
+ await this.redis.setJson(CacheKeys.appCategoryAll, categories);
|
|
|
|
|
|
this.logger.log(
|
|
|
- `Rebuilt categories:all with ${categories.length} item(s).`,
|
|
|
+ `Rebuilt ${CacheKeys.appCategoryAll} with ${categories.length} item(s).`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
@@ -285,18 +337,174 @@ export class CacheSyncService {
|
|
|
// ─────────────────────────────────────────────
|
|
|
|
|
|
private async handleAdAction(action: CacheSyncAction): Promise<void> {
|
|
|
- // TODO: implement real ad-by-id refresh using this.mongoPrisma.ad & Redis
|
|
|
+ const payload = action.payload as (CachePayload & { adId?: string }) | null;
|
|
|
+
|
|
|
+ if (!payload?.adId) {
|
|
|
+ this.logger.warn(
|
|
|
+ `handleAdAction: missing adId in payload for action id=${action.id}`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const adId = payload.adId;
|
|
|
+ const adType = payload.type;
|
|
|
+
|
|
|
+ await this.rebuildSingleAdCache(adId, adType);
|
|
|
+
|
|
|
this.logger.debug(
|
|
|
- `handleAdAction placeholder for id=${action.entityId}, operation=${action.operation}`,
|
|
|
+ `handleAdAction: rebuilt per-ad cache for adId=${adId}, adType=${adType ?? 'N/A'}, action id=${action.id}`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private async rebuildSingleAdCache(
|
|
|
+ adId: string,
|
|
|
+ adType?: string,
|
|
|
+ ): Promise<void> {
|
|
|
+ const now = this.nowBigInt();
|
|
|
+
|
|
|
+ // Fetch the ad by Mongo ObjectId
|
|
|
+ const ad = await this.mongoPrisma.ads.findUnique({
|
|
|
+ where: { id: adId },
|
|
|
+ include: {
|
|
|
+ adsModule: true, // if you want adType / placement info
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const cacheKey = CacheKeys.appAdById(adId);
|
|
|
+
|
|
|
+ if (!ad) {
|
|
|
+ // Ad no longer exists → ensure cache is cleared
|
|
|
+ await this.redis.del(cacheKey);
|
|
|
+ this.logger.log(
|
|
|
+ `rebuildSingleAdCache: ad not found, removed cache key=${cacheKey}`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Validate business rules:
|
|
|
+ // - status = 1 (enabled)
|
|
|
+ // - startDt <= now
|
|
|
+ // - expiryDt == 0 (no expiry) OR expiryDt >= now
|
|
|
+ const isActive =
|
|
|
+ ad.status === 1 &&
|
|
|
+ ad.startDt <= now &&
|
|
|
+ (ad.expiryDt === BigInt(0) || ad.expiryDt >= now);
|
|
|
+
|
|
|
+ if (!isActive) {
|
|
|
+ await this.redis.del(cacheKey);
|
|
|
+ this.logger.log(
|
|
|
+ `rebuildSingleAdCache: adId=${adId} is not active (status/time window), removed cache key=${cacheKey}`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Decide what to store in per-ad cache.
|
|
|
+ // You can store the full ad document or a trimmed DTO.
|
|
|
+ // For now, let’s store the full ad + its module's adType.
|
|
|
+ const cachedAd = {
|
|
|
+ id: ad.id,
|
|
|
+ channelId: ad.channelId,
|
|
|
+ adsModuleId: ad.adsModuleId,
|
|
|
+ advertiser: ad.advertiser,
|
|
|
+ title: ad.title,
|
|
|
+ adsContent: ad.adsContent,
|
|
|
+ adsCoverImg: ad.adsCoverImg,
|
|
|
+ adsUrl: ad.adsUrl,
|
|
|
+ startDt: ad.startDt,
|
|
|
+ expiryDt: ad.expiryDt,
|
|
|
+ seq: ad.seq,
|
|
|
+ status: ad.status,
|
|
|
+ createAt: ad.createAt,
|
|
|
+ updateAt: ad.updateAt,
|
|
|
+ // include adType from AdsModule so app-api can know its type
|
|
|
+ adType: ad.adsModule?.adType ?? adType ?? null,
|
|
|
+ };
|
|
|
+
|
|
|
+ await this.redis.setJson(cacheKey, cachedAd);
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `rebuildSingleAdCache: updated per-ad cache for adId=${adId}, key=${cacheKey}`,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
private async handleAdPoolAction(action: CacheSyncAction): Promise<void> {
|
|
|
- // const payload = action.payload as CachePayload | null;
|
|
|
- // const adType = payload?.type;
|
|
|
- // TODO: implement real pool rebuild logic
|
|
|
- this.logger.debug(
|
|
|
- `handleAdPoolAction placeholder, operation=${action.operation}`,
|
|
|
+ const payload = action.payload as CachePayload | null;
|
|
|
+ const adType = payload?.type;
|
|
|
+
|
|
|
+ if (!adType) {
|
|
|
+ this.logger.warn(
|
|
|
+ `handleAdPoolAction: missing adType in payload for action id=${action.id}`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const placements = ADTYPE_POOLS[adType];
|
|
|
+
|
|
|
+ if (!placements || placements.length === 0) {
|
|
|
+ this.logger.warn(
|
|
|
+ `handleAdPoolAction: no placements mapping found for adType=${adType}, action id=${action.id}`,
|
|
|
+ );
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `handleAdPoolAction: rebuilding ${placements.length} pool(s) for adType=${adType}, action id=${action.id}`,
|
|
|
+ );
|
|
|
+
|
|
|
+ for (const placement of placements) {
|
|
|
+ await this.rebuildAdPoolForPlacement(
|
|
|
+ adType,
|
|
|
+ placement.scene,
|
|
|
+ placement.slot,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async rebuildAdPoolForPlacement(
|
|
|
+ adType: string,
|
|
|
+ scene: string,
|
|
|
+ slot: string,
|
|
|
+ ): Promise<void> {
|
|
|
+ const now = this.nowBigInt();
|
|
|
+
|
|
|
+ // NOTE:
|
|
|
+ // - status: 1 means enabled
|
|
|
+ // - startDt <= now
|
|
|
+ // - expiryDt == 0 (no expiry) OR expiryDt >= now
|
|
|
+ //
|
|
|
+ // Adjust the expiry logic if your business rule is different.
|
|
|
+ const ads = await this.mongoPrisma.ads.findMany({
|
|
|
+ where: {
|
|
|
+ status: 1,
|
|
|
+ startDt: {
|
|
|
+ lte: now,
|
|
|
+ },
|
|
|
+ OR: [
|
|
|
+ { expiryDt: BigInt(0) }, // "no expiry" if you use 0 as sentinel
|
|
|
+ { expiryDt: { gte: now } },
|
|
|
+ ],
|
|
|
+ adsModule: {
|
|
|
+ adType, // join AdsModule on adType
|
|
|
+ },
|
|
|
+ },
|
|
|
+ orderBy: {
|
|
|
+ seq: 'asc', // IMPORTANT: you said Ads list must be ordered by seq
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ const poolEntries: AdPoolEntry[] = ads.map((ad) => ({
|
|
|
+ id: ad.id,
|
|
|
+ // For now use a flat weight.
|
|
|
+ // Later you can map weight from seq or a dedicated weight field.
|
|
|
+ weight: 1,
|
|
|
+ }));
|
|
|
+
|
|
|
+ const key = CacheKeys.appAdPool(scene, slot, adType);
|
|
|
+
|
|
|
+ await this.redis.setJson(key, poolEntries);
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `Rebuilt ad pool ${key} with ${poolEntries.length} ad(s) for adType=${adType}, scene=${scene}, slot=${slot}.`,
|
|
|
);
|
|
|
}
|
|
|
|