소스 검색

first commit

guoziyun 10 달 전
커밋
b7b7a64acf
44개의 변경된 파일7143개의 추가작업 그리고 0개의 파일을 삭제
  1. 3 0
      .prettierrc
  2. 53 0
      .vscode/launch.json
  3. 12 0
      .vscode/settings.json
  4. 94 0
      docker-compose.prod.yml
  5. 98 0
      docker-compose.yml
  6. BIN
      logs/coloring/coloring-20250823.log.gz
  7. BIN
      logs/coloring/coloring-20250825.log.gz
  8. 0 0
      logs/coloring/coloring.log
  9. 7 0
      oms/.dockerignore
  10. 15 0
      oms/.env
  11. 44 0
      oms/.gitignore
  12. 47 0
      oms/Dockerfile
  13. 71 0
      oms/README.md
  14. 104 0
      oms/ecosystem.config.js
  15. 3103 0
      oms/package-lock.json
  16. 51 0
      oms/package.json
  17. 175 0
      oms/services/cron-jobs/done-rate.ts
  18. 72 0
      oms/services/cron-jobs/index.ts
  19. 8 0
      oms/services/cron-jobs/sync/schema-sync-seq.js
  20. 6 0
      oms/services/cron-jobs/sync/sync-conn.js
  21. 7 0
      oms/services/cron-jobs/sync/sync-seq.js
  22. 98 0
      oms/services/cron-jobs/sync/sync-service.js
  23. 156 0
      oms/services/event-api-service.ts
  24. 8 0
      oms/services/howto.md
  25. 412 0
      oms/services/ingestor-service.ts
  26. 202 0
      oms/services/log-service.ts
  27. 83 0
      oms/src/app.ts
  28. 237 0
      oms/src/controllers/artController.ts
  29. 67 0
      oms/src/controllers/doneRateController.ts
  30. 215 0
      oms/src/controllers/userController.ts
  31. 197 0
      oms/src/models/artModel.ts
  32. 54 0
      oms/src/models/colorRecordModel.ts
  33. 61 0
      oms/src/models/doneRateModel.ts
  34. 64 0
      oms/src/models/userModel.ts
  35. 49 0
      oms/src/models/userPreferenceModel.ts
  36. 25 0
      oms/src/routes/apiRoutes.ts
  37. 432 0
      oms/src/scripts/ingestHistoricalData.ts
  38. 302 0
      oms/src/scripts/ingestHistoricalDataFromClog.ts
  39. 90 0
      oms/src/services/artService.ts
  40. 135 0
      oms/src/services/clickhouseService.ts
  41. 151 0
      oms/src/services/doneRateService.ts
  42. 114 0
      oms/src/services/userService.ts
  43. 20 0
      oms/tsconfig.json
  44. 1 0
      omsapp

+ 3 - 0
.prettierrc

@@ -0,0 +1,3 @@
+{
+  "printWidth": 200
+}

+ 53 - 0
.vscode/launch.json

@@ -0,0 +1,53 @@
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "app (TS-Node)",
+      "skipFiles": ["<node_internals>/**"],
+      "program": "${workspaceFolder}/oms/src/app.ts",
+      "runtimeArgs": [
+        "--require",
+        "ts-node/register" // This tells Node.js to use ts-node to register a TypeScript transpiler
+      ],
+      "args": [], // Optional arguments for your script
+      "env": {
+        // You can add environment variables needed by your script here,
+        "MONGO_URI": "mongodb://oms:oms123.@localhost:27017/omsdb?authSource=admin",
+        "CLICKHOUSE_HOST": "http://localhost:8123",
+        "CLICKHOUSE_DATABASE": "omsdb",
+        "CLICKHOUSE_USER": "ckuser",
+        "CLICKHOUSE_PASSWORD": "ckpassword"
+      },
+      "cwd": "${workspaceFolder}/oms", // Set the current working directory to the 'oms' folder
+      "console": "integratedTerminal", // Or "internalConsole"
+      "internalConsoleOptions": "openOnSessionStart"
+    },
+    {
+      "type": "node",
+      "request": "launch",
+      "name": "ingestHistoricalData (TS-Node)",
+      "skipFiles": ["<node_internals>/**"],
+      "program": "${workspaceFolder}/oms/src/scripts/ingestHistoricalData.ts",
+      "runtimeArgs": [
+        "--require",
+        "ts-node/register" // This tells Node.js to use ts-node to register a TypeScript transpiler
+      ],
+      "args": [], // Optional arguments for your script
+      "env": {
+        // You can add environment variables needed by your script here,
+        "MONGO_URI": "mongodb://oms:oms123.@localhost:27017/omsdb",
+        "CLICKHOUSE_HOST": "http://localhost:8123",
+        "CLICKHOUSE_DATABASE": "omsdb",
+        "CLICKHOUSE_USER": "ckuser",
+        "CLICKHOUSE_PASSWORD": "ckpassword",
+        "START_DATE": "20250818", // Example date range
+        "END_DATE": "20250820"
+      },
+      "cwd": "${workspaceFolder}/oms", // Set the current working directory to the 'oms' folder
+      "console": "integratedTerminal", // Or "internalConsole"
+      "internalConsoleOptions": "openOnSessionStart"
+    }
+  ]
+}

+ 12 - 0
.vscode/settings.json

@@ -0,0 +1,12 @@
+{
+  // 在文件浏览器中隐藏指定目录
+  "files.exclude": {
+    "**/dist": true,
+    "**/node_modules": true
+  },
+  // 在搜索时排除指定目录
+  "search.exclude": {
+    "**/dist": true,
+    "**/node_modules": true
+  }
+}

+ 94 - 0
docker-compose.prod.yml

@@ -0,0 +1,94 @@
+# ~/work/my_project_root/docker-compose.prod.yml (Production)
+version: "3.8"
+
+services:
+  # MongoDB Database Service
+  mongodb:
+    image: mongo:latest
+    container_name: mongodb
+    volumes:
+      - mongodb_data:/data/db
+    restart: always
+
+  # Redis Cache Service
+  redis:
+    image: redis:latest
+    container_name: redis
+    restart: always
+
+  # RabbitMQ Message Broker
+  rabbitmq:
+    image: rabbitmq:3-management-alpine
+    container_name: rabbitmq
+    environment:
+      RABBITMQ_DEFAULT_USER: coloring
+      RABBITMQ_DEFAULT_PASS: coloring123.
+    restart: always
+
+  # ClickHouse Columnar Database for Analytics
+  clickhouse:
+    image: clickhouse/clickhouse-server:latest
+    container_name: clickhouse
+    volumes:
+      - clickhouse_data:/var/lib/clickhouse
+    environment:
+      CLICKHOUSE_DB: omsdb
+      CLICKHOUSE_USER: ckuser
+      CLICKHOUSE_PASSWORD: ckpassword
+    restart: always
+
+  # Backend Service (OMS)
+  oms:
+    build:
+      context: ./oms
+      dockerfile: Dockerfile
+    container_name: oms
+    ports:
+      - "3000:3000"
+    environment:
+      NODE_ENV: production
+      PORT: 3000
+      MONGO_URI: mongodb://mongodb:27017/omsdb
+      REDIS_URI: redis://redis:6379
+      # !!! 生产环境请添加敏感环境变量 !!!
+    # 生产环境不挂载代码,依赖 Dockerfile 复制
+    # volumes:
+    #   - ./oms:/usr/src/app
+    restart: always
+    depends_on:
+      - mongodb
+      - redis
+
+  # Data Ingestor Service (独立服务)
+  data-ingestor:
+    build:
+      context: ./data-ingestor
+      dockerfile: Dockerfile
+    container_name: data-ingestor
+    environment:
+      RABBITMQ_URL: amqp://rabbitmq:5672
+      RABBITMQ_QUEUE: user_events
+      CLICKHOUSE_HOST: http://clickhouse:8123
+      CLICKHOUSE_DATABASE: omsdb
+      MONGODB_URI: mongodb://mongodb:27017/omsdb
+    restart: always
+    depends_on:
+      - rabbitmq
+      - clickhouse
+      - mongodb
+
+  # Frontend Nginx Service (OMSApp)
+  omsapp-nginx:
+    build:
+      context: ./omsapp # *** 关键变更:构建上下文是 ./omsapp
+      dockerfile: Dockerfile
+    container_name: omsapp-nginx
+    ports:
+      - "80:80"
+    restart: always
+    depends_on:
+      - oms
+
+volumes:
+  mongodb_data:
+  clickhouse_data:

+ 98 - 0
docker-compose.yml

@@ -0,0 +1,98 @@
+version: "3.8"
+
+services:
+  # MongoDB Database Service
+  mongodb:
+    image: mongo:latest
+    container_name: oms-mongodb
+    ports:
+      - "27017:27017"
+    volumes:
+      - mongodb_data:/data/db # Persist MongoDB data
+    environment:
+      MONGO_INITDB_ROOT_USERNAME: oms
+      MONGO_INITDB_ROOT_PASSWORD: "oms123." # 密码包含特殊字符,建议用引号
+    restart: "no"
+
+  # Redis Cache Service
+  redis:
+    image: redis:latest
+    container_name: oms-redis
+    ports:
+      - "6379:6379"
+    restart: "no"
+
+  # RabbitMQ Message Broker
+  rabbitmq:
+    image: rabbitmq:3-management-alpine
+    container_name: oms-rabbitmq
+    ports:
+      - "5672:5672" # AMQP 端口
+      - "15672:15672" # 管理界面端口
+    environment:
+      RABBITMQ_DEFAULT_USER: coloring
+      RABBITMQ_DEFAULT_PASS: coloring123.
+    restart: "no"
+
+  # ClickHouse Columnar Database for Analytics
+  clickhouse:
+    image: clickhouse/clickhouse-server:latest
+    container_name: clickhouse
+    ports:
+      - "8123:8123" # HTTP 接口
+      # - "9000:9000" # 原生 TCP 接口
+    volumes:
+      - clickhouse_data:/var/lib/clickhouse
+    environment:
+      CLICKHOUSE_DB: omsdb
+      CLICKHOUSE_USER: ckuser
+      CLICKHOUSE_PASSWORD: ckpassword
+    restart: "no"
+
+  # # Backend Service (OMS)
+  # oms:
+  #   build:
+  #     context: ./oms # Build context is the 'oms' directory
+  #     dockerfile: Dockerfile
+  #   container_name: oms
+  #   ports:
+  #     - "3000:3000"
+  #   environment:
+  #     NODE_ENV: development
+  #     PORT: 3000
+  #     EVENT_PORT: 3001
+  #     MONGO_URI: mongodb://mongodb:27017/omsdb # Docker 内部使用服务名
+  #     REDIS_URI: redis://redis:6379
+  #     RABBITMQ_URL: amqp://coloring:coloring123.@rabbitmq:5672 # Docker 内部使用服务名
+  #     RABBITMQ_EXCHANGE: event_exchange
+  #     RABBITMQ_LOG_QUEUE: log_event_queue
+  #     RABBITMQ_OMS_QUEUE: oms-event-queue # <-- 新增:摄取器队列名
+  #     CLICKHOUSE_HOST: http://clickhouse:8123 # <-- 新增:ClickHouse Host
+  #     CLICKHOUSE_DATABASE: omsdb # <-- 新增:ClickHouse DB
+  #     CLICKHOUSE_USER: ckuser # <-- 新增:ClickHouse 用户
+  #     CLICKHOUSE_PASSWORD: ckpassword # <-- 新增:ClickHouse 密码
+  #     LOG_DIR: /app/logs/coloring # 日志服务容器内部路径
+  #   volumes:
+  #     # 开发调试阶段, 挂载整个 oms 目录,但 .dockerignore 会忽略 node_modules 和 omsapp
+  #     - ./:/usr/src/app
+  #   depends_on:
+  #     - mongodb
+  #     - redis
+  #   restart: "no" # 不自动重启,方便测试和重建
+
+  # # Frontend Nginx Service (OMSApp)
+  # omsapp-nginx:
+  #   build:
+  #     context: ./oms/omsapp # *** 关键变更:构建上下文现在是 ./oms/omsapp
+  #     dockerfile: Dockerfile
+  #   container_name: omsapp-nginx
+  #   ports:
+  #     - "80:80"
+  #   depends_on:
+  #     - oms # 确保后端服务先启动
+  #   restart: "no"
+
+# Define volumes for data persistence
+volumes:
+  mongodb_data:
+  clickhouse_data:

BIN
logs/coloring/coloring-20250823.log.gz


BIN
logs/coloring/coloring-20250825.log.gz


+ 0 - 0
logs/coloring/coloring.log


+ 7 - 0
oms/.dockerignore

@@ -0,0 +1,7 @@
+node_modules
+dist
+.env
+npm-debug.log
+yarn-error.log
+.git
+.gitignore

+ 15 - 0
oms/.env

@@ -0,0 +1,15 @@
+PORT=3000
+EVENT_PORT=3001
+MONGO_URI=mongodb://oms:oms123.@localhost/omsdb?authSource=admin
+REDIS_URI=redis://localhost:6379
+RABBITMQ_URI=amqp://coloring:coloring123.@localhost
+RABBITMQ_EXCHANGE=event-exchange
+RABBITMQ_LOG_QUEUE=log-event-queue
+RABBITMQ_OMS_QUEUE=oms-event-queue
+CLICKHOUSE_HOST=http://localhost:8123
+CLICKHOUSE_DATABASE=omsdb
+CLICKHOUSE_USER=ckuser
+CLICKHOUSE_PASSWORD=ckpassword
+
+
+LOG_FILES_DIR=/home/guoziyun/tools/log

+ 44 - 0
oms/.gitignore

@@ -0,0 +1,44 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/node_modules
+npm-debug.log
+yarn-error.log
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+
+# System files
+.DS_Store
+Thumbs.db
+
+

+ 47 - 0
oms/Dockerfile

@@ -0,0 +1,47 @@
+# Stage 1: Build the TypeScript application
+FROM node:20-alpine AS build
+
+WORKDIR /usr/src/app
+
+# Copy package.json and package-lock.json first to leverage Docker cache
+COPY package.json package-lock.json ./
+RUN npm install
+
+# Copy the rest of the application source code
+COPY . .
+# Removed: RUN mkdir -p public/app - No longer needed as public/app will be copied directly in Stage 2
+
+# Build TypeScript to JavaScript
+RUN npm run build
+
+# 全局安装 PM2 用于进程管理
+RUN npm install -g pm2
+
+# Stage 2: Run the application
+FROM node:20-alpine
+
+WORKDIR /usr/src/app
+
+# 复制构建阶段的 artifact
+COPY --from=build /usr/src/app/dist ./dist
+COPY --from=build /usr/src/app/node_modules ./node_modules
+COPY --from=build /usr/src/app/package.json ./package.json
+COPY --from=build /usr/src/app/ecosystem.config.js ./ecosystem.config.js
+COPY --from=build /usr/src/app/public ./public
+
+# 全局安装 PM2 用于进程管理
+RUN npm install -g pm2
+
+# 创建日志目录 (确保容器内路径存在且可写)
+RUN mkdir -p /app/logs/coloring
+# 设置日志目录权限 (例如,给 node 用户)
+# Node.js 官方 alpine 镜像通常以 'node' 用户运行,UID 通常为 1000
+RUN chown -R node:node /app/logs/coloring
+USER node # 以非 root 用户运行应用
+
+# 暴露应用程序监听的端口
+EXPOSE 3000
+EXPOSE 3001
+
+# 定义容器启动命令
+CMD ["pm2-runtime", "ecosystem.config.js"]

+ 71 - 0
oms/README.md

@@ -0,0 +1,71 @@
+# OMS 运营管理系统
+
+通过数据挖掘,统计分析,构建一个集内容质量评估、用户分群画像、运营活动管理为一体的 LiveOps 一站式 管理平台,为 JCCY 游戏提供决策依据和个性化运营支撑。
+
+## 调试
+
+调试阶段, 可以考虑只跑 mongodb , clickhouse, redis docker, oms 直接在宿主机上运行
+
+docker-compose up -d mongodb clickhouse redis
+
+docker-compose down
+
+npm run dev
+
+## 构建
+
+cd omsapp
+npm run build --configuration production
+cd ..
+
+docker-compose build oms # 仅构建后端服务,因为它现在包含了前端静态文件
+docker-compose up -d --force-recreate oms # 以后台模式运行并强制重建 oms 容器以应用最新代码
+
+## 如何查看 mongodb 数据:
+
+mongosh mongodb://oms:oms123.@localhost:27017/omsdb?authSource=admin
+
+## 如何查看 clickhouse 数据:
+
+docker exec -it clickhouse clickhouse-client --user=ckuser --password=ckpassword --database=omsdb
+
+## 如何清除数据
+
+清除 mongodb 和 clickhouse 的 volumn
+
+1. 停止并移除 docker-compose.yml 中定义的所有容器、网络:
+
+```
+docker-compose -f docker-compose.yml down
+```
+
+2. 清除 mongodb 数据:
+
+```
+docker volume ls
+docker volume rm oms-project_mongodb_data
+```
+
+3. 清除 clickhouse 数据
+
+```
+docker volume rm oms-project_clickhouse_data
+```
+
+4. 重新构建并启动 docker
+
+```
+docker-compose -f docker-compose.yml up -d --build
+
+or :
+
+docker-compose -f docker-compose.yml up -d --build mongodb redis clickhouse
+```
+
+--build 选项会强制 Docker 重新构建所有服务的镜像,确保它们使用了最新的代码
+
+## 使用 pm2 启动应用
+
+```
+pm2 start ecosystem.config.js --env production
+```

+ 104 - 0
oms/ecosystem.config.js

@@ -0,0 +1,104 @@
+// oms/ecosystem.config.js
+module.exports = {
+  apps: [
+    {
+      name: "oms-backend", // PM2 进程名称
+      script: "dist/app.js", // 编译后的主应用文件
+      instances: 1, // 通常为 1,除非您需要多个 Node.js 实例
+      autorestart: true, // 崩溃后自动重启
+      watch: false, // 生产环境不监听文件变化
+      // max_memory_restart: "1G", // 内存使用超过 1GB 时重启
+      env_production: {
+        // 生产环境特定环境变量
+        NODE_ENV: "production",
+        PORT: 3000,
+        MONGO_URI: "mongodb://oms:oms123.@localhost/omsdb?authSource=admin", // MongoDB 宿主机端口 27717
+        REDIS_URI: "redis://localhost:6379", // Redis 宿主机端口 6379
+        RABBITMQ_URL: "amqp://coloring:coloring123.@localhost:5672", // RabbitMQ 宿主机端口 5672
+        RABBITMQ_EXCHANGE: "event-exchange",
+        RABBITMQ_LOG_QUQUE: "log-event-queue",
+        RABBITMQ_OMS_QUEUE: "oms-event-queue",
+        CLICKHOUSE_HOST: "http://localhost:8123", // ClickHouse 宿主机端口 8123
+        CLICKHOUSE_DATABASE: "omsdb",
+        CLICKHOUSE_USER: "ckuser",
+        CLICKHOUSE_PASSWORD: "ckpassword",
+      },
+      // 如果您需要本地开发调试,可以定义 env_development
+      env_development: {
+        NODE_ENV: "development",
+        PORT: 3000,
+        MONGO_URI: "mongodb://oms:oms123.@localhost/omsdb?authSource=admin",
+        REDIS_URI: "redis://localhost:6379",
+        RABBITMQ_URL: "amqp://coloring:coloring123.@localhost:5672",
+        RABBITMQ_EXCHANGE: "event-exchange",
+        RABBITMQ_LOG_QUQUE: "log-event-queue",
+        RABBITMQ_OMS_QUEUE: "oms-event-queue",
+        CLICKHOUSE_HOST: "http://localhost:8123",
+        CLICKHOUSE_DATABASE: "omsdb",
+        CLICKHOUSE_USER: "ckuser",
+        CLICKHOUSE_PASSWORD: "ckpassword",
+      },
+    },
+    {
+      name: "event-api-service", // <-- 新增的埋点 API 进程
+      script: "dist/services/event-api-service.js", // 指向编译后的埋点服务文件
+      instances: "max", // 可以根据 CPU 核心数启动多个实例
+      exec_mode: "cluster", // 以集群模式运行,利用多核 CPU
+      autorestart: true,
+      watch: false,
+      max_memory_restart: "500M", // 根据实际情况调整内存限制
+      env_production: {
+        NODE_ENV: "production",
+        EVENT_PORT: 3001, // <-- 埋点 API 进程使用不同的端口
+        RABBITMQ_URL: "amqp://coloring:coloring123.@localhost:5672", // RabbitMQ连接
+        RABBITMQ_EXCHANGE: "event-exchange", // 交换机名称
+        RABBITMQ_LOG_QUEUE: "log-event-queue", // 日志服务队列
+        RABBITMQ_OMS_QUEUE: "oms-event-queue", // 数据清洗整理到mongodb和clickhouse队列
+      },
+      env_development: {
+        NODE_ENV: "development",
+        EVENT_PORT: 3001, // <-- 本地开发调试也使用不同端口
+        RABBITMQ_URL: "amqp://coloring:coloring123.@localhost:5672",
+        RABBITMQ_EXCHANGE: "event-exchange", // 交换机名称
+        RABBITMQ_LOG_QUEUE: "log-event-queue", // 日志服务队列
+        RABBITMQ_OMS_QUEUE: "oms-event-queue", // 数据清洗整理到mongodb和clickhouse队列
+      },
+    },
+    {
+      name: "log-service", // <-- 新增的日志服务进程
+      script: "dist/services/log-service.js", // 指向编译后的日志服务文件
+      instances: 1, // 通常只需要一个日志写入进程,但也可以是多个并行写入到不同文件或同一文件
+      watch: false,
+      max_memory_restart: "500M", // 根据日志量调整内存限制
+      env_development: {
+        NODE_ENV: "production",
+        RABBITMQ_URL: "amqp://coloring:coloring123.@rabbitmq:5672", // RabbitMQ连接
+        RABBITMQ_LOG_QUEUE: "log_event_queue", // 日志服务订阅的队列
+        LOG_DIR: "/app/logs/coloring", // <-- 日志文件存储路径 (容器内部)
+      },
+      env_production: {
+        NODE_ENV: "production",
+        RABBITMQ_URL: "amqp://coloring:coloring123.@rabbitmq:5672", // RabbitMQ连接
+        RABBITMQ_LOG_QUEUE: "log_event_queue", // 日志服务订阅的队列
+        LOG_DIR: "/mnt/volume_sfo2_04/logs", // <-- 日志文件存储路径 (容器内部)
+      },
+    },
+    {
+      name: "ingestor-service", // <-- 新增的数据摄取器进程
+      script: "dist/services/ingestor-service.js", // 指向编译后的摄取器服务文件
+      instances: 1, // 可以根据处理能力调整实例数量
+      watch: false,
+      max_memory_restart: "1G", // 根据数据量和处理逻辑调整内存限制
+      env: {
+        NODE_ENV: "production",
+        RABBITMQ_URL: "amqp://coloring:coloring123.@rabbitmq:5672", // RabbitMQ连接
+        RABBITMQ_OMS_QUEUE: "oms-event-queue", // 摄取器订阅的队列
+        MONGO_URI: "mongodb://mongodb:27017/omsdb", // MongoDB连接
+        CLICKHOUSE_HOST: "http://clickhouse:8123", // ClickHouse连接
+        CLICKHOUSE_DATABASE: "omsdb", // ClickHouse数据库名
+        CLICKHOUSE_USER: "ckuser", // ClickHouse用户
+        CLICKHOUSE_PASSWORD: "ckpassword", // ClickHouse密码
+      },
+    },
+  ],
+};

+ 3103 - 0
oms/package-lock.json

