Просмотр исходного кода

feat: implement JWT authentication for ads URL retrieval and publish click events

Dave 3 месяцев назад
Родитель
Сommit
141c5d36ee

+ 128 - 3
apps/box-app-api/src/feature/ads/ad.controller.ts

@@ -1,17 +1,52 @@
 // apps/box-app-api/src/feature/ads/ad.controller.ts
-import { Controller, Get, Logger, Query, Post, Body } from '@nestjs/common';
-import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
+import {
+  Controller,
+  Get,
+  Logger,
+  Query,
+  Post,
+  Body,
+  Param,
+  UseGuards,
+  Req,
+  NotFoundException,
+} from '@nestjs/common';
+import {
+  ApiOperation,
+  ApiResponse,
+  ApiTags,
+  ApiBearerAuth,
+} from '@nestjs/swagger';
+import { Request } from 'express';
 import { AdService } from './ad.service';
 import { GetAdPlacementQueryDto } from './dto/get-ad-placement.dto';
 import { AdDto } from './dto/ad.dto';
 import { AdListRequestDto, AdListResponseDto } from './dto';
+import { AdUrlResponseDto } from './dto/ad-url-response.dto';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
+import { RabbitmqPublisherService } from '../../rabbitmq/rabbitmq-publisher.service';
+import { AdsClickEventPayload } from '@box/common/events/ads-click-event.dto';
+
+// Extend Express Request to include user from JWT
+interface JwtUser {
+  uid: string;
+  sub: string;
+  jti: string;
+}
+
+interface RequestWithUser extends Request {
+  user: JwtUser;
+}
 
 @ApiTags('广告')
 @Controller('ads')
 export class AdController {
   private readonly logger = new Logger(AdController.name);
 
-  constructor(private readonly adService: AdService) {}
+  constructor(
+    private readonly adService: AdService,
+    private readonly rabbitmqPublisher: RabbitmqPublisherService,
+  ) {}
 
   /**
    * GET /ads/placement
@@ -89,4 +124,94 @@ export class AdController {
 
     return response;
   }
+
+  /**
+   * GET /ads/:id/url
+   *
+   * Protected endpoint that requires JWT authentication.
+   * Returns the ad URL for the given ad ID and publishes an ADS_CLICK event.
+   *
+   * Example: GET /ads/507f1f77bcf86cd799439011/url
+   */
+  @Get(':id/url')
+  @UseGuards(JwtAuthGuard)
+  @ApiBearerAuth()
+  @ApiOperation({
+    summary: '获取广告URL',
+    description:
+      '通过广告ID获取广告链接。需要JWT认证。返回广告URL并记录点击事件。',
+  })
+  @ApiResponse({
+    status: 200,
+    description: '成功返回广告URL',
+    type: AdUrlResponseDto,
+  })
+  @ApiResponse({ status: 401, description: '未授权 - 需要JWT token' })
+  @ApiResponse({ status: 404, description: '广告不存在或已过期' })
+  async getAdUrl(
+    @Param('id') adsId: string,
+    @Req() req: RequestWithUser,
+  ): Promise<AdUrlResponseDto> {
+    const uid = req.user?.uid;
+
+    if (!uid) {
+      this.logger.error('JWT payload missing uid');
+      throw new NotFoundException('User not authenticated');
+    }
+
+    // Load and validate the ad
+    const ad = await this.adService.getAdByIdValidated(adsId);
+
+    if (!ad) {
+      throw new NotFoundException(
+        'Ad not found, disabled, or outside valid date range',
+      );
+    }
+
+    // Extract client IP
+    const ip =
+      (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
+      (req.headers['x-real-ip'] as string) ||
+      req.ip ||
+      'unknown';
+
+    // Extract optional headers (you can adjust these based on your client setup)
+    const userAgent = req.headers['user-agent'];
+    const appVersion = req.headers['app-version'] as string | undefined;
+    const os = req.headers['os'] as string | undefined;
+
+    // Publish ADS_CLICK event
+    const clickEvent: AdsClickEventPayload = {
+      adsId: ad.id,
+      adType: ad.adType,
+      channelId: ad.channelId,
+      adsModuleId: ad.adsModuleId,
+      uid,
+      ip,
+      appVersion,
+      os,
+      clickAt: Date.now(), // BigInt epoch milliseconds
+    };
+
+    try {
+      this.logger.log(
+        `Publishing ads.click event for adsId=${adsId}, uid=${uid}`,
+      );
+      await this.rabbitmqPublisher.publishAdsClick(clickEvent);
+    } catch (error) {
+      // Log error but don't fail the request
+      const errorMessage =
+        error instanceof Error ? error.message : String(error);
+      const errorStack = error instanceof Error ? error.stack : undefined;
+      this.logger.error(
+        `Failed to publish ads.click event for adsId=${adsId}: ${errorMessage}`,
+        errorStack,
+      );
+    }
+
+    return {
+      adsId: ad.id,
+      adsUrl: ad.adsUrl,
+    };
+  }
 }

+ 4 - 0
apps/box-app-api/src/feature/ads/ad.module.ts

@@ -4,11 +4,15 @@ import { RedisModule } from '@box/db/redis/redis.module';
 import { SharedModule } from '@box/db/shared.module';
 import { AdService } from './ad.service';
 import { AdController } from './ad.controller';
+import { AuthModule } from '../auth/auth.module';
+import { RabbitmqModule } from '../../rabbitmq/rabbitmq.module';
 
 @Module({
   imports: [
     RedisModule, // 👈 make RedisService available here
     SharedModule, // 👈 make MongoPrismaService available here
+    AuthModule, // 👈 make JwtAuthGuard available here
+    RabbitmqModule, // 👈 make RabbitmqPublisherService available here
   ],
   providers: [AdService],
   controllers: [AdController],

+ 66 - 0
apps/box-app-api/src/feature/ads/ad.service.ts

@@ -350,4 +350,70 @@ export class AdService {
       items,
     };
   }
+
+  /**
+   * Get an ad by ID and validate it's enabled and within date range.
+   * Returns the ad with its relationships (channel, adsModule) loaded.
+   * Returns null if ad is not found, disabled, or outside date range.
+   */
+  async getAdByIdValidated(adsId: string): Promise<{
+    id: string;
+    channelId: string;
+    adsModuleId: string;
+    adType: string;
+    adsUrl: string | null;
+    advertiser: string;
+    title: string;
+  } | null> {
+    const now = BigInt(Date.now());
+
+    try {
+      const ad = await this.mongoPrisma.ads.findUnique({
+        where: { id: adsId },
+        include: {
+          channel: { select: { id: true } },
+          adsModule: { select: { id: true, adType: true } },
+        },
+      });
+
+      if (!ad) {
+        this.logger.debug(`Ad not found: adsId=${adsId}`);
+        return null;
+      }
+
+      // Validate status (1 = enabled)
+      if (ad.status !== 1) {
+        this.logger.debug(`Ad is disabled: adsId=${adsId}`);
+        return null;
+      }
+
+      // Validate date range
+      if (ad.startDt > now) {
+        this.logger.debug(`Ad not started yet: adsId=${adsId}`);
+        return null;
+      }
+
+      // If expiryDt is 0, it means no expiry; otherwise check if expired
+      if (ad.expiryDt !== BigInt(0) && ad.expiryDt < now) {
+        this.logger.debug(`Ad expired: adsId=${adsId}`);
+        return null;
+      }
+
+      return {
+        id: ad.id,
+        channelId: ad.channelId,
+        adsModuleId: ad.adsModuleId,
+        adType: ad.adsModule.adType,
+        adsUrl: ad.adsUrl,
+        advertiser: ad.advertiser,
+        title: ad.title,
+      };
+    } catch (err) {
+      this.logger.error(
+        `Error fetching ad by ID: adsId=${adsId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      return null;
+    }
+  }
 }

+ 14 - 0
apps/box-app-api/src/feature/ads/dto/ad-url-response.dto.ts

@@ -0,0 +1,14 @@
+// apps/box-app-api/src/feature/ads/dto/ad-url-response.dto.ts
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AdUrlResponseDto {
+  @ApiProperty({ description: '广告ID', example: '507f1f77bcf86cd799439011' })
+  adsId: string;
+
+  @ApiProperty({
+    description: '广告链接URL',
+    example: 'https://example.com/ad',
+    nullable: true,
+  })
+  adsUrl: string | null;
+}

+ 1 - 0
apps/box-app-api/src/feature/ads/dto/index.ts

@@ -4,3 +4,4 @@ export { GetAdPlacementQueryDto } from './get-ad-placement.dto';
 export { AdListRequestDto, AdTypeEnum } from './ad-list-request.dto';
 export { AdItemDto } from './ad-item.dto';
 export { AdListResponseDto } from './ad-list-response.dto';
+export { AdUrlResponseDto } from './ad-url-response.dto';

+ 6 - 2
apps/box-app-api/src/feature/auth/auth.module.ts

@@ -1,17 +1,21 @@
 import { Module } from '@nestjs/common';
 import { JwtModule } from '@nestjs/jwt';
+import { PassportModule } from '@nestjs/passport';
 import { ConfigModule, ConfigService } from '@nestjs/config';
 import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
 import { RabbitmqModule } from '../../rabbitmq/rabbitmq.module';
 import { AuthController } from './auth.controller';
 import { AuthService } from './auth.service';
 import { CoreModule } from '@box/core/core.module';
+import { JwtStrategy } from './strategies/jwt.strategy';
+import { JwtAuthGuard } from './guards/jwt-auth.guard';
 
 @Module({
   imports: [
     PrismaMongoModule,
     RabbitmqModule,
     CoreModule,
+    PassportModule.register({ defaultStrategy: 'jwt' }),
     JwtModule.registerAsync({
       imports: [ConfigModule],
       inject: [ConfigService],
@@ -24,7 +28,7 @@ import { CoreModule } from '@box/core/core.module';
     }),
   ],
   controllers: [AuthController],
-  providers: [AuthService],
-  exports: [AuthService],
+  providers: [AuthService, JwtStrategy, JwtAuthGuard],
+  exports: [AuthService, JwtAuthGuard],
 })
 export class AuthModule {}

+ 11 - 0
apps/box-app-api/src/feature/auth/guards/jwt-auth.guard.ts

@@ -0,0 +1,11 @@
+// apps/box-app-api/src/feature/auth/guards/jwt-auth.guard.ts
+import { Injectable, ExecutionContext } from '@nestjs/common';
+import { AuthGuard } from '@nestjs/passport';
+
+@Injectable()
+export class JwtAuthGuard extends AuthGuard('jwt') {
+  canActivate(context: ExecutionContext) {
+    // Add any custom logic here if needed
+    return super.canActivate(context);
+  }
+}

+ 39 - 0
apps/box-app-api/src/feature/auth/strategies/jwt.strategy.ts

@@ -0,0 +1,39 @@
+// apps/box-app-api/src/feature/auth/strategies/jwt.strategy.ts
+import { Injectable } from '@nestjs/common';
+import { PassportStrategy } from '@nestjs/passport';
+import { ExtractJwt, Strategy } from 'passport-jwt';
+import { ConfigService } from '@nestjs/config';
+
+export interface JwtPayload {
+  sub: string; // subject (uid)
+  uid: string;
+  jti: string; // token ID
+  iat?: number; // issued at
+  exp?: number; // expiry
+}
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy) {
+  constructor(private readonly configService: ConfigService) {
+    super({
+      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+      ignoreExpiration: false,
+      secretOrKey:
+        configService.get<string>('JWT_SECRET') || 'default-secret-key',
+    });
+  }
+
+  /**
+   * This method is called after JWT is verified.
+   * The payload is already decoded and signature-checked.
+   * We return the user object that will be attached to req.user.
+   */
+  async validate(payload: JwtPayload) {
+    // Return an object that will become req.user
+    return {
+      uid: payload.uid,
+      sub: payload.sub,
+      jti: payload.jti,
+    };
+  }
+}

+ 46 - 1
apps/box-app-api/src/rabbitmq/rabbitmq-publisher.service.ts

@@ -9,6 +9,7 @@ import { ConfigService } from '@nestjs/config';
 import { Connection, Channel, ConfirmChannel } from 'amqplib';
 import * as amqp from 'amqplib';
 import { UserLoginEventPayload } from '@box/common/events/user-login-event.dto';
+import { AdsClickEventPayload } from '@box/common/events/ads-click-event.dto';
 
 @Injectable()
 export class RabbitmqPublisherService implements OnModuleInit, OnModuleDestroy {
@@ -19,6 +20,7 @@ export class RabbitmqPublisherService implements OnModuleInit, OnModuleDestroy {
 
   private exchange!: string;
   private routingKeyLogin!: string;
+  private routingKeyAdsClick!: string;
 
   constructor(private readonly config: ConfigService) {}
 
@@ -28,6 +30,8 @@ export class RabbitmqPublisherService implements OnModuleInit, OnModuleDestroy {
       this.config.get<string>('RABBITMQ_LOGIN_EXCHANGE') ?? 'stats.user';
     this.routingKeyLogin =
       this.config.get<string>('RABBITMQ_LOGIN_ROUTING_KEY') ?? 'user.login';
+    this.routingKeyAdsClick =
+      this.config.get<string>('RABBITMQ_ADS_CLICK_ROUTING_KEY') ?? 'ads.click';
 
     if (!url) {
       this.logger.error(
@@ -48,7 +52,7 @@ export class RabbitmqPublisherService implements OnModuleInit, OnModuleDestroy {
     });
 
     this.logger.log(
-      `RabbitMQ publisher ready. exchange="${this.exchange}", loginRoutingKey="${this.routingKeyLogin}"`,
+      `RabbitMQ publisher ready. exchange="${this.exchange}", loginRoutingKey="${this.routingKeyLogin}", adsClickRoutingKey="${this.routingKeyAdsClick}"`,
     );
   }
 
@@ -101,4 +105,45 @@ export class RabbitmqPublisherService implements OnModuleInit, OnModuleDestroy {
       );
     });
   }
+
+  /**
+   * Publish an ads.click event.
+   * We wait for the confirm callback, so we know if the broker accepted or rejected the message.
+   */
+  async publishAdsClick(event: AdsClickEventPayload): Promise<void> {
+    if (!this.channel) {
+      this.logger.warn(
+        'RabbitMQ channel not ready. Skipping ads.click publish.',
+      );
+      return;
+    }
+
+    const payloadBuffer = Buffer.from(JSON.stringify(event));
+
+    return new Promise((resolve, reject) => {
+      this.channel!.publish(
+        this.exchange,
+        this.routingKeyAdsClick,
+        payloadBuffer,
+        {
+          persistent: true,
+          contentType: 'application/json',
+        },
+        (err) => {
+          if (err) {
+            this.logger.error(
+              `Failed to publish ads.click event for adsId=${event.adsId}: ${err.message}`,
+              err.stack,
+            );
+            return reject(err);
+          }
+          // Broker confirmed the message
+          this.logger.debug(
+            `Published ads.click event for adsId=${event.adsId} to exchange=${this.exchange} routingKey=${this.routingKeyAdsClick}`,
+          );
+          resolve();
+        },
+      );
+    });
+  }
 }

+ 13 - 0
libs/common/src/events/ads-click-event.dto.ts

@@ -0,0 +1,13 @@
+// libs/common/src/events/ads-click-event.dto.ts
+
+export interface AdsClickEventPayload {
+  adsId: string; // Ads ObjectId
+  adType: string; // BANNER, STARTUP, etc.
+  channelId: string; // Channel ObjectId
+  adsModuleId: string; // AdsModule ObjectId
+  uid: string; // User device ID from JWT
+  ip: string; // Client IP
+  appVersion?: string; // App version
+  os?: string; // iOS / Android / Web
+  clickAt: number | bigint; // epoch millis; will be stored as BigInt in Mongo
+}

+ 32 - 0
prisma/mongo-stats/schema/ads-click-history.prisma

@@ -0,0 +1,32 @@
+model AdsClickHistory {
+  id           String   @id @map("_id") @default(auto()) @db.ObjectId
+  
+  adsId        String   @db.ObjectId                    // 广告 ID
+  adType       String                                   // 广告类型 (BANNER, STARTUP, etc.)
+  channelId    String   @db.ObjectId                    // 渠道 ID
+  adsModuleId  String   @db.ObjectId                    // 广告模块 ID
+  uid          String                                   // 用户设备码 (from JWT)
+  ip           String                                   // 点击 IP
+  appVersion   String?                                  // 客户端版本 (optional)
+  os           String?                                  // iOS / Android / Web (optional)
+
+  clickAt      BigInt                                   // 点击时间 (epoch millis)
+
+  // Indexes for common queries:
+  // 1. Query all clicks for a specific ad
+  @@index([adsId, clickAt])
+  
+  // 2. Query clicks by user (device)
+  @@index([uid, clickAt])
+  
+  // 3. Query clicks by IP (fraud detection)
+  @@index([ip, clickAt])
+  
+  // 4. Query clicks by ad type
+  @@index([adType, clickAt])
+  
+  // 5. Global stats by time
+  @@index([clickAt])
+
+  @@map("adsClickHistory")
+}