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

feat: implement user login history tracking with RabbitMQ integration and MongoDB schema updates

Dave 2 месяцев назад
Родитель
Сommit
bb5f2ef703

+ 27 - 0
.env.stats

@@ -0,0 +1,27 @@
+# 测试服环境变量
+APP_ENV=test
+
+# Prisma Config
+# MONGO_STATS_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_stats?authSource=admin"
+
+# dave local
+MONGO_STATS_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_stats?authSource=admin"
+
+# office dev env
+# MONGO_STATS_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_stats?authSource=admin&replicaSet=rs0"
+
+# App set to 0.0.0.0 for local LAN access
+APP_HOST=0.0.0.0
+APP_PORT=3302
+APP_CORS_ORIGIN=*
+
+
+# JWT
+JWT_SECRET=047df8aaa3d17dc1173c5a9a3052ba66c2b0bd96937147eb643319a0c90d132f
+JWT_ACCESS_TOKEN_TTL=43200
+
+# RabbitMQ Config
+RABBITMQ_URL="amqp://boxrabbit:BoxRabbit%232025@localhost:5672"
+RABBITMQ_LOGIN_EXCHANGE="stats.user"
+RABBITMQ_LOGIN_QUEUE="stats.user.login.q"
+RABBITMQ_LOGIN_ROUTING_KEY="user.login"

+ 27 - 0
.env.stats.dev

@@ -0,0 +1,27 @@
+# 测试服环境变量
+APP_ENV=development
+
+# Prisma Config
+# MONGO_STATS_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_stats?authSource=admin"
+
+# dave local
+MONGO_STATS_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_stats?authSource=admin"
+
+# office dev env
+# MONGO_STATS_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_stats?authSource=admin&replicaSet=rs0"
+
+# App set to 0.0.0.0 for local LAN access
+APP_HOST=0.0.0.0
+APP_PORT=3302
+APP_CORS_ORIGIN=*
+
+
+# JWT
+JWT_SECRET=047df8aaa3d17dc1173c5a9a3052ba66c2b0bd96937147eb643319a0c90d132f
+JWT_ACCESS_TOKEN_TTL=43200
+
+# RabbitMQ Config
+RABBITMQ_URL="amqp://boxrabbit:BoxRabbit%232025@localhost:5672"
+RABBITMQ_LOGIN_EXCHANGE="stats.user"
+RABBITMQ_LOGIN_QUEUE="stats.user.login.q"
+RABBITMQ_LOGIN_ROUTING_KEY="user.login"

+ 27 - 0
.env.stats.test

@@ -0,0 +1,27 @@
+# 测试服环境变量
+APP_ENV=test
+
+# Prisma Config
+# MONGO_STATS_URL="mongodb://boxuser:dwR%3D%29whu2Ze@localhost:27017/box_stats?authSource=admin"
+
+# dave local
+MONGO_STATS_URL="mongodb://admin:ZXcv%21%21996@localhost:27017/box_stats?authSource=admin"
+
+# office dev env
+# MONGO_STATS_URL="mongodb://msAdmin:Fl1%2A29MJe%26jLvj@192.168.0.100:27017/box_stats?authSource=admin&replicaSet=rs0"
+
+# App set to 0.0.0.0 for local LAN access
+APP_HOST=0.0.0.0
+APP_PORT=3302
+APP_CORS_ORIGIN=*
+
+
+# JWT
+JWT_SECRET=047df8aaa3d17dc1173c5a9a3052ba66c2b0bd96937147eb643319a0c90d132f
+JWT_ACCESS_TOKEN_TTL=43200
+
+# RabbitMQ Config
+RABBITMQ_URL="amqp://boxrabbit:BoxRabbit%232025@localhost:5672"
+RABBITMQ_LOGIN_EXCHANGE="stats.user"
+RABBITMQ_LOGIN_QUEUE="stats.user.login.q"
+RABBITMQ_LOGIN_ROUTING_KEY="user.login"

+ 0 - 4
apps/box-app-api/tsconfig.build.json

@@ -1,4 +0,0 @@
-{
-  "extends": "./tsconfig.json",
-  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
-}

+ 2 - 1
apps/box-app-api/tsconfig.json

@@ -4,5 +4,6 @@
     "outDir": "../../dist",
     "rootDir": "../../"
   },
-  "include": ["src/**/*.ts"]
+  "include": ["src/**/*.ts"],
+  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
 }

+ 18 - 0
apps/box-stats-api/src/app.module.ts