@@ -0,0 +1,3103 @@
+{
+  "name": "oms",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "oms",
+      "version": "1.0.0",
+      "hasInstallScript": true,
+      "license": "ISC",
+      "dependencies": {
+        "@clickhouse/client": "^1.12.1",
+        "amqplib": "^0.10.8",
+        "date-fns": "^4.1.0",
+        "dayjs": "^1.11.13",
+        "dotenv": "^17.2.1",
+        "express": "^5.1.0",
+        "moment": "^2.30.1",
+        "mongodb": "^5.0.0",
+        "mongoose": "^7.0.0",
+        "node-cron": "^4.2.1",
+        "pm2": "^6.0.8",
+        "redis": "^5.8.1",
+        "rotating-file-stream": "^3.2.6",
+        "uuid": "^11.1.0"
+      },
+      "devDependencies": {
+        "@types/amqplib": "^0.10.7",
+        "@types/dotenv": "^6.1.1",
+        "@types/express": "^5.0.3",
+        "@types/moment": "^2.11.29",
+        "@types/mongodb": "^4.0.6",
+        "@types/mongoose": "^5.11.96",
+        "@types/node": "^24.3.0",
+        "@types/node-cron": "^3.0.11",
+        "@types/redis": "^4.0.10",
+        "@types/uuid": "^10.0.0",
+        "ts-node": "^10.9.2",
+        "typescript": "^5.9.2"
+      }
+    },
+    "node_modules/@clickhouse/client": {
+      "version": "1.12.1",
+      "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.12.1.tgz",
+      "integrity": "sha512-7ORY85rphRazqHzImNXMrh4vsaPrpetFoTWpZYueCO2bbO6PXYDXp/GQ4DgxnGIqbWB/Di1Ai+Xuwq2o7DJ36A==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@clickhouse/client-common": "1.12.1"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/@clickhouse/client-common": {
+      "version": "1.12.1",
+      "resolved": "https://registry.npmjs.org/@clickhouse/client-common/-/client-common-1.12.1.tgz",
+      "integrity": "sha512-ccw1N6hB4+MyaAHIaWBwGZ6O2GgMlO99FlMj0B0UEGfjxM9v5dYVYql6FpP19rMwrVAroYs/IgX2vyZEBvzQLg==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/@cspotcode/source-map-support": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+      "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "0.3.9"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.9",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+      "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "node_modules/@mongodb-js/saslprep": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz",
+      "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "sparse-bitfield": "^3.0.3"
+      }
+    },
+    "node_modules/@pm2/agent": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@pm2/agent/-/agent-2.1.1.tgz",
+      "integrity": "sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==",
+      "license": "AGPL-3.0",
+      "dependencies": {
+        "async": "~3.2.0",
+        "chalk": "~3.0.0",
+        "dayjs": "~1.8.24",
+        "debug": "~4.3.1",
+        "eventemitter2": "~5.0.1",
+        "fast-json-patch": "^3.1.0",
+        "fclone": "~1.0.11",
+        "pm2-axon": "~4.0.1",
+        "pm2-axon-rpc": "~0.7.0",
+        "proxy-agent": "~6.4.0",
+        "semver": "~7.5.0",
+        "ws": "~7.5.10"
+      }
+    },
+    "node_modules/@pm2/agent/node_modules/dayjs": {
+      "version": "1.8.36",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.36.tgz",
+      "integrity": "sha512-3VmRXEtw7RZKAf+4Tv1Ym9AGeo8r8+CjDi26x+7SYQil1UqtqdaokhzoEJohqlzt0m5kacJSDhJQkG/LWhpRBw==",
+      "license": "MIT"
+    },
+    "node_modules/@pm2/agent/node_modules/debug": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+      "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@pm2/agent/node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@pm2/agent/node_modules/semver": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+      "license": "ISC",
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@pm2/io": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.1.0.tgz",
+      "integrity": "sha512-IxHuYURa3+FQ6BKePlgChZkqABUKFYH6Bwbw7V/pWU1pP6iR1sCI26l7P9ThUEB385ruZn/tZS3CXDUF5IA1NQ==",
+      "license": "Apache-2",
+      "dependencies": {
+        "async": "~2.6.1",
+        "debug": "~4.3.1",
+        "eventemitter2": "^6.3.1",
+        "require-in-the-middle": "^5.0.0",
+        "semver": "~7.5.4",
+        "shimmer": "^1.2.0",
+        "signal-exit": "^3.0.3",
+        "tslib": "1.9.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      }
+    },
+    "node_modules/@pm2/io/node_modules/async": {
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.14"
+      }
+    },
+    "node_modules/@pm2/io/node_modules/debug": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+      "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@pm2/io/node_modules/eventemitter2": {
+      "version": "6.4.9",
+      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
+      "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
+      "license": "MIT"
+    },
+    "node_modules/@pm2/io/node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@pm2/io/node_modules/semver": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+      "license": "ISC",
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@pm2/js-api": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.0.tgz",
+      "integrity": "sha512-nmWzrA/BQZik3VBz+npRcNIu01kdBhWL0mxKmP1ciF/gTcujPTQqt027N9fc1pK9ERM8RipFhymw7RcmCyOEYA==",
+      "license": "Apache-2",
+      "dependencies": {
+        "async": "^2.6.3",
+        "debug": "~4.3.1",
+        "eventemitter2": "^6.3.1",
+        "extrareqp2": "^1.0.0",
+        "ws": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/@pm2/js-api/node_modules/async": {
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.14"
+      }
+    },
+    "node_modules/@pm2/js-api/node_modules/debug": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+      "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@pm2/js-api/node_modules/eventemitter2": {
+      "version": "6.4.9",
+      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz",
+      "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
+      "license": "MIT"
+    },
+    "node_modules/@pm2/pm2-version-check": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz",
+      "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.1"
+      }
+    },
+    "node_modules/@redis/bloom": {
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.8.1.tgz",
+      "integrity": "sha512-hJOJr/yX6BttnyZ+nxD3Ddiu2lPig4XJjyAK1v7OSHOJNUTfn3RHBryB9wgnBMBdkg9glVh2AjItxIXmr600MA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "@redis/client": "^5.8.1"
+      }
+    },
+    "node_modules/@redis/client": {
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.1.tgz",
+      "integrity": "sha512-hD5Tvv7G0t8b3w8ao3kQ4jEPUmUUC6pqA18c8ciYF5xZGfUGBg0olQHW46v6qSt4O5bxOuB3uV7pM6H5wEjBwA==",
+      "license": "MIT",
+      "dependencies": {
+        "cluster-key-slot": "1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/@redis/json": {
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.8.1.tgz",
+      "integrity": "sha512-kyvM8Vn+WjJI++nRsIoI9TbdfCs1/TgD0Hp7Z7GiG6W4IEBzkXGQakli+R5BoJzUfgh7gED2fkncYy1NLprMNg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "@redis/client": "^5.8.1"
+      }
+    },
+    "node_modules/@redis/search": {
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.8.1.tgz",
+      "integrity": "sha512-CzuKNTInTNQkxqehSn7QiYcM+th+fhjQn5ilTvksP1wPjpxqK0qWt92oYg3XZc3tO2WuXkqDvTujc4D7kb6r/A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "@redis/client": "^5.8.1"
+      }
+    },
+    "node_modules/@redis/time-series": {
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.8.1.tgz",
+      "integrity": "sha512-klvdR96U9oSOyqvcectoAGhYlMOnMS3I5UWUOgdBn1buMODiwM/E4Eds7gxldKmtowe4rLJSF1CyIqyZTjy8Ow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "@redis/client": "^5.8.1"
+      }
+    },
+    "node_modules/@tootallnate/quickjs-emscripten": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+      "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node10": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+      "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node12": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+      "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node14": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+      "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@tsconfig/node16": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+      "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/amqplib": {
+      "version": "0.10.7",
+      "resolved": "https://registry.npmjs.org/@types/amqplib/-/amqplib-0.10.7.tgz",
+      "integrity": "sha512-IVj3avf9AQd2nXCx0PGk/OYq7VmHiyNxWFSb5HhU9ATh+i+gHWvVcljFTcTWQ/dyHJCTrzCixde+r/asL2ErDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/dotenv": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz",
+      "integrity": "sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz",
+      "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "*"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.0.7",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz",
+      "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/mime": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+      "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/moment": {
+      "version": "2.11.29",
+      "resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.11.29.tgz",
+      "integrity": "sha512-D5WIgbLYQzvgfsDnBhZFSTnt/BjGPOE+Jsh3k1BYYijJAkrn7ceeLvU4jtjKKXXuXN42O3ARlU4D/P9ezbQYFA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/mongodb": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-4.0.6.tgz",
+      "integrity": "sha512-XTbn1Z1j7fHzC1Vkd9LYO48lO2C581r+oRCi/KNzcTHIri7hEaya8r9vxoHJiKr+oeUWVK69+9xr84Mp+aReaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mongodb": "*"
+      }
+    },
+    "node_modules/@types/mongoose": {
+      "version": "5.11.96",
+      "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.11.96.tgz",
+      "integrity": "sha512-keiY22ljJtXyM7osgScmZOHV6eL5VFUD5tQumlu+hjS++HND5nM8jNEdj5CSWfKIJpVwQfPuwQ2SfBqUnCAVRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mongoose": "*"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "24.3.0",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
+      "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~7.10.0"
+      }
+    },
+    "node_modules/@types/node-cron": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
+      "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/redis": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.10.tgz",
+      "integrity": "sha512-7CLy5b5fzzEGVcOccgZjoMlNpPhX6d10jEeRy2YWbFuaMNrSPc9ExRsMYsd+0VxvEHucf4EWx24Ja7cSU1FGUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "redis": "*"
+      }
+    },
+    "node_modules/@types/send": {
+      "version": "0.17.5",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
+      "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/mime": "^1",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "1.15.8",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz",
+      "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/webidl-conversions": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+      "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/whatwg-url": {
+      "version": "8.2.2",
+      "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
+      "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/webidl-conversions": "*"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-walk": {
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+      "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "acorn": "^8.11.0"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/amp": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/amp/-/amp-0.3.1.tgz",
+      "integrity": "sha512-OwIuC4yZaRogHKiuU5WlMR5Xk/jAcpPtawWL05Gj8Lvm2F6mwoJt4O/bHI+DHwG79vWd+8OFYM4/BzYqyRd3qw==",
+      "license": "MIT"
+    },
+    "node_modules/amp-message": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/amp-message/-/amp-message-0.1.2.tgz",
+      "integrity": "sha512-JqutcFwoU1+jhv7ArgW38bqrE+LQdcRv4NxNw0mp0JHQyB6tXesWRjtYKlDgHRY2o3JE5UTaBGUK8kSWUdxWUg==",
+      "license": "MIT",
+      "dependencies": {
+        "amp": "0.3.1"
+      }
+    },
+    "node_modules/amqplib": {
+      "version": "0.10.8",
+      "resolved": "https://registry.npmjs.org/amqplib/-/amqplib-0.10.8.tgz",
+      "integrity": "sha512-Tfn1O9sFgAP8DqeMEpt2IacsVTENBpblB3SqLdn0jK2AeX8iyCvbptBc8lyATT9bQ31MsjVwUSQ1g8f4jHOUfw==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-more-ints": "~1.0.0",
+        "url-parse": "~1.5.10"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ansi-colors": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+      "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/ansis": {
+      "version": "4.0.0-node10",
+      "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.0.0-node10.tgz",
+      "integrity": "sha512-BRrU0Bo1X9dFGw6KgGz6hWrqQuOlVEDOzkb0QSLZY9sXHqA7pNj7yHPVJRz7y/rj4EOJ3d/D5uxH+ee9leYgsg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "license": "ISC",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/arg": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+      "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "license": "Python-2.0"
+    },
+    "node_modules/ast-types": {
+      "version": "0.13.4",
+      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+      "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/ast-types/node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/async": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+      "license": "MIT"
+    },
+    "node_modules/basic-ftp": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
+      "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/blessed": {
+      "version": "0.1.81",
+      "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
+      "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==",
+      "license": "MIT",
+      "bin": {
+        "blessed": "bin/tput.js"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/bodec": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz",
+      "integrity": "sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+      "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.0",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.6.3",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.0",
+        "raw-body": "^3.0.0",
+        "type-is": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/bson": {
+      "version": "5.5.1",
+      "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz",
+      "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=14.20.1"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "license": "MIT"
+    },
+    "node_modules/buffer-more-ints": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz",
+      "integrity": "sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg==",
+      "license": "MIT"
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+      "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/charm": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz",
+      "integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==",
+      "license": "MIT/X11"
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "license": "MIT",
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/cli-tableau": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz",
+      "integrity": "sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==",
+      "dependencies": {
+        "chalk": "3.0.0"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/cluster-key-slot": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+      "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "license": "MIT"
+    },
+    "node_modules/commander": {
+      "version": "2.15.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz",
+      "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==",
+      "license": "MIT"
+    },
+    "node_modules/content-disposition": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+      "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/create-require": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+      "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/croner": {
+      "version": "4.1.97",
+      "resolved": "https://registry.npmjs.org/croner/-/croner-4.1.97.tgz",
+      "integrity": "sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==",
+      "license": "MIT"
+    },
+    "node_modules/culvert": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz",
+      "integrity": "sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==",
+      "license": "MIT"
+    },
+    "node_modules/data-uri-to-buffer": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+      "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/date-fns": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+      "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/kossnocorp"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/degenerator": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+      "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ast-types": "^0.13.4",
+        "escodegen": "^2.1.0",
+        "esprima": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/diff": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+      "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "17.2.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
+      "integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "license": "MIT",
+      "dependencies": {
+        "ansi-colors": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/escodegen": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+      "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esprima": "^4.0.1",
+        "estraverse": "^5.2.0",
+        "esutils": "^2.0.2"
+      },
+      "bin": {
+        "escodegen": "bin/escodegen.js",
+        "esgenerate": "bin/esgenerate.js"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "optionalDependencies": {
+        "source-map": "~0.6.1"
+      }
+    },
+    "node_modules/esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "license": "BSD-2-Clause",
+      "bin": {
+        "esparse": "bin/esparse.js",
+        "esvalidate": "bin/esvalidate.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/eventemitter2": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz",
+      "integrity": "sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==",
+      "license": "MIT"
+    },
+    "node_modules/express": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+      "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.0",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/extrareqp2": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/extrareqp2/-/extrareqp2-1.0.0.tgz",
+      "integrity": "sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.14.0"
+      }
+    },
+    "node_modules/fast-json-patch": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
+      "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
+      "license": "MIT"
+    },
+    "node_modules/fclone": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz",
+      "integrity": "sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==",
+      "license": "MIT"
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+      "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-uri": {
+      "version": "6.0.5",
+      "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",
+      "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",
+      "license": "MIT",
+      "dependencies": {
+        "basic-ftp": "^5.0.2",
+        "data-uri-to-buffer": "^6.0.2",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/git-node-fs": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/git-node-fs/-/git-node-fs-1.0.0.tgz",
+      "integrity": "sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==",
+      "license": "MIT"
+    },
+    "node_modules/git-sha1": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/git-sha1/-/git-sha1-0.1.2.tgz",
+      "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==",
+      "license": "MIT"
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+      "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "2.0.0",
+        "inherits": "2.0.4",
+        "setprototypeof": "1.2.0",
+        "statuses": "2.0.1",
+        "toidentifier": "1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/http-errors/node_modules/statuses": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+      "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/ip-address": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+      "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "license": "MIT",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/js-git": {
+      "version": "0.7.8",
+      "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz",
+      "integrity": "sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==",
+      "license": "MIT",
+      "dependencies": {
+        "bodec": "^0.1.0",
+        "culvert": "^0.1.2",
+        "git-sha1": "^0.1.2",
+        "pako": "^0.2.5"
+      }
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-stringify-safe": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
+      "license": "ISC",
+      "optional": true
+    },
+    "node_modules/kareem": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz",
+      "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "7.18.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+      "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/make-error": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+      "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/memory-pager": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+      "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+      "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "license": "MIT",
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/module-details-from-path": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
+      "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
+      "license": "MIT"
+    },
+    "node_modules/moment": {
+      "version": "2.30.1",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+      "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mongodb": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz",
+      "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "bson": "^5.5.0",
+        "mongodb-connection-string-url": "^2.6.0",
+        "socks": "^2.7.1"
+      },
+      "engines": {
+        "node": ">=14.20.1"
+      },
+      "optionalDependencies": {
+        "@mongodb-js/saslprep": "^1.1.0"
+      },
+      "peerDependencies": {
+        "@aws-sdk/credential-providers": "^3.188.0",
+        "@mongodb-js/zstd": "^1.0.0",
+        "kerberos": "^1.0.0 || ^2.0.0",
+        "mongodb-client-encryption": ">=2.3.0 <3",
+        "snappy": "^7.2.2"
+      },
+      "peerDependenciesMeta": {
+        "@aws-sdk/credential-providers": {
+          "optional": true
+        },
+        "@mongodb-js/zstd": {
+          "optional": true
+        },
+        "kerberos": {
+          "optional": true
+        },
+        "mongodb-client-encryption": {
+          "optional": true
+        },
+        "snappy": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mongodb-connection-string-url": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
+      "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/whatwg-url": "^8.2.1",
+        "whatwg-url": "^11.0.0"
+      }
+    },
+    "node_modules/mongoose": {
+      "version": "7.8.7",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.7.tgz",
+      "integrity": "sha512-5Bo4CrUxrPITrhMKsqUTOkXXo2CoRC5tXxVQhnddCzqDMwRXfyStrxj1oY865g8gaekSBhxAeNkYyUSJvGm9Hw==",
+      "license": "MIT",
+      "dependencies": {
+        "bson": "^5.5.0",
+        "kareem": "2.5.1",
+        "mongodb": "5.9.2",
+        "mpath": "0.9.0",
+        "mquery": "5.0.0",
+        "ms": "2.1.3",
+        "sift": "16.0.1"
+      },
+      "engines": {
+        "node": ">=14.20.1"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mongoose"
+      }
+    },
+    "node_modules/mpath": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+      "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/mquery": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
+      "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4.x"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/mute-stream": {
+      "version": "0.0.8",
+      "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
+      "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
+      "license": "ISC"
+    },
+    "node_modules/needle": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz",
+      "integrity": "sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^3.2.6",
+        "iconv-lite": "^0.4.4",
+        "sax": "^1.2.4"
+      },
+      "bin": {
+        "needle": "bin/needle"
+      },
+      "engines": {
+        "node": ">= 4.4.x"
+      }
+    },
+    "node_modules/needle/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/needle/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/netmask": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
+      "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/node-cron": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
+      "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/pac-proxy-agent": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+      "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+      "license": "MIT",
+      "dependencies": {
+        "@tootallnate/quickjs-emscripten": "^0.23.0",
+        "agent-base": "^7.1.2",
+        "debug": "^4.3.4",
+        "get-uri": "^6.0.1",
+        "http-proxy-agent": "^7.0.0",
+        "https-proxy-agent": "^7.0.6",
+        "pac-resolver": "^7.0.1",
+        "socks-proxy-agent": "^8.0.5"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/pac-resolver": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+      "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+      "license": "MIT",
+      "dependencies": {
+        "degenerator": "^5.0.0",
+        "netmask": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/pako": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+      "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+      "license": "MIT"
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "license": "MIT"
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
+      "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pidusage": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-3.0.2.tgz",
+      "integrity": "sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "^5.2.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/pm2": {
+      "version": "6.0.8",
+      "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.8.tgz",
+      "integrity": "sha512-y7sO+UuGjfESK/ChRN+efJKAsHrBd95GY2p1GQfjVTtOfFtUfiW0NOuUhP5dN5QTF2F0EWcepgkLqbF32j90Iw==",
+      "license": "AGPL-3.0",
+      "dependencies": {
+        "@pm2/agent": "~2.1.1",
+        "@pm2/io": "~6.1.0",
+        "@pm2/js-api": "~0.8.0",
+        "@pm2/pm2-version-check": "latest",
+        "ansis": "4.0.0-node10",
+        "async": "~3.2.6",
+        "blessed": "0.1.81",
+        "chokidar": "^3.5.3",
+        "cli-tableau": "^2.0.0",
+        "commander": "2.15.1",
+        "croner": "~4.1.92",
+        "dayjs": "~1.11.13",
+        "debug": "^4.3.7",
+        "enquirer": "2.3.6",
+        "eventemitter2": "5.0.1",
+        "fclone": "1.0.11",
+        "js-yaml": "~4.1.0",
+        "mkdirp": "1.0.4",
+        "needle": "2.4.0",
+        "pidusage": "~3.0",
+        "pm2-axon": "~4.0.1",
+        "pm2-axon-rpc": "~0.7.1",
+        "pm2-deploy": "~1.0.2",
+        "pm2-multimeter": "^0.1.2",
+        "promptly": "^2",
+        "semver": "^7.6.2",
+        "source-map-support": "0.5.21",
+        "sprintf-js": "1.1.2",
+        "vizion": "~2.2.1"
+      },
+      "bin": {
+        "pm2": "bin/pm2",
+        "pm2-dev": "bin/pm2-dev",
+        "pm2-docker": "bin/pm2-docker",
+        "pm2-runtime": "bin/pm2-runtime"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      },
+      "optionalDependencies": {
+        "pm2-sysmonit": "^1.2.8"
+      }
+    },
+    "node_modules/pm2-axon": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/pm2-axon/-/pm2-axon-4.0.1.tgz",
+      "integrity": "sha512-kES/PeSLS8orT8dR5jMlNl+Yu4Ty3nbvZRmaAtROuVm9nYYGiaoXqqKQqQYzWQzMYWUKHMQTvBlirjE5GIIxqg==",
+      "license": "MIT",
+      "dependencies": {
+        "amp": "~0.3.1",
+        "amp-message": "~0.1.1",
+        "debug": "^4.3.1",
+        "escape-string-regexp": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=5"
+      }
+    },
+    "node_modules/pm2-axon-rpc": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/pm2-axon-rpc/-/pm2-axon-rpc-0.7.1.tgz",
+      "integrity": "sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.1"
+      },
+      "engines": {
+        "node": ">=5"
+      }
+    },
+    "node_modules/pm2-deploy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pm2-deploy/-/pm2-deploy-1.0.2.tgz",
+      "integrity": "sha512-YJx6RXKrVrWaphEYf++EdOOx9EH18vM8RSZN/P1Y+NokTKqYAca/ejXwVLyiEpNju4HPZEk3Y2uZouwMqUlcgg==",
+      "license": "MIT",
+      "dependencies": {
+        "run-series": "^1.1.8",
+        "tv4": "^1.3.0"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pm2-multimeter": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/pm2-multimeter/-/pm2-multimeter-0.1.2.tgz",
+      "integrity": "sha512-S+wT6XfyKfd7SJIBqRgOctGxaBzUOmVQzTAS+cg04TsEUObJVreha7lvCfX8zzGVr871XwCSnHUU7DQQ5xEsfA==",
+      "license": "MIT/X11",
+      "dependencies": {
+        "charm": "~0.1.1"
+      }
+    },
+    "node_modules/pm2-sysmonit": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/pm2-sysmonit/-/pm2-sysmonit-1.2.8.tgz",
+      "integrity": "sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==",
+      "license": "Apache",
+      "optional": true,
+      "dependencies": {
+        "async": "^3.2.0",
+        "debug": "^4.3.1",
+        "pidusage": "^2.0.21",
+        "systeminformation": "^5.7",
+        "tx2": "~1.0.4"
+      }
+    },
+    "node_modules/pm2-sysmonit/node_modules/pidusage": {
+      "version": "2.0.21",
+      "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz",
+      "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "safe-buffer": "^5.2.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/promptly": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/promptly/-/promptly-2.2.0.tgz",
+      "integrity": "sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA==",
+      "license": "MIT",
+      "dependencies": {
+        "read": "^1.0.4"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/proxy-agent": {
+      "version": "6.4.0",
+      "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz",
+      "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.0.2",
+        "debug": "^4.3.4",
+        "http-proxy-agent": "^7.0.1",
+        "https-proxy-agent": "^7.0.3",
+        "lru-cache": "^7.14.1",
+        "pac-proxy-agent": "^7.0.1",
+        "proxy-from-env": "^1.1.0",
+        "socks-proxy-agent": "^8.0.2"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/querystringify": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+      "license": "MIT"
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
+      "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.6.3",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/read": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
+      "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==",
+      "license": "ISC",
+      "dependencies": {
+        "mute-stream": "~0.0.4"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "license": "MIT",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/redis": {
+      "version": "5.8.1",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-5.8.1.tgz",
+      "integrity": "sha512-RZjBKYX/qFF809x6vDcE5VA6L3MmiuT+BkbXbIyyyeU0lPD47V4z8qTzN+Z/kKFwpojwCItOfaItYuAjNs8pTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@redis/bloom": "5.8.1",
+        "@redis/client": "5.8.1",
+        "@redis/json": "5.8.1",
+        "@redis/search": "5.8.1",
+        "@redis/time-series": "5.8.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/require-in-the-middle": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.2.0.tgz",
+      "integrity": "sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.1.1",
+        "module-details-from-path": "^1.0.3",
+        "resolve": "^1.22.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+      "license": "MIT"
+    },
+    "node_modules/resolve": {
+      "version": "1.22.10",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+      "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+      "license": "MIT",
+      "dependencies": {
+        "is-core-module": "^2.16.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/rotating-file-stream": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/rotating-file-stream/-/rotating-file-stream-3.2.6.tgz",
+      "integrity": "sha512-r8yShzMWUvWXkRzbOXDM1fEaMpc3qo2PzK7bBH/0p0Nl/uz8Mud/Y+0XTQxe3kbSnDF7qBH2tSe83WDKA7o3ww==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0"
+      },
+      "funding": {
+        "url": "https://www.blockchain.com/btc/address/12p1p5q7sK75tPyuesZmssiMYr4TKzpSCN"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/run-series": {
+      "version": "1.1.9",
+      "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.9.tgz",
+      "integrity": "sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/sax": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
+      "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
+      "license": "ISC"
+    },
+    "node_modules/semver": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+      "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.5",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "mime-types": "^3.0.1",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/shimmer": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
+      "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/sift": {
+      "version": "16.0.1",
+      "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz",
+      "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==",
+      "license": "MIT"
+    },
+    "node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "license": "ISC"
+    },
+    "node_modules/smart-buffer": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+      "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6.0.0",
+        "npm": ">= 3.0.0"
+      }
+    },
+    "node_modules/socks": {
+      "version": "2.8.7",
+      "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
+      "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "^10.0.1",
+        "smart-buffer": "^4.2.0"
+      },
+      "engines": {
+        "node": ">= 10.0.0",
+        "npm": ">= 3.0.0"
+      }
+    },
+    "node_modules/socks-proxy-agent": {
+      "version": "8.0.5",
+      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+      "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "^4.3.4",
+        "socks": "^2.8.3"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/sparse-bitfield": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+      "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "memory-pager": "^1.0.2"
+      }
+    },
+    "node_modules/sprintf-js": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
+      "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/systeminformation": {
+      "version": "5.27.7",
+      "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz",
+      "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==",
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin",
+        "linux",
+        "win32",
+        "freebsd",
+        "openbsd",
+        "netbsd",
+        "sunos",
+        "android"
+      ],
+      "bin": {
+        "systeminformation": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      },
+      "funding": {
+        "type": "Buy me a coffee",
+        "url": "https://www.buymeacoffee.com/systeminfo"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
+      "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/ts-node": {
+      "version": "10.9.2",
+      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+      "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cspotcode/source-map-support": "^0.8.0",
+        "@tsconfig/node10": "^1.0.7",
+        "@tsconfig/node12": "^1.0.7",
+        "@tsconfig/node14": "^1.0.0",
+        "@tsconfig/node16": "^1.0.2",
+        "acorn": "^8.4.1",
+        "acorn-walk": "^8.1.1",
+        "arg": "^4.1.0",
+        "create-require": "^1.1.0",
+        "diff": "^4.0.1",
+        "make-error": "^1.1.1",
+        "v8-compile-cache-lib": "^3.0.1",
+        "yn": "3.1.1"
+      },
+      "bin": {
+        "ts-node": "dist/bin.js",
+        "ts-node-cwd": "dist/bin-cwd.js",
+        "ts-node-esm": "dist/bin-esm.js",
+        "ts-node-script": "dist/bin-script.js",
+        "ts-node-transpile-only": "dist/bin-transpile.js",
+        "ts-script": "dist/bin-script-deprecated.js"
+      },
+      "peerDependencies": {
+        "@swc/core": ">=1.2.50",
+        "@swc/wasm": ">=1.2.50",
+        "@types/node": "*",
+        "typescript": ">=2.7"
+      },
+      "peerDependenciesMeta": {
+        "@swc/core": {
+          "optional": true
+        },
+        "@swc/wasm": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tslib": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz",
+      "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/tv4": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
+      "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==",
+      "license": [
+        {
+          "type": "Public Domain",
+          "url": "http://geraintluff.github.io/tv4/LICENSE.txt"
+        },
+        {
+          "type": "MIT",
+          "url": "http://jsonary.com/LICENSE.txt"
+        }
+      ],
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/tx2": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz",
+      "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "json-stringify-safe": "^5.0.1"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+      "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^1.0.5",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
+      "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "7.10.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
+      "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/url-parse": {
+      "version": "1.5.10",
+      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "querystringify": "^2.1.1",
+        "requires-port": "^1.0.0"
+      }
+    },
+    "node_modules/uuid": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
+      "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/esm/bin/uuid"
+      }
+    },
+    "node_modules/v8-compile-cache-lib": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+      "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vizion": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz",
+      "integrity": "sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "async": "^2.6.3",
+        "git-node-fs": "^1.0.0",
+        "ini": "^1.3.5",
+        "js-git": "^0.7.8"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/vizion/node_modules/async": {
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "11.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
+      "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^3.0.0",
+        "webidl-conversions": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "7.5.10",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
+      "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.3.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": "^5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "license": "ISC"
+    },
+    "node_modules/yn": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+      "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    }
+  }
+}

