|
|
@@ -1,13 +1,10 @@
|
|
|
// box-stats-api/src/feature/stats-events/stats-aggregation.service.ts
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
|
-import { ConfigService } from '@nestjs/config';
|
|
|
import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
|
|
|
-import { RedisService } from '@box/db/redis/redis.service';
|
|
|
-import { MongoPrismaService } from '@box/db/prisma/mongo-prisma.service';
|
|
|
import { nowEpochMsBigInt } from '@box/common/time/time.util';
|
|
|
|
|
|
interface AggregationOptions {
|
|
|
- windowDays?: number; // e.g., 30 for last 30 days, undefined for all time
|
|
|
+ windowDays?: number; // kept for API compatibility, but ignored per new requirements
|
|
|
}
|
|
|
|
|
|
interface AggregationResult {
|
|
|
@@ -16,651 +13,203 @@ interface AggregationResult {
|
|
|
errorCount: number;
|
|
|
}
|
|
|
|
|
|
+type AdIdentity =
|
|
|
+ | { kind: 'adsId'; adsId: string }
|
|
|
+ | { kind: 'adId'; adId: number };
|
|
|
+
|
|
|
@Injectable()
|
|
|
export class StatsAggregationService {
|
|
|
private readonly logger = new Logger(StatsAggregationService.name);
|
|
|
|
|
|
- // Smoothed CTR parameters
|
|
|
- private readonly ctrAlpha: number;
|
|
|
- private readonly ctrBeta: number;
|
|
|
-
|
|
|
- // Scoring weights
|
|
|
- private readonly weightPopularity: number;
|
|
|
- private readonly weightCtr: number;
|
|
|
- private readonly weightRecency: number;
|
|
|
-
|
|
|
- constructor(
|
|
|
- private readonly prisma: PrismaMongoService,
|
|
|
- private readonly configService: ConfigService,
|
|
|
- private readonly redis: RedisService,
|
|
|
- private readonly mainMongo: MongoPrismaService,
|
|
|
- ) {
|
|
|
- this.ctrAlpha = this.readFiniteNumber('STATS_CTR_ALPHA', 1);
|
|
|
- this.ctrBeta = this.readFiniteNumber('STATS_CTR_BETA', 2);
|
|
|
-
|
|
|
- const wPop = this.readFiniteNumber('STATS_WEIGHT_POPULARITY', 0.5);
|
|
|
- const wCtr = this.readFiniteNumber('STATS_WEIGHT_CTR', 0.3);
|
|
|
- const wRec = this.readFiniteNumber('STATS_WEIGHT_RECENCY', 0.2);
|
|
|
-
|
|
|
- const normalized = this.normalizeWeights(wPop, wCtr, wRec);
|
|
|
-
|
|
|
- this.weightPopularity = normalized.popularity;
|
|
|
- this.weightCtr = normalized.ctr;
|
|
|
- this.weightRecency = normalized.recency;
|
|
|
-
|
|
|
- this.logger.log(
|
|
|
- `📊 Scoring config loaded: CTR(α=${this.ctrAlpha}, β=${this.ctrBeta}), ` +
|
|
|
- `Weights(pop=${this.weightPopularity.toFixed(4)}, ctr=${this.weightCtr.toFixed(
|
|
|
- 4,
|
|
|
- )}, rec=${this.weightRecency.toFixed(4)})`,
|
|
|
- );
|
|
|
- }
|
|
|
+ constructor(private readonly prisma: PrismaMongoService) {}
|
|
|
|
|
|
/**
|
|
|
- * Aggregate ad statistics from raw events.
|
|
|
- * For now, this is a batch job that recalculates everything.
|
|
|
+ * Aggregate ads click statistics from raw events.
|
|
|
+ * Click-only stats collection: writes AdsGlobalStats(clicks, firstSeenAt, lastSeenAt, createAt, updateAt).
|
|
|
+ *
|
|
|
+ * Notes:
|
|
|
+ * - No scoring / recency / popularity.
|
|
|
+ * - No Redis sync.
|
|
|
+ * - windowDays is ignored (no cutoff filtering), per new requirements.
|
|
|
*/
|
|
|
async aggregateAdsStats(
|
|
|
- options: AggregationOptions = {},
|
|
|
+ _options: AggregationOptions = {},
|
|
|
): Promise<AggregationResult> {
|
|
|
const runId = this.newRunId('ads');
|
|
|
const startedAtMs = Date.now();
|
|
|
|
|
|
- const { windowDays } = options;
|
|
|
- const cutoffTime = this.computeCutoffTime(windowDays);
|
|
|
-
|
|
|
- this.logger.log(
|
|
|
- `[${runId}] Starting ads stats aggregation (window=${windowDays ?? 'all time'}, cutoff=${cutoffTime.toString()})`,
|
|
|
- );
|
|
|
-
|
|
|
- let adIds: string[] = [];
|
|
|
- try {
|
|
|
- adIds = await this.getUniqueAdIds(cutoffTime);
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `[${runId}] Failed to load unique adIds`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- return { totalProcessed: 0, successCount: 0, errorCount: 1 };
|
|
|
- }
|
|
|
-
|
|
|
- this.logger.log(`[${runId}] Found ${adIds.length} unique ads to aggregate`);
|
|
|
-
|
|
|
- let successCount = 0;
|
|
|
- let errorCount = 0;
|
|
|
-
|
|
|
- // score stats without storing whole array
|
|
|
- let scoreMin = Number.POSITIVE_INFINITY;
|
|
|
- let scoreMax = Number.NEGATIVE_INFINITY;
|
|
|
- let scoreSum = 0;
|
|
|
- let scoreCount = 0;
|
|
|
- let zeroScoreCount = 0;
|
|
|
-
|
|
|
- for (let i = 0; i < adIds.length; i++) {
|
|
|
- const adId = adIds[i];
|
|
|
-
|
|
|
- try {
|
|
|
- const score = await this.aggregateSingleAd(adId, cutoffTime, runId);
|
|
|
-
|
|
|
- scoreCount++;
|
|
|
- scoreSum += score;
|
|
|
- if (score < scoreMin) scoreMin = score;
|
|
|
- if (score > scoreMax) scoreMax = score;
|
|
|
- if (score === 0) zeroScoreCount++;
|
|
|
-
|
|
|
- successCount++;
|
|
|
- } catch (err) {
|
|
|
- errorCount++;
|
|
|
- this.logger.error(
|
|
|
- `[${runId}] Failed to aggregate adId=${adId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- // light progress log every 500 items (helps “silent startup” / long runs)
|
|
|
- if ((i + 1) % 500 === 0) {
|
|
|
- this.logger.log(
|
|
|
- `[${runId}] Progress ${i + 1}/${adIds.length} (ok=${successCount}, err=${errorCount})`,
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const durationMs = Date.now() - startedAtMs;
|
|
|
-
|
|
|
- const stats =
|
|
|
- scoreCount > 0
|
|
|
- ? {
|
|
|
- min: Number.isFinite(scoreMin) ? scoreMin : 0,
|
|
|
- max: Number.isFinite(scoreMax) ? scoreMax : 0,
|
|
|
- avg: scoreSum / scoreCount,
|
|
|
- }
|
|
|
- : { min: 0, max: 0, avg: 0 };
|
|
|
-
|
|
|
- this.logger.log(
|
|
|
- `[${runId}] ✅ Ads aggregation complete in ${durationMs}ms: updated=${successCount}, errors=${errorCount}, ` +
|
|
|
- `scores(min=${stats.min.toFixed(4)}, max=${stats.max.toFixed(4)}, avg=${stats.avg.toFixed(
|
|
|
- 4,
|
|
|
- )}), zeroScores=${zeroScoreCount}`,
|
|
|
- );
|
|
|
-
|
|
|
- return {
|
|
|
- totalProcessed: adIds.length,
|
|
|
- successCount,
|
|
|
- errorCount,
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Aggregate video statistics from raw events.
|
|
|
- */
|
|
|
- async aggregateVideoStats(
|
|
|
- options: AggregationOptions = {},
|
|
|
- ): Promise<AggregationResult> {
|
|
|
- const runId = this.newRunId('video');
|
|
|
- const startedAtMs = Date.now();
|
|
|
-
|
|
|
- const { windowDays } = options;
|
|
|
- const cutoffTime = this.computeCutoffTime(windowDays);
|
|
|
-
|
|
|
- this.logger.log(
|
|
|
- `[${runId}] Starting video stats aggregation (window=${windowDays ?? 'all time'}, cutoff=${cutoffTime.toString()})`,
|
|
|
- );
|
|
|
+ this.logger.log(`[${runId}] Starting ads clicks aggregation (all time)`);
|
|
|
|
|
|
- let videoIds: string[] = [];
|
|
|
+ let identities: AdIdentity[] = [];
|
|
|
try {
|
|
|
- videoIds = await this.getUniqueVideoIds(cutoffTime);
|
|
|
+ identities = await this.getUniqueAdIdentities();
|
|
|
} catch (err) {
|
|
|
this.logger.error(
|
|
|
- `[${runId}] Failed to load unique videoIds`,
|
|
|
+ `[${runId}] Failed to load unique ad identities`,
|
|
|
err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
return { totalProcessed: 0, successCount: 0, errorCount: 1 };
|
|
|
}
|
|
|
|
|
|
this.logger.log(
|
|
|
- `[${runId}] Found ${videoIds.length} unique videos to aggregate`,
|
|
|
+ `[${runId}] Found ${identities.length} unique ads to aggregate`,
|
|
|
);
|
|
|
|
|
|
let successCount = 0;
|
|
|
let errorCount = 0;
|
|
|
|
|
|
- let scoreMin = Number.POSITIVE_INFINITY;
|
|
|
- let scoreMax = Number.NEGATIVE_INFINITY;
|
|
|
- let scoreSum = 0;
|
|
|
- let scoreCount = 0;
|
|
|
- let zeroScoreCount = 0;
|
|
|
-
|
|
|
- for (let i = 0; i < videoIds.length; i++) {
|
|
|
- const videoId = videoIds[i];
|
|
|
+ for (let i = 0; i < identities.length; i++) {
|
|
|
+ const idn = identities[i];
|
|
|
|
|
|
try {
|
|
|
- const score = await this.aggregateSingleVideo(
|
|
|
- videoId,
|
|
|
- cutoffTime,
|
|
|
- runId,
|
|
|
- );
|
|
|
-
|
|
|
- scoreCount++;
|
|
|
- scoreSum += score;
|
|
|
- if (score < scoreMin) scoreMin = score;
|
|
|
- if (score > scoreMax) scoreMax = score;
|
|
|
- if (score === 0) zeroScoreCount++;
|
|
|
-
|
|
|
+ await this.aggregateSingleAdClicks(idn, runId);
|
|
|
successCount++;
|
|
|
} catch (err) {
|
|
|
errorCount++;
|
|
|
this.logger.error(
|
|
|
- `[${runId}] Failed to aggregate videoId=${videoId}`,
|
|
|
+ `[${runId}] Failed to aggregate ${this.identityLog(idn)}`,
|
|
|
err instanceof Error ? err.stack : String(err),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if ((i + 1) % 500 === 0) {
|
|
|
this.logger.log(
|
|
|
- `[${runId}] Progress ${i + 1}/${videoIds.length} (ok=${successCount}, err=${errorCount})`,
|
|
|
+ `[${runId}] Progress ${i + 1}/${identities.length} (ok=${successCount}, err=${errorCount})`,
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const durationMs = Date.now() - startedAtMs;
|
|
|
|
|
|
- const stats =
|
|
|
- scoreCount > 0
|
|
|
- ? {
|
|
|
- min: Number.isFinite(scoreMin) ? scoreMin : 0,
|
|
|
- max: Number.isFinite(scoreMax) ? scoreMax : 0,
|
|
|
- avg: scoreSum / scoreCount,
|
|
|
- }
|
|
|
- : { min: 0, max: 0, avg: 0 };
|
|
|
-
|
|
|
this.logger.log(
|
|
|
- `[${runId}] ✅ Video aggregation complete in ${durationMs}ms: updated=${successCount}, errors=${errorCount}, ` +
|
|
|
- `scores(min=${stats.min.toFixed(4)}, max=${stats.max.toFixed(4)}, avg=${stats.avg.toFixed(
|
|
|
- 4,
|
|
|
- )}), zeroScores=${zeroScoreCount}`,
|
|
|
+ `[${runId}] ✅ Ads clicks aggregation complete in ${durationMs}ms: updated=${successCount}, errors=${errorCount}`,
|
|
|
);
|
|
|
|
|
|
return {
|
|
|
- totalProcessed: videoIds.length,
|
|
|
+ totalProcessed: identities.length,
|
|
|
successCount,
|
|
|
errorCount,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
- private async getUniqueAdIds(cutoffTime: bigint): Promise<string[]> {
|
|
|
+ private async getUniqueAdIdentities(): Promise<AdIdentity[]> {
|
|
|
const client = this.prisma as any;
|
|
|
|
|
|
- const clickAdIds = await client.adClickEvents.findMany({
|
|
|
- where: { clickedAt: { gte: cutoffTime } },
|
|
|
- select: { adId: true },
|
|
|
- distinct: ['adId'],
|
|
|
+ // We do 2 distinct queries to support both identifiers cleanly.
|
|
|
+ const byAdsId = await client.adClickEvents.findMany({
|
|
|
+ where: { adsId: { not: null } },
|
|
|
+ select: { adsId: true },
|
|
|
+ distinct: ['adsId'],
|
|
|
});
|
|
|
|
|
|
- const impressionAdIds = await client.adImpressionEvents.findMany({
|
|
|
- where: { impressionAt: { gte: cutoffTime } },
|
|
|
+ const byAdId = await client.adClickEvents.findMany({
|
|
|
+ where: { adId: { not: null } },
|
|
|
select: { adId: true },
|
|
|
distinct: ['adId'],
|
|
|
});
|
|
|
|
|
|
- const allAdIds = new Set<string>();
|
|
|
- clickAdIds.forEach((item: any) => item?.adId && allAdIds.add(item.adId));
|
|
|
- impressionAdIds.forEach(
|
|
|
- (item: any) => item?.adId && allAdIds.add(item.adId),
|
|
|
- );
|
|
|
+ const identities: AdIdentity[] = [];
|
|
|
|
|
|
- return Array.from(allAdIds);
|
|
|
- }
|
|
|
+ for (const row of byAdsId) {
|
|
|
+ const adsId = row?.adsId as string | undefined;
|
|
|
+ if (adsId) identities.push({ kind: 'adsId', adsId });
|
|
|
+ }
|
|
|
|
|
|
- private async getUniqueVideoIds(cutoffTime: bigint): Promise<string[]> {
|
|
|
- const client = this.prisma as any;
|
|
|
+ for (const row of byAdId) {
|
|
|
+ const adId = row?.adId as number | undefined;
|
|
|
+ if (adId !== undefined && adId !== null)
|
|
|
+ identities.push({ kind: 'adId', adId });
|
|
|
+ }
|
|
|
|
|
|
- const videoIds = await client.videoClickEvents.findMany({
|
|
|
- where: { clickedAt: { gte: cutoffTime } },
|
|
|
- select: { videoId: true },
|
|
|
- distinct: ['videoId'],
|
|
|
+ // De-dupe in case some events contain both ids.
|
|
|
+ const seen = new Set<string>();
|
|
|
+ return identities.filter((idn) => {
|
|
|
+ const key = this.identityKey(idn);
|
|
|
+ if (seen.has(key)) return false;
|
|
|
+ seen.add(key);
|
|
|
+ return true;
|
|
|
});
|
|
|
-
|
|
|
- return videoIds.map((item: any) => item.videoId).filter(Boolean);
|
|
|
}
|
|
|
|
|
|
- private async aggregateSingleAd(
|
|
|
- adId: string,
|
|
|
- cutoffTime: bigint,
|
|
|
+ private async aggregateSingleAdClicks(
|
|
|
+ idn: AdIdentity,
|
|
|
runId: string,
|
|
|
- ): Promise<number> {
|
|
|
+ ): Promise<void> {
|
|
|
const client = this.prisma as any;
|
|
|
|
|
|
- // Count clicks
|
|
|
+ const whereId =
|
|
|
+ idn.kind === 'adsId' ? { adsId: idn.adsId } : { adId: idn.adId };
|
|
|
+
|
|
|
const clicks = await client.adClickEvents.count({
|
|
|
- where: {
|
|
|
- adId,
|
|
|
- clickedAt: { gte: cutoffTime },
|
|
|
- },
|
|
|
+ where: whereId,
|
|
|
});
|
|
|
|
|
|
- // Count impressions
|
|
|
- const impressions = await client.adImpressionEvents.count({
|
|
|
- where: {
|
|
|
- adId,
|
|
|
- impressionAt: { gte: cutoffTime },
|
|
|
- },
|
|
|
- });
|
|
|
+ if (clicks <= 0) {
|
|
|
+ // No clicks: nothing to write/update
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- // Get first/last seen times for clicks
|
|
|
- const clickTimes = await client.adClickEvents.findMany({
|
|
|
- where: { adId, clickedAt: { gte: cutoffTime } },
|
|
|
+ // Find first/last click time (epoch ms BigInt)
|
|
|
+ const firstRow = await client.adClickEvents.findFirst({
|
|
|
+ where: whereId,
|
|
|
select: { clickedAt: true },
|
|
|
orderBy: { clickedAt: 'asc' },
|
|
|
});
|
|
|
|
|
|
- const impressionTimes = await client.adImpressionEvents.findMany({
|
|
|
- where: { adId, impressionAt: { gte: cutoffTime } },
|
|
|
- select: { impressionAt: true },
|
|
|
- orderBy: { impressionAt: 'asc' },
|
|
|
- });
|
|
|
-
|
|
|
- const allTimes: bigint[] = [
|
|
|
- ...clickTimes.map((t: any) => t.clickedAt).filter((v: any) => v != null),
|
|
|
- ...impressionTimes
|
|
|
- .map((t: any) => t.impressionAt)
|
|
|
- .filter((v: any) => v != null),
|
|
|
- ];
|
|
|
-
|
|
|
- if (allTimes.length === 0) return 0;
|
|
|
-
|
|
|
- const firstSeenAt = allTimes.reduce((min, val) => (val < min ? val : min));
|
|
|
- const lastSeenAt = allTimes.reduce((max, val) => (val > max ? val : max));
|
|
|
-
|
|
|
- const computedPopularity = this.computePopularity(impressions);
|
|
|
- const computedCtr = this.computeCtr(clicks, impressions);
|
|
|
- const computedRecency = this.computeRecency(firstSeenAt);
|
|
|
- const computedScore = this.computeScore(
|
|
|
- computedPopularity,
|
|
|
- computedCtr,
|
|
|
- computedRecency,
|
|
|
- );
|
|
|
-
|
|
|
- const now = nowEpochMsBigInt();
|
|
|
-
|
|
|
- await client.adsGlobalStats.upsert({
|
|
|
- where: { adId },
|
|
|
- update: {
|
|
|
- impressions: BigInt(impressions),
|
|
|
- clicks: BigInt(clicks),
|
|
|
- lastSeenAt,
|
|
|
- computedCtr,
|
|
|
- computedPopularity,
|
|
|
- computedRecency,
|
|
|
- computedScore,
|
|
|
- updateAt: now,
|
|
|
- },
|
|
|
- create: {
|
|
|
- adId,
|
|
|
- impressions: BigInt(impressions),
|
|
|
- clicks: BigInt(clicks),
|
|
|
- firstSeenAt,
|
|
|
- lastSeenAt,
|
|
|
- computedCtr,
|
|
|
- computedPopularity,
|
|
|
- computedRecency,
|
|
|
- computedScore,
|
|
|
- createAt: now,
|
|
|
- updateAt: now,
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `[${runId}] adId=${adId} imp=${impressions} clk=${clicks} ctr=${computedCtr.toFixed(
|
|
|
- 4,
|
|
|
- )} score=${computedScore.toFixed(4)}`,
|
|
|
- );
|
|
|
-
|
|
|
- await this.syncAdScoreToRedis(adId, computedScore, runId);
|
|
|
-
|
|
|
- return computedScore;
|
|
|
- }
|
|
|
-
|
|
|
- private async aggregateSingleVideo(
|
|
|
- videoId: string,
|
|
|
- cutoffTime: bigint,
|
|
|
- runId: string,
|
|
|
- ): Promise<number> {
|
|
|
- const client = this.prisma as any;
|
|
|
-
|
|
|
- // Count clicks (no impressions for videos yet)
|
|
|
- const clicks = await client.videoClickEvents.count({
|
|
|
- where: {
|
|
|
- videoId,
|
|
|
- clickedAt: { gte: cutoffTime },
|
|
|
- },
|
|
|
- });
|
|
|
-
|
|
|
- const impressions = clicks;
|
|
|
-
|
|
|
- const clickTimes = await client.videoClickEvents.findMany({
|
|
|
- where: { videoId, clickedAt: { gte: cutoffTime } },
|
|
|
+ const lastRow = await client.adClickEvents.findFirst({
|
|
|
+ where: whereId,
|
|
|
select: { clickedAt: true },
|
|
|
- orderBy: { clickedAt: 'asc' },
|
|
|
+ orderBy: { clickedAt: 'desc' },
|
|
|
});
|
|
|
|
|
|
- if (clickTimes.length === 0) return 0;
|
|
|
-
|
|
|
- const allTimes: bigint[] = clickTimes
|
|
|
- .map((t: any) => t.clickedAt)
|
|
|
- .filter((v: any) => v != null);
|
|
|
-
|
|
|
- if (allTimes.length === 0) return 0;
|
|
|
-
|
|
|
- const firstSeenAt = allTimes.reduce((min, val) => (val < min ? val : min));
|
|
|
- const lastSeenAt = allTimes.reduce((max, val) => (val > max ? val : max));
|
|
|
+ const firstSeenAt = firstRow?.clickedAt as bigint | undefined;
|
|
|
+ const lastSeenAt = lastRow?.clickedAt as bigint | undefined;
|
|
|
|
|
|
- const computedPopularity = this.computePopularity(impressions);
|
|
|
- const computedCtr = this.computeCtr(clicks, impressions);
|
|
|
- const computedRecency = this.computeRecency(firstSeenAt);
|
|
|
- const computedScore = this.computeScore(
|
|
|
- computedPopularity,
|
|
|
- computedCtr,
|
|
|
- computedRecency,
|
|
|
- );
|
|
|
+ if (!firstSeenAt || !lastSeenAt) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
const now = nowEpochMsBigInt();
|
|
|
|
|
|
- await client.videoGlobalStats.upsert({
|
|
|
- where: { videoId },
|
|
|
+ // Upsert AdsGlobalStats by identity.
|
|
|
+ // IMPORTANT: AdsGlobalStats.adsId and AdsGlobalStats.adId should be @unique to allow upsert.
|
|
|
+ const whereGlobal =
|
|
|
+ idn.kind === 'adsId' ? { adsId: idn.adsId } : { adId: idn.adId };
|
|
|
+
|
|
|
+ await client.adsGlobalStats.upsert({
|
|
|
+ where: whereGlobal,
|
|
|
update: {
|
|
|
- impressions: BigInt(impressions),
|
|
|
clicks: BigInt(clicks),
|
|
|
lastSeenAt,
|
|
|
- computedCtr,
|
|
|
- computedPopularity,
|
|
|
- computedRecency,
|
|
|
- computedScore,
|
|
|
updateAt: now,
|
|
|
+ // impressions kept but not used in click-only mode
|
|
|
},
|
|
|
create: {
|
|
|
- videoId,
|
|
|
- impressions: BigInt(impressions),
|
|
|
+ adsId: idn.kind === 'adsId' ? idn.adsId : undefined,
|
|
|
+ adId: idn.kind === 'adId' ? idn.adId : undefined,
|
|
|
+ impressions: BigInt(0),
|
|
|
clicks: BigInt(clicks),
|
|
|
firstSeenAt,
|
|
|
lastSeenAt,
|
|
|
- computedCtr,
|
|
|
- computedPopularity,
|
|
|
- computedRecency,
|
|
|
- computedScore,
|
|
|
createAt: now,
|
|
|
updateAt: now,
|
|
|
},
|
|
|
});
|
|
|
|
|
|
this.logger.debug(
|
|
|
- `[${runId}] videoId=${videoId} imp=${impressions} clk=${clicks} ctr=${computedCtr.toFixed(
|
|
|
- 4,
|
|
|
- )} score=${computedScore.toFixed(4)}`,
|
|
|
+ `[${runId}] ${this.identityLog(idn)} clicks=${clicks} first=${firstSeenAt.toString()} last=${lastSeenAt.toString()}`,
|
|
|
);
|
|
|
-
|
|
|
- await this.syncVideoScoreToRedis(videoId, computedScore, runId);
|
|
|
-
|
|
|
- return computedScore;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Popularity score based on impressions (reach).
|
|
|
- * Formula: log(1 + impressions)
|
|
|
- */
|
|
|
- private computePopularity(impressions: number): number {
|
|
|
- return Math.log(1 + Math.max(0, impressions));
|
|
|
+ private identityKey(idn: AdIdentity): string {
|
|
|
+ return idn.kind === 'adsId' ? `adsId:${idn.adsId}` : `adId:${idn.adId}`;
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Smoothed CTR
|
|
|
- * Formula: (clicks + alpha) / (impressions + beta)
|
|
|
- */
|
|
|
- private computeCtr(clicks: number, impressions: number): number {
|
|
|
- const c = Math.max(0, clicks);
|
|
|
- const i = Math.max(0, impressions);
|
|
|
- const denom = i + this.ctrBeta;
|
|
|
-
|
|
|
- // denom should never be 0 because beta defaults to 2, but guard anyway
|
|
|
- if (!Number.isFinite(denom) || denom <= 0) return 0;
|
|
|
-
|
|
|
- return (c + this.ctrAlpha) / denom;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Recency score based on age since first appearance.
|
|
|
- * Formula: 1 / (1 + ageDays)
|
|
|
- */
|
|
|
- private computeRecency(firstSeenAt: bigint): number {
|
|
|
- const now = nowEpochMsBigInt();
|
|
|
-
|
|
|
- // If clocks/time data is weird, avoid negative ages blowing up score
|
|
|
- let ageMsBig = now - firstSeenAt;
|
|
|
- if (ageMsBig < BigInt(0)) ageMsBig = BigInt(0);
|
|
|
-
|
|
|
- // Convert safely to number for division; clamp if extremely large
|
|
|
- const ageMs = this.bigIntToSafeNumber(ageMsBig);
|
|
|
-
|
|
|
- const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
|
- return 1 / (1 + ageDays);
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Composite score: w1 * popularity + w2 * ctr + w3 * recency
|
|
|
- */
|
|
|
- private computeScore(
|
|
|
- popularity: number,
|
|
|
- ctr: number,
|
|
|
- recency: number,
|
|
|
- ): number {
|
|
|
- return (
|
|
|
- this.weightPopularity * popularity +
|
|
|
- this.weightCtr * ctr +
|
|
|
- this.weightRecency * recency
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- private async syncAdScoreToRedis(
|
|
|
- adId: string,
|
|
|
- score: number,
|
|
|
- runId: string,
|
|
|
- ): Promise<void> {
|
|
|
- try {
|
|
|
- const client = (this.redis as any).ensureClient();
|
|
|
- await client.zadd('ads:global:score', score, adId);
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `[${runId}] Redis sync adId=${adId} score=${score.toFixed(4)} ok`,
|
|
|
- );
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `[${runId}] Redis sync FAILED for adId=${adId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- private async syncVideoScoreToRedis(
|
|
|
- videoId: string,
|
|
|
- score: number,
|
|
|
- runId: string,
|
|
|
- ): Promise<void> {
|
|
|
- try {
|
|
|
- const client = (this.redis as any).ensureClient();
|
|
|
-
|
|
|
- await client.zadd('video:global:score', score, videoId);
|
|
|
-
|
|
|
- const video = await this.mainMongo.videoMedia.findUnique({
|
|
|
- where: { id: videoId },
|
|
|
- select: { tagIds: true },
|
|
|
- });
|
|
|
-
|
|
|
- const tagIds = video?.tagIds ?? [];
|
|
|
- if (Array.isArray(tagIds) && tagIds.length > 0) {
|
|
|
- for (const tagId of tagIds) {
|
|
|
- await client.zadd(`video:tag:${tagId}:score`, score, videoId);
|
|
|
- }
|
|
|
-
|
|
|
- this.logger.debug(
|
|
|
- `[${runId}] Redis sync videoId=${videoId} score=${score.toFixed(
|
|
|
- 4,
|
|
|
- )} ok (tags=${tagIds.length})`,
|
|
|
- );
|
|
|
- } else {
|
|
|
- this.logger.debug(
|
|
|
- `[${runId}] Redis sync videoId=${videoId} score=${score.toFixed(
|
|
|
- 4,
|
|
|
- )} ok (no tags)`,
|
|
|
- );
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- this.logger.error(
|
|
|
- `[${runId}] Redis sync FAILED for videoId=${videoId}`,
|
|
|
- err instanceof Error ? err.stack : String(err),
|
|
|
- );
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // -------------------------
|
|
|
- // helpers (config + safety)
|
|
|
- // -------------------------
|
|
|
-
|
|
|
- private computeCutoffTime(windowDays?: number): bigint {
|
|
|
- if (windowDays === undefined) return BigInt(0);
|
|
|
-
|
|
|
- const days = Number(windowDays);
|
|
|
- if (!Number.isFinite(days) || days <= 0) return BigInt(0);
|
|
|
-
|
|
|
- const ms = BigInt(Math.floor(days * 24 * 60 * 60 * 1000));
|
|
|
- const now = nowEpochMsBigInt();
|
|
|
- const cutoff = now - ms;
|
|
|
-
|
|
|
- return cutoff > BigInt(0) ? cutoff : BigInt(0);
|
|
|
- }
|
|
|
-
|
|
|
- private readFiniteNumber(key: string, fallback: number): number {
|
|
|
- const raw = this.configService.get<string>(key);
|
|
|
-
|
|
|
- if (raw == null || raw.trim() === '') return fallback;
|
|
|
-
|
|
|
- const n = Number.parseFloat(raw.trim());
|
|
|
- if (!Number.isFinite(n)) {
|
|
|
- this.logger.warn(
|
|
|
- `Config ${key}="${raw}" is not a finite number; using ${fallback}`,
|
|
|
- );
|
|
|
- return fallback;
|
|
|
- }
|
|
|
-
|
|
|
- return n;
|
|
|
- }
|
|
|
-
|
|
|
- private normalizeWeights(
|
|
|
- popularity: number,
|
|
|
- ctr: number,
|
|
|
- recency: number,
|
|
|
- ): {
|
|
|
- popularity: number;
|
|
|
- ctr: number;
|
|
|
- recency: number;
|
|
|
- } {
|
|
|
- const wp = Number.isFinite(popularity) && popularity >= 0 ? popularity : 0;
|
|
|
- const wc = Number.isFinite(ctr) && ctr >= 0 ? ctr : 0;
|
|
|
- const wr = Number.isFinite(recency) && recency >= 0 ? recency : 0;
|
|
|
-
|
|
|
- const sum = wp + wc + wr;
|
|
|
-
|
|
|
- if (sum <= 0) {
|
|
|
- this.logger.warn(
|
|
|
- `Invalid weights (sum<=0). Falling back to defaults (0.5,0.3,0.2)`,
|
|
|
- );
|
|
|
- return { popularity: 0.5, ctr: 0.3, recency: 0.2 };
|
|
|
- }
|
|
|
-
|
|
|
- // Normalize to sum=1 so config mistakes don’t blow up scoring scale
|
|
|
- const np = wp / sum;
|
|
|
- const nc = wc / sum;
|
|
|
- const nr = wr / sum;
|
|
|
-
|
|
|
- if (Math.abs(sum - 1) > 1e-9) {
|
|
|
- this.logger.warn(
|
|
|
- `Weights normalized from (pop=${wp}, ctr=${wc}, rec=${wr}, sum=${sum}) to ` +
|
|
|
- `(pop=${np.toFixed(4)}, ctr=${nc.toFixed(4)}, rec=${nr.toFixed(4)})`,
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- return { popularity: np, ctr: nc, recency: nr };
|
|
|
- }
|
|
|
-
|
|
|
- private bigIntToSafeNumber(v: bigint): number {
|
|
|
- const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
|
- if (v <= BigInt(0)) return 0;
|
|
|
-
|
|
|
- if (v > MAX_SAFE) return Number.MAX_SAFE_INTEGER;
|
|
|
-
|
|
|
- return Number(v);
|
|
|
+ private identityLog(idn: AdIdentity): string {
|
|
|
+ return idn.kind === 'adsId' ? `adsId=${idn.adsId}` : `adId=${idn.adId}`;
|
|
|
}
|
|
|
|
|
|
private newRunId(prefix: string): string {
|
|
|
- // tiny unique-ish id for logs
|
|
|
const ts = Date.now().toString(36);
|
|
|
const rnd = Math.floor(Math.random() * 1_000_000)
|
|
|
.toString(36)
|