@@ -0,0 +1,18 @@
+import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
+import { PrismaMongoModule } from './prisma/prisma-mongo.module';
+import { UserLoginHistoryModule } from './feature/user-login-history/user-login-history.module';
+import { RabbitmqConsumerModule } from './feature/rabbitmq/rabbitmq-consumer.module';
+
+@Module({
+  imports: [
+    ConfigModule.forRoot({
+      isGlobal: true,
+      envFilePath: ['.env.stats', '.env'], // adjust as needed
+    }),
+    PrismaMongoModule,
+    UserLoginHistoryModule,
+    RabbitmqConsumerModule,
+  ],
+})
+export class AppModule {}

+ 9 - 0
apps/box-stats-api/src/feature/rabbitmq/rabbitmq-consumer.module.ts

@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { RabbitmqConsumerService } from './rabbitmq-consumer.service';
+import { UserLoginHistoryModule } from '../user-login-history/user-login-history.module';
+
+@Module({
+  imports: [UserLoginHistoryModule],
+  providers: [RabbitmqConsumerService],
+})
+export class RabbitmqConsumerModule {}

+ 99 - 0
apps/box-stats-api/src/feature/rabbitmq/rabbitmq-consumer.service.ts

@@ -0,0 +1,99 @@
+import {
+  Injectable,
+  Logger,
+  OnModuleDestroy,
+  OnModuleInit,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Connection, Channel, ConsumeMessage } from 'amqplib';
+import * as amqp from 'amqplib';
+import { UserLoginHistoryService } from '../user-login-history/user-login-history.service';
+import { UserLoginEventPayload } from '../user-login-history/user-login-event.dto';
+
+@Injectable()
+export class RabbitmqConsumerService implements OnModuleInit, OnModuleDestroy {
+  private readonly logger = new Logger(RabbitmqConsumerService.name);
+
+  private connection?: Connection;
+  private channel?: Channel;
+
+  constructor(
+    private readonly config: ConfigService,
+    private readonly userLoginHistoryService: UserLoginHistoryService,
+  ) {}
+
+  async onModuleInit() {
+    const url = this.config.get<string>('RABBITMQ_URL');
+    const exchange =
+      this.config.get<string>('RABBITMQ_LOGIN_EXCHANGE') ?? 'stats.user';
+    const queue =
+      this.config.get<string>('RABBITMQ_LOGIN_QUEUE') ?? 'stats.user.login.q';
+    const routingKey =
+      this.config.get<string>('RABBITMQ_LOGIN_ROUTING_KEY') ?? 'user.login';
+
+    if (!url) {
+      this.logger.error('RABBITMQ_URL is not set');
+      return;
+    }
+
+    this.logger.log(`Connecting to RabbitMQ at ${url} ...`);
+
+    this.connection = await amqp.connect(url);
+    this.channel = await this.connection.createChannel();
+
+    await this.channel.assertExchange(exchange, 'topic', { durable: true });
+    await this.channel.assertQueue(queue, { durable: true });
+    await this.channel.bindQueue(queue, exchange, routingKey);
+
+    this.logger.log(
+      `Consuming queue="${queue}" exchange="${exchange}" routingKey="${routingKey}"`,
+    );
+
+    await this.channel.consume(queue, (msg) => this.handleMessage(msg), {
+      noAck: false,
+    });
+  }
+
+  private async handleMessage(msg: ConsumeMessage | null): Promise<void> {
+    if (!msg || !this.channel) return;
+
+    const content = msg.content.toString();
+
+    try {
+      const payload = JSON.parse(content) as UserLoginEventPayload;
+
+      // Basic sanity checks
+      if (!payload.uid || !payload.ip || !payload.loginAt) {
+        this.logger.warn(
+          `Invalid user.login payload, missing uid/ip/loginAt: ${content}`,
+        );
+        this.channel.ack(msg); // Don't retry poison messages
+        return;
+      }
+
+      await this.userLoginHistoryService.recordLogin(payload);
+      this.channel.ack(msg);
+    } catch (error) {
+      this.logger.error(
+        `Failed to process message: ${content}`,
+        error instanceof Error ? error.stack : String(error),
+      );
+
+      // For now: drop bad messages to avoid endless loops
+      // Later: route to DLQ.
+      this.channel.nack(msg, false, false);
+    }
+  }
+
+  async onModuleDestroy() {
+    try {
+      await this.channel?.close();
+      await this.connection?.close();
+    } catch (error) {
+      this.logger.error(
+        'Error while closing RabbitMQ connection',
+        error instanceof Error ? error.stack : String(error),
+      );
+    }
+  }
+}