+ 51 - 0
oms/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "oms",
+  "version": "1.0.0",
+  "main": "dist/app.js",
+  "scripts": {
+    "start": "node dist/app.js",
+    "dev": "nodemon src/app.ts",
+    "build": "tsc",
+    "start:prod": "pm2 start ecosystem.config.js --env production",
+    "postinstall": "npm run build",
+    "event-api-service": "nodemon services/event-api-service.ts",
+    "log-service": "nodemon services/log-service.ts",
+    "ingestor-service": "nodemon services/ingestor-service.ts",
+    "ingest-history": "ts-node src/scripts/ingestHistoricalData.ts",
+    "cron-jobs": "ts-node services/cron-jobs/index.ts"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "description": "",
+  "dependencies": {
+    "@clickhouse/client": "^1.12.1",
+    "amqplib": "^0.10.8",
+    "date-fns": "^4.1.0",
+    "dayjs": "^1.11.13",
+    "dotenv": "^17.2.1",
+    "express": "^5.1.0",
+    "moment": "^2.30.1",
+    "mongodb": "^5.0.0",
+    "mongoose": "^7.0.0",
+    "node-cron": "^4.2.1",
+    "pm2": "^6.0.8",
+    "redis": "^5.8.1",
+    "rotating-file-stream": "^3.2.6",
+    "uuid": "^11.1.0"
+  },
+  "devDependencies": {
+    "@types/amqplib": "^0.10.7",
+    "@types/dotenv": "^6.1.1",
+    "@types/express": "^5.0.3",
+    "@types/moment": "^2.11.29",
+    "@types/mongodb": "^4.0.6",
+    "@types/mongoose": "^5.11.96",
+    "@types/node": "^24.3.0",
+    "@types/node-cron": "^3.0.11",
+    "@types/redis": "^4.0.10",
+    "@types/uuid": "^10.0.0",
+    "ts-node": "^10.9.2",
+    "typescript": "^5.9.2"
+  }
+}

+ 175 - 0
oms/services/cron-jobs/done-rate.ts

@@ -0,0 +1,175 @@
+// oms/services/cron-jobs/done-rate.ts
+
+import dayjs from "dayjs";
+import doneRateService from "../../src/services/doneRateService"; // 导入 DoneRateService
+import artService from "../../src/services/artService"; // 👈 导入 ArtService
+import { clickhouseService } from "../../src/app"; // 导入 ClickhouseService 实例
+import mongoose from "mongoose"; // 导入 mongoose 用于处理 ObjectId
+import Art, { IArt } from "../../src/models/artModel"; // 👈 导入 Art 模型和 IArt 接口
+
+// ClickHouse 表名
+const CLICKHOUSE_EVENTS_TABLE = "events_raw"; // 确保与 ClickHouseService 中的表名一致
+
+/**
+ * ClickHouse 查询结果接口:每日每个作品的独立开始用户数
+ */
+interface ClickHouseStartCountResult {
+  res: string; // 作品 ID
+  unique_starts: number; // 独立开始用户数
+}
+
+/**
+ * ClickHouse 查询结果接口:每日每个作品的独立完成用户数
+ */
+interface ClickHouseDoneCountResult {
+  res: string; // 作品 ID
+  unique_dones: number; // 独立完成用户数
+}
+
+/**
+ * 每日统计昨天的作品完成率。
+ * 统计逻辑从 ClickHouse 中提取数据,得到每个作品的完成情况,并更新到 doneRateModel。
+ * 随后,根据这些日统计数据,累加更新 Art 表的总统计字段。
+ * @returns Promise<string> - 返回统计结果的摘要信息。
+ */
+async function run(): Promise<string> {
+  console.log("[DoneRate Cron] Starting daily done-rate calculation for yesterday...");
+
+  // 获取昨天和今天的日期
+  const yesterday = dayjs().subtract(1, "day");
+  const yesterdayYYYYMMDD = yesterday.format("YYYYMMDD");
+  const yesterdayStart = yesterday.startOf("day").toDate();
+  const yesterdayEnd = yesterday.endOf("day").toDate();
+
+  console.log(`[DoneRate Cron] Processing data for date: ${yesterdayYYYYMMDD}`);
+
+  try {
+    // --- 1. 从 ClickHouse 中提取数据 ---
+
+    // 查询昨天每个作品的独立开始用户数
+    const startCountsQuery = `
+      SELECT
+          res,
+          count(DISTINCT uid) AS unique_starts
+      FROM ${CLICKHOUSE_EVENTS_TABLE}
+      WHERE event = 'color_start'
+        AND time >= toDateTime('${dayjs(yesterdayStart).toISOString()}')
+        AND time < toDateTime('${dayjs(yesterdayEnd).toISOString()}')
+      GROUP BY res
+      HAVING res IS NOT NULL
+      FORMAT JSONEachRow
+    `;
+    const startResults = await clickhouseService.queryEvents<ClickHouseStartCountResult>(startCountsQuery);
+    const artworkStartCounts = new Map<string, number>();
+    startResults.forEach((row) => {
+      if (row.res && mongoose.Types.ObjectId.isValid(row.res)) {
+        artworkStartCounts.set(row.res, row.unique_starts);
+      } else {
+        console.warn(`[DoneRate Cron] Invalid artwork ID found in start_counts result: ${row.res}`);
+      }
+    });
+    console.log(`[DoneRate Cron] Retrieved ${startResults.length} unique start counts from ClickHouse.`);
+
+    // 查询昨天每个作品的独立完成用户数
+    const doneCountsQuery = `
+      SELECT
+          res,
+          count(DISTINCT uid) AS unique_dones
+      FROM ${CLICKHOUSE_EVENTS_TABLE}
+      WHERE event = 'color_done'
+        AND time >= toDateTime('${dayjs(yesterdayStart).toISOString()}')
+        AND time < toDateTime('${dayjs(yesterdayEnd).toISOString()}')
+      GROUP BY res
+      HAVING res IS NOT NULL
+      FORMAT JSONEachRow
+    `;
+    const doneResults = await clickhouseService.queryEvents<ClickHouseDoneCountResult>(doneCountsQuery);
+    const artworkDoneCounts = new Map<string, number>();
+    doneResults.forEach((row) => {
+      if (row.res && mongoose.Types.ObjectId.isValid(row.res)) {
+        artworkDoneCounts.set(row.res, row.unique_dones);
+      } else {
+        console.warn(`[DoneRate Cron] Invalid artwork ID found in done_counts result: ${row.res}`);
+      }
+    });
+    console.log(`[DoneRate Cron] Retrieved ${doneResults.length} unique done counts from ClickHouse.`);
+
+    // --- 2. 合并数据并更新 DoneRate 模型 ---
+    let updatedRecordsCount = 0; // for DoneRate
+    let createdRecordsCount = 0; // for DoneRate
+
+    // 遍历所有有开始事件的作品ID
+    for (const [resIdStr, startCount] of artworkStartCounts.entries()) {
+      const doneCount = artworkDoneCounts.get(resIdStr) || 0;
+      const resObjectId = new mongoose.Types.ObjectId(resIdStr);
+
+      // 使用 DoneRateService 来创建或更新记录
+      const doneRateDoc = await doneRateService.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
+      if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
+        createdRecordsCount++;
+      } else {
+        updatedRecordsCount++;
+      }
+      artworkDoneCounts.delete(resIdStr); // 已经处理过的作品ID从 doneCounts 中移除
+    }
+
+    // 处理只有完成事件但没有开始事件的作品 (通常不应发生,但以防万一)
+    for (const [resIdStr, doneCount] of artworkDoneCounts.entries()) {
+      const startCount = 0; // 没有开始事件,所以开始次数为0
+      const resObjectId = new mongoose.Types.ObjectId(resIdStr);
+
+      const doneRateDoc = await doneRateService.createOrUpdateDoneRate(yesterdayYYYYMMDD, resObjectId, startCount, doneCount);
+      if (doneRateDoc.createdAt.getTime() === doneRateDoc.updatedAt.getTime()) {
+        createdRecordsCount++;
+      } else {
+        updatedRecordsCount++;
+      }
+    }
+
+    const totalProcessedArtworks = createdRecordsCount + updatedRecordsCount;
+
+    console.log(`[DoneRate Cron] DoneRate model update completed. Total artworks processed: ${totalProcessedArtworks}. Created: ${createdRecordsCount}, Updated: ${updatedRecordsCount}.`);
+
+    // --- 3. 获取昨天的所有 DoneRate 记录,并更新 Art 表的统计字段 ---
+    let updatedArtworksCount = 0; // for Art model
+    const yesterdayDoneRates = await doneRateService.getDoneRatesByDate(yesterdayYYYYMMDD);
+    console.log(`[DoneRate Cron] Found ${yesterdayDoneRates.length} DoneRate records for yesterday to update Art table.`);
+
+    for (const doneRateDoc of yesterdayDoneRates) {
+      try {
+        const artworkId = doneRateDoc.res; // 获取作品 ObjectId
+        const currentArt = await artService.getArtById(artworkId.toString());
+
+        if (currentArt) {
+          // 累加总开始数和总完成数
+          const newTotalStartCount = (currentArt.totalStartCount || 0) + doneRateDoc.startCount;
+          const newTotalDoneCount = (currentArt.totalDoneCount || 0) + doneRateDoc.doneCount;
+
+          // 重新计算总完成率
+          const newCompletionRate = newTotalStartCount > 0 ? (newTotalDoneCount / newTotalStartCount) * 100 : 0;
+
+          // 更新 Art 文档
+          await artService.updateArt(artworkId.toString(), {
+            totalStartCount: newTotalStartCount,
+            totalDoneCount: newTotalDoneCount,
+            completionRate: newCompletionRate,
+          });
+          updatedArtworksCount++;
+        } else {
+          console.warn(`[DoneRate Cron] Art document with ID ${artworkId} not found for DoneRate record (date: ${doneRateDoc.date}). Skipping Art update.`);
+        }
+      } catch (artUpdateError) {
+        console.error(`[DoneRate Cron] Error updating Art document for artwork ID ${doneRateDoc.res}:`, artUpdateError);
+      }
+    }
+
+    const summary = `[DoneRate Cron] Daily done-rate calculation for ${yesterdayYYYYMMDD} completed. Total DoneRate processed: ${totalProcessedArtworks}. Created DoneRate: ${createdRecordsCount}, Updated DoneRate: ${updatedRecordsCount}. Updated Art records: ${updatedArtworksCount}.`;
+    console.log(summary);
+    return summary;
+  } catch (error) {
+    console.error(`[DoneRate Cron] Error during done-rate calculation for ${yesterdayYYYYMMDD}:`, error);
+    throw new Error("Failed to calculate daily done-rates."); // 抛出错误以通知 cron 调度器
+  }
+}
+
+export = { run }; // 导出 run 函数以供 cron-jobs/index.ts 使用

+ 72 - 0
oms/services/cron-jobs/index.ts

@@ -0,0 +1,72 @@
+// oms/services/cron-jobs/index.ts
+
+import cron from "node-cron"; // Import node-cron library
+
+// Define an interface for a cron job module
+interface CronJobModule {
+  run: () => Promise<any>; // Expecting a run function that returns a Promise
+}
+
+// Define the settings array for cron jobs
+// Each element: [name: string, schedule: string, jobModule: CronJobModule]
+const settings: [string, string, CronJobModule][] = [
+  // 假设这些文件将存在于 oms/services/cron-jobs/ 目录下
+  ["done-rate", "10 0 * * *", require("./done-rate") as CronJobModule], // 每天凌晨0点10分, 统计作品完成率
+  ["sync", "*/1 * * * *", require("./sync/sync-service") as CronJobModule], // 每10分钟跑一次同步
+];
+
+/**
+ * Starts all scheduled cron jobs.
+ * Includes database connection and task scheduling.
+ * @returns Promise<void>
+ */
+export async function startCronJobs(): Promise<void> {
+  console.log("[Cron Jobs] Initializing all scheduled tasks...");
+
+  // Iterate through settings and schedule each job
+  settings.forEach((setting) => {
+    const [name, schedule, job] = setting;
+    if (!job || typeof job.run !== "function") {
+      // Check if job module and run function exist
+      console.error(`[Cron Jobs] Job [${name}] is missing a run() function or is not a valid module. Skipping.`);
+      return;
+    }
+
+    console.log(`[Cron Jobs] Installing job [${name}] to run at '${schedule}'`);
+
+    cron.schedule(schedule, async () => {
+      const startTime = new Date().toLocaleString();
+      console.log(`[Cron Jobs] Running job [${name}]@'${schedule}' started @ ${startTime}`);
+      try {
+        const result = await job.run(); // Execute the job's run function
+        console.log(`[Cron Jobs] Job [${name}] completed successfully @ ${new Date().toLocaleString()}. Result:`, result);
+      } catch (error) {
+        console.error(`[Cron Jobs] Job [${name}] failed @ ${new Date().toLocaleString()}. Error:`, error);
+      }
+    });
+  });
+
+  console.log("[Cron Jobs] All cron jobs started.");
+}
+
+// If this file is run directly (e.g., using `node dist/services/cron-jobs/index.js`)
+if (require.main === module) {
+  startCronJobs().catch((error) => {
+    console.error("[Cron Jobs] Failed to start cron jobs:", error);
+    process.exit(1);
+  });
+
+  // Handle graceful shutdown
+  process.on("SIGINT", async () => {
+    console.log("[Cron Jobs] Shutting down...");
+    cron.getTasks().forEach((task) => task.stop()); // Stop all scheduled tasks
+    console.log("[Cron Jobs] All cron tasks stopped.");
+    process.exit(0);
+  });
+  process.on("SIGTERM", async () => {
+    console.log("[Cron Jobs] Shutting down...");
+    cron.getTasks().forEach((task) => task.stop());
+    console.log("[Cron Jobs] All cron tasks stopped.");
+    process.exit(0);
+  });
+}

+ 8 - 0
oms/services/cron-jobs/sync/schema-sync-seq.js

@@ -0,0 +1,8 @@
+const { Schema } = require("mongoose");
+
+// slave 记录同步event sequence
+let syncSeqSchema = new Schema({
+  seq: { type: Number, required: true, desc: '同步seq,只有一条记录' }
+});
+
+module.exports = syncSeqSchema;

+ 6 - 0
oms/services/cron-jobs/sync/sync-conn.js

@@ -0,0 +1,6 @@
+const mongoose = require("mongoose");
+
+const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost/omsdb?authSource=admin";
+const syncConn = mongoose.createConnection(MONGO_URI);
+
+module.exports = syncConn;

+ 7 - 0
oms/services/cron-jobs/sync/sync-seq.js

@@ -0,0 +1,7 @@
+const syncConn = require('./sync-conn');
+const syncSeqSchema = require('./schema-sync-seq');
+
+
+const SyncSeq = syncConn.model('SyncSeq', syncSeqSchema);
+
+module.exports = SyncSeq;

+ 98 - 0
oms/services/cron-jobs/sync/sync-service.js

@@ -0,0 +1,98 @@
+const MongoClient = require("mongoose").mongo.MongoClient;
+const SyncSeq = require("./sync-seq");
+const { format } = require("date-fns");
+
+const remotedb = "coloring_ol"; // 远端数据库
+const localdb = "omsdb"; // 本地的数据库
+/**
+ * sync from remote
+ */
+async function run() {
+  const MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost/omsdb?authSource=admin";
+  const REMOTE_SYNC_MONGO_URI = process.env.REMOTE_SYNC_MONGO_URI || "mongodb://coloring:coloring123.@hk.jccytech.cn:7881?authSource=admin";
+  const localClient = await new MongoClient(MONGO_URI).connect();
+  const remoteClient = await new MongoClient(REMOTE_SYNC_MONGO_URI).connect();
+
+  let remoteDB = remoteClient.db(remotedb);
+  let localDB = localClient.db(localdb);
+
+  console.log("Connect to remote mongodb " + REMOTE_SYNC_MONGO_URI + " success!");
+
+  // 读取远程syncevent表
+  const synceventTB = remoteDB.collection("syncevents");
+  // get current local slave sync seq (use mongoose)
+  let seq = -1;
+  let seqDoc = await SyncSeq.findOne();
+  if (seqDoc) seq = seqDoc.seq;
+  else seqDoc = new SyncSeq({});
+
+  let count, localtb, remotetb, localdoc, remotedoc;
+  setTimeout(async function cycleRun() {
+    try {
+      let eventDocs =
+        (await synceventTB
+          .find({ _id: { $gt: seq } })
+          .limit(200)
+          .toArray()) || [];
+      count = 0;
+      for (let i = 0; i < eventDocs.length; i++) {
+        let eventDoc = eventDocs[i];
+        // 只同步特定的arts表
+        if (eventDoc.tb == "arts") {
+          console.log(eventDoc);
+
+          if (!eventDoc.db) eventDoc.db = "coloring_ol";
+          // 对应的表
+          remotetb = remoteDB.collection(eventDoc.tb);
+          localtb = localDB.collection(eventDoc.tb);
+          if (remotetb && localtb) {
+            if (eventDoc.op == "remove") {
+              await localtb.deleteOne({ _id: eventDoc.rid });
+              console.log("sync remove : " + eventDoc.tb + " " + eventDoc.rid);
+            } else if (eventDoc.op == "save") {
+              remotedoc = await remotetb.findOne({ _id: eventDoc.rid });
+              localdoc = await localtb.findOne({ _id: eventDoc.rid });
+              if (!remotedoc) {
+                console.log("remote doc not found, may be deleted, skip: " + eventDoc.tb + " " + eventDoc.rid);
+              } else if (!localdoc) {
+                console.log("sync add :" + eventDoc.tb + " " + eventDoc.rid);
+                try {
+                  await localtb.insertOne(remotedoc);
+                } catch (e) {
+                  console.error(e);
+                }
+              } else {
+                console.log("sync update :" + eventDoc.tb + " " + eventDoc.rid);
+                remotedoc.totalStartCount = localdoc.totalStartCount;
+                remotedoc.totalDoneCount = localdoc.totalDoneCount;
+                remotedoc.completionRate = localdoc.completionRate;
+                await localtb.replaceOne({ _id: eventDoc.rid }, remotedoc);
+              }
+            }
+          }
+        }
+
+        seq = eventDoc._id;
+        seqDoc.seq = seq;
+        count++;
+      }
+
+      await seqDoc.save();
+
+      console.log(`${format(new Date(), "yyyy-MM-dd HH:mm")} sync event length: ${eventDocs.length}, precessed: ${count}`);
+    } catch (err) {
+      console.error(err);
+    }
+
+    if (count > 0) setTimeout(cycleRun, 0);
+    // else setTimeout(run, delay);
+  }, 0);
+}
+
+module.exports = {
+  run,
+};
+
+if (require.main == module) {
+  run().catch(console.error);
+}

+ 156 - 0
oms/services/event-api-service.ts

@@ -0,0 +1,156 @@
+// oms/src/event/app.ts
+
+// Load environment variables (e.g., RABBITMQ_URL, RABBITMQ_EXCHANGE, RABBITMQ_LOG_QUEUE, RABBITMQ_OMS_QUEUE, PORT)
+import * as dotenv from "dotenv";
+dotenv.config();
+
+import express, { Request, Response } from "express";
+import amqp from "amqplib"; // Import amqplib for RabbitMQ interaction
+
+import mongoose from "mongoose"; // 👈 导入 mongoose 用于生成 ObjectId
+import { v4 as uuidv4 } from "uuid"; // 👈 新增:导入 uuidv4 用于生成事件ID
+
+const app = express();
+const port = process.env.EVENT_PORT ? parseInt(process.env.EVENT_PORT, 10) : 3001; // Event API 监听端口,默认为 3001
+
+// RabbitMQ Configuration
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+const RABBITMQ_EXCHANGE_NAME = process.env.RABBITMQ_EXCHANGE || "event_exchange"; // <-- 从环境变量读取交换机名称
+const RABBITMQ_LOG_QUEUE = process.env.RABBITMQ_LOG_QUEUE || "log_event_queue"; // <-- 从环境变量读取日志队列名称
+const RABBITMQ_OMS_QUEUE = process.env.RABBITMQ_OMS_QUEUE || "oms_event_queue"; // <-- 从环境变量读取摄取器队列名称
+
+let amqpConnection: amqp.ChannelModel;
+let amqpChannel: amqp.Channel;
+
+// --- Initialize RabbitMQ Connection and Channel ---
+async function connectRabbitMQ() {
+  try {
+    amqpConnection = await amqp.connect(RABBITMQ_URL);
+    amqpConnection.on("error", (err) => {
+      console.error("[RabbitMQ] Connection error:", err);
+      // Implement reconnection logic here for production
+      setTimeout(connectRabbitMQ, 5000); // Attempt to reconnect after 5 seconds
+    });
+    amqpConnection.on("close", () => {
+      console.error("[RabbitMQ] Connection closed. Reconnecting...");
+      setTimeout(connectRabbitMQ, 5000); // Attempt to reconnect after 5 seconds
+    });
+
+    amqpChannel = await amqpConnection.createChannel();
+    console.log("[RabbitMQ] Channel created.");
+
+    // Assert the exchange for persistent broadcast
+    // 'fanout' exchange will broadcast messages to all bound queues.
+    await amqpChannel.assertExchange(RABBITMQ_EXCHANGE_NAME, "fanout", {
+      durable: true,
+    });
+    console.log(`[RabbitMQ] Exchange '${RABBITMQ_EXCHANGE_NAME}' asserted.`);
+
+    // Assert and bind the Log Queue
+    await amqpChannel.assertQueue(RABBITMQ_LOG_QUEUE, { durable: true });
+    console.log(`[RabbitMQ] Queue '${RABBITMQ_LOG_QUEUE}' asserted.`);
+    await amqpChannel.bindQueue(RABBITMQ_LOG_QUEUE, RABBITMQ_EXCHANGE_NAME, ""); // Routing key is ignored for fanout
+    console.log(`[RabbitMQ] Queue '${RABBITMQ_LOG_QUEUE}' bound to exchange '${RABBITMQ_EXCHANGE_NAME}'.`);
+
+    // Assert and bind the Ingestor Queue
+    await amqpChannel.assertQueue(RABBITMQ_OMS_QUEUE, { durable: true });
+    console.log(`[RabbitMQ] Queue '${RABBITMQ_OMS_QUEUE}' asserted.`);
+    await amqpChannel.bindQueue(RABBITMQ_OMS_QUEUE, RABBITMQ_EXCHANGE_NAME, ""); // Routing key is ignored for fanout
+    console.log(`[RabbitMQ] Queue '${RABBITMQ_OMS_QUEUE}' bound to exchange '${RABBITMQ_EXCHANGE_NAME}'.`);
+
+    console.log(`Event Producer connected to RabbitMQ: ${RABBITMQ_URL}`);
+  } catch (error) {
+    console.error("[RabbitMQ] Failed to connect or setup RabbitMQ:", error);
+    // Retry connection
+    setTimeout(connectRabbitMQ, 5000);
+  }
+}
+
+// --- Middleware ---
+// 服务部署在反向代理(如 Nginx)后面,需设置此项以正确获取客户端 IP
+app.set("trust proxy", true);
+app.use(express.json()); // To parse JSON request bodies
+
+// --- API Endpoint: /napi/event/v2 ---
+app.post("/napi/event/v2", async (req: Request, res: Response) => {
+  if (!amqpChannel) {
+    console.error("[Event API] RabbitMQ channel not available.");
+    return res.status(500).json({ message: "Server is not ready to process events." });
+  }
+
+  const eventData = req.body;
+  if (!eventData || Object.keys(eventData).length === 0) {
+    return res.status(400).json({ message: "Event data is required." });
+  }
+
+  // 添加必要字段
+  eventData.ip_client = req.ip;
+  eventData.create_at = new Date();
+  eventData.local_country = req.header("x-country-code") || "nil";
+  // 在纯事件生产者服务中,通常更推荐使用如 UUID 等通用 ID。
+  eventData._id = new mongoose.Types.ObjectId();
+  // eventData._id = uuidv4(); // 👈 讨论:使用 uuidv4 生成唯一 ID
+  eventData.message_id = eventData._id;
+
+  try {
+    const message = JSON.stringify(eventData);
+    // Publish the message to the exchange for broadcast
+    // persistent: true ensures the message survives RabbitMQ restarts
+    // '' as routing key for fanout exchange means it goes to all bound queues
+    const published = amqpChannel.publish(
+      RABBITMQ_EXCHANGE_NAME,
+      "", // Routing key (ignored by fanout exchange)
+      Buffer.from(message),
+      { persistent: true } // Mark message as persistent
+    );
+
+    if (published) {
+      // console.log('[Event API] Event published to RabbitMQ:', eventData); // Commented for high volume
+      res.status(200).json({ msg: "ok" });
+    } else {
+      // This case indicates the RabbitMQ buffer is full.
+      // In a real-world scenario, you might want to implement backpressure or retry.
+      console.warn("[Event API] Failed to publish event to RabbitMQ (channel full). Event:", eventData);
+      res.status(503).json({ message: "Service temporarily unavailable, please retry." });
+    }
+  } catch (error) {
+    console.error("[Event API] Error publishing event to RabbitMQ:", error);
+    res.status(500).json({ message: "Failed to process event." });
+  }
+});
+
+// --- Start the Express Server ---
+async function startServer() {
+  await connectRabbitMQ(); // Connect to RabbitMQ before starting the server
+  app
+    .listen(port, () => {
+      console.log(`Event Producer API listening on port ${port}`);
+      console.log(`Event endpoint: /napi/event/v2`);
+    })
+    .on("error", (err: NodeJS.ErrnoException) => {
+      if (err.code === "EADDRINUSE") {
+        console.error(`Port ${port} is already in use.`);
+        process.exit(1);
+      } else {
+        console.error(`Failed to start Event Producer API:`, err);
+        process.exit(1);
+      }
+    });
+}
+
+// Start the Event API server
+startServer().catch(console.error);
+
+// Handle graceful shutdown
+process.on("SIGINT", async () => {
+  console.log("[Event API] Shutting down...");
+  if (amqpChannel) await amqpChannel.close();
+  if (amqpConnection) await amqpConnection.close();
+  process.exit(0);
+});
+process.on("SIGTERM", async () => {
+  console.log("[Event API] Shutting down...");
+  if (amqpChannel) await amqpChannel.close();
+  if (amqpConnection) await amqpConnection.close();
+  process.exit(0);
+});

