|
|
@@ -0,0 +1,230 @@
|
|
|
+// box-stats-api/src/feature/stats-events/stats.hourly.aggregation.service.ts
|
|
|
+import { Injectable, Logger } from '@nestjs/common';
|
|
|
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
|
|
|
+import { computeHourlyWindowUtcSec } from '../time-helper';
|
|
|
+
|
|
|
+type AdsHourlyAggRow = {
|
|
|
+ adsId: string;
|
|
|
+ hourStartAt: number; // seconds
|
|
|
+ clicks: number;
|
|
|
+};
|
|
|
+
|
|
|
+type ChannelHourlyAggRow = {
|
|
|
+ channelId: string;
|
|
|
+ hourStartAt: number; // seconds
|
|
|
+ total: number;
|
|
|
+ uniqueUsers: number;
|
|
|
+};
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class StatsHourlyAggregationService {
|
|
|
+ private readonly logger = new Logger(StatsHourlyAggregationService.name);
|
|
|
+
|
|
|
+ constructor(private readonly prisma: PrismaMongoService) {}
|
|
|
+
|
|
|
+ async aggregateAdsHourly(startSec: number, endSec: number): Promise<number> {
|
|
|
+ const pipeline = [
|
|
|
+ {
|
|
|
+ $match: {
|
|
|
+ adsId: { $ne: null },
|
|
|
+ $expr: {
|
|
|
+ $and: [
|
|
|
+ { $gte: [{ $toLong: '$clickedAt' }, startSec] },
|
|
|
+ { $lt: [{ $toLong: '$clickedAt' }, endSec] },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ $project: {
|
|
|
+ adsId: { $toString: '$adsId' },
|
|
|
+ // Convert seconds -> Date(ms), truncate to hour in GMT+8, convert back to seconds
|
|
|
+ hourStartAt: {
|
|
|
+ $toLong: {
|
|
|
+ $divide: [
|
|
|
+ {
|
|
|
+ $toLong: {
|
|
|
+ $dateTrunc: {
|
|
|
+ date: { $toDate: { $multiply: ['$clickedAt', 1000] } },
|
|
|
+ unit: 'hour',
|
|
|
+ timezone: '+08:00',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ 1000,
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ $group: {
|
|
|
+ _id: { adsId: '$adsId', hourStartAt: '$hourStartAt' },
|
|
|
+ clicks: { $sum: 1 },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ $project: {
|
|
|
+ _id: 0,
|
|
|
+ adsId: '$_id.adsId',
|
|
|
+ hourStartAt: '$_id.hourStartAt',
|
|
|
+ clicks: 1,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ] as const;
|
|
|
+
|
|
|
+ const rows = (await (this.prisma.adClickEvents as any).aggregateRaw({
|
|
|
+ pipeline,
|
|
|
+ })) as AdsHourlyAggRow[];
|
|
|
+
|
|
|
+ const nowSec = Math.floor(Date.now() / 1000);
|
|
|
+
|
|
|
+ for (const row of rows) {
|
|
|
+ await this.prisma.adsHourlyStats.upsert({
|
|
|
+ where: {
|
|
|
+ adsId_hourStartAt: {
|
|
|
+ adsId: row.adsId,
|
|
|
+ hourStartAt: BigInt(row.hourStartAt),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ update: {
|
|
|
+ clicks: BigInt(row.clicks),
|
|
|
+ updateAt: BigInt(nowSec),
|
|
|
+ },
|
|
|
+ create: {
|
|
|
+ adsId: row.adsId,
|
|
|
+ hourStartAt: BigInt(row.hourStartAt),
|
|
|
+ clicks: BigInt(row.clicks),
|
|
|
+ createAt: BigInt(nowSec),
|
|
|
+ updateAt: BigInt(nowSec),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `AdsHourly aggregation: window=[${startSec},${endSec}) buckets=${rows.length}`,
|
|
|
+ );
|
|
|
+ return rows.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ async aggregateChannelHourly(
|
|
|
+ startSec: number,
|
|
|
+ endSec: number,
|
|
|
+ ): Promise<number> {
|
|
|
+ const pipeline = [
|
|
|
+ {
|
|
|
+ $match: {
|
|
|
+ $expr: {
|
|
|
+ $and: [
|
|
|
+ { $gte: [{ $toLong: '$createAt' }, startSec] },
|
|
|
+ { $lt: [{ $toLong: '$createAt' }, endSec] },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ $project: {
|
|
|
+ channelId: 1,
|
|
|
+ uid: 1,
|
|
|
+ hourStartAt: {
|
|
|
+ $toLong: {
|
|
|
+ $divide: [
|
|
|
+ {
|
|
|
+ $toLong: {
|
|
|
+ $dateTrunc: {
|
|
|
+ date: { $toDate: { $multiply: ['$createAt', 1000] } },
|
|
|
+ unit: 'hour',
|
|
|
+ timezone: '+08:00',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ 1000,
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ // 1) unique key per user per channel per hour
|
|
|
+ {
|
|
|
+ $group: {
|
|
|
+ _id: {
|
|
|
+ channelId: '$channelId',
|
|
|
+ hourStartAt: '$hourStartAt',
|
|
|
+ uid: '$uid',
|
|
|
+ },
|
|
|
+ cnt: { $sum: 1 },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ // 2) roll up to channel/hour
|
|
|
+ {
|
|
|
+ $group: {
|
|
|
+ _id: {
|
|
|
+ channelId: '$_id.channelId',
|
|
|
+ hourStartAt: '$_id.hourStartAt',
|
|
|
+ },
|
|
|
+ total: { $sum: '$cnt' },
|
|
|
+ uniqueUsers: { $sum: 1 },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ $project: {
|
|
|
+ _id: 0,
|
|
|
+ channelId: '$_id.channelId',
|
|
|
+ hourStartAt: '$_id.hourStartAt',
|
|
|
+ total: 1,
|
|
|
+ uniqueUsers: 1,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ] as const;
|
|
|
+
|
|
|
+ const rows = (await (this.prisma.userLoginHistory as any).aggregateRaw({
|
|
|
+ pipeline,
|
|
|
+ })) as ChannelHourlyAggRow[];
|
|
|
+
|
|
|
+ const nowSec = Math.floor(Date.now() / 1000);
|
|
|
+
|
|
|
+ for (const row of rows) {
|
|
|
+ await this.prisma.channelHourlyUserStats.upsert({
|
|
|
+ where: {
|
|
|
+ channelId_hourStartAt: {
|
|
|
+ channelId: row.channelId,
|
|
|
+ hourStartAt: BigInt(row.hourStartAt),
|
|
|
+ },
|
|
|
+ },
|
|
|
+ update: {
|
|
|
+ total: BigInt(row.total),
|
|
|
+ uniqueUsers: BigInt(row.uniqueUsers),
|
|
|
+ updateAt: BigInt(nowSec),
|
|
|
+ },
|
|
|
+ create: {
|
|
|
+ channelId: row.channelId,
|
|
|
+ hourStartAt: BigInt(row.hourStartAt),
|
|
|
+ total: BigInt(row.total),
|
|
|
+ uniqueUsers: BigInt(row.uniqueUsers),
|
|
|
+ createAt: BigInt(nowSec),
|
|
|
+ updateAt: BigInt(nowSec),
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `ChannelHourly aggregation: window=[${startSec},${endSec}) buckets=${rows.length}`,
|
|
|
+ );
|
|
|
+ return rows.length;
|
|
|
+ }
|
|
|
+
|
|
|
+ async aggregateRecentHourly(lookbackHours = 2): Promise<void> {
|
|
|
+ const nowSec = Math.floor(Date.now() / 1000);
|
|
|
+ const { startSec, endSec } = computeHourlyWindowUtcSec(
|
|
|
+ nowSec,
|
|
|
+ lookbackHours,
|
|
|
+ );
|
|
|
+
|
|
|
+ await this.aggregateAdsHourly(startSec, endSec);
|
|
|
+ await this.aggregateChannelHourly(startSec, endSec);
|
|
|
+
|
|
|
+ this.logger.log(
|
|
|
+ `Hourly aggregation window computed via time-helper: lookback=${lookbackHours} window=[${startSec},${endSec})`,
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|