+ 10 - 0
apps/box-stats-api/src/feature/user-login-history/user-login-event.dto.ts

@@ -0,0 +1,10 @@
+// This is the shape that box-app-api will publish to RabbitMQ
+export interface UserLoginEventPayload {
+  uid: string;
+  ip: string;
+  userAgent?: string;
+  appVersion?: string;
+  os?: string;
+  tokenId?: string;
+  loginAt: number | bigint; // epoch millis (will be stored as BigInt)
+}

+ 10 - 0
apps/box-stats-api/src/feature/user-login-history/user-login-history.module.ts

@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { UserLoginHistoryService } from './user-login-history.service';
+import { PrismaMongoModule } from '../../prisma/prisma-mongo.module';
+
+@Module({
+  imports: [PrismaMongoModule],
+  providers: [UserLoginHistoryService],
+  exports: [UserLoginHistoryService],
+})
+export class UserLoginHistoryModule {}

+ 37 - 0
apps/box-stats-api/src/feature/user-login-history/user-login-history.service.ts

@@ -0,0 +1,37 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { UserLoginEventPayload } from './user-login-event.dto';
+import { PrismaMongoService } from '../../prisma/prisma-mongo.service';
+
+@Injectable()
+export class UserLoginHistoryService {
+  private readonly logger = new Logger(UserLoginHistoryService.name);
+
+  constructor(private readonly prisma: PrismaMongoService) {}
+
+  async recordLogin(event: UserLoginEventPayload): Promise<void> {
+    try {
+      const createAt =
+        typeof event.loginAt === 'bigint'
+          ? event.loginAt
+          : BigInt(event.loginAt);
+
+      await this.prisma.userLoginHistory.create({
+        data: {
+          uid: event.uid,
+          ip: event.ip,
+          userAgent: event.userAgent ?? null,
+          appVersion: event.appVersion ?? null,
+          os: event.os ?? null,
+          createAt,
+          tokenId: event.tokenId ?? null,
+        },
+      });
+    } catch (error: any) {
+      this.logger.error(
+        `Failed to record login for uid=${event.uid}: ${error.message}`,
+        error.stack,
+      );
+      throw error;
+    }
+  }
+}

+ 87 - 0
apps/box-stats-api/src/main.ts

@@ -0,0 +1,87 @@
+import { NestFactory } from '@nestjs/core';
+import { Logger, ValidationPipe } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
+
+import { AppModule } from './app.module';
+
+async function bootstrap() {
+  const app = await NestFactory.create(AppModule, {
+    bufferLogs: true,
+  });
+
+  const logger = new Logger('Bootstrap');
+  app.useLogger(logger);
+
+  const configService = app.get(ConfigService);
+
+  const host =
+    configService.get<string>('APP_HOST') ??
+    configService.get<string>('HOST') ??
+    '0.0.0.0';
+
+  const port =
+    configService.get<number>('APP_PORT') ?? Number(process.env.PORT ?? 3302);
+
+  const crossOrigin =
+    configService.get<string>('APP_CROSS_ORIGIN') ??
+    configService.get<string>('CROSS_ORIGIN') ??
+    '*';
+
+  app.enableCors({
+    origin:
+      crossOrigin === '*' ? true : crossOrigin.split(',').map((o) => o.trim()),
+    methods: 'GET,PUT,PATCH,POST,DELETE',
+  });
+
+  // 👇 Important: this makes /health become /api/v1/health
+  app.setGlobalPrefix('api/v1', {
+    exclude: ['/'],
+  });
+
+  app.enableCors({
+    origin: true,
+    credentials: true,
+  });
+
+  app.useGlobalPipes(
+    new ValidationPipe({
+      whitelist: true,
+      transform: true,
+      transformOptions: {
+        enableImplicitConversion: true,
+      },
+      forbidNonWhitelisted: false,
+    }),
+  );
+
+  // Setup Swagger (OpenAPI)
+  const swaggerConfig = new DocumentBuilder()
+    .setTitle('盒子统计接口文档')
+    .setDescription(
+      'box-stats-api 的公开接口文档,面向前端应用和统计服务。包含用户登录历史等模块。',
+    )
+    .setVersion('1.0.0')
+    .build();
+  const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
+  SwaggerModule.setup('api-docs', app, swaggerDocument, {
+    jsonDocumentUrl: 'api-docs/json',
+    swaggerOptions: {
+      persistAuthorization: true,
+      docExpansion: 'none',
+    },
+  });
+
+  await app.listen(port, host);
+
+  const url = `http://${host}:${port}`;
+
+  logger.log(`🚀 box-stats-api listening on ${url} (global prefix: /api/v1)`);
+  logger.log(`📖 Swagger 文档: ${url}/api-docs`);
+}
+
+bootstrap().catch((error) => {
+  // eslint-disable-next-line no-console
+  console.error('❌ Failed to bootstrap box-stats-api', error);
+  process.exit(1);
+});