+ 8 - 0
oms/services/howto.md

@@ -0,0 +1,8 @@
+### test event api:
+
+```
+curl -X POST \
+     -H "Content-Type: application/json" \
+     -d '{"appUsedMem":32,"bad_device":true,"type":"color_start","appMaxMem":512,"manufacturer":"samsung","low_device":true,"uid":"cf2N4mJ1RiK5lXzKuIpQ6TXXXX","project_id":1,"library_name":"android","from":"latest","model":"SM-A105M","lowMemory":false,"batteryCapacity":82,"deviceMem":1742,"res":"68541feae6830677ae9d818b","prod":"number","log_num":27,"version_code":343,"deviceAvailMem":308,"android_api":30,"version_name":"5.7.6","content_group":"builtin","days":1137,"position":117,"device":"a10","ip":"179.186.223.35","t":"2025-08-19T15:59:59.967Z","cc":"BR"}' \
+     http://localhost:3001/napi/event/v2
+```

+ 412 - 0
oms/services/ingestor-service.ts

@@ -0,0 +1,412 @@
+// oms/src/ingestor-service/app.ts
+
+// Load environment variables (e.g., RABBITMQ_URL, RABBITMQ_OMS_QUEUE, MONGO_URI, CLICKHOUSE_*)
+import * as dotenv from "dotenv";
+dotenv.config();
+
+import amqp, { Connection, ChannelModel, Channel, Message } from "amqplib";
+import mongoose, { mongo } from "mongoose";
+import dayjs from "dayjs"; // For date manipulation
+import duration from "dayjs/plugin/duration"; // dayjs plugin for duration
+import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; // Day.js plugin for isSameOrBefore
+
+// Import OMS models and services
+import { User, IUser } from "../src/models/userModel"; // Assuming userModel.ts exports User
+import UserPreference, { IUserPreference } from "../src/models/userPreferenceModel"; // Assuming userPreferenceModel.ts exports UserPreference
+import { ClickhouseService, IEventLog } from "../src/services/clickhouseService"; // Assuming clickhouseService.ts exports ClickhouseService and IEventLog
+
+dayjs.extend(duration);
+dayjs.extend(isSameOrBefore);
+
+// --- Environment Variables ---
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+const RABBITMQ_OMS_QUEUE = process.env.RABBITMQ_OMS_QUEUE || "oms_event_queue"; // 摄取器订阅的队列
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27717/omsdb?authSource=admin"; // MongoDB URI
+const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST || "http://localhost:8123";
+const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE || "omsdb";
+const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || "ckuser";
+const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || "ckpassword";
+const CLICKHOUSE_EVENTS_TABLE = "events"; // ClickHouse 日志表的名称 (与 event/app.ts 和 ClickHouse table schema 保持一致)
+
+// --- Batching Configuration ---
+const CLICKHOUSE_BATCH_SIZE = 5000; // ClickHouse 批量插入大小
+const MONGO_USER_BATCH_SIZE = 1000; // MongoDB User 批量写入大小
+const MONGO_PREF_BATCH_SIZE = 500; // MongoDB UserPreference 批量写入大小
+const FLUSH_INTERVAL_MS = 5000; // 定时刷新缓冲区间隔 (毫秒)
+
+// --- Internal State ---
+let amqpConnection: ChannelModel | undefined;
+let amqpChannel: Channel | undefined;
+let mongoConnection: typeof mongoose | undefined;
+let clickhouseService: ClickhouseService;
+
+const clickhouseEventsBuffer: IEventLog[] = [];
+const mongoUserWriteOperations: any[] = [];
+const mongoUserPrefWriteOperations: any[] = [];
+
+// List of event types to process (reused from ingestHistoricalData.ts)
+const ALLOWED_EVENT_TYPES = ["visit", "show_deeplink_dialog", "share", "save", "revenue", "rate", "favorite", "color_tip", "color_start", "color_done", "color_data", "ad_color_tip", "ad_color_float"];
+
+// Define an array of valid IUser keys for copying from event log to User model
+const USER_FIELDS_TO_UPDATE: (keyof IUser)[] = [
+  "network",
+  "campaign",
+  "adgroup",
+  "creative",
+  "prod",
+  "libraryName", // maps to library_name
+  "cc",
+  "lang",
+  "manufacturer",
+  "deviceModel", // maps to model
+  "deviceInfo", // maps to device
+  "hardware",
+  "deviceMem",
+  "apiLevel", // maps to android_api
+  "versionName", // maps to version_name or library_version
+  "versionCode", // maps to version_code
+  "fmToken", // map to token
+  "project", // Add project field for updating, map to project_id
+];
+
+// --- Initialize Services ---
+async function initializeServices() {
+  try {
+    // Connect to RabbitMQ
+    amqpConnection = await amqp.connect(RABBITMQ_URL);
+    amqpConnection.on("error", (err) => {
+      console.error("[RabbitMQ] Connection error:", err);
+      // Reconnection logic will be handled by PM2 restarting the service, or a dedicated handler
+    });
+    amqpConnection.on("close", () => {
+      console.error("[RabbitMQ] Connection closed. Restarting service...");
+      // In a production setup, consider graceful shutdown and PM2 restarting
+      process.exit(1); // Exit to allow PM2 to restart
+    });
+
+    amqpChannel = await amqpConnection.createChannel();
+    console.log("[RabbitMQ] Channel created for Ingestor Service.");
+
+    // Assert the ingestor queue
+    await amqpChannel.assertQueue(RABBITMQ_OMS_QUEUE, { durable: true });
+    console.log(`[RabbitMQ] Ingestor Service queue '${RABBITMQ_OMS_QUEUE}' asserted.`);
+
+    // Connect to OMS MongoDB (using Mongoose)
+    mongoConnection = await mongoose.connect(OMS_MONGO_URI);
+    console.log(`Connected to OMS MongoDB: ${OMS_MONGO_URI}`);
+
+    // Initialize ClickHouse service with credentials
+    clickhouseService = new ClickhouseService(CLICKHOUSE_HOST, CLICKHOUSE_DATABASE, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD);
+    // Ensure ClickHouse table exists
+    await clickhouseService.ensureTable(CLICKHOUSE_EVENTS_TABLE);
+    console.log(`ClickHouse Service initialized for ${CLICKHOUSE_DATABASE} at ${CLICKHOUSE_HOST}`);
+  } catch (error) {
+    console.error("[Ingestor Service] Failed to initialize services:", error);
+    process.exit(1); // Exit to allow PM2 to restart on critical startup failure
+  }
+}
+
+// --- Helper function to flush ClickHouse buffer ---
+async function flushClickHouseBuffer() {
+  if (clickhouseEventsBuffer.length === 0) return;
+  const eventsToFlush = [...clickhouseEventsBuffer]; // Take a snapshot
+  clickhouseEventsBuffer.length = 0; // Clear buffer immediately
+
+  try {
+    await clickhouseService.insertEvent(CLICKHOUSE_EVENTS_TABLE, eventsToFlush);
+    console.log(`[ClickHouse] Flushed ${eventsToFlush.length} events to ClickHouse.`);
+  } catch (error) {
+    console.error(`[ClickHouse] Error flushing ClickHouse buffer:`, error);
+    // On error, decide whether to re-queue (if transient) or log and drop (if data issue)
+    // For now, we log and drop to avoid blocking the ingestor.
+  }
+}
+
+// --- Helper function to flush MongoDB User buffer ---
+async function flushMongoUserBuffer() {
+  if (mongoUserWriteOperations.length === 0) return;
+  const operationsToFlush = [...mongoUserWriteOperations]; // Take a snapshot
+  mongoUserWriteOperations.length = 0; // Clear buffer immediately
+
+  try {
+    const bulkResult = await User.bulkWrite(operationsToFlush);
+    console.log(`[MongoDB-User] Flushed ${operationsToFlush.length} operations. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+  } catch (bulkError) {
+    console.error(`[MongoDB-User] Error in MongoDB bulkWrite:`, bulkError);
+  }
+}
+
+// --- Helper function to flush MongoDB UserPreference buffer ---
+async function flushMongoUserPrefBuffer() {
+  if (mongoUserPrefWriteOperations.length === 0) return;
+  const operationsToFlush = [...mongoUserPrefWriteOperations]; // Take a snapshot
+  mongoUserPrefWriteOperations.length = 0; // Clear buffer immediately
+
+  try {
+    const bulkResult = await UserPreference.bulkWrite(operationsToFlush);
+    console.log(`[MongoDB-UserPref] Flushed ${operationsToFlush.length} operations. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+  } catch (bulkError) {
+    console.error(`[MongoDB-UserPref] Error in MongoDB UserPreference bulkWrite:`, bulkError);
+  }
+}
+
+// --- Process a single event message ---
+async function processMessage(msg: Message) {
+  if (!amqpChannel) {
+    console.error("[Ingestor Service] RabbitMQ channel not available for processing message.");
+    return;
+  }
+
+  let eventData: any;
+  try {
+    eventData = JSON.parse(msg.content.toString());
+    console.log("[Ingestor Service] Received raw event:", eventData); // Log for debugging, but be cautious with high volume
+  } catch (parseError) {
+    console.error(`[Ingestor Service] Error parsing message content: ${msg.content.toString()}. Error: ${parseError}`);
+    amqpChannel.reject(msg, false); // Reject malformed message, do not re-queue
+    return;
+  }
+
+  // Filter by project_id
+  const projectId: number = eventData.project_id || eventData.project; // project_id for android/ios events, project for oms_app events
+  if (projectId !== 1 && projectId !== 6) {
+    // Assuming project_id 1 and 6 are relevant
+    // console.log(`[Ingestor Service] Skipping event with unsupported project_id: ${projectId}`);
+    amqpChannel.ack(msg); // Acknowledge and drop unsupported events
+    return;
+  }
+
+  // Determine event type field name based on project_id and event source
+  // Assuming 'type' for Android-like events, 'name' for iOS-like events,
+  const eventType = eventData.type || eventData.name;
+
+  // Filter by allowed event types
+  if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
+    // console.log(`[Ingestor Service] Skipping event with unsupported event_type: ${eventType}`);
+    amqpChannel.ack(msg); // Acknowledge and drop unsupported events
+    return;
+  }
+
+  // Determine UID field name based on project_id
+  const uid: string = eventData.uid || eventData.user_id; // uid for Android, user_id for iOS
+  if (!uid) {
+    console.warn(`[Ingestor Service] Skipping event with missing UID: ${JSON.stringify(eventData)}`);
+    amqpChannel.reject(msg, false); // Reject if UID is missing, do not re-queue
+    return;
+  }
+
+  try {
+    // Calculate lastActiveAtDateObj once for consistency
+    let lastActiveAtDateObj: Date;
+    if (eventData.t) {
+      lastActiveAtDateObj = dayjs(eventData.t).toDate();
+    } else if (eventData.create_at) {
+      lastActiveAtDateObj = dayjs(eventData.create_at).toDate();
+    } else {
+      lastActiveAtDateObj = new Date();
+    }
+
+    // --- 1. Prepare Event Data for ClickHouse Batch ---
+    const clickhouseEvent: IEventLog = {
+      log_id: eventData._id ? eventData._id.toString() : new mongoose.Types.ObjectId().toHexString(), // Use existing _id or generate new
+      uid: uid,
+      project: projectId,
+      os: eventData.library_name || null,
+      version: projectId === 1 ? eventData.version_name : eventData.library_version || null,
+      event: eventType,
+      time: lastActiveAtDateObj, // Directly pass Date object, ClickhouseService handles formatting
+      res: projectId === 1 ? eventData.res : eventData.sku_id || null,
+      from: projectId === 1 ? eventData.from : eventData.tab_source || null,
+      position: projectId === 1 ? eventData.position : eventData.click_position || null,
+      duration: eventData.duration || null,
+      ad_type: eventData.ad_type || null,
+      ad_src: eventData.ad_src || null,
+      revenue: projectId === 1 ? eventData.rev : eventData.ad_revenue || null,
+      cc: eventData.cc || null,
+      raw_json_data: JSON.stringify(eventData),
+    };
+    clickhouseEventsBuffer.push(clickhouseEvent);
+
+    // --- 2. Prepare User Data for MongoDB Batch Update ---
+    // userSetData will contain fields to be updated using $set for both new and existing documents.
+    // 'project' is now excluded here as it will be handled by $setOnInsert only.
+    const userSetData: Partial<IUser> = { lastActiveAt: lastActiveAtDateObj };
+
+    // SetOnInsert fields will only apply when a new document is created
+    const setOnInsertFields: any = {
+      uid: uid,
+      createdAt: new Date(),
+      project: projectId, // `project` is a required field, must be set on insert
+    };
+
+    // Derive firstLoginAt from 'days' field if available
+    let derivedFirstLoginAt: Date | undefined;
+    if (eventData.days !== undefined && eventData.days !== null) {
+      derivedFirstLoginAt = dayjs(lastActiveAtDateObj).subtract(eventData.days, "day").toDate();
+    }
+    // Set firstLoginAt for $setOnInsert (for new documents)
+    // This value will only be used if the document is actually inserted (upsert: true creates a new doc)
+    setOnInsertFields.firstLoginAt = derivedFirstLoginAt || lastActiveAtDateObj;
+
+    // Copy relevant fields from event to userSetData (for $set)
+    for (const field of USER_FIELDS_TO_UPDATE) {
+      if (field === "uid" || field === "project") continue;
+      let sourceFieldName: string | undefined;
+      if (field === "libraryName") sourceFieldName = "library_name";
+      else if (field === "deviceModel") sourceFieldName = "model";
+      else if (field === "deviceInfo") sourceFieldName = "device";
+      else if (field === "apiLevel") sourceFieldName = "android_api";
+      else if (field === "versionName") sourceFieldName = projectId === 1 ? "version_name" : "library_version";
+      else if (field === "versionCode") sourceFieldName = "version_code";
+      else if (field === "deviceMem") sourceFieldName = "deviceMem";
+      else if (field === "fmToken") sourceFieldName = "token";
+      else sourceFieldName = field; // Default to same name
+
+      if (sourceFieldName && eventData[sourceFieldName] !== undefined && eventData[sourceFieldName] !== null) {
+        if (field === "deviceMem" && typeof eventData[sourceFieldName] === "number") {
+          userSetData.deviceMem = eventData[sourceFieldName];
+        } else {
+          // Type assertion needed as Partial<IUser> doesn't guarantee all keys are assignable at runtime
+          userSetData[field] = eventData[sourceFieldName];
+        }
+      }
+    }
+
+    // Initialize update object with $set and $setOnInsert
+    const updateOperation: mongo.UpdateFilter<IUser> = {
+      $set: userSetData,
+      $setOnInsert: setOnInsertFields,
+    };
+
+    // 👈 关键修改:移除 $min 操作符
+    // `firstLoginAt` 将只在 `$setOnInsert` 时被设置,
+    // 如果文档已存在,它将不会被更新,这符合您的需求。
+
+    mongoUserWriteOperations.push({
+      updateOne: {
+        filter: { uid: uid },
+        update: updateOperation, // 使用构建好的 updateOperation
+        upsert: true,
+      },
+    });
+
+    // --- 3. Conditionally Update UserPreference in MongoDB ---
+    // If 'color_start' event and it has tags (or can derive from prod)
+    const tagsToProcess: string[] = [];
+    if (eventType === "color_start") {
+      if (Array.isArray(eventData.tags) && eventData.tags.length > 0) {
+        tagsToProcess.push(...eventData.tags);
+      }
+    }
+
+    if (tagsToProcess.length > 0) {
+      for (const tag of tagsToProcess) {
+        mongoUserPrefWriteOperations.push({
+          updateOne: {
+            filter: { uid: uid, tag: tag },
+            update: { $inc: { count: 1 }, $set: { uid: uid, tag: tag } },
+            upsert: true,
+            setDefaultsOnInsert: true, // Ensures defaults are applied on upsert (like count: 0)
+          },
+        });
+      }
+    }
+
+    // --- Batch Flushing Logic ---
+    if (clickhouseEventsBuffer.length >= CLICKHOUSE_BATCH_SIZE) {
+      await flushClickHouseBuffer();
+    }
+    if (mongoUserWriteOperations.length >= MONGO_USER_BATCH_SIZE) {
+      await flushMongoUserBuffer();
+    }
+    if (mongoUserPrefWriteOperations.length >= MONGO_PREF_BATCH_SIZE) {
+      await flushMongoUserPrefBuffer();
+    }
+
+    amqpChannel.ack(msg); // Acknowledge message after successful buffering/processing
+  } catch (error) {
+    console.error(`[Ingestor Service] Error processing event from UID ${uid}:`, error, "Event:", eventData);
+    // Reject message without re-queuing to prevent infinite loops on consistent processing failures
+    amqpChannel.reject(msg, false);
+  }
+}
+
+// --- Main Ingestor Service Start Function ---
+async function startIngestorService() {
+  await initializeServices();
+
+  if (!amqpChannel) {
+    console.error("[Ingestor Service] RabbitMQ channel is not available after initialization. Exiting.");
+    process.exit(1);
+  }
+
+  // Set consumer prefetch count
+  amqpChannel.prefetch(100);
+
+  console.log(`[Ingestor Service] Waiting for messages in queue: ${RABBITMQ_OMS_QUEUE}`);
+
+  amqpChannel.consume(
+    RABBITMQ_OMS_QUEUE,
+    async (msg: Message | null) => {
+      if (msg === null) return; // Channel closed or other null message
+      await processMessage(msg);
+    },
+    { noAck: false } // Manual acknowledgment
+  );
+
+  // Set up periodic flushing for any remaining buffered events
+  setInterval(async () => {
+    // console.log("[Ingestor Service] Flushing any remaining buffers...");
+    await flushClickHouseBuffer();
+    await flushMongoUserBuffer();
+    await flushMongoUserPrefBuffer();
+  }, FLUSH_INTERVAL_MS);
+}
+
+// --- Graceful Shutdown ---
+async function gracefulShutdown() {
+  console.log("[Ingestor Service] Shutting down...");
+  // Flush any remaining buffers before closing connections
+  await flushClickHouseBuffer();
+  await flushMongoUserBuffer();
+  await flushMongoUserPrefBuffer();
+
+  if (amqpChannel) {
+    try {
+      await amqpChannel.close();
+      console.log("[Ingestor Service] RabbitMQ channel closed.");
+    } catch (e) {
+      console.error("[Ingestor Service] Error closing RabbitMQ channel:", e);
+    }
+  }
+  if (amqpConnection) {
+    try {
+      await amqpConnection.close();
+      console.log("[Ingestor Service] RabbitMQ connection closed.");
+    } catch (e) {
+      console.error("[Ingestor Service] Error closing RabbitMQ connection:", e);
+    }
+  }
+  if (mongoose.connection.readyState === 1) {
+    // Check if connected before trying to disconnect
+    try {
+      await mongoose.disconnect();
+      console.log("[Ingestor Service] MongoDB connection closed.");
+    } catch (e) {
+      console.error("[Ingestor Service] Error closing MongoDB connection:", e);
+    }
+  }
+  process.exit(0);
+}
+
+process.on("SIGINT", gracefulShutdown);
+process.on("SIGTERM", gracefulShutdown);
+
+// --- Start the service if run directly ---
+if (require.main === module) {
+  console.log("Ingestor Service started in standalone mode.");
+  startIngestorService().catch(console.error);
+}
+
+// Export the start function for PM2
+export default startIngestorService;

+ 202 - 0
oms/services/log-service.ts

