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

feat: enhance authentication flow with improved login handling and user registration

Dave 1 месяц назад
Родитель
Сommit
95d9ee1aa8

+ 4 - 9
apps/box-app-api/src/feature/auth/auth.controller.ts

@@ -9,7 +9,6 @@ import {
   ApiResponse,
   ApiOkResponse,
 } from '@nestjs/swagger';
-import { LoginResponseDto } from './dto/login-response.dto';
 
 @ApiTags('授权')
 @Controller('auth')
@@ -18,14 +17,13 @@ export class AuthController {
 
   @Post('login')
   @ApiOperation({
-    summary: '用户登录',
+    summary: '用户登录/落地注册',
     description:
-      '用户登录接口,返回访问令牌(Access Token),并在可用时附带一个来自广告池的随机 STARTUP 广告。',
+      '无 JWT 模式:首次登录会自动注册并绑定渠道(优先使用传入 channelId,否则使用默认渠道 isDefault=true)。后续登录仅依赖 uid(channelId 可忽略)。',
   })
   @ApiOkResponse({
     description:
-      '登录成功,返回访问token,并在可用时返回随机 STARTUP 广告(startupAd)。',
-    type: LoginResponseDto,
+      '登录成功,返回 uid、绑定后的 channelId,并在可用时返回启动广告 startupAds。',
   })
   @ApiResponse({ status: 400, description: '请求参数错误' })
   async login(@Body() body: LoginDto, @Req() req: Request) {
@@ -36,7 +34,7 @@ export class AuthController {
 
     const userAgent = req.headers['user-agent'];
 
-    const result = await this.authService.login({
+    return this.authService.login({
       uid: body.uid,
       ip,
       userAgent,
@@ -44,9 +42,6 @@ export class AuthController {
       os: body.os,
       channelId: body.channelId,
       machine: body.machine,
-      // add any other auth-related fields as needed
     });
-
-    return result;
   }
 }

+ 160 - 103
apps/box-app-api/src/feature/auth/auth.service.ts

@@ -1,158 +1,215 @@
 // apps/box-app-api/src/feature/auth/auth.service.ts
 import { Injectable, Logger } from '@nestjs/common';
-import { JwtService } from '@nestjs/jwt';
 import { UserLoginEventPayload } from '@box/common/events/user-login-event.dto';
 import { RabbitmqPublisherService } from '../../rabbitmq/rabbitmq-publisher.service';
-import { AppJwtPayload } from './interfaces/app-jwt-payload';
 import { PrismaMongoStatsService } from '../../prisma/prisma-mongo-stats.service';
 import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
 
+type LoginParams = {
+  uid: string;
+  ip: string;
+  userAgent?: string;
+  appVersion?: string;
+  os?: string;
+  channelId?: string;
+  machine?: string;
+};
+
+type LoginResult = {
+  uid: string;
+  channelId: string;
+  startupAds: any | null; // keep as any until you wire your Ad payload DTO
+};
+
 @Injectable()
 export class AuthService {
   private readonly logger = new Logger(AuthService.name);
+
   constructor(
-    private readonly jwtService: JwtService,
     private readonly rabbitmqPublisher: RabbitmqPublisherService,
     private readonly prismaMongoStatsService: PrismaMongoStatsService,
-    private readonly prismaMongoService: PrismaMongoService,
+    private readonly prismaMongoService: PrismaMongoService, // box-admin
   ) {}
 
-  async login(params: {
-    uid: string;
-    ip: string;
-    userAgent?: string;
-    appVersion?: string;
-    os?: string;
-    channelId?: string;
-    machine?: string;
-    // plus whatever you need like account, password, etc.
-  }): Promise<any> {
-    const { uid, ip, userAgent, appVersion, os, channelId, machine } = params;
-
-    // 1) Your existing auth logic (validate user, etc.)
-    // const user = await this.validateUser(...);
-
-    // 2) Generate tokenId (jti) and JWT
-    const tokenId = crypto.randomUUID(); // Node 18+; or use uuid library
-
-    const now = Date.now(); // number (ms since epoch)
-
-    const user = await this.getUserByUid(uid);
-    const firstChannel = await this.getChannelById(channelId || '');
-    // if user does not exist, channelId = firstChannel.channelId
-    let finalchannelId = channelId;
-    if (!user && firstChannel) {
-      finalchannelId = firstChannel.channelId;
-    }
-    // if user exists, take firstChannel.channelId
-    else if (user && firstChannel) {
-      finalchannelId = firstChannel.channelId;
+  async login(params: LoginParams): Promise<LoginResult> {
+    const { uid, ip, userAgent, appVersion, os } = params;
+    const channelIdInput = this.normalizeOptional(params.channelId);
+    const machine = this.normalizeOptional(params.machine);
+
+    const nowSec = BigInt(Math.floor(Date.now() / 1000)); // BigInt epoch seconds
+
+    // 1) Find user (in box-admin)
+    let user = await this.getUserByUid(uid);
+
+    // 2) First login: auto-register
+    if (!user) {
+      const resolvedChannel =
+        await this.resolveFirstLoginChannel(channelIdInput);
+      user = await this.createUser({
+        uid,
+        ip,
+        os: os ?? 'unknown',
+        channelId: resolvedChannel.channelId,
+        machine,
+        nowSec,
+      });
+    } else {
+      // 3) Subsequent login: keep stored user.channelId (ignore incoming channelId)
+      // Optionally update machine if provided (safe)
+      if (
+        machine &&
+        machine !== this.normalizeOptional((user as any).machine)
+      ) {
+        try {
+          user = await this.prismaMongoService.user.update({
+            where: { uid },
+            data: {
+              machine,
+              updateAt: nowSec,
+            },
+          });
+        } catch (e) {
+          // non-blocking
+          this.logger.warn(
+            `Failed to update machine for uid=${uid}: ${String(e)}`,
+          );
+        }
+      }
     }
 
-    // Build JWT payload with required and optional tracking fields.
-    // Tracking fields (channelId, machine, ip, userAgent, appVersion, os) are optional
-    // to preserve backward compatibility with older tokens and minimize JWT size for stability.
-    const payload: AppJwtPayload = {
-      sub: uid,
+    const finalChannelId = String((user as any).channelId);
+
+    // 4) Publish login history event (fire-and-forget)
+    const loginEvent: UserLoginEventPayload = {
       uid,
-      jti: tokenId,
-      channelId: finalchannelId,
-      machine,
       ip,
       userAgent,
       appVersion,
       os,
-      iat: Math.floor(now / 1000), // issued at (in seconds, per JWT spec)
+      channelId: finalChannelId,
+      machine: machine ?? undefined,
+      // IMPORTANT: use seconds (BigInt not always serializable across MQ)
+      loginAt: Number(nowSec), // if consumer expects ms, change consumer; per your rule we use seconds
     };
 
-    const accessToken = await this.jwtService.signAsync(payload);
+    this.publishLoginEvent(loginEvent).catch(() => {
+      // already logged inside
+    });
+
+    // 5) startupAds (placeholder: you’ll wire channel-specific ads later)
+    // For now return null to keep behaviour deterministic.
+    const startupAds: any | null = null;
 
-    // 3) Build login event payload
-    const loginEvent: UserLoginEventPayload = {
+    return {
       uid,
-      ip,
-      userAgent,
-      appVersion,
-      os,
-      channelId: finalchannelId,
-      machine,
-      tokenId,
-      loginAt: now,
+      channelId: finalChannelId,
+      startupAds,
     };
+  }
 
-    // 4) Fire-and-forget publish (but wait for broker confirm)
+  private async publishLoginEvent(
+    payload: UserLoginEventPayload,
+  ): Promise<void> {
     try {
-      this.logger.log(`Publishing user login event for uid=${uid}`);
-      await this.rabbitmqPublisher.publishUserLogin(loginEvent);
+      this.logger.log(`Publishing user login event for uid=${payload.uid}`);
+      await this.rabbitmqPublisher.publishUserLogin(payload);
     } catch (error) {
-      // Decide your policy:
-      //   - Either just log and continue (login succeeds even if stats fail),
-      //   - Or throw to fail login if stats are critical.
-      // For now, let's log and continue.
-      // If you want stricter behaviour, re-throw.
       const errorMessage =
         error instanceof Error ? error.message : String(error);
       const errorStack = error instanceof Error ? error.stack : undefined;
       this.logger.error(
-        `Failed to publish user login event for uid=${uid}: ${errorMessage}`,
+        `Failed to publish user login event for uid=${payload.uid}: ${errorMessage}`,
         errorStack,
       );
     }
+  }
 
-    // let startupAd: AdPayload | null = null;
-    // try {
-    //   startupAd = await this.adPoolService.getRandomAdByType(AdType.STARTUP);
-    // } catch (err) {
-    //   this.logger.error('Failed to get startup ad for login', err);
-    // }
-    // let startupAdWithoutUrl: Omit<AdPayload, 'adsUrl'> | null = null;
-
-    // if (startupAd) {
-    //   const { adsUrl, ...rest } = startupAd;
-    //   startupAdWithoutUrl = rest;
-    // }
-
-    return { accessToken };
+  private normalizeOptional(v?: string): string | undefined {
+    const s = (v ?? '').trim();
+    return s.length > 0 ? s : undefined;
   }
 
-  // add a function to retrieve user record from mongo by uid
-  async getUserByUid(uid: string): Promise<any> {
-    // Implement your MongoDB query here to find the user by uid
-    // For example, if you have a UserModel injected, you might do:
-    // return this.userModel.findOne({ uid }).exec();
-    // Placeholder implementation:
+  // ---------------------------
+  // User (box-admin)
+  // ---------------------------
 
-    const user = await this.prismaMongoStatsService.user.findUnique({
+  private async getUserByUid(uid: string): Promise<any | null> {
+    return this.prismaMongoService.user.findUnique({
       where: { uid },
     });
-
-    if (user) {
-      return user;
-    }
-    return null;
   }
 
-  // retrive the first channel from mongo collection, order by createAt ascending
-  async getFirstChannel(): Promise<any> {
-    const channel = await this.prismaMongoService.channel.findFirst({
-      orderBy: { createAt: 'asc' },
+  private async createUser(input: {
+    uid: string;
+    ip: string;
+    os: string;
+    channelId: string;
+    machine?: string;
+    nowSec: bigint;
+  }): Promise<any> {
+    const { uid, channelId, machine, nowSec } = input;
+
+    // Validate uniqueness explicitly (clear intention; Prisma will also enforce unique if set)
+    const existed = await this.getUserByUid(uid);
+    if (existed) return existed;
+
+    return this.prismaMongoService.user.create({
+      data: {
+        uid,
+        ip: input.ip,
+        os: input.os,
+        channelId,
+        machine: machine ?? null,
+        createAt: nowSec,
+        updateAt: nowSec,
+        lastLoginAt: nowSec,
+      },
     });
+  }
 
-    if (channel) {
-      return channel;
+  // ---------------------------
+  // Channel resolution (box-admin)
+  // ---------------------------
+
+  private async resolveFirstLoginChannel(
+    channelIdInput?: string,
+  ): Promise<any> {
+    // 1) If client provided channelId, validate it exists
+    if (channelIdInput) {
+      const byId = await this.getChannelByChannelId(channelIdInput);
+      if (byId) return byId;
     }
-    return null;
+
+    // 2) Use default channel where isDefault=true
+    const def = await this.getDefaultChannel();
+    if (def) return def;
+
+    // 3) Last resort: first created channel (so system can still run)
+    const first = await this.getFirstChannel();
+    if (first) return first;
+
+    // If no channel exists at all, this is a deployment/config error
+    throw new Error(
+      'No channel available (missing default channel and no channels exist)',
+    );
   }
 
-  // find by channelId
-  async getChannelById(channelId: string): Promise<any> {
-    const channel = await this.prismaMongoService.channel.findUnique({
+  private async getChannelByChannelId(channelId: string): Promise<any | null> {
+    return this.prismaMongoService.channel.findUnique({
       where: { channelId },
     });
+  }
 
-    if (channel) {
-      return this.getFirstChannel();
-    }
-    return null;
+  private async getDefaultChannel(): Promise<any | null> {
+    return this.prismaMongoService.channel.findFirst({
+      where: { isDefault: true },
+      orderBy: { createAt: 'asc' },
+    });
+  }
+
+  private async getFirstChannel(): Promise<any | null> {
+    return this.prismaMongoService.channel.findFirst({
+      orderBy: { createAt: 'asc' },
+    });
   }
 }

+ 5 - 4
apps/box-app-api/src/feature/auth/login.dto.ts

@@ -11,18 +11,18 @@ export class LoginDto {
   uid: string;
 
   @ApiProperty({
-    description: '渠道ID',
+    description: '渠道ID(仅首次登录可能提供)',
     example: 'channel-123',
-    required: true,
+    required: false,
   })
   @IsOptional()
   @IsString()
   channelId?: string;
 
   @ApiProperty({
-    description: '机器型号',
+    description: '机器型号(可能缺失)',
     example: 'iPhone 12 Pro xxxx',
-    required: true,
+    required: false,
   })
   @IsOptional()
   @IsString()
@@ -45,5 +45,6 @@ export class LoginDto {
   @IsOptional()
   @IsString()
   os?: string;
+
   // plus account/password etc.
 }

+ 17 - 17
apps/box-app-api/src/main.ts

@@ -42,23 +42,23 @@ async function bootstrap() {
     credentials: true,
   });
 
-  const imageRoot = path.resolve(
-    configService.get<string>('IMAGE_ROOT_PATH') || '/data/box-images',
-  );
-
-  app.use(
-    '/images',
-    express.static(imageRoot, {
-      setHeaders: (res: any) => {
-        res.setHeader('Access-Control-Allow-Origin', corsOrigin);
-        res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
-        res.setHeader(
-          'Access-Control-Allow-Headers',
-          'Origin, X-Requested-With, Content-Type, Accept, Authorization',
-        );
-      },
-    }),
-  );
+  // const imageRoot = path.resolve(
+  //   configService.get<string>('IMAGE_ROOT_PATH') || '/data/box-images',
+  // );
+
+  // app.use(
+  //   '/images',
+  //   express.static(imageRoot, {
+  //     setHeaders: (res: any) => {
+  //       res.setHeader('Access-Control-Allow-Origin', corsOrigin);
+  //       res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
+  //       res.setHeader(
+  //         'Access-Control-Allow-Headers',
+  //         'Origin, X-Requested-With, Content-Type, Accept, Authorization',
+  //       );
+  //     },
+  //   }),
+  // );
 
   // 👇 Important: this makes /health become /api/v1/health
   app.setGlobalPrefix('api/v1', {

+ 33 - 51
apps/box-stats-api/src/feature/user-login/user-login.service.ts

@@ -1,3 +1,4 @@
+// apps/box-stats-api/src/feature/user-login/user-login.service.ts
 import { Injectable, Logger } from '@nestjs/common';
 import { UserLoginEventPayload } from '@box/common/events/user-login-event.dto';
 import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
@@ -8,57 +9,39 @@ export class UserLoginService {
 
   constructor(private readonly prisma: PrismaMongoService) {}
 
-  // create a function createUser, to create a new user in mongo-stats database model User with fields from event payload
-  async createUser(event: UserLoginEventPayload): Promise<void> {
-    try {
-      // upsert user record based on uid, if user exists, update lastLoginAt, else create new user
-      this.logger.log(`Creating or updating user for uid=${event.uid}`);
-      await this.prisma.user.upsert({
-        where: { uid: event.uid },
-        update: {
-          lastLoginAt:
-            typeof event.loginAt === 'bigint'
-              ? event.loginAt
-              : BigInt(event.loginAt),
-          ip: event.ip,
-          os: event.os ?? null,
-          channelId: event.channelId ?? null,
-          machine: event.machine,
-        },
-        create: {
-          uid: event.uid,
-          os: event.os ?? null,
-          channelId: event.channelId ?? null,
-          machine: event.machine,
-          createAt:
-            typeof event.loginAt === 'bigint'
-              ? event.loginAt
-              : BigInt(event.loginAt),
-          lastLoginAt:
-            typeof event.loginAt === 'bigint'
-              ? event.loginAt
-              : BigInt(event.loginAt),
-          ip: event.ip,
-        },
-      });
-    } catch (error: any) {
-      this.logger.error(
-        `Failed to create user for uid=${event.uid}: ${error.message}`,
-        error.stack,
-      );
-      throw error;
+  /**
+   * Normalize loginAt into BigInt epoch seconds.
+   * Accept number | string | bigint from MQ payloads safely.
+   */
+  private toEpochSecondsBigInt(loginAt: unknown): bigint {
+    if (typeof loginAt === 'bigint') return loginAt;
+
+    if (typeof loginAt === 'number') {
+      if (!Number.isFinite(loginAt))
+        throw new Error(`Invalid loginAt number: ${loginAt}`);
+      return BigInt(Math.trunc(loginAt));
     }
+
+    if (typeof loginAt === 'string') {
+      const s = loginAt.trim();
+      if (s.length === 0) throw new Error('Invalid loginAt string: empty');
+      return BigInt(s);
+    }
+
+    throw new Error(`Unsupported loginAt type: ${typeof loginAt}`);
   }
 
+  /**
+   * Record login history in box-stats DB.
+   * NOTE: User model is no longer stored in box-stats.
+   */
   async recordLogin(event: UserLoginEventPayload): Promise<void> {
+    const createAt = this.toEpochSecondsBigInt((event as any).loginAt);
+
     try {
       this.logger.log(
-        `Recording login for uid=${event.uid} from ip=${event.ip}`,
+        `Recording login history uid=${event.uid} ip=${event.ip}`,
       );
-      const createAt =
-        typeof event.loginAt === 'bigint'
-          ? event.loginAt
-          : BigInt(event.loginAt);
 
       await this.prisma.userLoginHistory.create({
         data: {
@@ -68,18 +51,17 @@ export class UserLoginService {
           appVersion: event.appVersion ?? null,
           os: event.os ?? null,
           channelId: event.channelId ?? null,
-          machine: event.machine,
+          machine: event.machine ?? null,
           createAt,
           tokenId: event.tokenId ?? null,
         },
       });
-
-      // Also update or create user record
-      await this.createUser(event);
-    } catch (error: any) {
+    } catch (error) {
+      const message = error instanceof Error ? error.message : String(error);
+      const stack = error instanceof Error ? error.stack : undefined;
       this.logger.error(
-        `Failed to record login for uid=${event.uid}: ${error.message}`,
-        error.stack,
+        `Failed to record login for uid=${event.uid}: ${message}`,
+        stack,
       );
       throw error;
     }

+ 1 - 0
prisma/mongo-stats/schema/user.prisma → prisma/mongo/schema/user.prisma

@@ -7,6 +7,7 @@ model User {
   machine       String                      // 客户端提供 : 设备的信息,品牌及系统版本什么的 (required)
 
   createAt      BigInt      @default(0)     // 注册/创建时间
+  updateAt      BigInt      @default(0)     // 更新时间
   lastLoginAt   BigInt      @default(0)     // 最后登录时间
 
   // Query helpers