+ 9 - 0
apps/box-stats-api/src/prisma/prisma-mongo.module.ts

@@ -0,0 +1,9 @@
+import { Global, Module } from '@nestjs/common';
+import { PrismaMongoService } from './prisma-mongo.service';
+
+@Global()
+@Module({
+  providers: [PrismaMongoService],
+  exports: [PrismaMongoService],
+})
+export class PrismaMongoModule {}

+ 6 - 0
apps/box-stats-api/src/prisma/prisma-mongo.service.ts

@@ -0,0 +1,6 @@
+import { Injectable } from '@nestjs/common';
+// 👇 Reuse the shared Mongo Prisma service from libs/db
+import { MongoStatsPrismaService } from '@box/db/prisma/mongo-stats-prisma.service';
+
+@Injectable()
+export class PrismaMongoService extends MongoStatsPrismaService {}

+ 9 - 0
apps/box-stats-api/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "outDir": "../../dist",
+    "rootDir": "../../"
+  },
+  "include": ["src/**/*.ts"],
+  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
+}

+ 18 - 0
box-mgnt-note.md

@@ -35,6 +35,24 @@ redis-cli -h 127.0.0.1 -p 6379 ping
 
 # Generate a new module
 nest g module mgnt-backend/feature/video-medias --project box-mgnt-api
+
+docker run -d \
+  --name box-rabbitmq \
+  -p 5672:5672 \
+  -p 15672:15672 \
+  -e RABBITMQ_DEFAULT_USER=boxrabbit \
+  -e RABBITMQ_DEFAULT_PASS='BoxRabbit#2025' \
+  rabbitmq:3.13-management
+
+```
+
+```markdown
+# RabbitMQ Management UI Access
+
+http://localhost:15672
+
+Username: boxrabbit
+Password: BoxRabbit#2025
 ```
 
 ```bash

+ 2 - 2
libs/db/src/prisma/mongo-stats-prisma.service.ts

@@ -1,10 +1,10 @@
 // libs/db/src/prisma/mongo-stats-prisma.service.ts
 import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
-import { PrismaClient as MongoPrismaClient } from '@prisma/mongo/client';
+import { PrismaClient as MongoStatsPrismaClient } from '@prisma/mongo-stats/client';
 
 @Injectable()
 export class MongoStatsPrismaService
-  extends MongoPrismaClient
+  extends MongoStatsPrismaClient
   implements OnModuleInit, OnModuleDestroy
 {
   async onModuleInit() {

+ 9 - 0
nest-cli.json

@@ -19,6 +19,15 @@
       "compilerOptions": {
         "tsConfigPath": "apps/box-app-api/tsconfig.json"
       }
+    },
+    "box-stats-api": {
+      "type": "application",
+      "root": "apps/box-stats-api",
+      "entryFile": "main",
+      "sourceRoot": "apps/box-stats-api/src",
+      "compilerOptions": {
+        "tsConfigPath": "apps/box-stats-api/tsconfig.json"
+      }
     }
   }
 }

+ 4 - 0
package.json

@@ -9,6 +9,9 @@
     "dev:app": "dotenv -e .env.app -- nest start box-app-api --watch",
     "build:app": "nest build box-app-api",
     "start:app": "node dist/apps/box-app-api/src/main.js",
+    "dev:stats": "dotenv -e .env.stats -- nest start box-stats-api --watch",
+    "build:stats": "nest build box-stats-api",
+    "start:stats": "node dist/apps/box-stats-api/src/main.js",
     "prisma:migrate:dev:mysql": "dotenv -e .env.mgnt -- prisma migrate dev --schema=prisma/mysql/schema",
     "prisma:migrate:reset:mysql": "dotenv -e .env.mgnt -- prisma migrate reset --schema=prisma/mysql/schema",
     "prisma:generate:mysql": "dotenv -e .env.mgnt -- prisma generate --schema=prisma/mysql/schema",
@@ -48,6 +51,7 @@
     "@nestjs/schedule": "^6.0.1",
     "@nestjs/swagger": "^7.0.0",
     "@prisma/client": "^5.15.0",
+    "amqplib": "^0.10.9",
     "axios": "^1.6.8",
     "bcrypt": "^5.1.1",
     "bignumber.js": "^9.1.2",