@@ -0,0 +1,202 @@
+// oms/src/log-service/app.ts
+
+// Load environment variables (e.g., RABBITMQ_URL, RABBITMQ_LOG_QUEUE, LOG_DIR)
+import * as dotenv from "dotenv";
+dotenv.config();
+
+import amqp, { Connection, ChannelModel, Channel, Message } from "amqplib"; // 明确导入 Connection, Channel, Message 类型
+import * as rfs from "rotating-file-stream"; // 👈 关键修复:使用 `import * as rfs`
+import moment from "moment"; // 导入 moment (用于日志文件名生成)
+import * as path from "path"; // 导入 path 模块
+
+// --- Environment Variables ---
+const RABBITMQ_URL = process.env.RABBITMQ_URL || "amqp://coloring:coloring123.@localhost:5672";
+const RABBITMQ_LOG_QUEUE = process.env.RABBITMQ_LOG_QUEUE || "log_event_queue"; // 日志服务订阅的队列
+const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, "..", "..", "logs", "coloring"); // 日志文件存储路径
+
+let amqpConnection: ChannelModel | undefined; // 使用 undefined 初始化,因为连接是异步的
+let amqpChannel: Channel | undefined; // 使用 undefined 初始化,因为频道是异步的
+
+// --- Log Rotation Setup ---
+// 确保日志目录存在
+try {
+  if (!require("fs").existsSync(LOG_DIR)) {
+    require("fs").mkdirSync(LOG_DIR, { recursive: true });
+    console.log(`[Log Service] Created log directory: ${LOG_DIR}`);
+  }
+} catch (error) {
+  console.error(`[Log Service] Failed to create log directory ${LOG_DIR}:`, error);
+  process.exit(1);
+}
+
+// 日志文件名生成器
+const generator = (time: Date | number | undefined, index?: number): string => {
+  if (!time) return "coloring.log"; // 初始文件名
+  const suffix = moment(time).format("YYYYMMDD");
+  return `coloring-${suffix}.log.gz`; // 每日轮换并压缩
+};
+
+// 创建文件写入流
+const logStream = rfs.createStream(generator, {
+  interval: "1d", // 每日轮换
+  compress: "gzip", // 使用 gzip 压缩
+  path: LOG_DIR, // 日志文件路径
+  intervalBoundary: true, // 确保在指定时间边界轮换
+  // size: '10M',            // 也可以配置按大小轮换,例如每 10MB
+  // maxFiles: 10,           // 最多保留 10 个文件
+  // maxSize: '100M',        // 最大总大小
+});
+
+// 监听日志流事件 (可选,用于调试和监控)
+logStream.on("external", () => console.log("[Log Stream] external"));
+logStream.on("history", () => console.log("[Log Stream] history"));
+logStream.on("open", (f: string) => console.log(`[Log Stream] Opened: ${f}`));
+logStream.on("removed", (f: string) => console.log(`[Log Stream] Removed: ${f}`));
+logStream.on("rotation", () => console.log("[Log Stream] rotation"));
+logStream.on("rotated", (f: string) => console.log(`[Log Stream] Rotated to: ${f}`));
+logStream.on("warning", (e: Error) => console.warn("[Log Stream] warning:", e));
+logStream.on("error", (e: Error) => console.error("[Log Stream] error:", e)); // 捕获写入错误
+
+// --- Utility Functions ---
+function delay(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * 将事件数据写入日志文件。
+ * @param data - 要写入的日志数据字符串。
+ * @returns Promise<void> - 写入成功或失败。
+ */
+function pushToFile(data: string): Promise<void> {
+  return new Promise((resolve, reject) => {
+    // 使用 stream.write 的回调函数来判断写入是否成功
+    logStream.write(`${data}\n`, (error: Error | null | undefined) => {
+      if (error) {
+        return reject(error);
+      }
+      resolve();
+    });
+  });
+}
+
+/**
+ * 带重试机制地将消息推送到文件。
+ * @param data - 要写入的日志数据字符串。
+ * @param ms - 每次重试的延迟时间(毫秒)。
+ * @param retries - 最大重试次数。
+ * @returns Promise<void> - 如果写入成功或所有重试都失败。
+ */
+async function retryPushToFile(data: string, ms: number = 1000, retries: number = 5): Promise<void> {
+  try {
+    await pushToFile(data);
+    return; // 成功写入
+  } catch (err) {
+    console.error(`[Log Service] Failed to write to log (retries left: ${retries}):`, err);
+    if (retries > 0) {
+      await delay(ms);
+      return retryPushToFile(data, ms, retries - 1); // 递归重试
+    } else {
+      throw new Error(`Failed to write to log after ${retries} retries.`); // 抛出最终错误
+    }
+  }
+}
+
+// --- Main Log Service Start Function ---
+async function startLogService() {
+  try {
+    amqpConnection = await amqp.connect(RABBITMQ_URL);
+    amqpConnection.on("error", (err) => {
+      console.error("[RabbitMQ] Connection error:", err);
+      if (amqpChannel) amqpChannel.close();
+      if (amqpConnection) amqpConnection.close();
+      amqpConnection = undefined;
+      amqpChannel = undefined;
+      setTimeout(startLogService, 5000); // 尝试重新连接
+    });
+    amqpConnection.on("close", () => {
+      console.error("[RabbitMQ] Connection closed. Reconnecting...");
+      amqpConnection = undefined;
+      amqpChannel = undefined;
+      setTimeout(startLogService, 5000); // 尝试重新连接
+    });
+
+    amqpChannel = await amqpConnection.createChannel();
+    console.log("[RabbitMQ] Channel created for Log Service.");
+
+    // 确保队列存在且是持久化的
+    await amqpChannel.assertQueue(RABBITMQ_LOG_QUEUE, { durable: true });
+    console.log(`[RabbitMQ] Log Service queue '${RABBITMQ_LOG_QUEUE}' asserted.`);
+
+    // 设置消费者预取数量,平衡吞吐量和资源使用
+    amqpChannel.prefetch(100);
+
+    console.log(`Log Service connected to RabbitMQ and waiting for messages in queue: ${RABBITMQ_LOG_QUEUE}`);
+
+    amqpChannel.consume(
+      RABBITMQ_LOG_QUEUE,
+      async (msg: Message | null) => {
+        if (msg === null) return; // Channel closed or other null message
+
+        try {
+          const eventDataString = msg.content.toString();
+          console.log("[Log Service] Received event:", eventDataString); // 生产环境可以减少日志量
+
+          await retryPushToFile(eventDataString, 3000, 5); // 写入文件,带重试
+
+          if (amqpChannel) {
+            amqpChannel.ack(msg); // 成功写入文件后确认消息
+          }
+        } catch (err) {
+          console.error("[Log Service] Failed to process or write message to log after retries. Rejecting message:", err);
+          if (amqpChannel) {
+            // 拒绝消息,不重新入队,防止反复失败阻塞队列
+            // 或者根据您的策略,可以设置为 true 重新入队
+            amqpChannel.reject(msg, false);
+          }
+        }
+      },
+      { noAck: false } // 必须手动确认
+    );
+  } catch (error) {
+    console.error("[Log Service] Failed to connect to RabbitMQ or start consuming:", error);
+    // 在 PM2 管理下,这里不直接 exit(1),让 PM2 尝试重启整个服务
+    // setTimeout(startLogService, 5000); // 如果是单进程运行,可以尝试重连
+  }
+}
+
+// --- Graceful Shutdown ---
+async function gracefulShutdown() {
+  console.log("[Log Service] Shutting down...");
+  if (amqpChannel) {
+    try {
+      await amqpChannel.close();
+      console.log("[Log Service] RabbitMQ channel closed.");
+    } catch (e) {
+      console.error("[Log Service] Error closing RabbitMQ channel:", e);
+    }
+  }
+  if (amqpConnection) {
+    try {
+      await amqpConnection.close();
+      console.log("[Log Service] RabbitMQ connection closed.");
+    } catch (e) {
+      console.error("[Log Service] Error closing RabbitMQ connection:", e);
+    }
+  }
+  logStream.end(() => {
+    console.log("[Log Service] Log stream closed.");
+    process.exit(0);
+  });
+}
+
+process.on("SIGINT", gracefulShutdown);
+process.on("SIGTERM", gracefulShutdown);
+
+// --- Start the service if run directly ---
+if (require.main === module) {
+  console.log("Log Service started in standalone mode.");
+  startLogService().catch(console.error);
+}
+
+// Export the start function for PM2
+export default startLogService;

+ 83 - 0
oms/src/app.ts

@@ -0,0 +1,83 @@
+// oms/src/app.ts
+import dotenv from "dotenv";
+dotenv.config(); // 在读取环境变量之前加载 .env 文件
+
+import express, { Request, Response } from "express";
+import mongoose from "mongoose";
+import { createClient } from "redis"; // 👈 关键修改:从 redis 模块中解构导入 createClient
+import path from "path";
+import apiRoutes from "./routes/apiRoutes"; // Import the new API routes
+import { ClickhouseService } from "./services/clickhouseService"; // 👈 导入 ClickhouseService
+
+const app = express();
+const port = process.env.PORT || 3000;
+const mongoUri = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost/omsdb?authSource=admin";
+const redisUri = process.env.REDIS_URI || "redis://redis:6379";
+const clickhouseHost = process.env.CLICKHOUSE_HOST || "http://localhost:8123"; // 👈 ClickHouse 主机
+const clickhouseDatabase = process.env.CLICKHOUSE_DATABASE || "omsdb"; // 👈 ClickHouse 数据库名
+const clickhouseUser = process.env.CLICKHOUSE_USER;
+const clickhousePassword = process.env.CLICKHOUSE_PASSWORD;
+
+// Initialize ClickhouseService
+const clickhouseService = new ClickhouseService(clickhouseHost, clickhouseDatabase, clickhouseUser, clickhousePassword);
+const clickhouseTableName = "events"; // ClickHouse 日志表的名称
+
+// MongoDB connection
+mongoose
+  .connect(mongoUri)
+  .then(() => console.log("Connected to MongoDB"))
+  .catch((err) => console.error("MongoDB connection error:", err));
+
+// Redis client
+// Using the modern 'redis' package client setup
+const redisClient = createClient({ url: redisUri }); // 👈 使用解构后的 createClient
+
+redisClient.on("connect", () => console.log("Connected to Redis"));
+redisClient.on("error", (err: any) => console.error("Redis connection error:", err));
+
+// Connect to Redis when the application starts
+(async () => {
+  try {
+    await redisClient.connect();
+    // 确保 ClickHouse 表在应用启动时存在
+    await clickhouseService.ensureTable(clickhouseTableName);
+  } catch (err) {
+    console.error("Failed to connect to essential services:", err);
+    process.exit(1); // 如果连接失败,退出应用
+  }
+})();
+
+// Middleware
+app.use(express.json());
+
+app.use(express.static(path.join(__dirname, "public")));
+
+// API routes
+// All API routes will be handled by the imported apiRoutes
+app.use("/api", apiRoutes); // Mount the API routes under /api prefix
+
+// 使用正则表达式来匹配所有未被前面路由处理的 GET 请求
+// 这个路由会尝试服务 Angular 应用的 index.html
+app.get(/.*/, (req: Request, res: Response) => {
+  // 确保不是 API 请求,才服务 index.html
+  // Express 会按顺序处理路由。如果请求是 /api/xxx,会被 app.use('/api', apiRoutes) 捕获。
+  // 如果请求是静态文件 (如 /main.js),会被 app.use(express.static) 捕获。
+  // 因此,到这里的请求是既非静态文件也非 /api 开头的请求。
+  // 为了确保严谨性,可以再次检查路径不以 /api 开头
+  if (!req.path.startsWith("/api")) {
+    const angularAppPath = path.join(__dirname, "..", "public", "app");
+    res.sendFile(path.join(angularAppPath, "index.html"));
+  } else {
+    // If an API route is not found by apiRoutes, it will fall through here,
+    // which means it's an unhandled API route, so return 404.
+    res.status(404).json({ message: "API Route not found" });
+  }
+});
+
+// Start the server
+app.listen(port, () => {
+  console.log(`OMS Backend server listening on port ${port}`);
+});
+
+// Export services/clients for use in other modules if needed
+export { redisClient, clickhouseService }; // 👈 导出 ClickhouseService

+ 237 - 0
oms/src/controllers/artController.ts

@@ -0,0 +1,237 @@
+// oms/src/controllers/artController.ts
+import { Request, Response } from "express";
+import artService from "../services/artService"; // Import ArtService
+import { IArt, PageStatus, SpecialThumbType } from "../models/artModel"; // Import IArt interface and enums
+import mongoose, { FilterQuery, SortOrder } from "mongoose"; // Import Mongoose types
+
+// Define valid keys for Art model for runtime query parameter validation
+const ART_MODEL_QUERY_KEYS: (keyof IArt)[] = [
+  "status",
+  "pageId",
+  "user",
+  "areaCount",
+  "areaCountFloor",
+  "colorCount",
+  "hasSpecial",
+  "mystery",
+  "ai",
+  "width",
+  "height",
+  "name",
+  "desc",
+  "use",
+  "tags",
+  "epgs",
+  "date",
+  "lastMod",
+  "timeSubmit",
+  "lock",
+  "publishTime",
+  "drop",
+  "totalStartCount",
+  "totalDoneCount",
+  "completionRate",
+];
+
+class ArtController {
+  /**
+   * Handles requests to update art information.
+   * PUT /api/arts/:id
+   * @param req Express request object
+   * @param res Express response object
+   * @returns Promise<void>
+   */
+  public async updateArt(req: Request, res: Response): Promise<void> {
+    try {
+      const { id } = req.params; // Get art ID from URL parameters
+      const updateData = req.body; // Get update data from request body
+
+      // Validate if ID is a valid MongoDB ObjectId
+      if (!mongoose.Types.ObjectId.isValid(id)) {
+        res.status(400).json({ message: "Invalid Art ID format." });
+        return;
+      }
+
+      const updatedArt = await artService.updateArt(id, updateData);
+      if (updatedArt) {
+        res.status(200).json(updatedArt); // Return the updated art document
+      } else {
+        res.status(404).json({ message: `Art with ID: ${id} not found.` }); // Art not found
+      }
+    } catch (error: any) {
+      console.error("Failed to update art:", error);
+      res.status(500).json({ message: "Failed to update art", error: error.message }); // Internal server error
+    }
+  }
+
+  /**
+   * Handles requests to retrieve a paginated list of artworks based on conditions.
+   * GET /api/arts?page=1&limit=30&status=9000&name=example&tags=tag1,tag2&dateStart=2023-01-01&dateEnd=2023-12-31&sortBy=date&sortOrder=desc
+   * @param req Express request object
+   * @param res Express response object
+   * @returns Promise<void>
+   */
+  public async getArts(req: Request, res: Response): Promise<void> {
+    try {
+      const page = parseInt(req.query.page as string) || 1; // Parse page number, default to 1
+      const limit = parseInt(req.query.limit as string) || 30; // Parse limit per page, default to 30
+
+      const filters: FilterQuery<IArt> = {}; // Initialize MongoDB filter object
+      const sort: { [key: string]: SortOrder } = {}; // Initialize sort object
+
+      // Build filters
+      for (const key of ART_MODEL_QUERY_KEYS) {
+        const queryValue = req.query[key as string];
+        const startValue = req.query[`${key}Start`] as string; // For date range start
+        const endValue = req.query[`${key}End`] as string; // For date range end
+
+        if (key === "status") {
+          const statusNum = parseInt(queryValue as string, 10);
+          // Validate if status is a valid PageStatus enum value
+          if (!isNaN(statusNum) && Object.values(PageStatus).includes(statusNum)) {
+            filters.status = statusNum;
+          }
+        } else if (key === "name") {
+          // Fuzzy search for art name (case-insensitive)
+          filters.name = { $regex: queryValue as string, $options: "i" } as FilterQuery<IArt>["name"];
+        } else if (key === "tags" || key === "epgs") {
+          // For tags or EPGs array, allow multiple values separated by commas
+          if (queryValue) {
+            filters[key] = { $in: (queryValue as string).split(",").map((s) => s.trim()) } as any;
+          }
+        } else if (key === "hasSpecial" || key === "mystery" || key === "ai" || key === "lock" || key === "drop") {
+          // Boolean fields
+          if (queryValue !== undefined) {
+            filters[key] = (queryValue as string).toLowerCase() === "true";
+          }
+        } else if (key === "user" || key === "work" || key === "publishBy" || key === "pageId") {
+          // ObjectId fields
+          if (queryValue && mongoose.Types.ObjectId.isValid(queryValue as string)) {
+            filters[key] = new mongoose.Types.ObjectId(queryValue as string) as any;
+          }
+        } else if (["date", "lastMod", "timeSubmit", "timeReady", "publishTime"].includes(key)) {
+          // Date range query for date fields
+          const dateFilter: { $gte?: Date; $lte?: Date } = {};
+          let isValidDateQuery = false;
+
+          if (startValue) {
+            try {
+              const startDate = new Date(startValue);
+              if (!isNaN(startDate.getTime())) {
+                dateFilter.$gte = startDate;
+                isValidDateQuery = true;
+              } else {
+                console.warn(`Invalid start date format for '${key}Start': ${startValue}`);
+              }
+            } catch (e) {
+              console.warn(`Error parsing start date for '${key}Start': ${startValue}, Error: ${e}`);
+            }
+          }
+
+          if (endValue) {
+            try {
+              const endDate = new Date(endValue);
+              if (!isNaN(endDate.getTime())) {
+                dateFilter.$lte = endDate;
+                isValidDateQuery = true;
+              } else {
+                console.warn(`Invalid end date format for '${key}End': ${endValue}`);
+              }
+            } catch (e) {
+              console.warn(`Error parsing end date for '${key}End': ${endValue}, Error: ${e}`);
+            }
+          }
+
+          if (isValidDateQuery) {
+            filters[key] = dateFilter as any;
+          }
+        } else if (
+          // Numeric fields
+          typeof (filters as any)[key] === "number" ||
+          [
+            "areaCount",
+            "areaCountFloor",
+            "coloredAreaCount",
+            "colorCount",
+            "orderedColorCount",
+            "colorOrderStatus",
+            "mapVersion",
+            "workVersion",
+            "centersVersion",
+            "specialVersion",
+            "specialPkmVersion",
+            "useSpecialThumb",
+            "width",
+            "height",
+            "publishVersion",
+            "order",
+            "score",
+            "totalStartCount",
+            "totalDoneCount",
+            "completionRate",
+          ].includes(key)
+        ) {
+          if (queryValue !== undefined) {
+            const numValue = parseFloat(queryValue as string);
+            if (!isNaN(numValue)) {
+              (filters as any)[key] = numValue;
+            }
+          }
+        } else if (queryValue !== undefined) {
+          // Other string type fields, assign directly
+          (filters as any)[key] = queryValue;
+        }
+      }
+
+      // Build sort conditions
+      if (req.query.sortBy) {
+        const sortByField = req.query.sortBy as string;
+        // Ensure sort field is a valid key from IArt and exclude non-sortable fields like _id
+        if (ART_MODEL_QUERY_KEYS.includes(sortByField as keyof IArt) && sortByField !== "_id") {
+          const sortOrder = req.query.sortOrder === "asc" || parseInt(req.query.sortOrder as string) === 1 ? 1 : -1;
+          sort[sortByField] = sortOrder;
+        }
+      } else {
+        sort.lastMod = -1; // Default sort by last modification time, descending
+      }
+
+      const { arts, total } = await artService.getPaginatedArts(page, limit, filters, sort);
+      res.status(200).json({ page, limit, total, arts }); // Return paginated results
+    } catch (error: any) {
+      console.error("Failed to retrieve art list:", error);
+      res.status(500).json({ message: "Failed to retrieve art list", error: error.message }); // Internal server error
+    }
+  }
+
+  /**
+   * Handles requests to retrieve a single artwork by ID.
+   * GET /api/arts/:id
+   * @param req Express request object
+   * @param res Express response object
+   * @returns Promise<void>
+   */
+  public async getArtById(req: Request, res: Response): Promise<void> {
+    try {
+      const { id } = req.params; // Get art ID from URL parameters
+
+      // Validate if ID is a valid MongoDB ObjectId
+      if (!mongoose.Types.ObjectId.isValid(id)) {
+        res.status(400).json({ message: "Invalid Art ID format." });
+        return;
+      }
+
+      const art = await artService.getArtById(id);
+      if (art) {
+        res.status(200).json(art); // Return art document
+      } else {
+        res.status(404).json({ message: `Art with ID: ${id} not found.` }); // Art not found
+      }
+    } catch (error: any) {
+      console.error("Failed to retrieve art:", error);
+      res.status(500).json({ message: "Failed to retrieve art", error: error.message }); // Internal server error
+    }
+  }
+}
+
+const artController = new ArtController();
+export default artController;

+ 67 - 0
oms/src/controllers/doneRateController.ts

@@ -0,0 +1,67 @@
+// oms/src/controllers/doneRateController.ts
+import { Request, Response } from "express";
+import doneRateService from "../services/doneRateService"; // 导入 DoneRateService
+import mongoose from "mongoose"; // 导入 mongoose 用于验证 ObjectId
+
+class DoneRateController {
+  /**
+   * 根据作品 ID 获取该作品的所有历史完成率数据。
+   * GET /api/done-rates/artwork/:resId
+   * @param req Express 请求对象
+   * @param res Express 响应对象
+   * @returns Promise<void>
+   */
+  public async getDoneRatesByArtworkId(req: Request, res: Response): Promise<void> {
+    try {
+      const { resId } = req.params; // 从 URL 参数获取作品 ID
+
+      // 验证 resId 是否是有效的 MongoDB ObjectId
+      if (!mongoose.Types.ObjectId.isValid(resId)) {
+        res.status(400).json({ message: "Invalid artwork ID format." });
+        return;
+      }
+
+      const doneRates = await doneRateService.getDoneRatesByArtwork(new mongoose.Types.ObjectId(resId));
+      if (doneRates && doneRates.length > 0) {
+        res.status(200).json(doneRates); // 返回作品的历史完成率数据
+      } else {
+        res.status(404).json({ message: `No done rates found for artwork ID: ${resId}.` });
+      }
+    } catch (error: any) {
+      console.error("Failed to retrieve done rates by artwork ID:", error);
+      res.status(500).json({ message: "Failed to retrieve done rates", error: error.message });
+    }
+  }
+
+  /**
+   * 根据特定日期获取该日所有作品的完成率数据。
+   * GET /api/done-rates/date/:date
+   * @param req Express 请求对象
+   * @param res Express 响应对象
+   * @returns Promise<void>
+   */
+  public async getDoneRatesByDate(req: Request, res: Response): Promise<void> {
+    try {
+      const { date } = req.params; // 从 URL 参数获取日期 (YYYYMMDD)
+
+      // 验证日期格式 (YYYYMMDD)
+      if (!/^\d{8}$/.test(date)) {
+        res.status(400).json({ message: "Invalid date format. Expected YYYYMMDD." });
+        return;
+      }
+
+      const doneRates = await doneRateService.getDoneRatesByDate(date);
+      if (doneRates && doneRates.length > 0) {
+        res.status(200).json(doneRates); // 返回指定日期的所有完成率数据
+      } else {
+        res.status(404).json({ message: `No done rates found for date: ${date}.` });
+      }
+    } catch (error: any) {
+      console.error("Failed to retrieve done rates by date:", error);
+      res.status(500).json({ message: "Failed to retrieve done rates", error: error.message });
+    }
+  }
+}
+
+const doneRateController = new DoneRateController();
+export default doneRateController;

+ 215 - 0
oms/src/controllers/userController.ts

@@ -0,0 +1,215 @@
+// oms/src/controllers/userController.ts
+import { Request, Response } from "express";
+import userService from "../services/userService"; // 导入用户服务
+import { v4 as uuidv4 } from "uuid"; // 用于生成唯一的 uid (虽然现在uid是必需的,但保留以备其他用途)
+import { IUser } from "../models/userModel"; // 导入 IUser 接口
+
+// Define an array of valid IUser keys for runtime checking
+// This ensures that only properties defined in IUser are considered for MongoDB queries
+const USER_MODEL_KEYS: (keyof IUser)[] = [
+  "uid",
+  "project",
+  "network",
+  "campaign",
+  "adgroup",
+  "creative",
+  "prod",
+  "libraryName",
+  "cc",
+  "lang",
+  "manufacturer",
+  "deviceModel",
+  "deviceInfo",
+  "hardware",
+  "deviceMem",
+  "apiLevel",
+  "versionName",
+  "versionCode",
+  "fmToken",
+  "tags",
+  "firstLoginAt",
+  "lastActiveAt",
+];
+
+class UserController {
+  /**
+   * 处理创建新用户的请求。
+   * POST /api/users
+   */
+  public async createUser(req: Request, res: Response): Promise<void> {
+    try {
+      const { uid, ...otherData } = req.body; // uid 现在是必需的
+
+      // 检查 uid 是否存在
+      if (!uid) {
+        res.status(400).json({ message: "用户 UID 是必需的。" });
+        return;
+      }
+
+      // 如果 uid 存在,则直接用 req.body 来创建用户
+      const user = await userService.createUser(req.body);
+      res.status(201).json(user);
+    } catch (error: any) {
+      // 检查是否是重复键错误 (uid 唯一性冲突)
+      if (
+        error.message.includes("E11000 duplicate key error") ||
+        error.message.includes("duplicate key")
+      ) {
+        res.status(409).json({
+          message: "用户 UID 已存在,请使用其他 UID。",
+          error: error.message,
+        });
+      } else {
+        res
+          .status(500)
+          .json({ message: "创建用户时出错", error: error.message });
+      }
+    }
+  }
+
+  /**
+   * 处理通过 UID 获取用户的请求。
+   * GET /api/users/:uid
+   */
+  public async getUserByUid(req: Request, res: Response): Promise<void> {
+    try {
+      const { uid } = req.params;
+      const user = await userService.getUserByUid(uid);
+      if (user) {
+        res.status(200).json(user);
+      } else {
+        res.status(404).json({ message: `未找到 UID 为 ${uid} 的用户。` });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: "获取用户时出错", error: error.message });
+    }
+  }
+
+  /**
+   * 处理更新用户的请求。
+   * PUT /api/users/:uid
+   */
+  public async updateUser(req: Request, res: Response): Promise<void> {
+    try {
+      const { uid } = req.params;
+      const updateData = req.body;
+
+      // 业务逻辑检查:不允许直接通过 PUT 请求更新 UID
+      if (updateData.uid && updateData.uid !== uid) {
+        res.status(400).json({ message: "不允许修改用户 UID。" });
+        return;
+      }
+      delete updateData.uid; // 确保不会尝试更新 UID
+
+      const updatedUser = await userService.updateUser(uid, updateData);
+      if (updatedUser) {
+        res.status(200).json(updatedUser);
+      } else {
+        res.status(404).json({ message: `未找到 UID 为 ${uid} 的用户。` });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: "更新用户时出错", error: error.message });
+    }
+  }
+
+  /**
+   * 处理删除用户的请求。
+   * DELETE /api/users/:uid
+   */
+  public async deleteUser(req: Request, res: Response): Promise<void> {
+    try {
+      const { uid } = req.params;
+      const deleted = await userService.deleteUser(uid);
+      if (deleted) {
+        res.status(204).send(); // 204 No Content,表示成功删除但无返回内容
+      } else {
+        res.status(404).json({ message: `未找到 UID 为 ${uid} 的用户。` });
+      }
+    } catch (error: any) {
+      res.status(500).json({ message: "删除用户时出错", error: error.message });
+    }
+  }
+
+  /**
+   * 处理获取所有用户或按分页和查询参数获取用户列表的请求。
+   * GET /api/users?page=1&limit=10&project=1&cc=US
+   */
+  public async getPaginatedUsers(req: Request, res: Response): Promise<void> {
+    try {
+      const page = parseInt(req.query.page as string) || 1;
+      const limit = parseInt(req.query.limit as string) || 30;
+
+      // 从 req.query 中筛选出只与 IUser 相关的属性,作为 MongoDB 的查询条件
+      const mongooseQuery: Partial<IUser> = {};
+      for (const key in req.query) {
+        if (req.query.hasOwnProperty(key)) {
+          // 检查 key 是否在 USER_MODEL_KEYS 中 (即是否是 IUser 的有效属性)
+          // 并且不是分页参数 'page' 或 'limit'
+          if (USER_MODEL_KEYS.includes(key as keyof IUser)) {
+            const queryValue = req.query[key];
+
+            // 根据需要进行类型转换
+            if (
+              key === "project" ||
+              key === "apiLevel" ||
+              key === "versionCode"
+            ) {
+              const numValue = parseInt(queryValue as string);
+              if (!isNaN(numValue)) {
+                mongooseQuery[key as keyof IUser] = numValue as any;
+              }
+            } else if (key === "deviceMem") {
+              const numValue = parseFloat(queryValue as string);
+              if (!isNaN(numValue)) {
+                mongooseQuery[key as keyof IUser] = numValue as any;
+              }
+            } else if (key === "tags") {
+              // 如果 tags 是以逗号分隔的字符串,可以将其转换为数组
+              mongooseQuery[key as keyof IUser] = (queryValue as string)
+                .split(",")
+                .map((s) => s.trim()) as any;
+            } else if (key === "firstLoginAt" || key === "lastActiveAt") {
+              // 尝试将字符串转换为 Date 对象
+              try {
+                const dateValue = new Date(queryValue as string);
+                if (!isNaN(dateValue.getTime())) {
+                  // 检查日期是否有效
+                  mongooseQuery[key as keyof IUser] = dateValue as any;
+                }
+              } catch (e) {
+                console.warn(
+                  `Invalid date format for ${key}: ${queryValue}. Skipping.`
+                );
+                // 可以选择在这里返回错误或忽略该查询参数
+              }
+            }
+            // 对于其他字符串类型,直接赋值
+            else {
+              mongooseQuery[key as keyof IUser] = queryValue as any;
+            }
+          }
+        }
+      }
+
+      const { users, total } = await userService.getPaginatedUsers(
+        page,
+        limit,
+        mongooseQuery // 传入处理后的查询对象
+      );
+
+      res.status(200).json({
+        page,
+        limit,
+        total,
+        users,
+      });
+    } catch (error: any) {
+      res
+        .status(500)
+        .json({ message: "获取用户列表时出错", error: error.message });
+    }
+  }
+}
+
+const userController = new UserController();
+export default userController;

+ 197 - 0
oms/src/models/artModel.ts

@@ -0,0 +1,197 @@
+// oms/src/models/artModel.ts
+import mongoose, { Document, Schema } from "mongoose";
+
+// --- 定义常量 (使用 TypeScript 枚举或常量对象) ---
+
+// Special 缩略图类型
+export enum SpecialThumbType {
+  OUTLINE = 0, // special切线图
+  GRAY = 1, // special灰度图
+  UPLOAD = 2, // 设计师上传的special缩略图
+  GRADIENT = 3, // special渐变切线图
+}
+
+// 页面状态
+export enum PageStatus {
+  REFUSE = 500, // 拒稿
+  NEW = 1000, // 草稿
+  MODIFY = 2000, // 修改中
+  TESTING = 3000, // 测试中
+  READY = 7000, // 可以上线
+  OFFLINE = 8000, // 下线
+  ONLINE = 9000, // 线上
+}
+
+// 定义 Art 文档的 TypeScript 接口
+// 扩展 mongoose.Document 以包含 Mongoose 提供的 _id 属性
+// 注意: createdAt 和 updatedAt 不会自动添加,但 Document 接口仍可能包含这些属性定义
+export interface IArt extends Document {
+  status: PageStatus; // 状态,使用枚举类型
+  pageId: mongoose.Types.ObjectId; // Page Id
+  user: mongoose.Types.ObjectId; // 作者,关联 Designer
+  from?: string; // 素材来源
+
+  work?: mongoose.Types.ObjectId; // 上色效果图
+
+  pageVersion: number; // 底稿版本
+
+  areaCount: number; // 区块数
+  areaCountFloor: number; // 区块数/100向下取整
+  coloredAreaCount: number; // 填色区块数
+  colorCount: number; // 颜色数
+  orderedColorCount: number; // 已排序颜色数
+  colorOrderStatus: number; // 排序状态 (0-未排序,1-部分排序,2-已排序)
+
+  mapVersion: number; // map版本
+  workVersion: number; // work版本
+  centersVersion: number; // 中心点版本
+
+  hasSpecial: boolean; // 是否有special
+  specialVersion: number; // Special图版本
+  specialPkmVersion: number; // SpecialPkm版本
+  useSpecialThumb: SpecialThumbType; // 当前Special缩略图,使用枚举类型
+
+  mystery: boolean; // 是否神秘图
+  ai: boolean; // 是否AI参考图
+  aiPrompt?: string; // AI关键字
+
+  width: number; // 宽
+  height: number; // 高
+  name: string; // 作品名
+  desc?: string; // 作品描述
+  use: string; // 用途
+  tags: string[]; // 标签
+  epgs: string[]; // EPG
+
+  date: Date; // 上传时间
+  lastMod: Date; // 修改时间
+  timeSubmit?: Date; // 提测时间
+  timeReady?: Date; // 通过时间
+
+  refuseReason?: string; // 拒稿原因
+
+  lock: boolean; // 是否上锁
+  publishVersion: number; // 发布版本
+  publishTime?: Date; // 发布时间
+  publishBy?: mongoose.Types.ObjectId; // 发布者,关联 Designer
+  order: number;
+
+  drop: boolean; // 标记删除
+  dropReason?: string; // 删除原因
+
+  score?: number; // 总体评分0-5
+
+  // 统计数据
+  totalStartCount?: number; // 总开始数
+  totalDoneCount?: number; // 总完成数
+  completionRate?: number; // 完成比例
+
+  // 虚拟字段 (不会存储在数据库中,但在 toJSON/toObject 时会计算)
+  thumb?: string;
+
+  // 如果没有 timestamps: true,这些字段将不会被 Mongoose 自动管理
+  // 但如果它们在您的底层数据中存在,它们仍然是 Document 接口的一部分
+  // createdAt?: Date;
+  // updatedAt?: Date;
+}
+
+// 定义 Art Schema
+const ArtSchema: Schema = new Schema<IArt>(
+  {
+    status: { type: Number, required: true, index: true, default: PageStatus.NEW, enum: Object.values(PageStatus) /* desc: '状态' */ },
+    pageId: { type: Schema.Types.ObjectId, required: true /* desc: 'Page Id' */ },
+    user: { type: Schema.Types.ObjectId, ref: "Designer", required: true, index: true /* desc: '作者' */ },
+    from: { type: String /* desc: '素材来源' */ },
+
+    work: { type: Schema.Types.ObjectId, ref: "ArtBin" /*desc: '上色效果',*/ }, // 👈 关键修改:添加 work 字段到 Schema
+
+    pageVersion: { type: Number, default: 1 /* desc: '底稿版本' */ },
+
+    areaCount: { type: Number, required: true /* desc: '区块数' */ },
+    areaCountFloor: { type: Number, required: true, index: true /* desc: '区块数/100向下取整' */ },
+    coloredAreaCount: { type: Number, default: 0 /* desc: '填色区块数' */ },
+    colorCount: { type: Number, default: 0 /* desc: '颜色数' */ },
+    orderedColorCount: { type: Number, default: 0 /* desc: '已排序颜色数' */ },
+    colorOrderStatus: { type: Number, default: 0 /* desc: '排序状态' */ },
+
+    mapVersion: { type: Number, required: true, default: 1 /* desc: 'map版本' */ },
+    workVersion: { type: Number, required: true, default: 1 /* desc: 'work版本' */ },
+    centersVersion: { type: Number, required: true, default: 0 /* desc: '中心点版本' */ },
+
+    hasSpecial: { type: Boolean, default: false, index: true /* desc: '是否有special' */ },
+    specialVersion: { type: Number, default: 0 /* desc: 'Special图版本' */ },
+    specialPkmVersion: { type: Number, default: 0 /* desc: 'SpecialPkm版本' */ },
+    useSpecialThumb: { type: Number, required: true, default: SpecialThumbType.OUTLINE, enum: Object.values(SpecialThumbType) /* desc: '当前Special缩略图' */ },
+
+    mystery: { type: Boolean, default: false, index: true /* desc: '是否神秘图' */ },
+    ai: { type: Boolean, default: false, index: true /* desc: '是否AI参考图' */ },
+    aiPrompt: { type: String /* desc: 'AI关键字' */ },
+
+    width: { type: Number, required: true, index: true /* desc: '宽' */ },
+    height: { type: Number, index: true, required: true /* desc: '高' */ },
+    name: { type: String, required: true /* desc: '作品名' */ },
+    desc: { type: String /* desc: '作品描述' */ },
+    use: { type: String, required: true, index: true, default: "normal", lowercase: true, trim: true /* desc: '用途' */ },
+    tags: { type: [String], index: true, lowercase: true, trim: true /* desc: '标签' */ },
+
+    epgs: { type: [String], index: true, trim: true /* desc: 'EPG' */ },
+
+    date: { type: Date, default: Date.now, index: true /* desc: '上传时间' */ },
+    lastMod: { type: Date, default: Date.now, index: true /* desc: '修改时间' */ },
+    timeSubmit: { type: Date, index: true /* desc: '提测时间' */ },
+    timeReady: { type: Date, index: true /* desc: '通过时间' */ },
+
+    refuseReason: { type: String /* desc: '拒稿原因' */ },
+
+    lock: { type: Boolean, default: false, index: true /* desc: '是否上锁' */ },
+    publishVersion: { type: Number, default: 0, index: true /* desc: '发布版本' */ },
+    publishTime: { type: Date, index: true /* desc: '发布时间' */ },
+    publishBy: { type: Schema.Types.ObjectId, ref: "Designer", index: true /* desc: '发布者' */ },
+    order: { type: Number, default: 0, index: true },
+
+    drop: { type: Boolean, default: false, index: true /* desc: '标记删除' */ },
+    dropReason: { type: String /* desc: '删除原因' */ },
+
+    score: { type: Number /* desc: '评分' */ },
+
+    ////////////////////////// 统计数据 ////////////////////////////
+    totalStartCount: { type: Number, index: true /* desc: '总开始数' */ },
+    totalDoneCount: { type: Number, index: true /* desc: '总完成数' */ },
+    completionRate: { type: Number, index: true /* desc: '完成比例' */ },
+  },
+  {
+    strict: true,
+    toJSON: {
+      virtuals: true,
+      transform: artTransform,
+    },
+    toObject: {
+      virtuals: true,
+      transform: artTransform,
+    },
+  }
+);
+
+// Art 转换函数 (虚拟字段 thumb 的逻辑)
+function artTransform(doc: IArt, ret: Record<string, any>) {
+  // make thumb for art.
+  // 确保 doc.lastMod 存在或提供默认值,以避免运行时错误
+  const lastModTime = doc.lastMod ? doc.lastMod.getTime() : Date.now();
+  ret.thumb = `/thumbs/v2/page/320/${doc.pageId}.png?t=${lastModTime}`;
+
+  if (doc.work && doc.hasSpecial) {
+    ret.thumb = `/thumbs/v2/special/320/${doc._id}.png?t=${lastModTime}`;
+  } else if (doc.work) {
+    ret.thumb = `/thumbs/v2/work/320/${doc._id}.png?t=${lastModTime}`;
+  }
+}
+
+// 复合索引
+ArtSchema.index({
+  order: -1,
+  publishTime: -1,
+});
+
+// 创建并导出 Art Model
+const Art = mongoose.model<IArt>("Art", ArtSchema);
+export default Art;

+ 54 - 0
oms/src/models/colorRecordModel.ts

@@ -0,0 +1,54 @@
+// oms/src/models/colorRecordModel.ts
+import mongoose, { Document, Schema } from "mongoose";
+
+// 定义 ColorRecord 文档的 TypeScript 接口
+export interface IColorRecord extends Document {
+  uid: string; // 用户ID
+  res: string; // 作品ID
+  type: "color_start" | "color_done" | "color_tip"; // 操作类型 (填色开始, 填色完成, 填色提示等)
+  time: Date; // 操作时间
+  duration?: number; // 填色时长 (仅对 'color_done' 有效)
+  createdAt: Date; // 记录创建时间 (由timestamps自动管理)
+  updatedAt: Date; // 记录更新时间 (由timestamps自动管理)
+}
+
+// 定义 ColorRecord Schema
+const ColorRecordSchema: Schema = new Schema(
+  {
+    uid: {
+      type: String,
+      required: true,
+      index: true, // 为 uid 字段添加索引
+    },
+    res: {
+      type: String,
+      required: true,
+      index: true, // 为 作品id (res) 字段添加索引
+    },
+    event: {
+      type: String,
+      required: true,
+      enum: ["color_start", "color_done", "color_tip"], // 限制操作类型为枚举值
+      index: true, // 为 type 字段添加索引
+    },
+    time: {
+      type: Date,
+      required: true,
+      index: true, // 为 操作时间 (time) 字段添加索引
+    },
+    duration: {
+      type: Number,
+      min: 0, // 确保时长不为负数
+    },
+  },
+  {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+  }
+);
+
+// 创建并导出 ColorRecord Model
+const ColorRecord = mongoose.model<IColorRecord>(
+  "ColorRecord",
+  ColorRecordSchema
+);
+export default ColorRecord;

+ 61 - 0
oms/src/models/doneRateModel.ts

@@ -0,0 +1,61 @@
+// oms/src/models/doneRateModel.ts
+import mongoose, { Document, Schema } from "mongoose";
+
+// 定义 DoneRate 文档的 TypeScript 接口
+export interface IDoneRate extends Document {
+  date: string; // 日期(yyyyMMdd 字符串格式)
+  res: mongoose.Types.ObjectId; // 作品 ID,关联到 Art 模型
+  startCount: number; // 今日该作品点击进入填色的次数
+  doneCount: number; // 今日该作品的完成数
+  completionRate: number; // 完成率: 100 * doneCount / startCount
+  createdAt: Date; // 创建时间 (由 timestamps 自动管理)
+  updatedAt: Date; // 更新时间 (由 timestamps 自动管理)
+}
+
+// 定义 DoneRate Schema
+const DoneRateSchema: Schema = new Schema(
+  {
+    date: {
+      type: String,
+      required: true,
+      index: true, // 为日期添加索引,有助于按日期查询
+      match: /^\d{8}$/, // 验证日期格式是否为 YYYYMMDD
+    },
+    res: {
+      type: Schema.Types.ObjectId,
+      ref: "Art", // 关联到 Art 模型
+      required: true,
+      index: true, // 为作品 ID 添加索引
+    },
+    startCount: {
+      type: Number,
+      required: true,
+      default: 0,
+      min: 0, // 确保开始次数不为负
+    },
+    doneCount: {
+      type: Number,
+      required: true,
+      default: 0,
+      min: 0, // 确保完成次数不为负
+    },
+    completionRate: {
+      type: Number,
+      required: true,
+      default: 0,
+      min: 0, // 完成率不为负
+      max: 100, // 完成率不超100
+    },
+  },
+  {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+  }
+);
+
+// 为 date 和 res 字段创建复合唯一索引
+// 这确保了在给定日期,每个作品只有一条完成率记录
+DoneRateSchema.index({ date: 1, res: 1 }, { unique: true });
+
+// 创建并导出 DoneRate Model
+const DoneRate = mongoose.model<IDoneRate>("DoneRate", DoneRateSchema);
+export default DoneRate;

+ 64 - 0
oms/src/models/userModel.ts

@@ -0,0 +1,64 @@
+// oms/src/models/userModel.ts
+import mongoose, { Document, Schema } from "mongoose";
+
+// 定义 User 文档的 TypeScript 接口
+// 注意:createdAt 和 updatedAt 将由 Mongoose 自动添加,所以我们在这里不显式定义它们
+export interface IUser extends Document {
+  uid: string; // 唯一索引
+  project: number; // 项目id
+  network?: string; // 来源渠道
+  campaign?: string; // 设备当前归因推广活动的名称
+  adgroup?: string; // 设备当前归因广告组的名称
+  creative?: string; // 设备当前归因素材的名称
+  prod?: string; // 归属产品 (填色产品,jigsaw产品等)
+  libraryName?: string; // 其实就是操作系统,取值android/ios
+  cc?: string; // 国家代码
+  lang?: string; // 语言
+  manufacturer?: string; // 生产厂商
+  deviceModel?: string; // 设备型号 (已从 model 更改为 deviceModel)
+  deviceInfo?: string; // 设备信息 (已从 device 更改为 deviceInfo)
+  hardware?: string; // 硬件信息
+  deviceMem?: number; // 机身内存
+  apiLevel?: number; // api版本号
+  versionName?: string; // 软件版本
+  versionCode?: number; // 软件编号
+  fmToken?: string; // firebase message token
+  tags?: string[]; // 用户画像(基础维度、内容偏好维度、社区属性维度等),现在是字符串数组
+  firstLoginAt?: Date; // 首次登录时间
+  lastActiveAt?: Date; // 上次活跃时间
+  // createdAt 和 updatedAt 将由 timestamps: true 自动添加并包含在 Document 接口中
+}
+
+// 定义 User Schema
+const UserSchema: Schema = new Schema(
+  {
+    uid: { type: String, required: true, unique: true }, // uid 字段,唯一且必需, 对于project_id=1, 对应原始日志的uid字段,对于project_id=6, 对应原始日志的user_id
+    project: { type: Number, index: true }, // 来自原始日志的project_id字段,多个事件都有上报,属于公共属性, 填色android应用project_id=1, ios版本project_id=6
+    network: { type: String, index: true }, // 来自原始visit事件日志的 network 字段, 可能为null
+    campaign: { type: String, index: true }, // 来自原始visit事件日志的 campaign 字段, 可能为null
+    adgroup: { type: String, index: true }, // 来自原始visit事件日志的 adgroup 字段, 可能为null
+    creative: { type: String, index: true }, // 来自原始visit事件日志的 creative 字段, 可能为null
+    prod: { type: String, index: true }, // 来自原始日志的prod字段,多个事件都有上报, "number"表示填色应用, "jigsaw"表示拼图游戏等
+    libraryName: { type: String, enum: ["ios", "android"], index: true }, // 来自原始日志的library_name字段
+    cc: { type: String, index: true }, // 来自原始日志的cc字段,表示国家代码
+    lang: { type: String, index: true }, // 来自原始日志的lang字段,表示语言,目前没有上报,全是null,以后app更新会加上
+    manufacturer: { type: String }, // 来自原始日志的manufacturer字段,生产厂商
+    deviceModel: { type: String, index: true }, // 来自原始日志的model字段,表示设备型号
+    deviceInfo: { type: String }, // 来自原始日志的device字段,表示设备机型代号
+    hardware: { type: String }, // 来自原始日志的hardware字段,硬件的型号吧
+    deviceMem: { type: Number }, // 来自原始日志的deviceMem字段,表示机身内存,单位MB
+    apiLevel: { type: Number, index: true }, // 来自原始日志的android_api字段,表示andriod api级别,只对android版本有用
+    versionName: { type: String, index: true }, // 来自原始日志的version_name字段, 如果是ios填色应用(project_id=6)则对应的是library_version字段
+    versionCode: { type: Number, index: true }, // 来自原始日志的version_code 字段
+    fmToken: { type: String }, // 来自原始firebase_message_token日志的token字段,解析到firebase_message_token日志后更新
+    tags: { type: [String], index: true }, // <--- 关键修改:现在是字符串数组类型
+    firstLoginAt: { type: Date, index: true }, // 来自原始日志,表示首次登陆事件,考虑到前面的日志的丢失,可以通过visit事件日志的days天数字段来倒推
+    lastActiveAt: { type: Date, index: true }, // 来自原始日志的t字段或者create_at字段,表示上次活跃时间,结合days(如果有的话)可以倒推firstLoginAt
+  },
+  {
+    timestamps: true, // 启用时间戳,自动维护 createdAt 和 updatedAt
+  }
+);
+
+// 创建并导出 User Model
+export const User = mongoose.model<IUser>("User", UserSchema);

+ 49 - 0
oms/src/models/userPreferenceModel.ts

@@ -0,0 +1,49 @@
+// oms/src/models/userPreferenceModel.ts
+import mongoose, { Document, Schema } from "mongoose";
+
+// 定义 UserPreference 文档的 TypeScript 接口
+export interface IUserPreference extends Document {
+  uid: string; // 用户唯一ID
+  tag: string; // 内容标签
+  count: number; // 使用次数或偏好强度
+  createdAt: Date; // 创建时间 (由timestamps自动管理)
+  updatedAt: Date; // 更新时间 (由timestamps自动管理)
+}
+
+// 定义 UserPreference Schema
+const UserPreferenceSchema: Schema = new Schema(
+  {
+    uid: {
+      type: String,
+      required: true,
+      // uid 需要索引,因为它与 tag 构成复合唯一索引
+      index: true,
+    },
+    tag: {
+      type: String,
+      required: true,
+      // tag 需要索引,因为它与 uid 构成复合唯一索引
+      index: true,
+    },
+    count: {
+      type: Number,
+      required: true,
+      default: 0, // 默认值为0
+      min: 0, // 确保使用次数不为负
+    },
+  },
+  {
+    timestamps: true, // 自动添加 createdAt 和 updatedAt 字段
+  }
+);
+
+// 为 uid 和 tag 字段创建复合唯一索引
+// 这确保了每个 uid 和 tag 的组合都是唯一的,符合 "uid 和 tag 构成唯一索引" 的要求
+UserPreferenceSchema.index({ uid: 1, tag: 1 }, { unique: true });
+
+// 创建并导出 UserPreference Model
+const UserPreference = mongoose.model<IUserPreference>(
+  "UserPreference",
+  UserPreferenceSchema
+);
+export default UserPreference;

+ 25 - 0
oms/src/routes/apiRoutes.ts

@@ -0,0 +1,25 @@
+// oms/src/routes/apiRoutes.ts
+import { Router } from "express";
+import userController from "../controllers/userController"; // Import the user controller
+import artController from "../controllers/artController";
+import doneRateController from "../controllers/doneRateController"; // 👈 新增:导入 DoneRateController
+
+const router = Router();
+
+// User routes
+router.post("/users", userController.createUser);
+// Updated to the paginated user list interface
+router.get("/users", userController.getPaginatedUsers); // GET /api/users?page=1&limit=10&project=1
+router.get("/users/:uid", userController.getUserByUid);
+router.put("/users/:uid", userController.updateUser);
+router.delete("/users/:uid", userController.deleteUser);
+
+router.get("/arts", artController.getArts); // 获取作品列表 (支持分页、筛选、排序)
+router.get("/arts/:id", artController.getArtById); // 获取单个作品
+router.put("/arts/:id", artController.updateArt); // 更新作品信息
+
+// 👈 新增:完成率 DoneRate 路由 (只读)
+router.get("/done-rates/artwork/:resId", doneRateController.getDoneRatesByArtworkId); // 按作品 ID 获取历史完成率
+router.get("/done-rates/date/:date", doneRateController.getDoneRatesByDate); // 按日期获取所有作品完成率
+
+export default router;

+ 432 - 0
oms/src/scripts/ingestHistoricalData.ts