+ 37 - 0
pnpm-lock.yaml

@@ -65,6 +65,9 @@ importers:
       '@prisma/client':
         specifier: ^5.15.0
         version: 5.22.0(prisma@5.22.0)
+      amqplib:
+        specifier: ^0.10.9
+        version: 0.10.9
       axios:
         specifier: ^1.6.8
         version: 1.13.2
@@ -279,6 +282,8 @@ importers:
 
   apps/box-app-api: {}
 
+  apps/box-stats-api: {}
+
   libs/common: {}
 
   libs/core: {}
@@ -2173,6 +2178,10 @@ packages:
   ajv@8.17.1:
     resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
 
+  amqplib@0.10.9:
+    resolution: {integrity: sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA==}
+    engines: {node: '>=10'}
+
   ansi-colors@4.1.3:
     resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
     engines: {node: '>=6'}
@@ -2397,6 +2406,9 @@ packages:
     resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==}
     engines: {node: '>=0.10'}
 
+  buffer-more-ints@1.0.0:
+    resolution: {integrity: sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==}
+
   buffer@5.7.1:
     resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
 
@@ -4459,6 +4471,9 @@ packages:
     resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
     engines: {node: '>=0.6'}
 
+  querystringify@2.2.0:
+    resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
+
   queue-microtask@1.2.3:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 
@@ -4539,6 +4554,9 @@ packages:
   require-main-filename@2.0.0:
     resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
 
+  requires-port@1.0.0:
+    resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+
   resolve-alpn@1.2.1:
     resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
 
@@ -5142,6 +5160,9 @@ packages:
   uri-js@4.4.1:
     resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
 
+  url-parse@1.5.10:
+    resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
+
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
@@ -7862,6 +7883,11 @@ snapshots:
       json-schema-traverse: 1.0.0
       require-from-string: 2.0.2
 
+  amqplib@0.10.9:
+    dependencies:
+      buffer-more-ints: 1.0.0
+      url-parse: 1.5.10
+
   ansi-colors@4.1.3: {}
 
   ansi-escapes@4.3.2:
@@ -8134,6 +8160,8 @@ snapshots:
 
   buffer-indexof-polyfill@1.0.2: {}
 
+  buffer-more-ints@1.0.0: {}
+
   buffer@5.7.1:
     dependencies:
       base64-js: 1.5.1
@@ -10452,6 +10480,8 @@ snapshots:
     dependencies:
       side-channel: 1.1.0
 
+  querystringify@2.2.0: {}
+
   queue-microtask@1.2.3: {}
 
   quick-format-unescaped@4.0.4: {}
@@ -10532,6 +10562,8 @@ snapshots:
 
   require-main-filename@2.0.0: {}
 
+  requires-port@1.0.0: {}
+
   resolve-alpn@1.2.1: {}
 
   resolve-cwd@3.0.0:
@@ -11149,6 +11181,11 @@ snapshots:
     dependencies:
       punycode: 2.3.1
 
+  url-parse@1.5.10:
+    dependencies:
+      querystringify: 2.2.0
+      requires-port: 1.0.0
+
   util-deprecate@1.0.2: {}
 
   utils-merge@1.0.1: {}

+ 1 - 1
prisma/mongo-stats/schema/main.prisma

@@ -1,7 +1,7 @@
 // prisma/mongo/schema/main.schema
 generator client {
   provider        = "prisma-client-js"
-  output          = "./../../../node_modules/@prisma/mongo/client"
+  output          = "./../../../node_modules/@prisma/mongo-stats/client"
   previewFeatures = ["prismaSchemaFolder"]
 }
 

+ 3 - 1
prisma/mongo-stats/schema/user-login-history.prisma

@@ -8,7 +8,9 @@ model UserLoginHistory {
   os          String?                         // iOS / Android / Browser
 
   createAt    BigInt                          // 登录时间 (epoch)
-  
+
+  tokenId     String?                         // 登录 token
+
   // Queries you will use a lot:
   // 1. 查某设备所有登录记录
   @@index([uid, createAt])

+ 0 - 13
prisma/mongo-stats/schema/user-session-history.prisma

@@ -1,13 +0,0 @@
-model UserSessionHistory {
-  id          String   @id @map("_id") @default(auto()) @db.ObjectId
-
-  uid         String
-  ip          String
-  tokenId     String
-  startAt     BigInt
-  endAt       BigInt   @default(0)
-
-  @@index([uid, startAt])
-  @@index([tokenId])
-  @@map("userSessionHistory")
-}