@@ -0,0 +1,432 @@
+// oms/scripts/ingestHistoricalData.ts
+
+// Load environment variables for database credentials (MONGO_URI, CLICKHOUSE_*)
+// Log file directory and date range are now passed as function arguments.
+import * as dotenv from "dotenv";
+dotenv.config();
+
+import mongoose from "mongoose"; // Mongoose for OMS MongoDB models
+import dayjs from "dayjs"; // For date manipulation
+import duration from "dayjs/plugin/duration"; // dayjs plugin for duration
+import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; // Day.js plugin for isSameOrBefore
+import { v4 as uuidv4 } from "uuid"; // For generating unique IDs where needed
+import { User, IUser } from "../models/userModel"; // OMS User Model
+import { ClickhouseService, IEventLog } from "../services/clickhouseService"; // OMS ClickHouse Service
+
+// Node.js built-in modules for file processing
+import * as fs from "fs";
+import * as path from "path";
+import * as zlib from "zlib";
+import * as readline from "readline";
+
+dayjs.extend(duration);
+dayjs.extend(isSameOrBefore); // Extend dayjs with the isSameOrBefore plugin
+
+// --- Persistent Configuration (can still come from .env) ---
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost/omsdb?authSource=admin";
+const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST || "http://localhost:8123";
+const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE || "omsdb"; // Aligned with docker-compose.yml
+const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || "ckuser";
+const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || "ckpassword";
+const CLICKHOUSE_EVENTS_TABLE = "events"; // Aligned with clickhouseService.ts table name
+
+// --- Batching Configuration ---
+const CLICKHOUSE_BATCH_SIZE = 5000; // ClickHouse 批量插入大小
+const MONGO_BATCH_SIZE = 1000; // MongoDB 批量写入大小
+
+// List of event types to process
+const ALLOWED_EVENT_TYPES = ["visit", "show_deeplink_dialog", "share", "save", "revenue", "rate", "favorite", "color_tip", "color_start", "color_done", "color_data", "ad_color_tip", "ad_color_float"];
+
+// Define an array of valid IUser keys for copying from event log to User model
+const USER_FIELDS_TO_UPDATE: (keyof IUser)[] = [
+  "network",
+  "campaign",
+  "adgroup",
+  "creative",
+  "prod",
+  "libraryName", // maps to library_name
+  "cc",
+  "lang",
+  "manufacturer",
+  "deviceModel", // maps to model
+  "deviceInfo", // maps to device
+  "hardware",
+  "deviceMem",
+  "apiLevel", // maps to android_api
+  "versionName", // maps to version_name or library_version
+  "versionCode", // maps to version_code
+  "fmToken", // map to token
+  "project", // Add project field for updating, map to project_id
+];
+
+// --- Initialize Services ---
+let clickhouseService: ClickhouseService;
+
+async function initializeServices() {
+  try {
+    // Connect to OMS MongoDB (using Mongoose)
+    await mongoose.connect(OMS_MONGO_URI);
+    console.log(`Connected to OMS MongoDB: ${OMS_MONGO_URI}`);
+
+    // Initialize ClickHouse service with credentials
+    clickhouseService = new ClickhouseService(CLICKHOUSE_HOST, CLICKHOUSE_DATABASE, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD);
+    // Ensure ClickHouse table exists
+    await clickhouseService.ensureTable(CLICKHOUSE_EVENTS_TABLE);
+    console.log(`ClickHouse Service initialized for ${CLICKHOUSE_DATABASE} at ${CLICKHOUSE_HOST}`);
+  } catch (error) {
+    console.error("Failed to initialize services:", error);
+    process.exit(1); // Exit if essential services cannot connect
+  }
+}
+
+// --- Helper function to flush ClickHouse buffer ---
+async function flushClickHouseBuffer(buffer: IEventLog[], tableName: string, processedCount: number) {
+  if (buffer.length === 0) return;
+  try {
+    // clickhouseService.insertEvent now accepts IEventLog | IEventLog[]
+    await clickhouseService.insertEvent(tableName, buffer);
+    console.log(`Flushed ${buffer.length} events to ClickHouse. Total processed: ${processedCount}`);
+    buffer.length = 0; // Clear buffer
+  } catch (error) {
+    console.error(`Error flushing ClickHouse buffer:`, error);
+    // Depending on error handling strategy, might rethrow or log and continue
+  }
+}
+
+// --- Historical Data Ingestion Logic ---
+// Now accepts logFilesDir, startDateStr, endDateStr as arguments
+async function ingestHistoricalData(logFilesDir: string, startDateStr: string, endDateStr: string) {
+  console.log(`Starting historical ingestion from local files in: ${logFilesDir}`);
+  console.log(`Filtering log files for dates from ${startDateStr} to ${endDateStr}`);
+
+  const startDate = dayjs(startDateStr, "YYYYMMDD");
+  const endDate = dayjs(endDateStr, "YYYYMMDD");
+
+  let totalProcessedEvents = 0;
+  let totalUpsertedUsers = 0;
+
+  // Buffers for batching
+  const clickhouseEventsBuffer: IEventLog[] = [];
+  const mongoUserWriteOperations: any[] = []; // Array of Mongoose bulkWrite operations
+
+  // Iterate through each day in the specified range to construct expected filenames
+  let currentDate = dayjs(startDateStr, "YYYYMMDD");
+  while (currentDate.isSameOrBefore(endDate, "day")) {
+    const fileDateStr = currentDate.format("YYYYMMDD");
+    const expectedFilename = `coloring-${fileDateStr}.log.gz`;
+    const filePath = path.join(logFilesDir, expectedFilename);
+
+    if (!fs.existsSync(filePath)) {
+      console.warn(`File '${expectedFilename}' not found in '${logFilesDir}'. Skipping this date.`);
+      currentDate = currentDate.add(1, "day"); // 文件不存在,日期推进
+      continue;
+    }
+
+    console.log(`\n--- Processing file: ${expectedFilename} ---`);
+    let processedEventsCount = 0;
+    let upsertedUsersCount = 0;
+
+    const fileStream = fs.createReadStream(filePath);
+    const gunzip = zlib.createGunzip();
+    const rl = readline.createInterface({
+      input: fileStream.pipe(gunzip),
+      crlfDelay: Infinity, // Handle Windows/Unix line endings
+    });
+
+    try {
+      // Cache for firstLoginAt lookup for the current batch
+      const firstLoginAtCache: Map<string, Date | undefined> = new Map();
+      const currentBatchUids: Set<string> = new Set();
+      const eventsInCurrentBatch: any[] = []; // Temporary store for raw eventLog in a batch
+
+      for await (const line of rl) {
+        if (!line.trim()) continue; // Skip empty lines
+
+        let eventLog: any;
+        try {
+          eventLog = JSON.parse(line);
+        } catch (parseError) {
+          console.error(`Error parsing JSON from line in '${expectedFilename}': ${line}. Error: ${parseError}`);
+          continue; // Skip malformed JSON lines
+        }
+
+        // Filter by project_id
+        const projectId = eventLog.project_id;
+        if (projectId !== 1 && projectId !== 6) {
+          continue; // Skip if not project 1 or 6
+        }
+
+        // Determine event type field name based on project_id
+        const eventType = projectId === 1 ? eventLog.type : eventLog.name;
+
+        // Filter by allowed event types
+        if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
+          continue; // Skip if not an allowed event type
+        }
+
+        const uid = projectId === 1 ? eventLog.uid : eventLog.user_id;
+        if (!uid) {
+          console.warn(`Skipping event with missing UID in '${expectedFilename}': ${JSON.stringify(eventLog)}`);
+          continue;
+        }
+
+        // --- Accumulate for User Data (MongoDB) Batch ---
+        currentBatchUids.add(uid);
+        eventsInCurrentBatch.push(eventLog); // Store raw eventLog temporarily
+
+        // Process a batch when Mongo batch size is reached
+        if (eventsInCurrentBatch.length >= MONGO_BATCH_SIZE) {
+          // 1. Fetch existing firstLoginAt for all UIDs in the current batch
+          const existingUsers = await User.find({ uid: { $in: Array.from(currentBatchUids) } }, { uid: 1, firstLoginAt: 1 });
+          existingUsers.forEach((user) => {
+            firstLoginAtCache.set(user.uid, user.firstLoginAt);
+          });
+
+          for (const batchedEventLog of eventsInCurrentBatch) {
+            const batchUid = batchedEventLog.project_id === 1 ? batchedEventLog.uid : batchedEventLog.user_id;
+            const batchProjectId = batchedEventLog.project_id;
+
+            let batchLastActiveAtDateObj: Date; // Declare here
+            if (batchedEventLog.t) {
+              batchLastActiveAtDateObj = dayjs(batchedEventLog.t).toDate();
+            } else if (batchedEventLog.create_at) {
+              batchLastActiveAtDateObj = dayjs(batchedEventLog.create_at).toDate();
+            } else {
+              batchLastActiveAtDateObj = new Date();
+            }
+
+            let batchFirstLoginAt: Date | undefined;
+            if (batchedEventLog.days !== undefined && batchedEventLog.days !== null) {
+              // Changed to use batchLastActiveAtDateObj directly
+              batchFirstLoginAt = dayjs(batchLastActiveAtDateObj).subtract(batchedEventLog.days, "day").toDate();
+            }
+
+            const batchUserUpdate: Partial<IUser> = { project: batchProjectId };
+            batchUserUpdate.lastActiveAt = batchLastActiveAtDateObj;
+
+            if (batchFirstLoginAt) {
+              const cachedFirstLoginAt = firstLoginAtCache.get(batchUid);
+              if (!cachedFirstLoginAt || !cachedFirstLoginAt.getTime() || dayjs(batchFirstLoginAt).isBefore(cachedFirstLoginAt)) {
+                batchUserUpdate.firstLoginAt = batchFirstLoginAt;
+              }
+            }
+
+            for (const field of USER_FIELDS_TO_UPDATE) {
+              let sourceFieldName: string | undefined;
+              if (field === "libraryName") sourceFieldName = "library_name";
+              else if (field === "deviceModel") sourceFieldName = "model";
+              else if (field === "deviceInfo") sourceFieldName = "device";
+              else if (field === "apiLevel") sourceFieldName = "android_api";
+              else if (field === "versionName") sourceFieldName = batchProjectId === 1 ? "version_name" : "library_version";
+              else if (field === "versionCode") sourceFieldName = "version_code";
+              else if (field === "deviceMem") sourceFieldName = "deviceMem";
+              else if (field === "project") continue;
+              else if (field === "fmToken") sourceFieldName = "token";
+              else sourceFieldName = field;
+
+              if (field === "uid") continue;
+
+              if (sourceFieldName && batchedEventLog[sourceFieldName] !== undefined && batchedEventLog[sourceFieldName] !== null) {
+                if (field === "deviceMem" && typeof batchedEventLog[sourceFieldName] === "number") {
+                  batchUserUpdate.deviceMem = batchedEventLog[sourceFieldName];
+                } else {
+                  batchUserUpdate[field] = batchedEventLog[sourceFieldName];
+                }
+              }
+            }
+
+            mongoUserWriteOperations.push({
+              updateOne: {
+                filter: { uid: batchUid },
+                update: {
+                  $set: batchUserUpdate,
+                  $setOnInsert: { uid: batchUid, createdAt: new Date() },
+                },
+                upsert: true,
+              },
+            });
+          }
+
+          if (mongoUserWriteOperations.length > 0) {
+            try {
+              const bulkResult = await User.bulkWrite(mongoUserWriteOperations);
+              upsertedUsersCount += (bulkResult.upsertedCount || 0) + (bulkResult.modifiedCount || 0);
+              console.log(`MongoDB batch written. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+            } catch (bulkError) {
+              console.error(`Error in MongoDB bulkWrite for file '${expectedFilename}':`, bulkError);
+            }
+            mongoUserWriteOperations.length = 0; // Clear buffer
+          }
+          eventsInCurrentBatch.length = 0; // Clear raw events buffer
+          currentBatchUids.clear(); // Clear UIDs for next batch
+          firstLoginAtCache.clear(); // Clear cache for next batch
+        }
+
+        // --- Prepare Event Data for ClickHouse Batch ---
+        // Redeclare lastActiveAtDateObj for ClickHouse event processing if not already in scope.
+        // It's safer to recalculate or ensure it's in scope, here we use `let` to declare it
+        // in the scope of the `for await` loop.
+        let lastActiveAtDateObj: Date;
+        if (eventLog.t) {
+          lastActiveAtDateObj = dayjs(eventLog.t).toDate();
+        } else if (eventLog.create_at) {
+          lastActiveAtDateObj = dayjs(eventLog.create_at).toDate();
+        } else {
+          lastActiveAtDateObj = new Date();
+        }
+
+        const clickhouseEvent: IEventLog = {
+          log_id: eventLog._id ? eventLog._id.toString() : uuidv4(),
+          uid: uid,
+          project: projectId,
+          os: eventLog.library_name || null,
+          version: projectId === 1 ? eventLog.version_name : eventLog.library_version || null,
+          event: eventType,
+          time: lastActiveAtDateObj, // Use the explicitly formatted string for ClickHouse
+          res: projectId === 1 ? eventLog.res : eventLog.sku_id || null,
+          from: projectId === 1 ? eventLog.from : eventLog.tab_source || null,
+          position: projectId === 1 ? eventLog.position : eventLog.click_position || null,
+          duration: eventLog.duration || null, // Handles Nullable(UInt32) if present
+          ad_type: eventLog.ad_type || null,
+          ad_src: eventLog.ad_src || null,
+          revenue: projectId === 1 ? eventLog.rev : eventLog.ad_revenue || null,
+          cc: eventLog.cc || null,
+          raw_json_data: JSON.stringify(eventLog),
+        };
+
+        clickhouseEventsBuffer.push(clickhouseEvent);
+        processedEventsCount++;
+
+        // Flush ClickHouse buffer if batch size reached
+        if (clickhouseEventsBuffer.length >= CLICKHOUSE_BATCH_SIZE) {
+          await flushClickHouseBuffer(clickhouseEventsBuffer, CLICKHOUSE_EVENTS_TABLE, processedEventsCount);
+        }
+      } // End of for await (const line of rl) loop
+
+      // --- Flush any remaining buffers after file processing ---
+      // Flush remaining MongoDB operations
+      // Process remaining user data (MongoDB) that didn't form a full batch
+      if (eventsInCurrentBatch.length > 0) {
+        // If UIDs exist and cache is empty (meaning no full batch was processed), fetch existing users
+        if (currentBatchUids.size > 0 && firstLoginAtCache.size === 0) {
+          const existingUsers = await User.find({ uid: { $in: Array.from(currentBatchUids) } }, { uid: 1, firstLoginAt: 1 });
+          existingUsers.forEach((user) => {
+            firstLoginAtCache.set(user.uid, user.firstLoginAt);
+          });
+        }
+
+        for (const batchedEventLog of eventsInCurrentBatch) {
+          const batchUid = batchedEventLog.project_id === 1 ? batchedEventLog.uid : batchedEventLog.user_id;
+          const batchProjectId = batchedEventLog.project_id;
+
+          let batchLastActiveAtDateObj: Date;
+          if (batchedEventLog.t) {
+            batchLastActiveAtDateObj = dayjs(batchedEventLog.t).toDate();
+          } else if (batchedEventLog.create_at) {
+            batchLastActiveAtDateObj = dayjs(batchedEventLog.create_at).toDate();
+          } else {
+            batchLastActiveAtDateObj = new Date();
+          }
+
+          let batchFirstLoginAt: Date | undefined;
+          if (batchedEventLog.days !== undefined && batchedEventLog.days !== null) {
+            // Changed to use batchLastActiveAtDateObj directly
+            batchFirstLoginAt = dayjs(batchLastActiveAtDateObj).subtract(batchedEventLog.days, "day").toDate();
+          }
+
+          const batchUserUpdate: Partial<IUser> = { project: batchProjectId };
+          batchUserUpdate.lastActiveAt = batchLastActiveAtDateObj;
+
+          if (batchFirstLoginAt) {
+            const cachedFirstLoginAt = firstLoginAtCache.get(batchUid);
+            // Ensure cachedFirstLoginAt is a valid Date before comparison
+            if (!cachedFirstLoginAt || !cachedFirstLoginAt.getTime() || dayjs(batchFirstLoginAt).isBefore(cachedFirstLoginAt)) {
+              batchUserUpdate.firstLoginAt = batchFirstLoginAt;
+            }
+          }
+
+          for (const field of USER_FIELDS_TO_UPDATE) {
+            let sourceFieldName: string | undefined;
+            if (field === "libraryName") sourceFieldName = "library_name";
+            else if (field === "deviceModel") sourceFieldName = "model";
+            else if (field === "deviceInfo") sourceFieldName = "device";
+            else if (field === "apiLevel") sourceFieldName = "android_api";
+            else if (field === "versionName") sourceFieldName = batchProjectId === 1 ? "version_name" : "library_version";
+            else if (field === "versionCode") sourceFieldName = "version_code";
+            else if (field === "deviceMem") sourceFieldName = "deviceMem";
+            else if (field === "project") continue;
+            else if (field === "fmToken") sourceFieldName = "token";
+            else sourceFieldName = field;
+
+            if (field === "uid") continue;
+
+            if (sourceFieldName && batchedEventLog[sourceFieldName] !== undefined && batchedEventLog[sourceFieldName] !== null) {
+              if (field === "deviceMem" && typeof batchedEventLog[sourceFieldName] === "number") {
+                batchUserUpdate.deviceMem = batchedEventLog[sourceFieldName];
+              } else {
+                batchUserUpdate[field] = batchedEventLog[sourceFieldName];
+              }
+            }
+          }
+
+          mongoUserWriteOperations.push({
+            updateOne: {
+              filter: { uid: batchUid },
+              update: {
+                $set: batchUserUpdate,
+                $setOnInsert: { uid: batchUid, createdAt: new Date() },
+              },
+              upsert: true,
+            },
+          });
+        }
+      }
+
+      if (mongoUserWriteOperations.length > 0) {
+        try {
+          const bulkResult = await User.bulkWrite(mongoUserWriteOperations);
+          upsertedUsersCount += (bulkResult.upsertedCount || 0) + (bulkResult.modifiedCount || 0);
+          // console.log(`MongoDB final batch written. Upserted/Modified: ${bulkResult.upsertedCount + bulkResult.modifiedCount}`);
+        } catch (bulkError) {
+          console.error(`Error in final MongoDB bulkWrite for file '${expectedFilename}':`, bulkError);
+        }
+        mongoUserWriteOperations.length = 0; // Clear buffer
+      }
+
+      // Flush remaining ClickHouse events
+      await flushClickHouseBuffer(clickhouseEventsBuffer, CLICKHOUSE_EVENTS_TABLE, processedEventsCount);
+
+      totalProcessedEvents += processedEventsCount;
+      totalUpsertedUsers += upsertedUsersCount;
+      console.log(`File '${expectedFilename}' processed: ${processedEventsCount} events, ${upsertedUsersCount} users upserted.`);
+      // Move to the next day only after successful processing of the current day's file
+      currentDate = currentDate.add(1, "day");
+    } catch (error) {
+      console.error(`Error processing file '${expectedFilename}':`, error);
+      // If an error occurs during file processing, we still want to move to the next day
+      currentDate = currentDate.add(1, "day");
+    }
+  }
+  console.log(`\n--- Historical data ingestion complete ---`);
+  console.log(`Total processed events: ${totalProcessedEvents}`);
+  console.log(`Total upserted users: ${totalUpsertedUsers}`);
+}
+
+// --- Main execution ---
+async function main() {
+  await initializeServices(); // Initialize OMS DB and ClickHouse
+
+  // --- Hardcoded parameters for one-time ingestion ---
+  const logFilesDirectory = process.env.LOG_FILES_DIR || path.join(__dirname, "../logs_archive"); // Fallback directory
+  const ingestionStartDate = "20250821"; // Start date for log files
+  const ingestionEndDate = "20250821"; // End date for log files
+
+  await ingestHistoricalData(logFilesDirectory, ingestionStartDate, ingestionEndDate);
+
+  // Ensure mongoose connection is properly closed only if it was connected
+  if (mongoose.connection.readyState === 1) await mongoose.disconnect();
+  console.log("Database connections closed.");
+  process.exit(0);
+}
+
+main().catch(console.error);

+ 302 - 0
oms/src/scripts/ingestHistoricalDataFromClog.ts

@@ -0,0 +1,302 @@
+// oms/scripts/ingestHistoricalData.ts
+
+// Load environment variables from .env (or .env.ingest if specified in script command)
+import * as dotenv from "dotenv";
+dotenv.config();
+
+import { MongoClient, Db } from "mongodb"; // Native MongoDB driver for external connection
+import mongoose from "mongoose"; // Mongoose for OMS MongoDB models
+import dayjs from "dayjs"; // For date manipulation
+import duration from "dayjs/plugin/duration"; // dayjs plugin for duration
+import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; // 👈 NEW: dayjs plugin for isSameOrBefore
+import { User, IUser } from "../models/userModel"; // OMS User Model
+import { ClickhouseService, IEventLog } from "../services/clickhouseService"; // OMS ClickHouse Service
+
+import { v4 as uuidv4 } from "uuid";
+
+dayjs.extend(duration);
+dayjs.extend(isSameOrBefore); // Extend dayjs with the isSameOrBefore plugin
+
+// --- Configuration ---
+const CLOGS_MONGO_URI =
+  process.env.CLOGS_MONGO_URI || "mongodb://localhost:27017/clogs";
+const CLOGS_DATABASE_NAME = process.env.CLOGS_DATABASE_NAME || "clogs"; // Database containing daily collections
+
+const OMS_MONGO_URI = process.env.MONGO_URI || "mongodb://oms:oms123.@localhost:27017/omsdb";
+const CLICKHOUSE_HOST = process.env.CLICKHOUSE_HOST || "http://localhost:8123";
+const CLICKHOUSE_DATABASE = process.env.CLICKHOUSE_DATABASE || "omsdb";
+const CLICKHOUSE_USER = process.env.CLICKHOUSE_USER || "ckuser";
+const CLICKHOUSE_PASSWORD = process.env.CLICKHOUSE_PASSWORD || "ckpassword";
+const CLICKHOUSE_EVENTS_TABLE = "events";
+
+// Specify the date range for ingestion (inclusive)
+// Format: YYYYMMDD
+const START_DATE_STR = process.env.START_DATE || "20250820";
+const END_DATE_STR = process.env.END_DATE || "20250820";
+
+// List of event types to process
+const ALLOWED_EVENT_TYPES = [
+  "visit",
+  "show_deeplink_dialog",
+  "share",
+  "save",
+  "revenue",
+  "rate",
+  "favorite",
+  "color_tip",
+  "color_start",
+  "color_done",
+  "color_data",
+  "ad_color_tip",
+  "ad_color_float",
+];
+
+// Define an array of valid IUser keys for copying from event log to User model
+const USER_FIELDS_TO_UPDATE: (keyof IUser)[] = [
+  "network",
+  "campaign",
+  "adgroup",
+  "creative",
+  "prod",
+  "libraryName", // maps to library_name
+  "cc",
+  "lang",
+  "manufacturer",
+  "deviceModel", // maps to model
+  "deviceInfo", // maps to device
+  "hardware",
+  "deviceMem",
+  "apiLevel", // maps to android_api
+  "versionName", // maps to version_name or library_version
+  "versionCode", // maps to version_code
+  "fmToken",  // map to token
+  // tags and loginAt are handled specially
+  "project" // Add project field for updating, map to project_id
+];
+
+// --- Initialize Services ---
+let clogsClient: MongoClient;
+let clickhouseService: ClickhouseService;
+
+async function initializeServices() {
+  try {
+    // Connect to external clogs MongoDB
+    clogsClient = new MongoClient(CLOGS_MONGO_URI);
+    await clogsClient.connect();
+    console.log(`Connected to CLOGS MongoDB: ${CLOGS_MONGO_URI}`);
+
+    // Connect to OMS MongoDB (using Mongoose)
+    await mongoose.connect(OMS_MONGO_URI);
+    console.log(`Connected to OMS MongoDB: ${OMS_MONGO_URI}`);
+
+    // Initialize ClickHouse service with credentials
+    clickhouseService = new ClickhouseService(
+      CLICKHOUSE_HOST,
+      CLICKHOUSE_DATABASE,
+      CLICKHOUSE_USER,
+      CLICKHOUSE_PASSWORD
+    );
+    // Ensure ClickHouse table exists
+    await clickhouseService.ensureTable(CLICKHOUSE_EVENTS_TABLE); 
+    console.log(
+      `ClickHouse Service initialized for ${CLICKHOUSE_DATABASE} at ${CLICKHOUSE_HOST}`
+    );
+  } catch (error) {
+    console.error("Failed to initialize services:", error);
+    process.exit(1); // Exit if essential services cannot connect
+  }
+}
+
+// --- Historical Data Ingestion Logic ---
+async function ingestHistoricalData() {
+  
+  console.log(`ingestHistoricalData from ${START_DATE_STR} to ${END_DATE_STR}`);
+
+  let currentDate = dayjs(START_DATE_STR, "YYYYMMDD");
+  const endDate = dayjs(END_DATE_STR, "YYYYMMDD");
+
+  while (currentDate.isSameOrBefore(endDate, "day")) {
+    const collectionName = currentDate.format("YYYYMMDD");
+    console.log(`\n--- Processing collection: ${collectionName} ---`);
+
+    const clogsDb = clogsClient.db(CLOGS_DATABASE_NAME);
+    const collections = await clogsDb
+      .listCollections({ name: collectionName })
+      .toArray();
+    const collectionExists = collections.length > 0;
+
+    if (!collectionExists) {
+      console.warn(`Collection '${collectionName}' does not exist. Skipping.`);
+      currentDate = currentDate.add(1, "day");
+      continue;
+    }
+
+    const collection = clogsDb.collection(collectionName);
+    let processedEventsCount = 0;
+    let upsertedUsersCount = 0;
+
+    try {
+      const cursor = collection.find({});
+      for await (const eventLog of cursor) {
+        // Filter by project_id
+        const projectId = eventLog.project_id;
+        if (projectId !== 1 && projectId !== 6) {
+          continue; // Skip if not project 1 or 6
+        }
+
+        // Determine event type field name based on project_id
+        const eventType = projectId === 1 ? eventLog.type : eventLog.name;
+
+        // Filter by allowed event types
+        if (!ALLOWED_EVENT_TYPES.includes(eventType)) {
+          continue; // Skip if not an allowed event type
+        }
+
+        const uid = projectId === 1 ? eventLog.uid : eventLog.user_id;
+        if (!uid) {
+          console.warn(
+            `Skipping event with missing UID: ${JSON.stringify(eventLog)}`
+          );
+          continue;
+        }
+
+        // --- 1. Process User Data (MongoDB) ---
+        const lastActiveAtDateObj = eventLog.t
+          ? dayjs(eventLog.t).toDate()
+          : eventLog.create_at
+          ? dayjs(eventLog.create_at).toDate()
+          : new Date();
+
+        // Format lastActiveAt for ClickHouse specifically
+        // Explicitly format to 'YYYY-MM-DD HH:mm:ss' which ClickHouse DateTime type expects
+        const lastActiveAtClickHouse = dayjs(lastActiveAtDateObj).format(
+          "YYYY-MM-DD HH:mm:ss"
+        );
+
+        let firstLoginAt: Date | undefined;
+
+        if (
+          eventLog.days !== undefined &&
+          eventLog.days !== null &&
+          lastActiveAtDateObj
+        ) {
+          // days 字段是天数,倒推 firstLoginAt
+          firstLoginAt = dayjs(lastActiveAtDateObj)
+            .subtract(eventLog.days, "day")
+            .toDate();
+        }
+
+        // Initialize userUpdate without 'uid' here, as it's in the query filter
+        const userUpdate: Partial<IUser> = { project: projectId }; // project is okay here
+        userUpdate.lastActiveAt = lastActiveAtDateObj; // Still use Date object for MongoDB
+
+        // Logic for firstLoginAt: only update if current event's calculated firstLoginAt is earlier or if it's new
+        if (firstLoginAt) {
+          const existingUser = await User.findOne(
+            { uid: uid },
+            { firstLoginAt: 1 }
+          );
+          if (
+            !existingUser ||
+            !existingUser.firstLoginAt ||
+            dayjs(firstLoginAt).isBefore(existingUser.firstLoginAt)
+          ) {
+            userUpdate.firstLoginAt = firstLoginAt;
+          }
+        }
+ 
+        // Copy other relevant user profile fields from event log
+        for (const field of USER_FIELDS_TO_UPDATE) {
+          // Map source field names to target model names
+          let sourceFieldName: string | undefined;
+            if (field === 'libraryName') sourceFieldName = 'library_name';
+            else if (field === 'deviceModel') sourceFieldName = 'model';
+            else if (field === 'deviceInfo') sourceFieldName = 'device';
+            else if (field === 'apiLevel') sourceFieldName = 'android_api';
+            else if (field === 'versionName') sourceFieldName = projectId === 1 ? 'version_name' : 'library_version';
+            else if (field === 'versionCode') sourceFieldName = 'version_code';
+            else if (field === 'deviceMem') sourceFieldName = 'deviceMem';
+            else if (field === 'project') continue; // project is already handled and should not be re-copied from loop
+          else sourceFieldName = field; // For fields with consistent names
+
+            // Skip uid and project as they are handled explicitly or are part of the query
+            if (field === 'uid') continue;
+
+
+            if (sourceFieldName && eventLog[sourceFieldName] !== undefined && eventLog[sourceFieldName] !== null) {
+              userUpdate[field] = eventLog[sourceFieldName];
+            }
+        }
+
+
+        // Perform upsert
+        // Use updateOne for more precise control and to avoid the findOneAndUpdate 'uid' conflict
+        const result = await User.updateOne(
+          { uid: uid },
+          {
+            $set: userUpdate,
+            $setOnInsert: { uid: uid, createdAt: new Date() },
+          },
+          { upsert: true }
+        );
+        if (result.upsertedCount > 0 || result.modifiedCount > 0) {
+          upsertedUsersCount++;
+        }
+
+        // --- 2. Process Event Data (ClickHouse) ---
+        const clickhouseEvent: IEventLog = {
+          log_id: eventLog._id ? eventLog._id.toString() : uuidv4(),
+          uid: uid,
+          project: projectId,
+          os: eventLog.library_name || null,
+          version:
+            projectId === 1
+              ? eventLog.version_name
+              : eventLog.library_version || null,
+          event: eventType,
+          time: lastActiveAtClickHouse as any, // <--- Use the explicitly formatted string for ClickHouse
+          res: projectId === 1 ? eventLog.res : eventLog.sku_id || null,
+          from: projectId === 1 ? eventLog.from : eventLog.tab_source || null,
+          position:
+            projectId === 1
+              ? eventLog.position
+              : eventLog.click_position || null,
+          duration: eventLog.duration || null, // Handles Nullable(UInt32) if present
+          ad_type: eventLog.ad_type || null,
+          ad_src: eventLog.ad_src || null,
+          revenue: projectId === 1 ? eventLog.rev : eventLog.ad_revenue || null, // project_id=1 uses 'rev', project_id=6 uses 'ad_revenue'
+          cc: eventLog.cc || null,
+          raw_json_data: JSON.stringify(eventLog),
+        };
+
+        await clickhouseService.insertEvent(
+          CLICKHOUSE_EVENTS_TABLE,
+          clickhouseEvent
+        );
+        processedEventsCount++;
+      }
+      console.log(
+        `Processed ${processedEventsCount} events and upserted ${upsertedUsersCount} users for collection '${collectionName}'.`
+      );
+    } catch (error) {
+      console.error(`Error processing collection '${collectionName}':`, error);
+    }
+
+    currentDate = currentDate.add(1, "day");
+  }
+  console.log("\n--- Historical data ingestion complete ---");
+}
+
+// --- Main execution ---
+async function main() {
+  await initializeServices(); // Initialize OMS DB and ClickHouse
+  await ingestHistoricalData(); // Run historical ingestion
+  // Close connections gracefully for historical script
+  if (clogsClient) await clogsClient.close();
+  // Ensure mongoose connection is properly closed only if it was connected
+  if (mongoose.connection.readyState === 1) await mongoose.disconnect();
+  console.log("Database connections closed.");
+  process.exit(0);
+}
+
+main().catch(console.error);

+ 90 - 0
oms/src/services/artService.ts

@@ -0,0 +1,90 @@
+// oms/src/services/artService.ts
+import { FilterQuery, SortOrder } from "mongoose";
+import Art, { IArt, PageStatus, SpecialThumbType } from "../models/artModel"; // 导入 Art 模型和 IArt 接口,以及枚举
+
+class ArtService {
+  /**
+   * 按 ID 更新作品信息。
+   * 特别适用于更新统计字段,并确保核心字段不被误改。
+   * @param artId 作品的 MongoDB _id。
+   * @param updateData 包含要更新字段的对象。
+   * @returns 更新后的作品文档,如果未找到则为 null。
+   */
+  public async updateArt(artId: string, updateData: Partial<IArt>): Promise<IArt | null> {
+    try {
+      // 过滤掉不应通过此方法更新的核心字段,例如:
+      // _id, pageId, user, work 等通常在创建后不应改变
+      const mutableUpdateData: Partial<IArt> = { ...updateData };
+      delete mutableUpdateData._id;
+      delete mutableUpdateData.pageId;
+      delete mutableUpdateData.user;
+      delete mutableUpdateData.work;
+      // 'date' 字段通常只在创建时设置,但 'lastMod' 可以更新
+      delete mutableUpdateData.date;
+
+      // Mongoose 默认会更新 'updatedAt' (如果开启 timestamps),但这里我们有 'lastMod'
+      // 如果 updateData 中没有提供 lastMod,并且您希望它在每次更新时自动更新,
+      // 可以在 Mongoose Schema 的 pre('save') 钩子中处理,或者在这里显式设置。
+      // 根据 ArtModel,lastMod 有 default: Date.now,这通常只在 insert 时生效。
+      // 如果需要每次 update 都更新 lastMod,可以在此显式设置或 Schema pre('findOneAndUpdate')。
+      mutableUpdateData.lastMod = new Date(); // 显式更新 lastMod
+
+      const updatedArt = await Art.findByIdAndUpdate(
+        artId,
+        { $set: mutableUpdateData }, // 使用 $set 操作符更新部分字段
+        { new: true, runValidators: true } // 返回更新后的文档,并运行 Schema 验证器
+      );
+      return updatedArt;
+    } catch (error) {
+      console.error(`更新作品 (ID: ${artId}) 时出错:`, error);
+      throw new Error("无法更新作品。");
+    }
+  }
+
+  /**
+   * 按条件分页获取作品列表。
+   * 支持多条件筛选、分页和排序。
+   * @param page 当前页码 (从 1 开始)。
+   * @param limit 每页作品数量。
+   * @param filters MongoDB 查询筛选条件 (例如: { status: PageStatus.ONLINE, tags: { $in: ['popular'] } })。
+   * @param sort 排序条件 (例如: { date: -1, totalStartCount: -1 })。
+   * @returns 包含作品列表和总数的对象。
+   */
+  public async getPaginatedArts(
+    page: number = 1,
+    limit: number = 30,
+    filters: FilterQuery<IArt> = {},
+    sort: { [key: string]: SortOrder } = { lastMod: -1 } // 默认按最近修改时间降序
+  ): Promise<{ arts: IArt[]; total: number }> {
+    try {
+      const skip = (page - 1) * limit;
+
+      // 并行执行查询和计数,提高效率
+      const [arts, total] = await Promise.all([Art.find(filters).sort(sort).skip(skip).limit(limit).exec(), Art.countDocuments(filters).exec()]);
+
+      return { arts, total };
+    } catch (error) {
+      console.error("获取作品列表时出错:", error);
+      throw new Error("无法检索作品列表。");
+    }
+  }
+
+  /**
+   * 按 ID 获取单个作品。
+   * @param artId 作品的 MongoDB _id。
+   * @returns 作品文档,如果未找到则为 null。
+   */
+  public async getArtById(artId: string): Promise<IArt | null> {
+    try {
+      const art = await Art.findById(artId);
+      return art;
+    } catch (error) {
+      console.error(`获取作品 (ID: ${artId}) 时出错:`, error);
+      throw new Error("无法检索作品。");
+    }
+  }
+
+  // 根据您的要求,不提供 create 和 delete 方法
+}
+
+export default new ArtService();

+ 135 - 0
oms/src/services/clickhouseService.ts

@@ -0,0 +1,135 @@
+// oms/src/services/clickhouseService.ts
+import { createClient, ClickHouseClient } from "@clickhouse/client";
+import dayjs from "dayjs"; // 👈 新增:导入 dayjs 用于日期格式化
+
+// 定义 ClickHouse Event Log 的 TypeScript 接口
+export interface IEventLog {
+  log_id: string; // 消息唯一id,主键,对应原始日志的_id字段
+  uid: string; // 用户id,对应原始日志的uid字段(project_id=1) 或 user_id字段(project_id=6)
+  project: number; // 项目id, ,project_id=1表示android填色应用,project_id=6表示ios版本
+  os?: string; // 系统版本,取值android/ios, 对应原始日志的library_name字段
+  version?: string; // 应用版本,对应原始日志的version_name字段(project_id=1), 或 library_version字段(project_id=6)
+  event: string; // 事件类型,对应原始日志的type字段(project_id=1),或 name 字段(project_id=6)
+  time: Date; // 事件时间,对应原始日志的t字段(project_id=1), 或 create_at 字段(project_id=6)
+  res?: string | null; // 关联的作品,对应原始日志的res字段(project_id=1) 或 sku_id 字段(project_id=6)
+  from?: string | null; // 事件来源,如来自daily,gallery之类的,对应原始日志的from字段(project_id=1)或tab source字段(project_id=6)
+  position?: number | null; // 点击位置, 对应原始日志的position字段(project_id=1)或click_position字段(project_id=6)
+  duration?: number | null; // 填色时长, 对应原始日志的duration字段, 只在color_data 和 color_done事件上报,目前都没有
+  ad_type?: string | null; // 广告类型
+  ad_src?: string | null; // 广告位
+  revenue?: number | null; // 广告收益值
+  cc?: string | null; // 国家
+  raw_json_data: string; // 原始日志报文
+}
+
+class ClickhouseService {
+  private client: ClickHouseClient;
+  private readonly database: string;
+
+  constructor(host: string, database: string, username?: string, password?: string) {
+    this.database = database;
+    this.client = createClient({
+      url: host,
+      database: database,
+      username: username, // 传递用户名
+      password: password, // 传递密码
+    });
+    console.log(`ClickHouseService initialized for database: ${database} at ${host}`);
+  }
+
+  /**
+   * 确保 ClickHouse 表存在。
+   * @param tableName - 要检查的表名。
+   */
+  public async ensureTable(tableName: string) {
+    const createTableSql = `
+      CREATE TABLE IF NOT EXISTS ${tableName} (
+          log_id String, -- 消息唯一id,主键,对应原始日志的_id字段
+          uid String, -- 用户id,对应原始日志的uid字段(对应project_id=1 android填色应用), 或user_id字段(对应project_id=6的ios应用)
+          project UInt8, -- 项目id,对应原始日志的project_id字段, project_id=1表示android填色应用,project_id=6表示ios版本
+          os Nullable(String), -- 系统版本,取值android/ios, 对应原始日志的library_name字段
+          version Nullable(String), -- 应用版本,对应原始日志的version_name字段(project_id=1), 或 library_version字段(project_id=6)
+          event String, -- 事件类型,对应原始日志的type字段(project_id=1),或 name 字段(project_id=6)
+          time DateTime, -- 事件时间,对应原始日志的t字段(project_id=1), 或 create_at 字段(project_id=6)
+          res Nullable(String), -- 关联的作品,对应原始日志的res字段(project_id=1) 或 sku_id 字段(project_id=6)
+          from Nullable(String), -- 事件来源,如来自daily,gallery之类的,对应原始日志的from字段(project_id=1)或tab source字段(project_id=6)
+          position Nullable(Int32), -- 点击位置, 对应原始日志的position字段(project_id=1)或click_position字段(对应project_id=6)
+          duration Nullable(UInt32), -- 填色时长,对于color_done/color_data事件有用,提取出来作为公共字段方便后续统计,对应原始日志的duration字段 目前都没有上报,以后版本会加上
+          ad_type Nullable(String), -- 广告类型,对于广告事件才有用,提取出来作为公共字段方便后续统计,对应原始日志的ad_type字段
+          ad_src Nullable(String), -- 广告位,对于广告事件才有用,提取出来作为公共字段方便后续统计,对应原始日志的ad_src字段
+          revenue Nullable(Float64), -- 广告收益值,对于广告的revenue事件才有用,提取出来作为公共字段方便后续统计,对应原始日志的rev字段(project_id=1), 或ad revenue字段(project_id=6)
+          cc Nullable(String), -- 国家,对应原始日志cc字段(目前project_id=1的android有上报, ios没有上报)
+          raw_json_data String -- 原始json数据
+      ) ENGINE = MergeTree()
+      ORDER BY (uid, time, event) -- 建议的排序键
+      PRIMARY KEY (uid, time) -- 主键可用于数据去重和部分查询优化
+      -- TTL time + INTERVAL 30 DAY -- 示例:数据保留30天,根据需求调整。 暂时先注释掉
+    `;
+    try {
+      await this.client.exec({ query: createTableSql });
+      console.log(`ClickHouse table '${tableName}' ensured.`);
+    } catch (error) {
+      console.error(`Failed to ensure ClickHouse table '${tableName}':`, error);
+      throw error; // Rethrow to handle in calling context
+    }
+  }
+
+  /**
+   * 插入单个或多个事件日志到 ClickHouse。
+   * @param tableName - 要插入的表名。
+   * @param events - 单个事件日志数据或事件日志数据数组。
+   */
+  public async insertEvent(tableName: string, events: IEventLog | IEventLog[]): Promise<void> {
+    const valuesToInsert = Array.isArray(events) ? events : [events]; // <--- 关键修改:确保 values 始终是一个数组
+    if (valuesToInsert.length === 0) {
+      return; // No events to insert
+    }
+
+    // 👈 关键修改:在发送到 ClickHouse 之前,格式化 Date 对象
+    const formattedValues = valuesToInsert.map((event) => ({
+      ...event,
+      // 将 Date 对象格式化为 ClickHouse 期望的字符串格式
+      time: dayjs(event.time).format("YYYY-MM-DD HH:mm:ss"),
+    }));
+
+    try {
+      await this.client.insert({
+        table: tableName,
+        values: formattedValues, // <--- 使用处理后的数组
+        format: "JSONEachRow", // ClickHouse 客户端支持多种格式
+      });
+      // console.log(`Inserted ${valuesToInsert.length} events into ${tableName}.`); // 更适合批量插入的日志
+    } catch (error) {
+      console.error(`Error inserting event(s) into ${tableName}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * 查询 ClickHouse 事件日志。
+   * @param querySql - ClickHouse 查询 SQL 语句。
+   * @returns 查询结果数组。
+   */
+  public async queryEvents<T>(querySql: string): Promise<T[]> {
+    try {
+      const resultSet = await this.client.query({ query: querySql });
+      const response = await resultSet.json<T[]>();
+      // 从响应中提取数据数组,通常在data属性中
+      const result = response.data as T[];
+      return result;
+    } catch (error) {
+      console.error(`Error querying ClickHouse:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * 获取 ClickHouse 客户端实例 (如果需要更高级的操作)。
+   */
+  public getClient(): ClickHouseClient {
+    return this.client;
+  }
+}
+
+// 在 app.ts 中实例化并导出此服务
+export { ClickhouseService };

+ 151 - 0
oms/src/services/doneRateService.ts

@@ -0,0 +1,151 @@
+// oms/src/services/doneRateService.ts
+import DoneRate, { IDoneRate } from "../models/doneRateModel"; // 导入 DoneRate 模型和 IDoneRate 接口
+import mongoose, { FilterQuery } from "mongoose"; // 导入 mongoose 类型,如 FilterQuery
+
+class DoneRateService {
+  /**
+   * 创建或更新单条作品每日完成率记录。
+   * 如果记录已存在 (由 date 和 res 唯一确定),则更新其统计字段;否则创建新记录。
+   * 会自动计算 completionRate。
+   *
+   * @param date - 日期 (yyyyMMdd 字符串格式)。
+   * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+   * @param startCount - 今日该作品点击进入填色的次数。
+   * @param doneCount - 今日该作品的完成数。
+   * @returns 创建或更新后的 DoneRate 文档。
+   */
+  public async createOrUpdateDoneRate(date: string, resId: mongoose.Types.ObjectId, startCount: number, doneCount: number): Promise<IDoneRate> {
+    try {
+      if (startCount < 0 || doneCount < 0) {
+        throw new Error("startCount and doneCount cannot be negative.");
+      }
+
+      // 计算完成率
+      const completionRate = startCount > 0 ? (doneCount / startCount) * 100 : 0;
+
+      const filter: FilterQuery<IDoneRate> = { date, res: resId };
+      const update = {
+        $set: {
+          startCount: startCount,
+          doneCount: doneCount,
+          completionRate: completionRate,
+        },
+        $setOnInsert: {
+          // 如果是新插入,确保这些字段也设置
+          date: date,
+          res: resId,
+        },
+      };
+      const options = { upsert: true, new: true, setDefaultsOnInsert: true }; // 插入或更新,返回新文档,并在插入时应用默认值
+
+      const doneRate = await DoneRate.findOneAndUpdate(filter, update, options);
+
+      if (!doneRate) {
+        throw new Error("Failed to create or update DoneRate record unexpectedly.");
+      }
+      return doneRate;
+    } catch (error) {
+      console.error(`创建或更新 DoneRate (date: ${date}, res: ${resId}) 时出错:`, error);
+      throw new Error("无法创建或更新作品完成率记录。");
+    }
+  }
+
+  /**
+   * 根据作品 ID 获取该作品的所有历史完成率数据。
+   * 结果按日期升序排列。
+   *
+   * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+   * @returns DoneRate 文档数组。
+   */
+  public async getDoneRatesByArtwork(resId: mongoose.Types.ObjectId): Promise<IDoneRate[]> {
+    try {
+      const doneRates = await DoneRate.find({ res: resId }).sort({ date: 1 }).exec();
+      return doneRates;
+    } catch (error) {
+      console.error(`获取作品 (ID: ${resId}) 的完成率历史数据时出错:`, error);
+      throw new Error("无法检索作品完成率历史数据。");
+    }
+  }
+
+  /**
+   * 根据特定日期获取该日所有作品的完成率数据。
+   *
+   * @param date - 日期 (yyyyMMdd 字符串格式)。
+   * @returns DoneRate 文档数组。
+   */
+  public async getDoneRatesByDate(date: string): Promise<IDoneRate[]> {
+    try {
+      // 验证日期格式
+      if (!/^\d{8}$/.test(date)) {
+        throw new Error("Invalid date format. Expected YYYYMMDD.");
+      }
+      const doneRates = await DoneRate.find({ date: date }).exec();
+      return doneRates;
+    } catch (error) {
+      console.error(`获取日期 (date: ${date}) 的完成率数据时出错:`, error);
+      throw new Error("无法检索指定日期的完成率数据。");
+    }
+  }
+
+  /**
+   * 更新现有作品每日完成率记录的统计字段。
+   *
+   * @param date - 日期 (yyyyMMdd 字符串格式)。
+   * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+   * @param updateData - 包含要更新字段的对象 (Partial<IDoneRate>)。
+   * @returns 更新后的 DoneRate 文档,如果未找到则为 null。
+   */
+  public async updateDoneRate(date: string, resId: mongoose.Types.ObjectId, updateData: Partial<IDoneRate>): Promise<IDoneRate | null> {
+    try {
+      // 避免更新 date 和 res 字段,因为它们是复合索引的一部分
+      const mutableUpdateData: Partial<IDoneRate> = { ...updateData };
+      delete mutableUpdateData.date;
+      delete mutableUpdateData.res;
+
+      // 如果提供了 startCount 或 doneCount,重新计算 completionRate
+      if ((mutableUpdateData.startCount !== undefined && mutableUpdateData.startCount >= 0) || (mutableUpdateData.doneCount !== undefined && mutableUpdateData.doneCount >= 0)) {
+        // 先获取现有记录,以计算正确的完成率
+        const existingRecord = await DoneRate.findOne({ date, res: resId }).lean().exec();
+        if (existingRecord) {
+          const currentStartCount = mutableUpdateData.startCount !== undefined ? mutableUpdateData.startCount : existingRecord.startCount;
+          const currentDoneCount = mutableUpdateData.doneCount !== undefined ? mutableUpdateData.doneCount : existingRecord.doneCount;
+          mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
+        } else {
+          // 如果记录不存在,则尝试更新时,完成率应基于传入数据或默认0
+          const currentStartCount = mutableUpdateData.startCount || 0;
+          const currentDoneCount = mutableUpdateData.doneCount || 0;
+          mutableUpdateData.completionRate = currentStartCount > 0 ? (currentDoneCount / currentStartCount) * 100 : 0;
+        }
+      }
+
+      const updatedDoneRate = await DoneRate.findOneAndUpdate(
+        { date, res: resId },
+        { $set: mutableUpdateData },
+        { new: true, runValidators: true } // 返回更新后的文档,并运行 Schema 验证器
+      );
+      return updatedDoneRate;
+    } catch (error) {
+      console.error(`更新 DoneRate (date: ${date}, res: ${resId}) 时出错:`, error);
+      throw new Error("无法更新作品完成率记录。");
+    }
+  }
+
+  /**
+   * 根据 date 和 res 删除单条完成率记录。
+   *
+   * @param date - 日期 (yyyyMMdd 字符串格式)。
+   * @param resId - 作品 ID (mongoose.Types.ObjectId)。
+   * @returns 如果删除成功则为 true,否则为 false。
+   */
+  public async deleteDoneRate(date: string, resId: mongoose.Types.ObjectId): Promise<boolean> {
+    try {
+      const result = await DoneRate.deleteOne({ date, res: resId }).exec();
+      return result.deletedCount === 1; // 如果删除了一条记录,则返回 true
+    } catch (error) {
+      console.error(`删除 DoneRate (date: ${date}, res: ${resId}) 时出错:`, error);
+      throw new Error("无法删除作品完成率记录。");
+    }
+  }
+}
+
+export default new DoneRateService();

+ 114 - 0
oms/src/services/userService.ts

@@ -0,0 +1,114 @@
+// oms/src/services/userService.ts
+import { User, IUser } from "../models/userModel"; // 导入 User 模型及其接口
+import { FilterQuery } from "mongoose"; // 👈 导入 FilterQuery 类型
+
+class UserService {
+  /**
+   * 创建一个新用户。
+   * @param userData - 新用户的数据。
+   * @returns 创建的用户文档。
+   */
+  public async createUser(userData: Partial<IUser>): Promise<IUser> {
+    try {
+      const newUser = new User(userData);
+      await newUser.save();
+      return newUser;
+    } catch (error) {
+      console.error("创建用户时出错:", error);
+      throw new Error("无法创建用户。");
+    }
+  }
+
+  /**
+   * 通过 uid 获取用户。
+   * @param uid - 用户的唯一标识符。
+   * @returns 用户文档,如果未找到则为 null。
+   */
+  public async getUserByUid(uid: string): Promise<IUser | null> {
+    try {
+      // 直接从 MongoDB 中获取
+      const user = await User.findOne({ uid: uid });
+      return user;
+    } catch (error) {
+      console.error(`通过 UID ${uid} 获取用户时出错:`, error);
+      throw new Error("无法检索用户。");
+    }
+  }
+
+  /**
+   * 更新现有用户。
+   * @param uid - 要更新用户的唯一标识符。
+   * @param updateData - 要更新的数据。
+   * @returns 更新后的用户文档,如果未找到则为 null。
+   */
+  public async updateUser(
+    uid: string,
+    updateData: Partial<IUser>
+  ): Promise<IUser | null> {
+    try {
+      // 避免直接更新 uid
+      if (updateData.uid) {
+        delete updateData.uid;
+      }
+
+      const updatedUser = await User.findOneAndUpdate(
+        { uid: uid },
+        updateData,
+        { new: true } // 返回更新后的文档
+      );
+      return updatedUser;
+    } catch (error) {
+      console.error(`更新用户 ${uid} 时出错:`, error);
+      throw new Error("无法更新用户。");
+    }
+  }
+
+  /**
+   * 通过 uid 删除用户。
+   * @param uid - 要删除用户的唯一标识符。
+   * @returns 如果删除成功则为 true,如果未找到则为 false。
+   */
+  public async deleteUser(uid: string): Promise<boolean> {
+    try {
+      const result = await User.deleteOne({ uid: uid });
+      return result.deletedCount > 0;
+    } catch (error) {
+      console.error(`删除用户 ${uid} 时出错:`, error);
+      throw new Error("无法删除用户。");
+    }
+  }
+
+  /**
+   * 按照分页和查询参数获取用户列表。
+   * @param page - 当前页码(从1开始)。
+   * @param limit - 每页的用户数量。
+   * @param query - 用于过滤用户的查询条件 (例如 { project: 1, cc: 'US' })。
+   * @returns 包含用户列表和总数的对象。
+   */
+  public async getPaginatedUsers(
+    page: number,
+    limit: number,
+    query: Partial<IUser>
+  ): Promise<{ users: IUser[]; total: number }> {
+    try {
+      const skip = (page - 1) * limit;
+
+      // 异步执行查询和计数
+      const [users, total] = await Promise.all([
+        // 👈 关键修改:将 query 断言为 FilterQuery<IUser>
+        User.find(query as FilterQuery<IUser>)
+          .skip(skip)
+          .limit(limit)
+          .exec(),
+        User.countDocuments(query as FilterQuery<IUser>).exec(),
+      ]);
+
+      return { users, total };
+    } catch (error) {
+      console.error("获取分页用户列表时出错:", error);
+      throw new Error("无法检索用户列表。");
+    }
+  }
+}
+
+export default new UserService();

+ 20 - 0
oms/tsconfig.json

@@ -0,0 +1,20 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "module": "CommonJS",
+    "outDir": "./dist",
+    "rootDir": "./",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "allowJs": true, // <-- 允许引入 JavaScript 文件
+    "checkJs": false // <-- 如果不想对 JS 文件进行类型检查,可以设置为 false
+  },
+  "include": [
+    "src/**/*.ts",
+    "services/**/*.ts",
+    "services/**/*.js" // <-- 确保也包含了 .js 文件
+  ],
+  "exclude": ["node_modules"]
+}

+ 1 - 0
omsapp

@@ -0,0 +1 @@
+Subproject commit 1ca43ae52d6d029d03400dbfa6d7350e1f5d73d2